// Proxmox RouterOS Frontend Application Logic
let state = {
interfaces: [],
vlans: [],
systemStatus: null,
vpnStatus: null,
routes: [],
activeTab: 'dashboard',
fwSubTab: 'filter',
bridgeSubTab: 'bridges',
bridges: [],
bridgePorts: [],
bridgeVLANs: [],
bridgeHosts: [],
bridgeSummary: null,
routesSubTab: 'routes',
pppSubTab: 'secrets',
eoipTunnels: [],
pppSecrets: [],
pppProfiles: [],
pppActiveSessions: [],
pppoeServer: null,
pptpServer: null,
l2tpServer: null,
ipsecServer: null,
ifaceFilter: 'all',
currentModalIface: null,
isPolling: true,
expandedParents: new Set(['eth0']),
pppoeData: null,
wgData: null,
ipsecData: null,
l2tpData: null,
pptpData: null,
fwSummary: null,
filterRules: [],
natRules: [],
mangleRules: [],
addressLists: [],
connections: [],
policyRules: [],
routingTables: []
};
const API_BASE = '/api';
function escapeHtml(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', () => {
const hash = window.location.hash.replace('#', '') || 'dashboard';
navigate(hash);
fetchData();
// Live polling every 2.5 seconds
setInterval(() => {
if (state.isPolling) {
fetchData(false);
}
}, 2500);
});
// Navigation Handler
function navigate(tabName) {
if (tabName === 'dynamic-routing' || tabName === 'routing') {
navigate('bgp');
return;
}
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: 'Hierarchical view of physical interfaces and nested 802.1Q VLANs' },
bridge: { title: 'Bridge Interfaces', sub: 'MikroTik-style IEEE 802.1D / 802.1Q VLAN Filtering Bridge with RSTP & FDB' },
vlans: { title: 'VLAN Virtual Networks', sub: 'IEEE 802.1Q sub-interface segmentation and routing' },
firewall: { title: 'MikroTik Firewall', sub: 'Filter rules, NAT, Mangle, Address Lists & Active Connections tracking' },
routes: { title: 'Routing & Policy', sub: 'Routing tables, static routes (DAS/DAC), and policy routing rules' },
bgp: { title: 'BGP Routing (/routing bgp)', sub: 'FRRouting BGP peering sessions, advertised networks, and BGP routing table (RIB)' },
ospf: { title: 'OSPF Routing (/routing ospf)', sub: 'FRRouting OSPFv2 link-state routing, dynamic neighbor adjacencies, and multi-area topology' },
'dynamic-routing': { title: 'BGP Routing (/routing bgp)', sub: 'FRRouting dynamic routing' },
ppp: { title: 'PPP Servers & Secrets', sub: 'MikroTik RouterOS user accounts, IP pools and VPN server concentrators' },
eoip: { title: 'EoIP Tunnels', sub: 'Ethernet over IP Layer 2 tunneling (MikroTik RouterOS style)' },
pppoe: { title: 'PPPoE Client WAN', sub: 'Point-to-Point Protocol over Ethernet WAN access' },
wireguard: { title: 'WireGuard Kernel VPN', sub: 'Modern ultra-fast cryptographic VPN tunnel manager' },
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' },
persistence: { title: 'Debian Configuration', sub: 'Native /etc/network/interfaces.d/ persistence manager' }
};
const header = titles[tabName] || titles.dashboard;
const pTitle = document.getElementById('page-title');
if (pTitle) pTitle.innerText = header.title;
const pSub = document.getElementById('page-subtitle');
if (pSub) pSub.innerText = header.sub;
// Toggle sections dynamically for all section elements
document.querySelectorAll('main section[id^="section-"]').forEach(el => {
if (el.id === `section-${tabName}`) {
el.classList.remove('hidden');
} else {
el.classList.add('hidden');
}
});
if (tabName === 'bridge') loadBridge();
if (tabName === 'firewall') loadFirewall();
if (tabName === 'routes') loadRoutes();
if (tabName === 'bgp') {
loadDynamicRouting();
switchBgpSubTab(state.activeBgpSubTab || 'bgp-peers');
}
if (tabName === 'ospf') {
loadDynamicRouting();
switchOspfSubTab(state.activeOspfSubTab || 'ospf-ifaces');
}
if (tabName === 'dynamic-routing') loadDynamicRouting();
if (tabName === 'ppp') loadPPP();
if (tabName === 'eoip') loadEoIP();
if (tabName === 'persistence') loadDebianConfig();
if (tabName === 'pppoe') loadPPPoE();
if (tabName === 'wireguard') loadWireGuard();
if (tabName === 'ipsec') loadIPSec();
if (tabName === 'l2tp') loadL2TP();
if (tabName === 'pptp') loadPPTP();
}
// Fetch all core system, network and VPN telemetry
async function fetchData(isManual = false) {
const spinner = document.getElementById('refresh-spinner');
if (isManual && spinner) spinner.classList.add('animate-spin');
try {
const [statusRes, ifacesRes, vlansRes, vpnRes, fwRes, dynRes, brSummaryRes] = await Promise.all([
fetch(`${API_BASE}/system/status`),
fetch(`${API_BASE}/interfaces`),
fetch(`${API_BASE}/vlans`),
fetch(`${API_BASE}/vpn/status`),
fetch(`${API_BASE}/firewall/summary`),
fetch(`${API_BASE}/routing/dynamic/summary`),
fetch(`${API_BASE}/bridge/summary`)
]);
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);
});
}
if (vlansRes.ok) {
const vlansData = await vlansRes.json();
state.vlans = Array.isArray(vlansData) ? vlansData : [];
}
if (vpnRes.ok) state.vpnStatus = await vpnRes.json();
if (fwRes && fwRes.ok) state.fwSummary = await fwRes.json();
if (dynRes && dynRes.ok) state.dynSummary = await dynRes.json();
updateSystemMetrics();
updateBadges();
if (state.activeTab === 'dashboard') renderDashboard();
if (state.activeTab === 'interfaces') renderInterfacesList();
if (state.activeTab === 'vlans') renderVlansList();
if (state.activeTab === 'firewall') loadFirewall(false);
if (state.activeTab === 'routes') loadRoutes(false);
if (state.activeTab === 'dynamic-routing' || state.activeTab === 'bgp' || state.activeTab === 'ospf') loadDynamicRouting(false);
if (state.activeTab === 'ppp') loadPPP(false);
if (state.activeTab === 'eoip') loadEoIP(false);
if (state.activeTab === 'pppoe') loadPPPoE(false);
if (state.activeTab === 'wireguard') loadWireGuard(false);
if (state.activeTab === 'ipsec') loadIPSec(false);
if (state.activeTab === 'l2tp') loadL2TP(false);
if (state.activeTab === 'pptp') loadPPTP(false);
} 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
document.getElementById('side-hostname').innerText = s.hostname || 'debian-lxc';
document.getElementById('side-uptime').innerText = s.uptime_human || '0m';
document.getElementById('side-gateway-ip').innerText = s.default_gateway ? `GW: ${s.default_gateway}` : 'GW: Isolated';
if (s.cpu_pct !== undefined) {
const pct = s.cpu_pct;
document.getElementById('side-cpu-val').innerText = `${pct.toFixed(1)}%`;
document.getElementById('side-cpu-bar').style.width = `${Math.max(4, Math.min(100, pct))}%`;
const loadStr = s.cpu_load ? s.cpu_load.join(', ') : '-';
document.getElementById('side-cpu-bar').title = `Container: ${pct}% | Host Loadavg: ${loadStr} (${s.cpu_cores || 1} cores)`;
} else if (s.cpu_load && s.cpu_load.length > 0) {
const cores = s.cpu_cores || 4;
const pct = Math.min(100, Math.round((parseFloat(s.cpu_load[0]) / cores) * 100));
document.getElementById('side-cpu-val').innerText = `${pct}%`;
document.getElementById('side-cpu-bar').style.width = `${Math.max(4, pct)}%`;
}
document.getElementById('side-ram-val').innerText = `${Math.round(s.mem_usage_pct || 0)}%`;
document.getElementById('side-ram-bar').style.width = `${Math.max(5, Math.round(s.mem_usage_pct || 0))}%`;
// Dashboard Stats
document.getElementById('stat-ifaces-up').innerText = s.up_ifaces || 0;
document.getElementById('stat-ifaces-total').innerText = `${s.total_ifaces || 0} Total`;
document.getElementById('stat-ifaces-down').innerText = `${s.down_ifaces || 0} DOWN`;
document.getElementById('stat-vlans-count').innerText = s.vlan_ifaces || 0;
// VPN Status Indicators
if (v) {
const totalActive = v.total_active_tunnels || 0;
const statVpnActive = document.getElementById('stat-vpn-active');
if (statVpnActive) statVpnActive.innerText = totalActive;
const tunnelsDot = document.getElementById('tunnels-dot');
const tunnelsLabel = document.getElementById('tunnels-label');
if (tunnelsDot && tunnelsLabel) {
if (totalActive > 0) {
tunnelsDot.className = 'w-2 h-2 rounded-full bg-emerald-400 animate-pulse';
tunnelsLabel.innerText = `${totalActive} Active VPN Tunnel${totalActive > 1 ? 's' : ''}`;
} else {
tunnelsDot.className = 'w-2 h-2 rounded-full bg-slate-500';
tunnelsLabel.innerText = '0 Active VPN Tunnels';
}
}
setDotBadge('badge-pppoe-status', (v.active_pppoe || 0) > 0 || v.pppoe_connected);
setDotBadge('badge-wg-status', (v.active_wireguard || 0) > 0 || v.wireguard_active);
setDotBadge('badge-ipsec-status', (v.active_ipsec || 0) > 0 || v.ipsec_active);
setDotBadge('badge-l2tp-status', (v.active_l2tp || 0) > 0 || v.l2tp_connected);
setDotBadge('badge-pptp-status', (v.active_pptp || 0) > 0 || v.pptp_connected);
}
// Total Live Bandwidth Rate
let totalRx = 0;
let totalTx = 0;
(state.interfaces || []).forEach(i => {
if (i.rate) {
totalRx += i.rate.rx_bytes_sec || 0;
totalTx += i.rate.tx_bytes_sec || 0;
}
});
document.getElementById('stat-traffic-rx').innerText = formatTraffic(totalRx);
document.getElementById('stat-traffic-tx').innerText = formatTraffic(totalTx);
}
function setDotBadge(id, active) {
const el = document.getElementById(id);
if (!el) return;
if (active) {
el.className = 'w-2 h-2 rounded-full bg-emerald-400 shadow-sm shadow-emerald-400/80';
} else {
el.className = 'w-2 h-2 rounded-full bg-slate-600';
}
}
function updateBadges() {
const bIfaces = document.getElementById('badge-ifaces-count');
if (bIfaces) bIfaces.innerText = (state.interfaces || []).length;
const bVlans = document.getElementById('badge-vlans-count');
if (bVlans) bVlans.innerText = (state.vlans || []).length;
if (state.fwSummary) {
const totalFwRules = (state.fwSummary.filter_rules_count || 0) + (state.fwSummary.nat_rules_count || 0) + (state.fwSummary.mangle_rules_count || 0);
const badgeFw = document.getElementById('badge-firewall-count');
if (badgeFw) badgeFw.innerText = totalFwRules;
}
if (state.vpnStatus) {
const v = state.vpnStatus;
const bEoIP = document.getElementById('badge-eoip-count');
if (bEoIP) bEoIP.innerText = v.total_eoip || 0;
const bPPPSess = document.getElementById('badge-ppp-sessions');
if (bPPPSess) bPPPSess.innerText = v.total_ppp_sessions || 0;
setDotBadge('badge-pppoe-status', v.active_pppoe > 0 || v.pppoe_server_running);
setDotBadge('badge-wg-status', v.active_wireguard > 0);
setDotBadge('badge-ipsec-status', v.active_ipsec > 0 || v.ipsec_server_running);
setDotBadge('badge-l2tp-status', v.active_l2tp > 0 || v.l2tp_server_running);
setDotBadge('badge-pptp-status', v.active_pptp > 0 || v.pptp_server_running);
}
if (state.dynSummary) {
const d = state.dynSummary;
setDotBadge('badge-bgp-dot', d.bgp_running);
setDotBadge('badge-ospf-dot', d.ospf_running);
const bBgp = document.getElementById('badge-bgp-count');
if (bBgp) bBgp.innerText = (state.bgpPeers || []).length;
const bOspf = document.getElementById('badge-ospf-count');
if (bOspf) bOspf.innerText = (state.ospfInterfaces || []).length;
}
}
// Render Dashboard View
function renderDashboard() {
const matrix = document.getElementById('port-matrix');
matrix.innerHTML = '';
(state.interfaces || []).forEach(iface => {
const isUp = iface.state === 'UP';
const isVlan = iface.is_vlan;
const portCard = document.createElement('div');
portCard.className = `p-3 rounded-xl border transition-all cursor-pointer select-none group ${
isUp
? (isVlan ? 'bg-cyan-950/20 border-cyan-800/60 hover:border-cyan-500' : 'bg-slate-800/80 border-slate-700/80 hover:border-brand-500')
: 'bg-slate-900/40 border-slate-800/60 opacity-60 hover:opacity-100 hover:border-slate-700'
}`;
portCard.onclick = () => openIfaceModal(iface.name);
let statusPillClass = isUp ? 'bg-emerald-500 shadow-sm shadow-emerald-500/50' : 'bg-rose-500';
if (isVlan) statusPillClass = isUp ? 'bg-cyan-400 shadow-sm shadow-cyan-400/50' : 'bg-rose-500';
portCard.innerHTML = `
${iface.name}
${iface.ipv4 && iface.ipv4.length > 0 ? iface.ipv4[0] : (iface.mac || 'No IP')}
${isVlan ? `VLAN ${iface.vlan_id}` : iface.type}
${iface.rate?.rx_human || '0 B/s'}
`;
matrix.appendChild(portCard);
});
const ifaceTbody = document.getElementById('quick-interfaces-table');
ifaceTbody.innerHTML = '';
(state.interfaces || []).slice(0, 6).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);
tr.innerHTML = `
${i.name}
|
${i.role || 'LAN'} |
${i.ipv4 && i.ipv4.length > 0 ? i.ipv4[0] : '-'} |
${i.state} |
`;
ifaceTbody.appendChild(tr);
});
const vlanTbody = document.getElementById('quick-vlans-table');
vlanTbody.innerHTML = '';
const vlans = state.vlans || [];
if (vlans.length === 0) {
vlanTbody.innerHTML = `| No VLANs configured. Click "+ Add" above to create one. |
`;
} else {
vlans.slice(0, 6).forEach(v => {
const tr = document.createElement('tr');
tr.className = 'hover:bg-slate-800/40 transition cursor-pointer';
tr.onclick = () => openIfaceModal(v.name);
tr.innerHTML = `
VLAN ${v.vlan_id} |
${v.name} |
${v.ipv4 && v.ipv4.length > 0 ? v.ipv4[0] : 'Unnumbered'} |
${v.parent_name || 'eth0'} |
`;
vlanTbody.appendChild(tr);
});
}
}
// Expand / Collapse Handlers
function toggleExpand(parentName) {
if (state.expandedParents.has(parentName)) {
state.expandedParents.delete(parentName);
} else {
state.expandedParents.add(parentName);
}
renderInterfacesList();
}
function expandAllIfaces(expand) {
if (expand) {
(state.interfaces || []).forEach(i => {
if (!i.is_vlan) state.expandedParents.add(i.name);
});
} else {
state.expandedParents.clear();
}
renderInterfacesList();
}
// Filter and Render Hierarchical Interfaces List
function filterIfaces(filter) {
state.ifaceFilter = filter;
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.className = 'filter-btn px-3 py-1.5 rounded-lg text-xs font-medium text-slate-400 hover:bg-slate-800 hover:text-white';
});
const activeBtn = document.getElementById(`filter-${filter}`);
if (activeBtn) {
activeBtn.className = 'filter-btn px-3 py-1.5 rounded-lg text-xs font-medium bg-slate-800 text-white border border-slate-700';
}
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 || '').toLowerCase();
tbody.innerHTML = '';
const allIfaces = state.interfaces || [];
const allVlans = state.vlans || [];
const parentIfaces = allIfaces.filter(i => !i.is_vlan);
const filteredParents = parentIfaces.filter(parent => {
const childVlans = allVlans.filter(v => v.parent_name === parent.name || v.name.startsWith(parent.name + '.'));
if (query) {
const matchParent = parent.name.toLowerCase().includes(query) || (parent.role || '').toLowerCase().includes(query);
const matchVlan = childVlans.some(v => v.name.toLowerCase().includes(query) || String(v.vlan_id).includes(query) || (v.description || '').toLowerCase().includes(query));
if (!matchParent && !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} Physical/Parent Links • ${allVlans.length} Active VLANs`;
}
if (filteredParents.length === 0) {
emptyState?.classList.remove('hidden');
return;
}
emptyState?.classList.add('hidden');
filteredParents.forEach(parent => {
const isUp = parent.state === 'UP';
const childVlans = allVlans.filter(v => v.parent_name === parent.name || v.name.startsWith(parent.name + '.'));
const isExpanded = state.expandedParents.has(parent.name);
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 = `
|
${parent.type === 'loopback' ? 'LO' : 'NIC'}
${parent.name}
${childVlans.length > 0
? `${childVlans.length} VLAN${childVlans.length > 1 ? 's' : ''}`
: `0 VLANs`
}
${parent.type}
|
${parent.role || 'LAN'}
${parent.description || '-'}
|
${parent.ipv4 && parent.ipv4.length > 0
? ` ${parent.ipv4[0]} `
: 'Unassigned'
}
|
${parent.mac || 'N/A'}
MTU ${parent.mtu}
|
↓ ${parent.rate?.rx_human || '0 B/s'}
↑ ${parent.rate?.tx_human || '0 B/s'}
|
|
|
`;
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'}
|
${v.role || 'VLAN'}
${v.description || '-'}
|
${v.mtu} |
↓ ${v.rate?.rx_human || '0 B/s'}
↑ ${v.rate?.tx_human || '0 B/s'}
|
|
|
`;
}).join('')}
`}
`;
drawerRow.appendChild(drawerCell);
tbody.appendChild(drawerRow);
}
});
}
// Render VLANs Dedicated View
function renderVlansList() {
const tbody = document.getElementById('vlans-table-body');
const emptyState = document.getElementById('vlans-empty-state');
const countBadge = document.getElementById('vlans-table-count');
const query = (document.getElementById('vlan-search')?.value || '').toLowerCase();
tbody.innerHTML = '';
const allVlans = state.vlans || [];
countBadge.innerText = allVlans.length;
const filtered = allVlans.filter(v => {
if (!query) return true;
return v.name.toLowerCase().includes(query) ||
String(v.vlan_id).includes(query) ||
(v.description || '').toLowerCase().includes(query) ||
(v.ipv4 && v.ipv4.some(ip => ip.includes(query)));
});
if (allVlans.length === 0) {
emptyState.classList.remove('hidden');
return;
}
emptyState.classList.add('hidden');
filtered.forEach(v => {
const isUp = v.state === 'UP';
const tr = document.createElement('tr');
tr.className = 'hover:bg-slate-800/40 transition font-mono text-xs';
tr.innerHTML = `
VLAN ${v.vlan_id}
|
${v.name} |
${v.parent_name || 'eth0'}
|
${v.ipv4 && v.ipv4.length > 0 ? v.ipv4[0] : 'Unassigned'}
|
${v.description || v.role || '-'}
|
${v.mtu} |
|
|
`;
tbody.appendChild(tr);
});
}
// Toggle interface UP / DOWN
async function toggleInterfaceState(name, newState) {
try {
const res = await fetch(`${API_BASE}/interfaces/${encodeURIComponent(name)}/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ state: newState })
});
const data = await res.json();
if (res.ok) {
showToast('Interface Updated', `Interface ${name} is now ${newState.toUpperCase()}`, 'success');
fetchData(false);
} else {
showToast('Error', data.message || 'Failed to toggle interface state', 'error');
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
// ---------------- MikroTik Flag & Utilities ----------------
function renderMikroTikFlag(running, disabled) {
if (running) {
return `R`;
}
if (disabled) {
return `X`;
}
return `-`;
}
function copyToClipboard(text, label = 'Text') {
navigator.clipboard.writeText(text).then(() => {
showToast('Copied', `${label} copied to clipboard`, 'success');
}).catch(() => {
prompt('Copy value:', text);
});
}
// ======================== PPPoE Multi-Instance ========================
async function loadPPPoE(populateInputs = true) {
try {
const res = await fetch(`${API_BASE}/vpn/pppoe/clients`);
const list = await res.json();
state.pppoeList = Array.isArray(list) ? list : [];
const badge = document.getElementById('pppoe-count-badge');
if (badge) badge.innerText = `${state.pppoeList.length} Interface${state.pppoeList.length !== 1 ? 's' : ''}`;
const tbody = document.getElementById('pppoe-table-body');
const empty = document.getElementById('pppoe-empty');
if (!tbody) return;
tbody.innerHTML = '';
if (state.pppoeList.length === 0) {
if (empty) empty.classList.remove('hidden');
} else {
if (empty) empty.classList.add('hidden');
state.pppoeList.forEach(inst => {
const tr = document.createElement('tr');
tr.className = 'hover:bg-slate-800/40 transition text-xs';
tr.innerHTML = `
${renderMikroTikFlag(inst.running, inst.disabled)} |
${inst.name} |
${inst.interface} |
${inst.username || '-'} |
${inst.assigned_ip || '-'} |
${inst.ppp_device || '-'} |
${inst.default_route ? 'Yes' : 'No'}
|
${inst.running ? `
` : `
`}
|
`;
tbody.appendChild(tr);
});
}
} catch (err) {
console.error('Failed to load PPPoE:', err);
}
}
function openAddPPPoEModal() {
document.getElementById('pppoe-modal-title').innerText = 'Add PPPoE Client Interface';
document.getElementById('pppoe-modal-edit-orig').value = '';
document.getElementById('pppoe-modal-name').value = `pppoe-out${(state.pppoeList || []).length + 1}`;
document.getElementById('pppoe-modal-name').readOnly = false;
document.getElementById('pppoe-modal-user').value = '';
document.getElementById('pppoe-modal-pass').value = '';
document.getElementById('pppoe-modal-service').value = '';
document.getElementById('pppoe-modal-mtu').value = '1492';
document.getElementById('pppoe-modal-defroute').checked = true;
document.getElementById('pppoe-modal-reconnect').checked = true;
document.getElementById('pppoe-modal-disabled').checked = false;
// Populate interfaces
const sel = document.getElementById('pppoe-modal-iface');
sel.innerHTML = '';
(state.interfaces || []).forEach(i => {
if (i.name === 'lo') return;
const opt = document.createElement('option');
opt.value = i.name;
opt.innerText = `${i.name} (${i.role || i.type})`;
sel.appendChild(opt);
});
document.getElementById('pppoe-modal').classList.remove('hidden');
}
function openEditPPPoEModal(name) {
const inst = (state.pppoeList || []).find(i => i.name === name);
if (!inst) return;
document.getElementById('pppoe-modal-title').innerText = `Edit PPPoE Client: ${name}`;
document.getElementById('pppoe-modal-edit-orig').value = name;
document.getElementById('pppoe-modal-name').value = inst.name;
document.getElementById('pppoe-modal-name').readOnly = true;
document.getElementById('pppoe-modal-user').value = inst.username || '';
document.getElementById('pppoe-modal-pass').value = inst.password || '';
document.getElementById('pppoe-modal-service').value = inst.service_name || '';
document.getElementById('pppoe-modal-mtu').value = inst.mtu || 1492;
document.getElementById('pppoe-modal-defroute').checked = inst.default_route ?? true;
document.getElementById('pppoe-modal-reconnect').checked = inst.auto_reconnect ?? true;
document.getElementById('pppoe-modal-disabled').checked = !!inst.disabled;
const sel = document.getElementById('pppoe-modal-iface');
sel.innerHTML = '';
(state.interfaces || []).forEach(i => {
if (i.name === 'lo') return;
const opt = document.createElement('option');
opt.value = i.name;
opt.innerText = `${i.name} (${i.role || i.type})`;
if (i.name === inst.interface) opt.selected = true;
sel.appendChild(opt);
});
document.getElementById('pppoe-modal').classList.remove('hidden');
}
function closePPPoEModal() {
document.getElementById('pppoe-modal').classList.add('hidden');
}
async function submitSavePPPoE(e) {
e.preventDefault();
const name = document.getElementById('pppoe-modal-name').value.trim();
const iface = document.getElementById('pppoe-modal-iface').value;
const user = document.getElementById('pppoe-modal-user').value.trim();
const pass = document.getElementById('pppoe-modal-pass').value;
const service = document.getElementById('pppoe-modal-service').value.trim();
const mtu = parseInt(document.getElementById('pppoe-modal-mtu').value, 10) || 1492;
const defRoute = document.getElementById('pppoe-modal-defroute').checked;
const reconnect = document.getElementById('pppoe-modal-reconnect').checked;
const disabled = document.getElementById('pppoe-modal-disabled').checked;
try {
const res = await fetch(`${API_BASE}/vpn/pppoe/clients`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
interface: iface,
username: user,
password: pass,
service_name: service,
mtu,
default_route: defRoute,
auto_reconnect: reconnect,
disabled
})
});
if (res.ok) {
showToast('PPPoE Saved', `Configuration for ${name} saved`, 'success');
closePPPoEModal();
loadPPPoE(false);
fetchData(false);
} else {
const err = await res.json();
showToast('Error', err.message || 'Failed to save PPPoE', 'error');
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function connectPPPoEInstance(name) {
try {
const res = await fetch(`${API_BASE}/vpn/pppoe/clients/${encodeURIComponent(name)}/connect`, { method: 'POST' });
if (res.ok) {
showToast('Connecting', `Dialing ${name}...`, 'info');
setTimeout(() => loadPPPoE(false), 2000);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function disconnectPPPoEInstance(name) {
try {
const res = await fetch(`${API_BASE}/vpn/pppoe/clients/${encodeURIComponent(name)}/disconnect`, { method: 'POST' });
if (res.ok) {
showToast('Disconnected', `${name} session terminated`, 'info');
setTimeout(() => loadPPPoE(false), 1000);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function togglePPPoEState(name, enable) {
try {
const res = await fetch(`${API_BASE}/vpn/pppoe/clients/${encodeURIComponent(name)}/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: enable })
});
if (res.ok) {
showToast(enable ? 'Enabled' : 'Disabled', `${name} is now ${enable ? 'enabled' : 'disabled'}`, 'info');
loadPPPoE(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function deletePPPoEInstance(name) {
if (!confirm(`Are you sure you want to delete PPPoE client ${name}?`)) return;
try {
const res = await fetch(`${API_BASE}/vpn/pppoe/clients/${encodeURIComponent(name)}`, { method: 'DELETE' });
if (res.ok) {
showToast('Deleted', `PPPoE client ${name} removed`, 'success');
loadPPPoE(false);
fetchData(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
// ======================== WireGuard Multi-Interface & Multi-Peer ========================
state.wgSubTab = 'ifaces';
function switchWGSubTab(tab) {
state.wgSubTab = tab;
const ifacesPanel = document.getElementById('wg-subtab-ifaces');
const peersPanel = document.getElementById('wg-subtab-peers');
const ifacesBtn = document.getElementById('wg-subtab-btn-ifaces');
const peersBtn = document.getElementById('wg-subtab-btn-peers');
if (tab === 'ifaces') {
ifacesPanel?.classList.remove('hidden');
peersPanel?.classList.add('hidden');
ifacesBtn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-rose-500 text-white transition flex items-center gap-2';
peersBtn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2';
} else {
ifacesPanel?.classList.add('hidden');
peersPanel?.classList.remove('hidden');
ifacesBtn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2';
peersBtn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-rose-500 text-white transition flex items-center gap-2';
renderWGPeers();
}
}
async function loadWireGuard(populateInputs = true) {
try {
const [ifacesRes, peersRes] = await Promise.all([
fetch(`${API_BASE}/vpn/wireguard/interfaces`),
fetch(`${API_BASE}/vpn/wireguard/peers`)
]);
const ifaces = ifacesRes.ok ? await ifacesRes.json() : [];
const peers = peersRes.ok ? await peersRes.json() : [];
state.wgInterfaces = Array.isArray(ifaces) ? ifaces : [];
state.wgPeers = Array.isArray(peers) ? peers : [];
// Badges
const ifacesCountBadge = document.getElementById('wg-ifaces-count');
if (ifacesCountBadge) ifacesCountBadge.innerText = `${state.wgInterfaces.length} Interfaces`;
const peersCountBadge = document.getElementById('wg-peers-count');
if (peersCountBadge) peersCountBadge.innerText = `${state.wgPeers.length} Peers`;
const subTabIfaces = document.getElementById('wg-subtab-badge-ifaces');
if (subTabIfaces) subTabIfaces.innerText = state.wgInterfaces.length;
const subTabPeers = document.getElementById('wg-subtab-badge-peers');
if (subTabPeers) subTabPeers.innerText = state.wgPeers.length;
// Render Interfaces
const ifacesTbody = document.getElementById('wg-ifaces-table-body');
const ifacesEmpty = document.getElementById('wg-ifaces-empty');
if (ifacesTbody) {
ifacesTbody.innerHTML = '';
if (state.wgInterfaces.length === 0) {
if (ifacesEmpty) ifacesEmpty.classList.remove('hidden');
} else {
if (ifacesEmpty) ifacesEmpty.classList.add('hidden');
state.wgInterfaces.forEach(iface => {
const peersOnIface = state.wgPeers.filter(p => p.interface === iface.name).length;
const tr = document.createElement('tr');
tr.className = 'hover:bg-slate-800/40 transition text-xs';
tr.innerHTML = `
${renderMikroTikFlag(iface.running, iface.disabled)} |
${iface.name} |
${iface.listen_port} |
${iface.address || '-'} |
${(iface.public_key || '').substring(0, 10)}... 📋
|
${iface.mtu || 1420} |
${peersOnIface} peer${peersOnIface !== 1 ? 's' : ''}
|
${iface.running ? `
` : `
`}
|
`;
ifacesTbody.appendChild(tr);
});
}
}
// Populate filter dropdown in peers tab
const filterSel = document.getElementById('wg-peers-filter-iface');
if (filterSel) {
const currentVal = filterSel.value;
filterSel.innerHTML = '';
state.wgInterfaces.forEach(i => {
const opt = document.createElement('option');
opt.value = i.name;
opt.innerText = i.name;
if (i.name === currentVal) opt.selected = true;
filterSel.appendChild(opt);
});
}
renderWGPeers();
} catch (err) {
console.error('Failed to load WireGuard:', err);
}
}
function renderWGPeers() {
const filterSel = document.getElementById('wg-peers-filter-iface');
const selectedIface = filterSel ? filterSel.value : '';
const tbody = document.getElementById('wg-peers-table-body');
const empty = document.getElementById('wg-peers-empty');
if (!tbody) return;
tbody.innerHTML = '';
const filtered = selectedIface ? state.wgPeers.filter(p => p.interface === selectedIface) : state.wgPeers;
if (filtered.length === 0) {
if (empty) empty.classList.remove('hidden');
} else {
if (empty) empty.classList.add('hidden');
filtered.forEach(p => {
const isOnline = p.latest_handshake && !p.latest_handshake.includes('Never');
const tr = document.createElement('tr');
tr.className = 'hover:bg-slate-800/40 transition text-xs';
tr.innerHTML = `
${renderMikroTikFlag(isOnline, p.disabled)} |
${p.interface} |
${p.name || 'Unnamed'} |
${p.public_key.substring(0, 10)}... 📋
|
${(p.allowed_ips || []).join(', ')} |
${p.endpoint_address ? (p.endpoint_address + (p.endpoint_port ? ':' + p.endpoint_port : '')) : '-'} |
${p.latest_handshake || 'Never'} |
↓ ${p.transfer_rx_human || '0 B'}
↑ ${p.transfer_tx_human || '0 B'}
|
|
`;
tbody.appendChild(tr);
});
}
}
function openAddWGInterfaceModal() {
document.getElementById('wg-iface-modal-title').innerText = 'Add WireGuard Interface';
document.getElementById('wg-iface-modal-edit-orig').value = '';
document.getElementById('wg-iface-modal-name').value = `wg${(state.wgInterfaces || []).length}`;
document.getElementById('wg-iface-modal-name').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-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;
document.getElementById('wg-iface-modal-name').value = iface.name;
document.getElementById('wg-iface-modal-name').readOnly = true;
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-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 submitSaveWGInterface(e) {
e.preventDefault();
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 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({
name,
listen_port: port,
address: addr,
mtu,
public_key: pub,
private_key: priv,
disabled
})
});
if (res.ok) {
showToast('WireGuard Saved', `Interface ${name} configured`, 'success');
closeAddWGInterfaceModal();
loadWireGuard(false);
fetchData(false);
}
} 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);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function deleteWGInterface(name) {
if (!confirm(`Are you sure you want to delete WireGuard interface ${name}?`)) 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 openAddWGPeerModal() {
const sel = document.getElementById('wg-peer-modal-iface');
sel.innerHTML = '';
(state.wgInterfaces || []).forEach(i => {
const opt = document.createElement('option');
opt.value = i.name;
opt.innerText = `${i.name} (${i.address || 'No IP'})`;
sel.appendChild(opt);
});
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-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-keepalive').value = '25';
document.getElementById('wg-peer-modal-preshared').value = '';
document.getElementById('wg-peer-modal-disabled').checked = false;
document.getElementById('wg-peer-modal').classList.remove('hidden');
}
function closeAddWGPeerModal() {
document.getElementById('wg-peer-modal').classList.add('hidden');
}
async function submitSaveWGPeer(e) {
e.preventDefault();
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());
const ep = document.getElementById('wg-peer-modal-endpoint').value.trim();
const keepalive = parseInt(document.getElementById('wg-peer-modal-keepalive').value, 10) || 25;
const psk = document.getElementById('wg-peer-modal-preshared').value.trim();
const disabled = document.getElementById('wg-peer-modal-disabled').checked;
let epAddr = ep;
let epPort = 0;
if (ep.includes(':')) {
const parts = ep.split(':');
epAddr = parts[0];
epPort = parseInt(parts[1], 10) || 0;
}
try {
const res = await fetch(`${API_BASE}/vpn/wireguard/peers`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
interface: iface,
name,
public_key: pub,
allowed_ips: allowed,
endpoint_address: epAddr,
endpoint_port: epPort,
persistent_keepalive: keepalive,
preshared_key: psk,
disabled
})
});
if (res.ok) {
showToast('Peer Saved', `Peer ${name} added to ${iface}`, 'success');
closeAddWGPeerModal();
loadWireGuard(false);
}
} 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/${encodeURIComponent(iface)}/${encodeURIComponent(pubkey)}`, {
method: 'DELETE'
});
if (res.ok) {
showToast('Peer Deleted', 'WireGuard peer removed', 'success');
loadWireGuard(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
// ======================== IPSec Multi-Tunnel ========================
async function loadIPSec(populateInputs = true) {
try {
const res = await fetch(`${API_BASE}/vpn/ipsec/tunnels`);
const list = await res.json();
state.ipsecTunnels = Array.isArray(list) ? list : [];
const activeCount = state.ipsecTunnels.filter(t => t.running || t.status === 'ESTABLISHED').length;
const badge = document.getElementById('ipsec-sas-badge');
if (badge) badge.innerText = `${activeCount} Active SAs`;
const tbody = document.getElementById('ipsec-table-body');
const empty = document.getElementById('ipsec-empty');
if (!tbody) return;
tbody.innerHTML = '';
if (state.ipsecTunnels.length === 0) {
if (empty) empty.classList.remove('hidden');
} else {
if (empty) empty.classList.add('hidden');
state.ipsecTunnels.forEach(c => {
const isEstablished = c.running || c.status === 'ESTABLISHED';
const tr = document.createElement('tr');
tr.className = 'hover:bg-slate-800/40 transition text-xs';
tr.innerHTML = `
${renderMikroTikFlag(isEstablished, c.disabled)} |
${c.name} |
${c.local_subnet || '-'} |
${c.remote_addrs || '-'} |
${c.remote_subnet || '-'} |
${c.status || (isEstablished ? 'ESTABLISHED' : 'DISCONNECTED')}
|
${isEstablished ? `
` : `
`}
|
`;
tbody.appendChild(tr);
});
}
} catch (err) {
console.error('Failed to load IPSec:', err);
}
}
function openAddIPSecModal() {
document.getElementById('ipsec-modal-title').innerText = 'Configure IPSec IKEv2 Tunnel';
document.getElementById('ipsec-modal-name').value = `ipsec-${(state.ipsecTunnels || []).length + 1}`;
document.getElementById('ipsec-modal-name').readOnly = false;
document.getElementById('ipsec-modal-localts').value = '192.168.10.0/24';
document.getElementById('ipsec-modal-remotets').value = '192.168.20.0/24';
document.getElementById('ipsec-modal-remotegw').value = '';
document.getElementById('ipsec-modal-psk').value = '';
document.getElementById('ipsec-modal-ikeprop').value = 'aes256-sha256-modp2048,default';
document.getElementById('ipsec-modal-espprop').value = 'aes256-sha256-modp2048,default';
document.getElementById('ipsec-modal-autostart').checked = true;
document.getElementById('ipsec-modal-disabled').checked = false;
document.getElementById('ipsec-modal').classList.remove('hidden');
}
function openEditIPSecModal(name) {
const conn = (state.ipsecTunnels || []).find(c => c.name === name);
if (!conn) return;
document.getElementById('ipsec-modal-title').innerText = `Edit IPSec Tunnel: ${name}`;
document.getElementById('ipsec-modal-name').value = conn.name;
document.getElementById('ipsec-modal-name').readOnly = true;
document.getElementById('ipsec-modal-localts').value = conn.local_subnet || '';
document.getElementById('ipsec-modal-remotets').value = conn.remote_subnet || '';
document.getElementById('ipsec-modal-remotegw').value = conn.remote_addrs || '';
document.getElementById('ipsec-modal-psk').value = conn.preshared_key || '';
document.getElementById('ipsec-modal-ikeprop').value = conn.ike_proposals || 'aes256-sha256-modp2048,default';
document.getElementById('ipsec-modal-espprop').value = conn.esp_proposals || 'aes256-sha256-modp2048,default';
document.getElementById('ipsec-modal-autostart').checked = conn.auto_start ?? true;
document.getElementById('ipsec-modal-disabled').checked = !!conn.disabled;
document.getElementById('ipsec-modal').classList.remove('hidden');
}
function closeAddIPSecModal() {
document.getElementById('ipsec-modal').classList.add('hidden');
}
async function submitSaveIPSec(e) {
e.preventDefault();
const name = document.getElementById('ipsec-modal-name').value.trim();
const localts = document.getElementById('ipsec-modal-localts').value.trim();
const remotets = document.getElementById('ipsec-modal-remotets').value.trim();
const remotegw = document.getElementById('ipsec-modal-remotegw').value.trim();
const psk = document.getElementById('ipsec-modal-psk').value;
const ike = document.getElementById('ipsec-modal-ikeprop').value.trim();
const esp = document.getElementById('ipsec-modal-espprop').value.trim();
const autostart = document.getElementById('ipsec-modal-autostart').checked;
const disabled = document.getElementById('ipsec-modal-disabled').checked;
try {
const res = await fetch(`${API_BASE}/vpn/ipsec/tunnels`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
local_addrs: '%any',
remote_addrs: remotegw,
local_subnet: localts,
remote_subnet: remotets,
preshared_key: psk,
ike_proposals: ike,
esp_proposals: esp,
auto_start: autostart,
disabled
})
});
if (res.ok) {
showToast('IPSec Saved', `Tunnel ${name} configured`, 'success');
closeAddIPSecModal();
loadIPSec(false);
fetchData(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function startIPSecTunnel(name) {
try {
const res = await fetch(`${API_BASE}/vpn/ipsec/tunnels/${encodeURIComponent(name)}/start`, { method: 'POST' });
if (res.ok) {
showToast('Initiating Tunnel', `Attempting SA handshake for ${name}...`, 'info');
setTimeout(() => loadIPSec(false), 2000);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function stopIPSecTunnel(name) {
try {
const res = await fetch(`${API_BASE}/vpn/ipsec/tunnels/${encodeURIComponent(name)}/stop`, { method: 'POST' });
if (res.ok) {
showToast('Terminated', `IPSec SA for ${name} closed`, 'info');
loadIPSec(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function toggleIPSecState(name, enable) {
try {
const res = await fetch(`${API_BASE}/vpn/ipsec/tunnels/${encodeURIComponent(name)}/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: enable })
});
if (res.ok) {
showToast(enable ? 'Enabled' : 'Disabled', `IPSec tunnel ${name} updated`, 'info');
loadIPSec(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function deleteIPSecConn(name) {
if (!confirm(`Are you sure you want to delete IPSec tunnel ${name}?`)) return;
try {
const res = await fetch(`${API_BASE}/vpn/ipsec/tunnels/${encodeURIComponent(name)}`, { method: 'DELETE' });
if (res.ok) {
showToast('Deleted', `IPSec connection ${name} removed`, 'success');
loadIPSec(false);
fetchData(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
// ======================== L2TP Multi-Instance ========================
async function loadL2TP(populateInputs = true) {
try {
const res = await fetch(`${API_BASE}/vpn/l2tp/clients`);
const list = await res.json();
state.l2tpList = Array.isArray(list) ? list : [];
const badge = document.getElementById('l2tp-count-badge');
if (badge) badge.innerText = `${state.l2tpList.length} Client${state.l2tpList.length !== 1 ? 's' : ''}`;
const tbody = document.getElementById('l2tp-table-body');
const empty = document.getElementById('l2tp-empty');
if (!tbody) return;
tbody.innerHTML = '';
if (state.l2tpList.length === 0) {
if (empty) empty.classList.remove('hidden');
} else {
if (empty) empty.classList.add('hidden');
state.l2tpList.forEach(inst => {
const tr = document.createElement('tr');
tr.className = 'hover:bg-slate-800/40 transition text-xs';
tr.innerHTML = `
${renderMikroTikFlag(inst.running, inst.disabled)} |
${inst.name} |
${inst.connect_to} |
${inst.username || '-'} |
${inst.assigned_ip || '-'} |
${inst.ppp_device || '-'} |
${inst.default_route ? 'Yes' : 'No'}
|
${inst.running ? `
` : `
`}
|
`;
tbody.appendChild(tr);
});
}
} catch (err) {
console.error('Failed to load L2TP:', err);
}
}
function openAddL2TPModal() {
document.getElementById('l2tp-modal-title').innerText = 'Add L2TP Client Interface';
document.getElementById('l2tp-modal-name').value = `l2tp-out${(state.l2tpList || []).length + 1}`;
document.getElementById('l2tp-modal-name').readOnly = false;
document.getElementById('l2tp-modal-server').value = '';
document.getElementById('l2tp-modal-user').value = '';
document.getElementById('l2tp-modal-pass').value = '';
document.getElementById('l2tp-modal-ipsec').value = '';
document.getElementById('l2tp-modal-defroute').checked = true;
document.getElementById('l2tp-modal-disabled').checked = false;
document.getElementById('l2tp-modal').classList.remove('hidden');
}
function openEditL2TPModal(name) {
const inst = (state.l2tpList || []).find(i => i.name === name);
if (!inst) return;
document.getElementById('l2tp-modal-title').innerText = `Edit L2TP Client: ${name}`;
document.getElementById('l2tp-modal-name').value = inst.name;
document.getElementById('l2tp-modal-name').readOnly = true;
document.getElementById('l2tp-modal-server').value = inst.connect_to || '';
document.getElementById('l2tp-modal-user').value = inst.username || '';
document.getElementById('l2tp-modal-pass').value = inst.password || '';
document.getElementById('l2tp-modal-ipsec').value = inst.ipsec_secret || '';
document.getElementById('l2tp-modal-defroute').checked = inst.default_route ?? true;
document.getElementById('l2tp-modal-disabled').checked = !!inst.disabled;
document.getElementById('l2tp-modal').classList.remove('hidden');
}
function closeL2TPModal() {
document.getElementById('l2tp-modal').classList.add('hidden');
}
async function submitSaveL2TP(e) {
e.preventDefault();
const name = document.getElementById('l2tp-modal-name').value.trim();
const server = document.getElementById('l2tp-modal-server').value.trim();
const user = document.getElementById('l2tp-modal-user').value.trim();
const pass = document.getElementById('l2tp-modal-pass').value;
const ipsec = document.getElementById('l2tp-modal-ipsec').value;
const defRoute = document.getElementById('l2tp-modal-defroute').checked;
const disabled = document.getElementById('l2tp-modal-disabled').checked;
try {
const res = await fetch(`${API_BASE}/vpn/l2tp/clients`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
connect_to: server,
username: user,
password: pass,
ipsec_secret: ipsec,
default_route: defRoute,
disabled
})
});
if (res.ok) {
showToast('L2TP Saved', `Client ${name} configuration saved`, 'success');
closeL2TPModal();
loadL2TP(false);
fetchData(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function connectL2TPInstance(name) {
try {
const res = await fetch(`${API_BASE}/vpn/l2tp/clients/${encodeURIComponent(name)}/connect`, { method: 'POST' });
if (res.ok) {
showToast('Connecting', `Dialing L2TP ${name}...`, 'info');
setTimeout(() => loadL2TP(false), 2000);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function disconnectL2TPInstance(name) {
try {
const res = await fetch(`${API_BASE}/vpn/l2tp/clients/${encodeURIComponent(name)}/disconnect`, { method: 'POST' });
if (res.ok) {
showToast('Disconnected', `L2TP tunnel ${name} terminated`, 'info');
loadL2TP(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function toggleL2TPState(name, enable) {
try {
const res = await fetch(`${API_BASE}/vpn/l2tp/clients/${encodeURIComponent(name)}/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: enable })
});
if (res.ok) {
showToast(enable ? 'Enabled' : 'Disabled', `${name} state updated`, 'info');
loadL2TP(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function deleteL2TPInstance(name) {
if (!confirm(`Are you sure you want to delete L2TP client ${name}?`)) return;
try {
const res = await fetch(`${API_BASE}/vpn/l2tp/clients/${encodeURIComponent(name)}`, { method: 'DELETE' });
if (res.ok) {
showToast('Deleted', `L2TP client ${name} removed`, 'success');
loadL2TP(false);
fetchData(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
// ======================== PPTP Multi-Instance ========================
async function loadPPTP(populateInputs = true) {
try {
const res = await fetch(`${API_BASE}/vpn/pptp/clients`);
const list = await res.json();
state.pptpList = Array.isArray(list) ? list : [];
const badge = document.getElementById('pptp-count-badge');
if (badge) badge.innerText = `${state.pptpList.length} Client${state.pptpList.length !== 1 ? 's' : ''}`;
const tbody = document.getElementById('pptp-table-body');
const empty = document.getElementById('pptp-empty');
if (!tbody) return;
tbody.innerHTML = '';
if (state.pptpList.length === 0) {
if (empty) empty.classList.remove('hidden');
} else {
if (empty) empty.classList.add('hidden');
state.pptpList.forEach(inst => {
const tr = document.createElement('tr');
tr.className = 'hover:bg-slate-800/40 transition text-xs';
tr.innerHTML = `
${renderMikroTikFlag(inst.running, inst.disabled)} |
${inst.name} |
${inst.connect_to} |
${inst.username || '-'} |
${inst.assigned_ip || '-'} |
${inst.ppp_device || '-'} |
${inst.require_mppe ? 'MPPE-128' : 'None'}
|
${inst.running ? `
` : `
`}
|
`;
tbody.appendChild(tr);
});
}
} catch (err) {
console.error('Failed to load PPTP:', err);
}
}
function openAddPPTPModal() {
document.getElementById('pptp-modal-title').innerText = 'Add PPTP Client Interface';
document.getElementById('pptp-modal-name').value = `pptp-out${(state.pptpList || []).length + 1}`;
document.getElementById('pptp-modal-name').readOnly = false;
document.getElementById('pptp-modal-server').value = '';
document.getElementById('pptp-modal-user').value = '';
document.getElementById('pptp-modal-pass').value = '';
document.getElementById('pptp-modal-mppe').checked = true;
document.getElementById('pptp-modal-defroute').checked = true;
document.getElementById('pptp-modal-disabled').checked = false;
document.getElementById('pptp-modal').classList.remove('hidden');
}
function openEditPPTPModal(name) {
const inst = (state.pptpList || []).find(i => i.name === name);
if (!inst) return;
document.getElementById('pptp-modal-title').innerText = `Edit PPTP Client: ${name}`;
document.getElementById('pptp-modal-name').value = inst.name;
document.getElementById('pptp-modal-name').readOnly = true;
document.getElementById('pptp-modal-server').value = inst.connect_to || '';
document.getElementById('pptp-modal-user').value = inst.username || '';
document.getElementById('pptp-modal-pass').value = inst.password || '';
document.getElementById('pptp-modal-mppe').checked = inst.require_mppe ?? true;
document.getElementById('pptp-modal-defroute').checked = inst.default_route ?? true;
document.getElementById('pptp-modal-disabled').checked = !!inst.disabled;
document.getElementById('pptp-modal').classList.remove('hidden');
}
function closePPTPModal() {
document.getElementById('pptp-modal').classList.add('hidden');
}
async function submitSavePPTP(e) {
e.preventDefault();
const name = document.getElementById('pptp-modal-name').value.trim();
const server = document.getElementById('pptp-modal-server').value.trim();
const user = document.getElementById('pptp-modal-user').value.trim();
const pass = document.getElementById('pptp-modal-pass').value;
const mppe = document.getElementById('pptp-modal-mppe').checked;
const defRoute = document.getElementById('pptp-modal-defroute').checked;
const disabled = document.getElementById('pptp-modal-disabled').checked;
try {
const res = await fetch(`${API_BASE}/vpn/pptp/clients`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
connect_to: server,
username: user,
password: pass,
require_mppe: mppe,
default_route: defRoute,
auto_reconnect: true,
disabled
})
});
if (res.ok) {
showToast('PPTP Saved', `Client ${name} configuration saved`, 'success');
closePPTPModal();
loadPPTP(false);
fetchData(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function connectPPTPInstance(name) {
try {
const res = await fetch(`${API_BASE}/vpn/pptp/clients/${encodeURIComponent(name)}/connect`, { method: 'POST' });
if (res.ok) {
showToast('Connecting', `Dialing PPTP ${name}...`, 'info');
setTimeout(() => loadPPTP(false), 2000);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function disconnectPPTPInstance(name) {
try {
const res = await fetch(`${API_BASE}/vpn/pptp/clients/${encodeURIComponent(name)}/disconnect`, { method: 'POST' });
if (res.ok) {
showToast('Disconnected', `PPTP tunnel ${name} terminated`, 'info');
loadPPTP(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function togglePPTPState(name, enable) {
try {
const res = await fetch(`${API_BASE}/vpn/pptp/clients/${encodeURIComponent(name)}/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: enable })
});
if (res.ok) {
showToast(enable ? 'Enabled' : 'Disabled', `${name} state updated`, 'info');
loadPPTP(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function deletePPTPInstance(name) {
if (!confirm(`Are you sure you want to delete PPTP client ${name}?`)) return;
try {
const res = await fetch(`${API_BASE}/vpn/pptp/clients/${encodeURIComponent(name)}`, { method: 'DELETE' });
if (res.ok) {
showToast('Deleted', `PPTP client ${name} removed`, 'success');
loadPPTP(false);
fetchData(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
// ---------------- Modal & Helper Functions ----------------
function openVlanModal(defaultParent = '') {
const select = document.getElementById('vlan-parent');
select.innerHTML = '';
const allIfaces = state.interfaces || [];
const eligible = allIfaces.filter(i => !i.is_vlan && i.name !== 'lo');
if (eligible.length === 0) {
allIfaces.forEach(i => eligible.push(i));
}
eligible.forEach(i => {
const opt = document.createElement('option');
opt.value = i.name;
opt.innerText = `${i.name} (${i.role || 'Interface'} - ${i.state})`;
if (i.name === defaultParent) opt.selected = true;
select.appendChild(opt);
});
document.getElementById('vlan-id').value = '';
document.getElementById('vlan-name').value = '';
document.getElementById('vlan-ip').value = '';
document.getElementById('vlan-desc').value = '';
autoSuggestVlanName();
document.getElementById('vlan-modal').classList.remove('hidden');
}
function closeVlanModal() {
document.getElementById('vlan-modal').classList.add('hidden');
}
function autoSuggestVlanName() {
const parent = document.getElementById('vlan-parent').value;
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;
const id = parseInt(document.getElementById('vlan-id').value, 10);
const name = document.getElementById('vlan-name').value.trim();
const ip = document.getElementById('vlan-ip').value.trim();
const role = document.getElementById('vlan-role').value;
const desc = document.getElementById('vlan-desc').value.trim();
try {
const res = await fetch(`${API_BASE}/vlans`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
parent_name: parent,
vlan_id: id,
name: name,
ip_address: ip,
role: role,
description: desc,
mtu: 1500,
auto_up: true
})
});
const data = await res.json();
if (res.ok) {
showToast('VLAN Created', `Created ${data.name} (VLAN ${data.vlan_id})`, 'success');
state.expandedParents.add(parent);
closeVlanModal();
await fetchData(true);
if (state.activeTab !== 'interfaces') navigate('interfaces');
} else {
showToast('Creation Failed', data.message || 'Failed to create VLAN', 'error');
}
} catch (err) {
showToast('Error', err.message, 'error');
} finally {
btn.disabled = false;
btn.innerText = 'Create VLAN';
}
}
async function confirmDeleteVlan(name) {
if (!confirm(`Delete VLAN interface ${name}?`)) return;
try {
const res = await fetch(`${API_BASE}/vlans/${encodeURIComponent(name)}`, { method: 'DELETE' });
if (res.ok) {
showToast('VLAN Deleted', `Interface ${name} removed`, 'success');
closeIfaceModal();
fetchData(true);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
function openIfaceModal(name) {
const iface = (state.interfaces || []).find(i => i.name === name);
if (!iface) return;
state.currentModalIface = iface.name;
document.getElementById('modal-iface-name').innerText = iface.name;
document.getElementById('modal-iface-badge').innerText = iface.is_vlan ? `VLAN ${iface.vlan_id}` : iface.type;
document.getElementById('modal-iface-mtu').value = iface.mtu;
document.getElementById('modal-iface-role').value = iface.role || 'LAN';
document.getElementById('modal-iface-desc').value = iface.description || '';
const delBtn = document.getElementById('modal-delete-vlan-btn');
if (iface.is_vlan) {
delBtn.classList.remove('hidden');
} else {
delBtn.classList.add('hidden');
}
renderModalIPs(iface);
document.getElementById('iface-modal').classList.remove('hidden');
}
function renderModalIPs(iface) {
const container = document.getElementById('modal-ip-list');
container.innerHTML = '';
const addrs = [...(iface.ipv4 || []), ...(iface.ipv6 || [])];
if (addrs.length === 0) {
container.innerHTML = `No IP addresses currently bound`;
return;
}
addrs.forEach(addr => {
const row = document.createElement('div');
row.className = 'flex items-center justify-between p-2 rounded bg-slate-900 border border-slate-800';
row.innerHTML = `
${addr}
`;
container.appendChild(row);
});
}
function closeIfaceModal() {
document.getElementById('iface-modal').classList.add('hidden');
state.currentModalIface = null;
}
async function setIfaceState(name, newState) {
await toggleInterfaceState(name, newState);
const updated = (state.interfaces || []).find(i => i.name === name);
if (updated) openIfaceModal(name);
}
async function applyMTU() {
const mtu = parseInt(document.getElementById('modal-iface-mtu').value, 10);
try {
const res = await fetch(`${API_BASE}/interfaces/${encodeURIComponent(state.currentModalIface)}/mtu`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mtu })
});
if (res.ok) {
showToast('MTU Applied', `Set MTU to ${mtu} on ${state.currentModalIface}`, 'success');
fetchData(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function addInterfaceIP() {
const ip = document.getElementById('modal-new-ip').value.trim();
if (!ip) return;
try {
const res = await fetch(`${API_BASE}/interfaces/${encodeURIComponent(state.currentModalIface)}/ip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ip })
});
if (res.ok) {
showToast('IP Added', `Bound ${ip} to ${state.currentModalIface}`, 'success');
document.getElementById('modal-new-ip').value = '';
await fetchData(false);
const iface = (state.interfaces || []).find(i => i.name === state.currentModalIface);
if (iface) renderModalIPs(iface);
} else {
const err = await res.json();
showToast('Error', err.error || err.message || 'Failed to add IP address', 'error');
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function removeInterfaceIP(ip) {
if (!confirm(`Remove IP ${ip}?`)) return;
try {
const res = await fetch(`${API_BASE}/interfaces/${encodeURIComponent(state.currentModalIface)}/ip`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ip })
});
if (res.ok) {
showToast('IP Removed', `Removed ${ip}`, 'success');
await fetchData(false);
const iface = (state.interfaces || []).find(i => i.name === state.currentModalIface);
if (iface) renderModalIPs(iface);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function saveInterfaceMetadata() {
const role = document.getElementById('modal-iface-role').value;
const desc = document.getElementById('modal-iface-desc').value.trim();
try {
const res = await fetch(`${API_BASE}/interfaces/${encodeURIComponent(state.currentModalIface)}/metadata`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role, description: desc })
});
if (res.ok) {
showToast('Saved', 'Metadata updated', 'success');
closeIfaceModal();
fetchData(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
// ======================== MIKROTIK FIREWALL (/ip firewall) ========================
state.fwSubTab = 'filter';
state.routesSubTab = 'routes';
function renderRuleFlag(disabled, hasComment) {
let flags = '';
if (disabled) flags += 'X';
if (hasComment) flags += 'C';
return flags || '-';
}
function renderActionBadge(action) {
const act = (action || '').toLowerCase();
let color = 'bg-slate-800 text-slate-300';
if (act === 'accept') color = 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/30';
else if (act === 'drop' || act === 'reject') color = 'bg-rose-500/20 text-rose-300 border border-rose-500/30';
else if (act === 'masquerade' || act === 'src-nat' || act === 'dst-nat') color = 'bg-amber-500/20 text-amber-300 border border-amber-500/30';
else if (act.startsWith('mark-')) color = 'bg-purple-500/20 text-purple-300 border border-purple-500/30';
else if (act === 'log') color = 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30';
return `${action}`;
}
function switchFWSubTab(tab) {
state.fwSubTab = tab;
const tabs = ['filter', 'nat', 'mangle', 'addrlist', 'geo', 'connections'];
tabs.forEach(t => {
const panel = document.getElementById(`fw-subtab-${t}`);
const btn = document.getElementById(`fw-subtab-btn-${t}`);
if (panel) {
if (t === tab) panel.classList.remove('hidden');
else panel.classList.add('hidden');
}
if (btn) {
if (t === tab) {
btn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-emerald-500 text-white transition flex items-center gap-2 whitespace-nowrap';
} else {
btn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2 whitespace-nowrap';
}
}
});
const addBtn = document.getElementById('fw-primary-add-btn');
if (addBtn) {
if (tab === 'filter') addBtn.innerHTML = '+ Add Filter Rule';
else if (tab === 'nat') addBtn.innerHTML = '+ Add NAT Rule';
else if (tab === 'mangle') addBtn.innerHTML = '+ Add Mangle Rule';
else if (tab === 'addrlist') addBtn.innerHTML = '+ Add Address List';
else if (tab === 'geo') addBtn.innerHTML = '⚙️ Schedule Settings';
else if (tab === 'connections') addBtn.innerHTML = 'Refresh Flows';
}
if (tab === 'geo') loadGeoIP();
if (tab === 'connections') loadConnections();
}
function openCurrentFWModal() {
if (state.fwSubTab === 'filter') openAddFilterModal();
else if (state.fwSubTab === 'nat') openAddNATModal();
else if (state.fwSubTab === 'mangle') openAddMangleModal();
else if (state.fwSubTab === 'addrlist') openAddAddrListModal();
else if (state.fwSubTab === 'geo') openGeoIntervalModal();
else if (state.fwSubTab === 'connections') loadConnections();
}
async function loadFirewall(populateDropdowns = true) {
try {
const [summaryRes, filterRes, natRes, mangleRes, addrlistRes, geoStatusRes, geoCountriesRes] = await Promise.all([
fetch(`${API_BASE}/firewall/summary`),
fetch(`${API_BASE}/firewall/filter`),
fetch(`${API_BASE}/firewall/nat`),
fetch(`${API_BASE}/firewall/mangle`),
fetch(`${API_BASE}/firewall/address-lists`),
fetch(`${API_BASE}/firewall/geoip/status`),
fetch(`${API_BASE}/firewall/geoip/countries`)
]);
if (summaryRes.ok) state.fwSummary = await summaryRes.json();
if (filterRes.ok) state.filterRules = await filterRes.json();
if (natRes.ok) state.natRules = await natRes.json();
if (mangleRes.ok) state.mangleRules = await mangleRes.json();
if (addrlistRes.ok) state.addressLists = await addrlistRes.json();
if (geoStatusRes.ok) state.geoStatus = await geoStatusRes.json();
if (geoCountriesRes.ok) state.geoCountries = await geoCountriesRes.json();
// Badges
const bFilter = document.getElementById('fw-badge-filter');
if (bFilter) bFilter.innerText = (state.filterRules || []).length;
const bNat = document.getElementById('fw-badge-nat');
if (bNat) bNat.innerText = (state.natRules || []).length;
const bMangle = document.getElementById('fw-badge-mangle');
if (bMangle) bMangle.innerText = (state.mangleRules || []).length;
const bAddr = document.getElementById('fw-badge-addrlist');
if (bAddr) bAddr.innerText = (state.addressLists || []).length;
const bGeo = document.getElementById('fw-badge-geo');
if (bGeo) bGeo.innerText = state.geoStatus?.enabled_count || 0;
renderFilterRules();
renderNATRules();
renderMangleRules();
renderAddressLists();
renderGeoCountries();
if (state.fwSubTab === 'connections') {
loadConnections();
}
} catch (err) {
console.error('Failed to load firewall:', err);
}
}
// ----------------- Filter Rules -----------------
function renderFilterRules() {
const chainFilter = document.getElementById('filter-filter-chain')?.value || '';
const tbody = document.getElementById('filter-table-body');
const empty = document.getElementById('filter-empty');
if (!tbody) return;
tbody.innerHTML = '';
const rules = (state.filterRules || []).filter(r => !chainFilter || r.chain === chainFilter);
document.getElementById('filter-count-badge').innerText = `${rules.length} Rules`;
if (rules.length === 0) {
empty?.classList.remove('hidden');
return;
}
empty?.classList.add('hidden');
rules.forEach((r, idx) => {
const tr = document.createElement('tr');
tr.className = `hover:bg-slate-800/40 transition text-xs ${r.disabled ? 'opacity-50' : ''}`;
const ports = [r.src_port ? 's:' + r.src_port : '', r.dst_port ? 'd:' + r.dst_port : ''].filter(Boolean).join(' ');
const ifaces = [r.in_interface ? 'in:' + r.in_interface : '', r.out_interface ? 'out:' + r.out_interface : ''].filter(Boolean).join(' ');
tr.innerHTML = `
${r.order || idx + 1} |
${renderRuleFlag(r.disabled, !!r.comment)} |
${r.chain} |
${formatTarget(r.src_address, r.src_address_list, r.src_geoip)} |
${formatTarget(r.dst_address, r.dst_address_list, r.dst_geoip)} |
${r.protocol || 'all'} |
${ports || '-'} |
${ifaces || '-'} |
${renderActionBadge(r.action)} |
${(r.packets || 0).toLocaleString()}p /
${formatTraffic(r.bytes || 0).replace('/s', '')}
|
${r.comment || '-'} |
|
`;
tbody.appendChild(tr);
});
}
function formatTarget(addr, list, geo) {
const parts = [];
if (addr) parts.push(`${addr}`);
if (list) {
const inv = list.startsWith('!') ? '!' : '';
const clean = list.replace(/^!/, '');
parts.push(`${inv}list:${clean}`);
}
if (geo) {
const inv = geo.startsWith('!') ? '!' : '';
const clean = geo.replace(/^!/, '').replace(/^geo:/, '');
parts.push(`🌍 ${inv}${clean}`);
}
if (parts.length === 0) return '0.0.0.0/0';
return parts.join(' ');
}
function populateInterfaceSelect(selectId, currentVal = '') {
const sel = document.getElementById(selectId);
if (!sel) return;
sel.innerHTML = '';
(state.interfaces || []).forEach(i => {
const opt = document.createElement('option');
opt.value = i.name;
opt.innerText = `${i.name} (${i.role || i.type})`;
if (i.name === currentVal) opt.selected = true;
sel.appendChild(opt);
});
}
function populateAddressListSelect(selectId, currentVal = '') {
const sel = document.getElementById(selectId);
if (!sel) return;
sel.innerHTML = '';
const cleanVal = (currentVal || '').replace(/^!/, '');
const customNames = Array.from(new Set((state.addressLists || []).map(a => a.name))).sort();
customNames.forEach(name => {
const count = (state.addressLists || []).filter(a => a.name === name).length;
const opt = document.createElement('option');
opt.value = name;
opt.innerText = `${name} (${count} entries)`;
if (name === cleanVal) opt.selected = true;
sel.appendChild(opt);
});
if (cleanVal && !sel.querySelector(`option[value="${cleanVal}"]`)) {
const opt = document.createElement('option');
opt.value = cleanVal;
opt.innerText = cleanVal;
opt.selected = true;
sel.appendChild(opt);
}
}
function populateGeoIPSelect(selectId, currentVal = '') {
const sel = document.getElementById(selectId);
if (!sel) return;
sel.innerHTML = '';
const cleanVal = (currentVal || '').replace(/^!/, '').replace(/^geo:/, '').toUpperCase();
// Presets optgroup
const grpPresets = document.createElement('optgroup');
grpPresets.label = '⚡ Regional Presets';
const presets = [
{ code: 'EU', label: '🇪🇺 European Union (EU - 27 countries)' },
{ code: 'BALKANS', label: '🏛️ Balkans (GR, CY, BG, RO, AL, etc.)' },
{ code: 'BLOCKLIST', label: '🛡️ High-Risk Blocklist (CN, RU, IR, KP, BY)' }
];
presets.forEach(p => {
const opt = document.createElement('option');
opt.value = p.code;
opt.innerText = p.label;
if (p.code === cleanVal) opt.selected = true;
grpPresets.appendChild(opt);
});
sel.appendChild(grpPresets);
// All Countries optgroup
const grpCountries = document.createElement('optgroup');
grpCountries.label = '🌍 All Countries';
const countries = (state.geoCountries || []).slice().sort((a, b) => a.name.localeCompare(b.name));
countries.forEach(c => {
const opt = document.createElement('option');
opt.value = c.code;
const subnetsInfo = c.subnet_count ? ` [${c.subnet_count.toLocaleString()} subnets]` : '';
opt.innerText = `${c.name} (${c.code})${subnetsInfo}`;
if (c.code === cleanVal) opt.selected = true;
grpCountries.appendChild(opt);
});
sel.appendChild(grpCountries);
}
function openAddFilterModal() {
document.getElementById('filter-modal-title').innerText = 'Add Firewall Filter Rule';
document.getElementById('filter-modal-id').value = '';
document.getElementById('filter-modal-chain').value = 'forward';
document.getElementById('filter-modal-action').value = 'accept';
document.getElementById('filter-modal-proto').value = 'all';
document.getElementById('filter-modal-src').value = '';
document.getElementById('filter-modal-dst').value = '';
document.getElementById('filter-modal-sport').value = '';
document.getElementById('filter-modal-dport').value = '';
document.getElementById('filter-modal-connmark').value = '';
document.getElementById('filter-modal-comment').value = '';
document.getElementById('filter-modal-disabled').checked = false;
document.getElementById('filter-modal-cs-established').checked = false;
document.getElementById('filter-modal-cs-related').checked = false;
document.getElementById('filter-modal-cs-new').checked = false;
document.getElementById('filter-modal-cs-invalid').checked = false;
populateInterfaceSelect('filter-modal-iniface');
populateInterfaceSelect('filter-modal-outiface');
if (document.getElementById('filter-modal-srclist-inv')) document.getElementById('filter-modal-srclist-inv').checked = false;
if (document.getElementById('filter-modal-dstlist-inv')) document.getElementById('filter-modal-dstlist-inv').checked = false;
populateAddressListSelect('filter-modal-srclist');
populateAddressListSelect('filter-modal-dstlist');
if (document.getElementById('filter-modal-srcgeoip-inv')) document.getElementById('filter-modal-srcgeoip-inv').checked = false;
if (document.getElementById('filter-modal-dstgeoip-inv')) document.getElementById('filter-modal-dstgeoip-inv').checked = false;
populateGeoIPSelect('filter-modal-srcgeoip');
populateGeoIPSelect('filter-modal-dstgeoip');
toggleFilterActionFields();
document.getElementById('filter-modal').classList.remove('hidden');
}
function openEditFilterModal(id) {
const r = (state.filterRules || []).find(rule => rule.id === id);
if (!r) return;
document.getElementById('filter-modal-title').innerText = `Edit Filter Rule (#${r.order})`;
document.getElementById('filter-modal-id').value = r.id;
document.getElementById('filter-modal-chain').value = r.chain || 'forward';
document.getElementById('filter-modal-action').value = r.action || 'accept';
document.getElementById('filter-modal-proto').value = r.protocol || 'all';
document.getElementById('filter-modal-src').value = r.src_address || '';
document.getElementById('filter-modal-dst').value = r.dst_address || '';
document.getElementById('filter-modal-sport').value = r.src_port || '';
document.getElementById('filter-modal-dport').value = r.dst_port || '';
document.getElementById('filter-modal-connmark').value = r.connection_mark || '';
document.getElementById('filter-modal-comment').value = r.comment || '';
document.getElementById('filter-modal-disabled').checked = !!r.disabled;
const states = r.connection_state || [];
document.getElementById('filter-modal-cs-established').checked = states.includes('established');
document.getElementById('filter-modal-cs-related').checked = states.includes('related');
document.getElementById('filter-modal-cs-new').checked = states.includes('new');
document.getElementById('filter-modal-cs-invalid').checked = states.includes('invalid');
populateInterfaceSelect('filter-modal-iniface', r.in_interface);
populateInterfaceSelect('filter-modal-outiface', r.out_interface);
const srcInv = (r.src_address_list || '').startsWith('!');
const dstInv = (r.dst_address_list || '').startsWith('!');
if (document.getElementById('filter-modal-srclist-inv')) document.getElementById('filter-modal-srclist-inv').checked = srcInv;
if (document.getElementById('filter-modal-dstlist-inv')) document.getElementById('filter-modal-dstlist-inv').checked = dstInv;
populateAddressListSelect('filter-modal-srclist', r.src_address_list || '');
populateAddressListSelect('filter-modal-dstlist', r.dst_address_list || '');
const srcGeoInv = (r.src_geoip || '').startsWith('!');
const dstGeoInv = (r.dst_geoip || '').startsWith('!');
if (document.getElementById('filter-modal-srcgeoip-inv')) document.getElementById('filter-modal-srcgeoip-inv').checked = srcGeoInv;
if (document.getElementById('filter-modal-dstgeoip-inv')) document.getElementById('filter-modal-dstgeoip-inv').checked = dstGeoInv;
populateGeoIPSelect('filter-modal-srcgeoip', r.src_geoip || '');
populateGeoIPSelect('filter-modal-dstgeoip', r.dst_geoip || '');
toggleFilterActionFields();
document.getElementById('filter-modal').classList.remove('hidden');
}
function closeFilterModal() {
document.getElementById('filter-modal').classList.add('hidden');
}
function toggleFilterActionFields() {
const act = document.getElementById('filter-modal-action').value;
const rejectBox = document.getElementById('filter-action-reject-box');
if (rejectBox) {
if (act === 'reject') rejectBox.classList.remove('hidden');
else rejectBox.classList.add('hidden');
}
}
async function submitSaveFilterRule(e) {
e.preventDefault();
const id = document.getElementById('filter-modal-id').value;
const chain = document.getElementById('filter-modal-chain').value;
const action = document.getElementById('filter-modal-action').value;
const proto = document.getElementById('filter-modal-proto').value;
const src = document.getElementById('filter-modal-src').value.trim();
const dst = document.getElementById('filter-modal-dst').value.trim();
const sport = document.getElementById('filter-modal-sport').value.trim();
const dport = document.getElementById('filter-modal-dport').value.trim();
const inIface = document.getElementById('filter-modal-iniface').value;
const outIface = document.getElementById('filter-modal-outiface').value;
const connmark = document.getElementById('filter-modal-connmark').value.trim();
let srclist = document.getElementById('filter-modal-srclist')?.value || '';
if (srclist && document.getElementById('filter-modal-srclist-inv')?.checked) {
srclist = '!' + srclist.replace(/^!/, '');
}
let dstlist = document.getElementById('filter-modal-dstlist')?.value || '';
if (dstlist && document.getElementById('filter-modal-dstlist-inv')?.checked) {
dstlist = '!' + dstlist.replace(/^!/, '');
}
let srcgeoip = document.getElementById('filter-modal-srcgeoip')?.value || '';
if (srcgeoip && document.getElementById('filter-modal-srcgeoip-inv')?.checked) {
srcgeoip = '!' + srcgeoip.replace(/^!/, '');
}
let dstgeoip = document.getElementById('filter-modal-dstgeoip')?.value || '';
if (dstgeoip && document.getElementById('filter-modal-dstgeoip-inv')?.checked) {
dstgeoip = '!' + dstgeoip.replace(/^!/, '');
}
const comment = document.getElementById('filter-modal-comment').value.trim();
const disabled = document.getElementById('filter-modal-disabled').checked;
const rejectWith = document.getElementById('filter-modal-rejectwith')?.value || '';
const states = [];
if (document.getElementById('filter-modal-cs-established').checked) states.push('established');
if (document.getElementById('filter-modal-cs-related').checked) states.push('related');
if (document.getElementById('filter-modal-cs-new').checked) states.push('new');
if (document.getElementById('filter-modal-cs-invalid').checked) states.push('invalid');
try {
const res = await fetch(`${API_BASE}/firewall/filter`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id,
chain,
action,
reject_with: rejectWith,
protocol: proto,
src_address: src,
dst_address: dst,
src_port: sport,
dst_port: dport,
in_interface: inIface,
out_interface: outIface,
connection_state: states,
connection_mark: connmark,
src_address_list: srclist,
dst_address_list: dstlist,
src_geoip: srcgeoip,
dst_geoip: dstgeoip,
comment,
disabled
})
});
if (res.ok) {
showToast('Filter Rule Saved', 'Rule applied successfully to firewall', 'success');
closeFilterModal();
loadFirewall(false);
} else {
const err = await res.json();
showToast('Error', err.message, 'error');
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function moveFilterRule(id, dir) {
try {
const res = await fetch(`${API_BASE}/firewall/filter/${encodeURIComponent(id)}/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ direction: dir })
});
if (res.ok) loadFirewall(false);
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function toggleFilterRuleState(id, disabled) {
try {
const res = await fetch(`${API_BASE}/firewall/filter/${encodeURIComponent(id)}/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ disabled })
});
if (res.ok) loadFirewall(false);
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function deleteFilterRule(id) {
if (!confirm('Are you sure you want to delete this filter rule?')) return;
try {
const res = await fetch(`${API_BASE}/firewall/filter/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (res.ok) {
showToast('Deleted', 'Filter rule removed', 'success');
loadFirewall(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
// ----------------- NAT Rules -----------------
function renderNATRules() {
const chainFilter = document.getElementById('nat-filter-chain')?.value || '';
const tbody = document.getElementById('nat-table-body');
const empty = document.getElementById('nat-empty');
if (!tbody) return;
tbody.innerHTML = '';
const rules = (state.natRules || []).filter(r => !chainFilter || r.chain === chainFilter);
document.getElementById('nat-count-badge').innerText = `${rules.length} Rules`;
if (rules.length === 0) {
empty?.classList.remove('hidden');
return;
}
empty?.classList.add('hidden');
rules.forEach((r, idx) => {
const tr = document.createElement('tr');
tr.className = `hover:bg-slate-800/40 transition text-xs ${r.disabled ? 'opacity-50' : ''}`;
const toTarget = [r.to_addresses, r.to_ports].filter(Boolean).join(':');
const ifaces = [r.out_interface ? 'out:' + r.out_interface : '', r.in_interface ? 'in:' + r.in_interface : ''].filter(Boolean).join(' ');
tr.innerHTML = `
${r.order || idx + 1} |
${renderRuleFlag(r.disabled, !!r.comment)} |
${r.chain} |
${formatTarget(r.src_address, r.src_address_list, r.src_geoip)} |
${formatTarget(r.dst_address, r.dst_address_list, r.dst_geoip)} |
${r.protocol || 'all'}${r.dst_port ? ':' + r.dst_port : ''} |
${ifaces || '-'} |
${renderActionBadge(r.action)} |
${toTarget || '-'} |
${(r.packets || 0).toLocaleString()}p /
${formatTraffic(r.bytes || 0).replace('/s', '')}
|
${r.comment || '-'} |
|
`;
tbody.appendChild(tr);
});
}
function openAddNATModal() {
document.getElementById('nat-modal-title').innerText = 'Add NAT Rule';
document.getElementById('nat-modal-id').value = '';
document.getElementById('nat-modal-chain').value = 'srcnat';
document.getElementById('nat-modal-action').value = 'masquerade';
document.getElementById('nat-modal-toaddr').value = '';
document.getElementById('nat-modal-toport').value = '';
document.getElementById('nat-modal-proto').value = 'all';
document.getElementById('nat-modal-src').value = '';
document.getElementById('nat-modal-dst').value = '';
document.getElementById('nat-modal-dport').value = '';
document.getElementById('nat-modal-comment').value = '';
document.getElementById('nat-modal-disabled').checked = false;
populateInterfaceSelect('nat-modal-iniface');
populateInterfaceSelect('nat-modal-outiface');
populateAddressListSelect('nat-modal-srclist');
populateAddressListSelect('nat-modal-dstlist');
const srcInvEl = document.getElementById('nat-modal-srclist-inv');
if (srcInvEl) srcInvEl.checked = false;
const dstInvEl = document.getElementById('nat-modal-dstlist-inv');
if (dstInvEl) dstInvEl.checked = false;
populateGeoIPSelect('nat-modal-srcgeoip');
populateGeoIPSelect('nat-modal-dstgeoip');
const srcGeoInvEl = document.getElementById('nat-modal-srcgeoip-inv');
if (srcGeoInvEl) srcGeoInvEl.checked = false;
const dstGeoInvEl = document.getElementById('nat-modal-dstgeoip-inv');
if (dstGeoInvEl) dstGeoInvEl.checked = false;
toggleNATActionFields();
document.getElementById('nat-modal').classList.remove('hidden');
}
function openEditNATModal(id) {
const r = (state.natRules || []).find(rule => rule.id === id);
if (!r) return;
document.getElementById('nat-modal-title').innerText = `Edit NAT Rule (#${r.order})`;
document.getElementById('nat-modal-id').value = r.id;
document.getElementById('nat-modal-chain').value = r.chain || 'srcnat';
document.getElementById('nat-modal-action').value = r.action || 'masquerade';
document.getElementById('nat-modal-toaddr').value = r.to_addresses || '';
document.getElementById('nat-modal-toport').value = r.to_ports || '';
document.getElementById('nat-modal-proto').value = r.protocol || 'all';
document.getElementById('nat-modal-src').value = r.src_address || '';
document.getElementById('nat-modal-dst').value = r.dst_address || '';
document.getElementById('nat-modal-dport').value = r.dst_port || '';
document.getElementById('nat-modal-comment').value = r.comment || '';
document.getElementById('nat-modal-disabled').checked = !!r.disabled;
populateInterfaceSelect('nat-modal-iniface', r.in_interface);
populateInterfaceSelect('nat-modal-outiface', r.out_interface);
const srcInv = (r.src_address_list || '').startsWith('!');
const dstInv = (r.dst_address_list || '').startsWith('!');
const srcInvEl = document.getElementById('nat-modal-srclist-inv');
if (srcInvEl) srcInvEl.checked = srcInv;
const dstInvEl = document.getElementById('nat-modal-dstlist-inv');
if (dstInvEl) dstInvEl.checked = dstInv;
populateAddressListSelect('nat-modal-srclist', r.src_address_list || '');
populateAddressListSelect('nat-modal-dstlist', r.dst_address_list || '');
const srcGeoInv = (r.src_geoip || '').startsWith('!');
const dstGeoInv = (r.dst_geoip || '').startsWith('!');
const srcGeoInvEl = document.getElementById('nat-modal-srcgeoip-inv');
if (srcGeoInvEl) srcGeoInvEl.checked = srcGeoInv;
const dstGeoInvEl = document.getElementById('nat-modal-dstgeoip-inv');
if (dstGeoInvEl) dstGeoInvEl.checked = dstGeoInv;
populateGeoIPSelect('nat-modal-srcgeoip', r.src_geoip || '');
populateGeoIPSelect('nat-modal-dstgeoip', r.dst_geoip || '');
toggleNATActionFields();
document.getElementById('nat-modal').classList.remove('hidden');
}
function closeNATModal() {
document.getElementById('nat-modal').classList.add('hidden');
}
function toggleNATChainFields() {
const ch = document.getElementById('nat-modal-chain').value;
const actSel = document.getElementById('nat-modal-action');
if (ch === 'srcnat' && actSel.value === 'dst-nat') {
actSel.value = 'masquerade';
} else if (ch === 'dstnat' && actSel.value === 'masquerade') {
actSel.value = 'dst-nat';
}
toggleNATActionFields();
}
function toggleNATActionFields() {
const act = document.getElementById('nat-modal-action').value;
const toBox = document.getElementById('nat-action-to-box');
if (toBox) {
if (act === 'masquerade' || act === 'accept') toBox.classList.add('hidden');
else toBox.classList.remove('hidden');
}
}
async function submitSaveNATRule(e) {
e.preventDefault();
const id = document.getElementById('nat-modal-id').value;
const chain = document.getElementById('nat-modal-chain').value;
const action = document.getElementById('nat-modal-action').value;
const toaddr = document.getElementById('nat-modal-toaddr').value.trim();
const toport = document.getElementById('nat-modal-toport').value.trim();
const proto = document.getElementById('nat-modal-proto').value;
const src = document.getElementById('nat-modal-src').value.trim();
const dst = document.getElementById('nat-modal-dst').value.trim();
const dport = document.getElementById('nat-modal-dport').value.trim();
const inIface = document.getElementById('nat-modal-iniface').value;
const outIface = document.getElementById('nat-modal-outiface').value;
let srclist = document.getElementById('nat-modal-srclist')?.value || '';
if (srclist && document.getElementById('nat-modal-srclist-inv')?.checked) {
srclist = '!' + srclist.replace(/^!/, '');
}
let dstlist = document.getElementById('nat-modal-dstlist')?.value || '';
if (dstlist && document.getElementById('nat-modal-dstlist-inv')?.checked) {
dstlist = '!' + dstlist.replace(/^!/, '');
}
let srcgeoip = document.getElementById('nat-modal-srcgeoip')?.value || '';
if (srcgeoip && document.getElementById('nat-modal-srcgeoip-inv')?.checked) {
srcgeoip = '!' + srcgeoip.replace(/^!/, '');
}
let dstgeoip = document.getElementById('nat-modal-dstgeoip')?.value || '';
if (dstgeoip && document.getElementById('nat-modal-dstgeoip-inv')?.checked) {
dstgeoip = '!' + dstgeoip.replace(/^!/, '');
}
const comment = document.getElementById('nat-modal-comment').value.trim();
const disabled = document.getElementById('nat-modal-disabled').checked;
try {
const res = await fetch(`${API_BASE}/firewall/nat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id,
chain,
action,
to_addresses: toaddr,
to_ports: toport,
protocol: proto,
src_address: src,
dst_address: dst,
dst_port: dport,
in_interface: inIface,
out_interface: outIface,
src_address_list: srclist,
dst_address_list: dstlist,
src_geoip: srcgeoip,
dst_geoip: dstgeoip,
comment,
disabled
})
});
if (res.ok) {
showToast('NAT Rule Saved', 'NAT mapping updated', 'success');
closeNATModal();
loadFirewall(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function moveNATRule(id, dir) {
try {
const res = await fetch(`${API_BASE}/firewall/nat/${encodeURIComponent(id)}/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ direction: dir })
});
if (res.ok) loadFirewall(false);
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function toggleNATRuleState(id, disabled) {
try {
const res = await fetch(`${API_BASE}/firewall/nat/${encodeURIComponent(id)}/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ disabled })
});
if (res.ok) loadFirewall(false);
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function deleteNATRule(id) {
if (!confirm('Are you sure you want to delete this NAT rule?')) return;
try {
const res = await fetch(`${API_BASE}/firewall/nat/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (res.ok) {
showToast('Deleted', 'NAT rule removed', 'success');
loadFirewall(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
// ----------------- Mangle Rules -----------------
function renderMangleRules() {
const chainFilter = document.getElementById('mangle-filter-chain')?.value || '';
const tbody = document.getElementById('mangle-table-body');
const empty = document.getElementById('mangle-empty');
if (!tbody) return;
tbody.innerHTML = '';
const rules = (state.mangleRules || []).filter(r => !chainFilter || r.chain === chainFilter);
document.getElementById('mangle-count-badge').innerText = `${rules.length} Rules`;
if (rules.length === 0) {
empty?.classList.remove('hidden');
return;
}
empty?.classList.add('hidden');
rules.forEach((r, idx) => {
const tr = document.createElement('tr');
tr.className = `hover:bg-slate-800/40 transition text-xs ${r.disabled ? 'opacity-50' : ''}`;
const markVal = r.new_connection_mark || r.new_routing_mark || r.new_packet_mark || r.new_mss || (r.new_dscp ? 'DSCP:' + r.new_dscp : '');
tr.innerHTML = `
${r.order || idx + 1} |
${renderRuleFlag(r.disabled, !!r.comment)} |
${r.chain} |
${formatTarget(r.src_address, r.src_address_list, r.src_geoip)} |
${formatTarget(r.dst_address, r.dst_address_list, r.dst_geoip)} |
${renderActionBadge(r.action)} |
${markVal || '-'} |
${r.passthrough ? 'yes' : 'no'} |
${(r.packets || 0).toLocaleString()}p /
${formatTraffic(r.bytes || 0).replace('/s', '')}
|
${r.comment || '-'} |
|
`;
tbody.appendChild(tr);
});
}
function openAddMangleModal() {
document.getElementById('mangle-modal-title').innerText = 'Add Mangle Rule';
document.getElementById('mangle-modal-id').value = '';
document.getElementById('mangle-modal-chain').value = 'prerouting';
document.getElementById('mangle-modal-action').value = 'mark-connection';
document.getElementById('mangle-modal-newmark').value = '';
document.getElementById('mangle-modal-passthrough').checked = true;
document.getElementById('mangle-modal-src').value = '';
document.getElementById('mangle-modal-dst').value = '';
document.getElementById('mangle-modal-connmark').value = '';
document.getElementById('mangle-modal-comment').value = '';
document.getElementById('mangle-modal-disabled').checked = false;
populateInterfaceSelect('mangle-modal-iniface');
populateAddressListSelect('mangle-modal-srclist');
populateAddressListSelect('mangle-modal-dstlist');
const srcInvEl = document.getElementById('mangle-modal-srclist-inv');
if (srcInvEl) srcInvEl.checked = false;
const dstInvEl = document.getElementById('mangle-modal-dstlist-inv');
if (dstInvEl) dstInvEl.checked = false;
populateGeoIPSelect('mangle-modal-srcgeoip');
populateGeoIPSelect('mangle-modal-dstgeoip');
const srcGeoInvEl = document.getElementById('mangle-modal-srcgeoip-inv');
if (srcGeoInvEl) srcGeoInvEl.checked = false;
const dstGeoInvEl = document.getElementById('mangle-modal-dstgeoip-inv');
if (dstGeoInvEl) dstGeoInvEl.checked = false;
document.getElementById('mangle-modal').classList.remove('hidden');
}
function openEditMangleModal(id) {
const r = (state.mangleRules || []).find(rule => rule.id === id);
if (!r) return;
document.getElementById('mangle-modal-title').innerText = `Edit Mangle Rule (#${r.order})`;
document.getElementById('mangle-modal-id').value = r.id;
document.getElementById('mangle-modal-chain').value = r.chain || 'prerouting';
document.getElementById('mangle-modal-action').value = r.action || 'mark-connection';
document.getElementById('mangle-modal-newmark').value = r.new_connection_mark || r.new_routing_mark || r.new_packet_mark || r.new_mss || '';
document.getElementById('mangle-modal-passthrough').checked = r.passthrough ?? true;
document.getElementById('mangle-modal-src').value = r.src_address || '';
document.getElementById('mangle-modal-dst').value = r.dst_address || '';
document.getElementById('mangle-modal-connmark').value = r.connection_mark || '';
document.getElementById('mangle-modal-comment').value = r.comment || '';
document.getElementById('mangle-modal-disabled').checked = !!r.disabled;
populateInterfaceSelect('mangle-modal-iniface', r.in_interface);
const srcInv = (r.src_address_list || '').startsWith('!');
const dstInv = (r.dst_address_list || '').startsWith('!');
const srcInvEl = document.getElementById('mangle-modal-srclist-inv');
if (srcInvEl) srcInvEl.checked = srcInv;
const dstInvEl = document.getElementById('mangle-modal-dstlist-inv');
if (dstInvEl) dstInvEl.checked = dstInv;
populateAddressListSelect('mangle-modal-srclist', r.src_address_list || '');
populateAddressListSelect('mangle-modal-dstlist', r.dst_address_list || '');
const srcGeoInv = (r.src_geoip || '').startsWith('!');
const dstGeoInv = (r.dst_geoip || '').startsWith('!');
const srcGeoInvEl = document.getElementById('mangle-modal-srcgeoip-inv');
if (srcGeoInvEl) srcGeoInvEl.checked = srcGeoInv;
const dstGeoInvEl = document.getElementById('mangle-modal-dstgeoip-inv');
if (dstGeoInvEl) dstGeoInvEl.checked = dstGeoInv;
populateGeoIPSelect('mangle-modal-srcgeoip', r.src_geoip || '');
populateGeoIPSelect('mangle-modal-dstgeoip', r.dst_geoip || '');
document.getElementById('mangle-modal').classList.remove('hidden');
}
function closeMangleModal() {
document.getElementById('mangle-modal').classList.add('hidden');
}
function toggleMangleActionFields() {
// Handled smoothly by single dynamic input
}
async function submitSaveMangleRule(e) {
e.preventDefault();
const id = document.getElementById('mangle-modal-id').value;
const chain = document.getElementById('mangle-modal-chain').value;
const action = document.getElementById('mangle-modal-action').value;
const newmark = document.getElementById('mangle-modal-newmark').value.trim();
const passthrough = document.getElementById('mangle-modal-passthrough').checked;
const src = document.getElementById('mangle-modal-src').value.trim();
const dst = document.getElementById('mangle-modal-dst').value.trim();
const inIface = document.getElementById('mangle-modal-iniface').value;
let srclist = document.getElementById('mangle-modal-srclist')?.value || '';
if (srclist && document.getElementById('mangle-modal-srclist-inv')?.checked) {
srclist = '!' + srclist.replace(/^!/, '');
}
let dstlist = document.getElementById('mangle-modal-dstlist')?.value || '';
if (dstlist && document.getElementById('mangle-modal-dstlist-inv')?.checked) {
dstlist = '!' + dstlist.replace(/^!/, '');
}
let srcgeoip = document.getElementById('mangle-modal-srcgeoip')?.value || '';
if (srcgeoip && document.getElementById('mangle-modal-srcgeoip-inv')?.checked) {
srcgeoip = '!' + srcgeoip.replace(/^!/, '');
}
let dstgeoip = document.getElementById('mangle-modal-dstgeoip')?.value || '';
if (dstgeoip && document.getElementById('mangle-modal-dstgeoip-inv')?.checked) {
dstgeoip = '!' + dstgeoip.replace(/^!/, '');
}
const connmark = document.getElementById('mangle-modal-connmark').value.trim();
const comment = document.getElementById('mangle-modal-comment').value.trim();
const disabled = document.getElementById('mangle-modal-disabled').checked;
const payload = {
id,
chain,
action,
passthrough,
src_address: src,
dst_address: dst,
in_interface: inIface,
src_address_list: srclist,
dst_address_list: dstlist,
src_geoip: srcgeoip,
dst_geoip: dstgeoip,
connection_mark: connmark,
comment,
disabled
};
if (action === 'mark-connection') payload.new_connection_mark = newmark;
else if (action === 'mark-routing') payload.new_routing_mark = newmark;
else if (action === 'mark-packet') payload.new_packet_mark = newmark;
else if (action === 'change-mss') payload.new_mss = newmark || 'clamp-to-pmtu';
try {
const res = await fetch(`${API_BASE}/firewall/mangle`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
showToast('Mangle Rule Saved', 'Marking rule applied', 'success');
closeMangleModal();
loadFirewall(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function moveMangleRule(id, dir) {
try {
const res = await fetch(`${API_BASE}/firewall/mangle/${encodeURIComponent(id)}/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ direction: dir })
});
if (res.ok) loadFirewall(false);
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function toggleMangleRuleState(id, disabled) {
try {
const res = await fetch(`${API_BASE}/firewall/mangle/${encodeURIComponent(id)}/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ disabled })
});
if (res.ok) loadFirewall(false);
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function deleteMangleRule(id) {
if (!confirm('Are you sure you want to delete this mangle rule?')) return;
try {
const res = await fetch(`${API_BASE}/firewall/mangle/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (res.ok) {
showToast('Deleted', 'Mangle rule removed', 'success');
loadFirewall(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
// ----------------- Address Lists -----------------
function renderAddressLists() {
const filterList = document.getElementById('addrlist-filter-name')?.value || '';
const tbody = document.getElementById('addrlist-table-body');
const empty = document.getElementById('addrlist-empty');
if (!tbody) return;
tbody.innerHTML = '';
const entries = (state.addressLists || []).filter(e => !filterList || e.name === filterList);
document.getElementById('addrlist-count-badge').innerText = `${entries.length} Entries`;
// Update filter select
const sel = document.getElementById('addrlist-filter-name');
if (sel) {
const cur = sel.value;
sel.innerHTML = '';
const set = new Set((state.addressLists || []).map(a => a.name));
set.forEach(n => {
const opt = document.createElement('option');
opt.value = n;
opt.innerText = n;
if (n === cur) opt.selected = true;
sel.appendChild(opt);
});
}
if (entries.length === 0) {
empty?.classList.remove('hidden');
return;
}
empty?.classList.add('hidden');
entries.forEach((e, idx) => {
const tr = document.createElement('tr');
tr.className = `hover:bg-slate-800/40 transition text-xs cursor-pointer ${e.disabled ? 'opacity-50' : ''}`;
tr.setAttribute('ondblclick', `openEditAddrListModal('${e.id}')`);
tr.innerHTML = `
${idx + 1} |
${e.disabled ? 'X' : '✓'} |
${e.name} |
${e.address} |
${e.comment || '-'} |
|
`;
tbody.appendChild(tr);
});
}
function openAddAddrListModal() {
document.getElementById('addrlist-modal-title').innerText = 'Add Address List Entry';
document.getElementById('addrlist-modal-id').value = '';
document.getElementById('addrlist-modal-name').value = '';
document.getElementById('addrlist-modal-addr').value = '';
document.getElementById('addrlist-modal-comment').value = '';
document.getElementById('addrlist-modal-disabled').checked = false;
const submitBtn = document.getElementById('addrlist-modal-submit-btn');
if (submitBtn) submitBtn.innerText = 'Add to List';
document.getElementById('addrlist-modal').classList.remove('hidden');
}
function openEditAddrListModal(id) {
const e = (state.addressLists || []).find(item => item.id === id);
if (!e) return;
document.getElementById('addrlist-modal-title').innerText = `Edit Address List Entry: ${e.name}`;
document.getElementById('addrlist-modal-id').value = e.id;
document.getElementById('addrlist-modal-name').value = e.name;
document.getElementById('addrlist-modal-addr').value = e.address;
document.getElementById('addrlist-modal-comment').value = e.comment || '';
document.getElementById('addrlist-modal-disabled').checked = !!e.disabled;
const submitBtn = document.getElementById('addrlist-modal-submit-btn');
if (submitBtn) submitBtn.innerText = 'Save Changes';
document.getElementById('addrlist-modal').classList.remove('hidden');
}
function closeAddrListModal() {
document.getElementById('addrlist-modal').classList.add('hidden');
}
async function submitSaveAddrList(e) {
e.preventDefault();
const id = document.getElementById('addrlist-modal-id').value;
const name = document.getElementById('addrlist-modal-name').value.trim();
const addr = document.getElementById('addrlist-modal-addr').value.trim();
const comment = document.getElementById('addrlist-modal-comment').value.trim();
const disabled = document.getElementById('addrlist-modal-disabled').checked;
try {
const res = await fetch(`${API_BASE}/firewall/address-lists`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, name, address: addr, comment, disabled })
});
if (res.ok) {
showToast('Address List Item Saved', `Saved ${addr} in ${name}`, 'success');
closeAddrListModal();
loadFirewall(false);
} else {
const err = await res.json();
showToast('Error', err.message || 'Failed to save address list', 'error');
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function toggleAddrListState(id, disabled) {
try {
const res = await fetch(`${API_BASE}/firewall/address-lists/${encodeURIComponent(id)}/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ disabled })
});
if (res.ok) {
loadFirewall(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function deleteAddrList(id) {
if (!confirm('Are you sure you want to delete this address list item?')) return;
try {
const res = await fetch(`${API_BASE}/firewall/address-lists/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (res.ok) {
showToast('Deleted', 'Address list item removed', 'info');
loadFirewall(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
// ----------------- Geo-Location IP Management -----------------
async function loadGeoIP() {
try {
const [statusRes, countriesRes] = await Promise.all([
fetch(`${API_BASE}/firewall/geoip/status`),
fetch(`${API_BASE}/firewall/geoip/countries`)
]);
if (statusRes.ok) state.geoStatus = await statusRes.json();
if (countriesRes.ok) state.geoCountries = await countriesRes.json();
updateGeoHeader();
renderGeoCountries();
} catch (err) {
console.error('Failed to load GeoIP:', err);
}
}
function getGeoUsageMap() {
const usage = {};
const checkRule = (r, type) => {
['src_geoip', 'dst_geoip'].forEach(field => {
let val = (r && r[field]) || '';
let code = val.replace(/^!/, '').replace(/^geo:/, '').replace(/^geo_/, '').toUpperCase().trim();
if (code) {
if (!usage[code]) usage[code] = [];
const label = `${type} #${r.priority !== undefined ? r.priority : (r.id || '')}`;
if (!usage[code].includes(label)) usage[code].push(label);
}
});
};
(state.filterRules || []).forEach(r => checkRule(r, 'Filter'));
(state.natRules || []).forEach(r => checkRule(r, 'NAT'));
(state.mangleRules || []).forEach(r => checkRule(r, 'Mangle'));
return usage;
}
function updateGeoHeader() {
const s = state.geoStatus || {};
const statusBadge = document.getElementById('geo-status-badge');
if (statusBadge) {
statusBadge.innerText = s.auto_update ? 'Auto-Update ON' : 'Auto-Update OFF';
statusBadge.className = s.auto_update
? 'px-2 py-0.5 rounded text-[10px] font-mono font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30'
: 'px-2 py-0.5 rounded text-[10px] font-mono font-bold bg-slate-800 text-slate-400';
}
const intervalLabel = document.getElementById('geo-interval-label');
if (intervalLabel) {
intervalLabel.innerText = `Every ${s.interval_hours || 24}h`;
}
const usageMap = getGeoUsageMap();
const inUseCount = Object.keys(usageMap).length;
const activeCountEl = document.getElementById('geo-stat-active-countries');
if (activeCountEl) activeCountEl.innerText = inUseCount.toLocaleString();
const totalSubnetsEl = document.getElementById('geo-stat-total-subnets');
if (totalSubnetsEl) totalSubnetsEl.innerText = (s.total_subnets || 0).toLocaleString();
const lastSyncEl = document.getElementById('geo-stat-last-sync');
if (lastSyncEl) {
if (s.last_updated && !s.last_updated.startsWith('0001')) {
const d = new Date(s.last_updated);
lastSyncEl.innerText = d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else {
lastSyncEl.innerText = 'Never (Sync Pending)';
}
}
const nextSyncEl = document.getElementById('geo-stat-next-sync');
if (nextSyncEl) {
if (s.next_update && !s.next_update.startsWith('0001')) {
const d = new Date(s.next_update);
nextSyncEl.innerText = d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else {
nextSyncEl.innerText = '-';
}
}
const badge = document.getElementById('fw-badge-geo');
if (badge) badge.innerText = inUseCount || (state.geoCountries || []).filter(c => c.subnet_count > 0).length || 0;
}
function renderGeoCountries() {
const tbody = document.getElementById('geo-table-body');
const empty = document.getElementById('geo-empty');
if (!tbody) return;
tbody.innerHTML = '';
const q = (document.getElementById('geo-search-input')?.value || '').trim().toLowerCase();
const continent = document.getElementById('geo-continent-filter')?.value || '';
const inUseOnly = document.getElementById('geo-filter-inuse-only')?.checked;
const cachedOnly = document.getElementById('geo-filter-cached-only')?.checked;
const usageMap = getGeoUsageMap();
const countries = (state.geoCountries || []).filter(c => {
const isUsed = !!usageMap[c.code];
if (inUseOnly && !isUsed) return false;
if (cachedOnly && (!c.subnet_count || c.subnet_count === 0)) return false;
if (continent && c.continent !== continent) return false;
if (q) {
const matchCode = c.code.toLowerCase().includes(q);
const matchName = c.name.toLowerCase().includes(q);
if (!matchCode && !matchName) return false;
}
return true;
});
if (countries.length === 0) {
empty?.classList.remove('hidden');
return;
}
empty?.classList.add('hidden');
countries.forEach(c => {
const tr = document.createElement('tr');
const isUsed = !!usageMap[c.code];
const rulesUsing = usageMap[c.code] || [];
tr.className = `hover:bg-slate-800/40 transition text-xs ${isUsed ? 'bg-emerald-950/20' : ''}`;
let statusHtml = '';
if (isUsed) {
statusHtml = `IN USE (${rulesUsing.length})`;
} else if (c.subnet_count > 0) {
statusHtml = 'CACHED';
} else {
statusHtml = 'READY';
}
tr.innerHTML = `
${c.code}
|
${c.name} |
${c.continent} |
${c.subnet_count > 0 ? c.subnet_count.toLocaleString() + ' CIDRs' : 'Not Cached'}
|
${c.ipset_name} |
${statusHtml}
|
|
`;
tbody.appendChild(tr);
});
}
function quickFilterGeo(preset) {
const searchInput = document.getElementById('geo-search-input');
const continentSel = document.getElementById('geo-continent-filter');
const inUseCheck = document.getElementById('geo-filter-inuse-only');
const cachedCheck = document.getElementById('geo-filter-cached-only');
if (searchInput) searchInput.value = '';
if (continentSel) continentSel.value = '';
if (inUseCheck) inUseCheck.checked = false;
if (cachedCheck) cachedCheck.checked = false;
if (preset === 'GR') {
if (searchInput) searchInput.value = 'Greece';
} else if (preset === 'Europe') {
if (continentSel) continentSel.value = 'Europe';
} else if (preset === 'HIGHRISK') {
if (searchInput) searchInput.value = 'China';
}
renderGeoCountries();
}
async function toggleGeoCountry(code, enabled) {
try {
const res = await fetch(`${API_BASE}/firewall/geoip/countries/${encodeURIComponent(code)}/toggle`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled })
});
if (res.ok) {
showToast(enabled ? 'Country Enabled' : 'Country Disabled', `${code} ${enabled ? 'active in firewall' : 'removed from firewall'}`, 'success');
loadGeoIP();
} else {
const err = await res.json();
showToast('Error', err.message || 'Failed to toggle country', 'error');
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function updateGeoCountry(code) {
try {
showToast('Syncing Country', `Downloading latest IP blocks for ${code}...`, 'info');
const res = await fetch(`${API_BASE}/firewall/geoip/countries/${encodeURIComponent(code)}/update`, { method: 'POST' });
if (res.ok) {
const data = await res.json();
showToast('Synced', `Loaded ${data.subnets} CIDR blocks for ${code}`, 'success');
loadGeoIP();
} else {
const err = await res.json();
showToast('Sync Error', err.message || 'Failed to sync country', 'error');
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function triggerGeoUpdateNow() {
const spinner = document.getElementById('geo-update-spinner');
if (spinner) spinner.classList.remove('hidden');
try {
const res = await fetch(`${API_BASE}/firewall/geoip/update-now`, { method: 'POST' });
if (res.ok) {
showToast('Update Started', 'Updating all active GeoIP country zones in background...', 'info');
setTimeout(() => {
loadGeoIP();
if (spinner) spinner.classList.add('hidden');
}, 2500);
} else {
const err = await res.json();
showToast('Notice', err.message || 'Update in progress', 'info');
if (spinner) spinner.classList.add('hidden');
}
} catch (err) {
showToast('Error', err.message, 'error');
if (spinner) spinner.classList.add('hidden');
}
}
async function applyGeoPreset(preset) {
try {
const res = await fetch(`${API_BASE}/firewall/geoip/batch-toggle`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ preset, enabled: preset !== 'NONE' })
});
if (res.ok) {
showToast('Preset Applied', `Preset ${preset} updated in firewall`, 'success');
loadGeoIP();
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
function openGeoIntervalModal() {
const s = state.geoStatus || {};
document.getElementById('geo-cfg-autoupdate').checked = s.auto_update !== false;
const sel = document.getElementById('geo-cfg-interval');
const customBox = document.getElementById('geo-cfg-custom-box');
const customHoursInput = document.getElementById('geo-cfg-custom-hours');
const curHours = s.interval_hours || 24;
const matchOption = Array.from(sel.options).find(o => parseInt(o.value, 10) === curHours);
if (matchOption) {
sel.value = String(curHours);
customBox.classList.add('hidden');
} else {
sel.value = 'custom';
customHoursInput.value = curHours;
customBox.classList.remove('hidden');
}
document.getElementById('geo-interval-modal').classList.remove('hidden');
}
function closeGeoIntervalModal() {
document.getElementById('geo-interval-modal').classList.add('hidden');
}
function toggleCustomGeoInterval() {
const val = document.getElementById('geo-cfg-interval').value;
const customBox = document.getElementById('geo-cfg-custom-box');
if (val === 'custom') {
customBox.classList.remove('hidden');
} else {
customBox.classList.add('hidden');
}
}
async function submitGeoIntervalSettings(e) {
e.preventDefault();
const autoUpdate = document.getElementById('geo-cfg-autoupdate').checked;
const intervalVal = document.getElementById('geo-cfg-interval').value;
let hours = 24;
if (intervalVal === 'custom') {
hours = parseInt(document.getElementById('geo-cfg-custom-hours').value, 10) || 24;
} else {
hours = parseInt(intervalVal, 10) || 24;
}
try {
const res = await fetch(`${API_BASE}/firewall/geoip/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ auto_update: autoUpdate, interval_hours: hours })
});
if (res.ok) {
showToast('Schedule Saved', `Auto-update set to every ${hours} hours`, 'success');
closeGeoIntervalModal();
loadGeoIP();
} else {
const err = await res.json();
showToast('Error', err.message || 'Failed to save schedule', 'error');
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function viewGeoSubnets(code, name) {
if (!name) {
const found = (state.geoCountries || []).find(x => x.code === code);
name = found ? found.name : code;
}
document.getElementById('geo-subnets-title').innerText = `${name} (${code}) Subnets`;
document.getElementById('geo-subnets-subtitle').innerText = `Kernel IPSet: mk_geo_${code}`;
const listEl = document.getElementById('geo-subnets-list');
listEl.innerHTML = 'Loading subnets...';
document.getElementById('geo-subnets-modal').classList.remove('hidden');
try {
const res = await fetch(`${API_BASE}/firewall/geoip/countries/${encodeURIComponent(code)}/subnets`);
if (res.ok) {
const data = await res.json();
const subnets = data.subnets || [];
if (subnets.length === 0) {
listEl.innerHTML = 'No cached subnets found. Click Sync to download this zone.';
} else {
listEl.innerHTML = subnets.map(s => `${s}
`).join('');
}
} else {
listEl.innerHTML = 'Failed to load subnets.';
}
} catch (err) {
listEl.innerHTML = `Error: ${err.message}`;
}
}
function closeGeoSubnetsModal() {
document.getElementById('geo-subnets-modal').classList.add('hidden');
}
// ----------------- Active Connections Tracking -----------------
async function loadConnections() {
try {
const res = await fetch(`${API_BASE}/firewall/connections`);
const data = await res.json();
state.connections = Array.isArray(data) ? data : [];
document.getElementById('fw-badge-connections').innerText = state.connections.length;
document.getElementById('conns-count-badge').innerText = `${state.connections.length} Flows`;
renderConnections();
} catch (err) {
console.error('Failed to load connections:', err);
}
}
function renderConnections() {
const search = (document.getElementById('conns-search-input')?.value || '').toLowerCase();
const protoFilter = document.getElementById('conns-filter-proto')?.value || '';
const tbody = document.getElementById('conns-table-body');
const empty = document.getElementById('conns-empty');
if (!tbody) return;
tbody.innerHTML = '';
const conns = (state.connections || []).filter(c => {
if (protoFilter && c.protocol !== protoFilter) return false;
if (search) {
const line = `${c.protocol} ${c.src_address} ${c.dst_address} ${c.src_port} ${c.dst_port} ${c.tcp_state} ${c.connection_mark}`.toLowerCase();
if (!line.includes(search)) return false;
}
return true;
});
if (conns.length === 0) {
empty?.classList.remove('hidden');
return;
}
empty?.classList.add('hidden');
conns.slice(0, 150).forEach(c => {
const tr = document.createElement('tr');
tr.className = 'hover:bg-slate-800/40 transition text-xs';
let stateColor = 'text-slate-400';
if (c.tcp_state === 'ESTABLISHED') stateColor = 'text-emerald-400 font-bold';
else if (c.tcp_state === 'TIME_WAIT' || c.tcp_state === 'SYN_SENT') stateColor = 'text-amber-400';
tr.innerHTML = `
${c.protocol} |
${c.src_address}:${c.src_port} |
${c.dst_address}:${c.dst_port} |
${c.reply_src_address ? c.reply_src_address + ':' + c.reply_src_port : '-'} |
${c.reply_dst_address ? c.reply_dst_address + ':' + c.reply_dst_port : '-'} |
${c.tcp_state} |
${c.connection_mark || '-'} |
${c.timeout ? c.timeout + 's' : '-'} |
|
`;
tbody.appendChild(tr);
});
}
async function killConnection(protocol, src, sport, dst, dport) {
try {
const res = await fetch(`${API_BASE}/firewall/connections/kill`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ protocol, src, sport, dst, dport })
});
if (res.ok) {
showToast('Terminated', `Killed flow ${src}:${sport} -> ${dst}:${dport}`, 'info');
loadConnections();
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function resetFirewallCounters() {
if (!confirm('Reset all firewall packet and byte counters to zero?')) return;
try {
const res = await fetch(`${API_BASE}/firewall/reset-counters`, { method: 'POST' });
if (res.ok) {
showToast('Counters Reset', 'All rules counters reset to 0', 'success');
loadFirewall(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
// ======================== MIKROTIK ROUTING & POLICY (/ip route) ========================
function switchRoutesSubTab(tab) {
state.routesSubTab = tab;
const tabs = ['routes', 'rules', 'tables'];
tabs.forEach(t => {
const panel = document.getElementById(`routes-subtab-${t}`);
const btn = document.getElementById(`routes-subtab-btn-${t}`);
if (panel) {
if (t === tab) panel.classList.remove('hidden');
else panel.classList.add('hidden');
}
if (btn) {
if (t === tab) {
btn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-cyan-500 text-white transition flex items-center gap-2';
} else {
btn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2';
}
}
});
}
async function loadRoutes() {
try {
const [routesRes, rulesRes, tablesRes] = await Promise.all([
fetch(`${API_BASE}/routes`),
fetch(`${API_BASE}/routing/rules`),
fetch(`${API_BASE}/routing/tables`)
]);
if (routesRes.ok) state.routes = await routesRes.json();
if (rulesRes.ok) state.policyRules = await rulesRes.json();
if (tablesRes.ok) state.routingTables = await tablesRes.json();
const bRoutes = document.getElementById('routes-badge-routes');
if (bRoutes) bRoutes.innerText = (state.routes || []).length;
const bRules = document.getElementById('routes-badge-rules');
if (bRules) bRules.innerText = (state.policyRules || []).length;
const bTables = document.getElementById('routes-badge-tables');
if (bTables) bTables.innerText = (state.routingTables || []).length;
const bRoutesCount = document.getElementById('badge-routes-count');
if (bRoutesCount) bRoutesCount.innerText = (state.routes || []).length;
renderRoutesList();
renderPolicyRules();
renderRoutingTables();
} catch (err) {
console.error('Failed to load routing:', err);
}
}
function renderRoutesList() {
const tableFilter = document.getElementById('routes-filter-table')?.value || '';
const tbody = document.getElementById('routes-table-body');
const empty = document.getElementById('routes-empty');
if (!tbody) return;
tbody.innerHTML = '';
// Update table filter select
const sel = document.getElementById('routes-filter-table');
if (sel) {
const cur = sel.value;
sel.innerHTML = '';
(state.routingTables || []).forEach(t => {
const opt = document.createElement('option');
opt.value = t.name;
opt.innerText = t.name;
if (t.name === cur) opt.selected = true;
sel.appendChild(opt);
});
}
const routes = (state.routes || []).filter(r => !tableFilter || r.routing_table === tableFilter);
document.getElementById('routes-count-badge').innerText = `${routes.length} Routes`;
if (routes.length === 0) {
empty?.classList.remove('hidden');
return;
}
empty?.classList.add('hidden');
routes.forEach(r => {
const tr = document.createElement('tr');
tr.className = `hover:bg-slate-800/40 transition text-xs cursor-pointer ${r.disabled ? 'opacity-50' : ''}`;
tr.setAttribute('ondblclick', `openEditRouteModal('${r.id}')`);
// MikroTik Flags string: D=Dynamic, A=Active, C=Connect, S=Static, X=Disabled
let flags = '';
if (r.dynamic) flags += 'D';
if (r.active) flags += 'A';
if (r.connect) flags += 'C';
if (r.static) flags += 'S';
if (r.disabled) flags += 'X';
const isDefault = r.dst_address === 'default' || r.dst_address === '0.0.0.0/0';
tr.innerHTML = `
${flags || '-'}
|
${r.dst_address} |
${r.gateway || 'Direct link'} |
${(r.distance !== undefined && r.distance !== null) ? r.distance : 0} |
${r.routing_table || 'main'} |
${r.interface || '-'} |
${r.comment || '-'} |
${r.static ? `
` : `
`}
|
`;
tbody.appendChild(tr);
});
}
function openAddRouteModal() {
document.getElementById('route-modal-title').innerText = 'Add Static Route';
document.getElementById('route-modal-id').value = '';
document.getElementById('route-modal-old-dst').value = '';
document.getElementById('route-modal-old-gw').value = '';
document.getElementById('route-modal-old-distance').value = '';
document.getElementById('route-modal-old-table').value = '';
document.getElementById('route-modal-old-iface').value = '';
const dstInput = document.getElementById('route-modal-dst');
const gwInput = document.getElementById('route-modal-gw');
const distInput = document.getElementById('route-modal-distance');
const ifaceSelect = document.getElementById('route-modal-iface');
const tableSelect = document.getElementById('route-modal-table');
const commentInput = document.getElementById('route-modal-comment');
const disCheck = document.getElementById('route-modal-disabled');
const saveBtn = document.querySelector('#route-modal button[type="submit"]');
dstInput.disabled = false;
dstInput.value = '0.0.0.0/0';
gwInput.disabled = false;
gwInput.value = '';
distInput.disabled = false;
distInput.value = '1';
ifaceSelect.disabled = false;
tableSelect.disabled = false;
commentInput.disabled = false;
commentInput.value = '';
disCheck.disabled = false;
disCheck.checked = false;
if (saveBtn) saveBtn.classList.remove('hidden');
populateInterfaceSelect('route-modal-iface');
// Populate tables
tableSelect.innerHTML = '';
(state.routingTables || [{ name: 'main' }]).forEach(t => {
const opt = document.createElement('option');
opt.value = t.name;
opt.innerText = t.name;
if (t.name === 'main') opt.selected = true;
tableSelect.appendChild(opt);
});
document.getElementById('route-modal').classList.remove('hidden');
}
function openEditRouteModal(id) {
const r = (state.routes || []).find(route => route.id === id);
if (!r) return;
const isStatic = !!r.static;
document.getElementById('route-modal-title').innerText = isStatic
? `Edit Route: ${r.dst_address}`
: `Route Properties (Read Only): ${r.dst_address}`;
document.getElementById('route-modal-id').value = r.id;
document.getElementById('route-modal-old-dst').value = r.dst_address || '';
document.getElementById('route-modal-old-gw').value = r.gateway || '';
document.getElementById('route-modal-old-distance').value = (r.distance !== undefined && r.distance !== null) ? r.distance : '';
document.getElementById('route-modal-old-table').value = r.routing_table || 'main';
document.getElementById('route-modal-old-iface').value = r.interface || '';
const dstInput = document.getElementById('route-modal-dst');
const gwInput = document.getElementById('route-modal-gw');
const distInput = document.getElementById('route-modal-distance');
const ifaceSelect = document.getElementById('route-modal-iface');
const tableSelect = document.getElementById('route-modal-table');
const commentInput = document.getElementById('route-modal-comment');
const disCheck = document.getElementById('route-modal-disabled');
const saveBtn = document.querySelector('#route-modal button[type="submit"]');
dstInput.value = r.dst_address;
gwInput.value = r.gateway || '';
distInput.value = (r.distance !== undefined && r.distance !== null) ? r.distance : 1;
commentInput.value = r.comment || '';
disCheck.checked = !!r.disabled;
dstInput.disabled = !isStatic;
gwInput.disabled = !isStatic;
distInput.disabled = !isStatic;
ifaceSelect.disabled = !isStatic;
tableSelect.disabled = !isStatic;
commentInput.disabled = !isStatic;
disCheck.disabled = !isStatic;
if (saveBtn) {
if (isStatic) {
saveBtn.classList.remove('hidden');
} else {
saveBtn.classList.add('hidden');
}
}
populateInterfaceSelect('route-modal-iface', r.interface);
tableSelect.innerHTML = '';
(state.routingTables || [{ name: 'main' }]).forEach(t => {
const opt = document.createElement('option');
opt.value = t.name;
opt.innerText = t.name;
if (t.name === (r.routing_table || 'main')) opt.selected = true;
tableSelect.appendChild(opt);
});
document.getElementById('route-modal').classList.remove('hidden');
}
function closeRouteModal() {
document.getElementById('route-modal').classList.add('hidden');
}
async function submitSaveRoute(e) {
e.preventDefault();
const id = document.getElementById('route-modal-id').value;
const oldDst = document.getElementById('route-modal-old-dst')?.value || '';
const oldGw = document.getElementById('route-modal-old-gw')?.value || '';
const oldDistStr = document.getElementById('route-modal-old-distance')?.value;
const oldTable = document.getElementById('route-modal-old-table')?.value || '';
const oldIface = document.getElementById('route-modal-old-iface')?.value || '';
const dst = document.getElementById('route-modal-dst').value.trim();
const gw = document.getElementById('route-modal-gw').value.trim();
const table = document.getElementById('route-modal-table').value;
const distance = parseInt(document.getElementById('route-modal-distance').value, 10) || 0;
const iface = document.getElementById('route-modal-iface').value;
const comment = document.getElementById('route-modal-comment').value.trim();
const disabled = document.getElementById('route-modal-disabled').checked;
if (!dst) {
showToast('Error', 'Destination address is required', 'error');
return;
}
if (!gw && !iface) {
showToast('Error', 'Either Gateway IP or Interface must be specified', 'error');
return;
}
const payload = {
id,
old_dst_address: oldDst,
old_gateway: oldGw,
old_routing_table: oldTable,
old_interface: oldIface,
dst_address: dst,
gateway: gw || iface,
routing_table: table,
distance,
interface: iface,
comment,
disabled
};
if (oldDistStr !== undefined && oldDistStr !== '') {
payload.old_distance = parseInt(oldDistStr, 10);
}
try {
const res = await fetch(`${API_BASE}/routes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
showToast('Route Saved', `Route to ${dst} installed`, 'success');
closeRouteModal();
loadRoutes();
} else {
const err = await res.json();
showToast('Error', err.message || 'Failed to save route', 'error');
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function toggleRouteState(id, disabled) {
try {
const res = await fetch(`${API_BASE}/routes/${encodeURIComponent(id)}/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ disabled })
});
if (res.ok) loadRoutes();
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function deleteRoute(id) {
if (!confirm('Are you sure you want to delete this route?')) return;
try {
const res = await fetch(`${API_BASE}/routes/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (res.ok) {
showToast('Deleted', 'Route removed', 'success');
loadRoutes();
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
// ----------------- Policy Rules (/ip route rule) -----------------
function renderPolicyRules() {
const tbody = document.getElementById('rules-table-body');
const empty = document.getElementById('rules-empty');
if (!tbody) return;
tbody.innerHTML = '';
const rules = state.policyRules || [];
document.getElementById('rules-count-badge').innerText = `${rules.length} Rules`;
if (rules.length === 0) {
empty?.classList.remove('hidden');
return;
}
empty?.classList.add('hidden');
rules.forEach(pr => {
const tr = document.createElement('tr');
tr.className = 'hover:bg-slate-800/40 transition text-xs';
tr.innerHTML = `
${pr.priority} |
${pr.src_address || 'all'} |
${pr.dst_address || 'all'} |
${pr.routing_mark || '-'} |
${pr.table} |
${pr.action} |
${pr.priority < 32766 && pr.priority > 0 ? `
` : `Default`}
|
`;
tbody.appendChild(tr);
});
}
function openAddPolicyRuleModal() {
document.getElementById('prule-modal-pref').value = (state.policyRules || []).length * 10 + 1000;
document.getElementById('prule-modal-mark').value = '';
document.getElementById('prule-modal-src').value = '';
document.getElementById('prule-modal-dst').value = '';
const sel = document.getElementById('prule-modal-table');
sel.innerHTML = '';
(state.routingTables || []).forEach(t => {
const opt = document.createElement('option');
opt.value = t.name;
opt.innerText = t.name;
sel.appendChild(opt);
});
document.getElementById('prule-modal').classList.remove('hidden');
}
function closePolicyRuleModal() {
document.getElementById('prule-modal').classList.add('hidden');
}
async function submitSavePolicyRule(e) {
e.preventDefault();
const pref = parseInt(document.getElementById('prule-modal-pref').value, 10) || 1000;
const mark = document.getElementById('prule-modal-mark').value.trim();
const src = document.getElementById('prule-modal-src').value.trim();
const dst = document.getElementById('prule-modal-dst').value.trim();
const table = document.getElementById('prule-modal-table').value;
const action = document.getElementById('prule-modal-action').value;
try {
const res = await fetch(`${API_BASE}/routing/rules`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priority: pref, routing_mark: mark, src_address: src, dst_address: dst, table, action })
});
if (res.ok) {
showToast('Rule Added', `Policy rule installed for table ${table}`, 'success');
closePolicyRuleModal();
loadRoutes();
} else {
const err = await res.json();
showToast('Error', err.message, 'error');
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function deletePolicyRule(id) {
if (!confirm('Are you sure you want to remove this policy rule?')) return;
try {
const res = await fetch(`${API_BASE}/routing/rules/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (res.ok) {
showToast('Rule Removed', 'Policy rule deleted', 'success');
loadRoutes();
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
// ----------------- Routing Tables -----------------
function renderRoutingTables() {
const tbody = document.getElementById('tables-table-body');
if (!tbody) return;
tbody.innerHTML = '';
(state.routingTables || []).forEach(t => {
const tr = document.createElement('tr');
tr.className = 'hover:bg-slate-800/40 transition text-xs font-mono';
tr.innerHTML = `
${t.table_id} |
${t.name} |
${t.comment || '-'} |
`;
tbody.appendChild(tr);
});
}
function openAddTableModal() {
document.getElementById('table-modal-name').value = '';
document.getElementById('table-modal-id').value = '';
document.getElementById('table-modal-comment').value = '';
document.getElementById('table-modal').classList.remove('hidden');
}
function closeTableModal() {
document.getElementById('table-modal').classList.add('hidden');
}
async function submitSaveTable(e) {
e.preventDefault();
const name = document.getElementById('table-modal-name').value.trim();
const id = parseInt(document.getElementById('table-modal-id').value, 10) || 0;
const comment = document.getElementById('table-modal-comment').value.trim();
try {
const res = await fetch(`${API_BASE}/routing/tables`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, table_id: id, comment })
});
if (res.ok) {
showToast('Table Created', `Routing table ${name} registered`, 'success');
closeTableModal();
loadRoutes();
} else {
const err = await res.json();
showToast('Error', err.message, 'error');
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function loadDebianConfig() {
const view = document.getElementById('debian-config-view');
view.innerText = 'Generating live configuration...';
try {
const res = await fetch(`${API_BASE}/debian/config`);
const data = await res.json();
view.innerText = data.content || '# Empty config';
} catch (err) {
view.innerText = 'Failed to generate Debian network config.';
}
}
async function saveDebianConfig() {
try {
const res = await fetch(`${API_BASE}/debian/apply`, { method: 'POST' });
const data = await res.json();
if (res.ok) {
showToast('Configuration Saved', data.message || 'Saved to /etc/network/interfaces.d/vlans.cfg', 'success');
loadDebianConfig();
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
function showToast(title, message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
const borderColors = {
success: 'border-emerald-500/40 bg-slate-900/95 text-emerald-400',
error: 'border-rose-500/40 bg-slate-900/95 text-rose-400',
info: 'border-cyan-500/40 bg-slate-900/95 text-cyan-400'
};
toast.className = `pointer-events-auto border rounded-xl p-4 shadow-2xl backdrop-blur-md flex items-start space-x-3 w-80 transform transition-all duration-300 translate-y-2 opacity-0 ${borderColors[type] || borderColors.info}`;
toast.innerHTML = `
`;
container.appendChild(toast);
requestAnimationFrame(() => toast.classList.remove('translate-y-2', 'opacity-0'));
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-y-2');
setTimeout(() => toast.remove(), 300);
}, 4000);
}
function formatTraffic(bytesPerSec) {
if (bytesPerSec < 1024) return `${Math.round(bytesPerSec)} B/s`;
const kb = bytesPerSec / 1024;
if (kb < 1024) return `${kb.toFixed(1)} KB/s`;
const mb = kb / 1024;
return `${mb.toFixed(2)} MB/s`;
}
function toggleSidebar() {
const sb = document.getElementById('sidebar');
sb.classList.toggle('-translate-x-full');
}
// ======================== EoIP (Ethernet over IP) ========================
async function loadEoIP(populate = true) {
try {
const res = await fetch(`${API_BASE}/vpn/eoip`);
const data = await res.json();
state.eoipTunnels = Array.isArray(data) ? data : [];
const badge = document.getElementById('eoip-count-badge');
if (badge) badge.innerText = `${state.eoipTunnels.length} Tunnels`;
const sideBadge = document.getElementById('badge-eoip-count');
if (sideBadge) sideBadge.innerText = state.eoipTunnels.length;
renderEoIPTunnels();
} catch (err) {
console.error('Failed to load EoIP:', err);
}
}
function renderEoIPTunnels() {
const tbody = document.getElementById('eoip-table-body');
const empty = document.getElementById('eoip-empty');
if (!tbody) return;
tbody.innerHTML = '';
const tunnels = state.eoipTunnels || [];
if (tunnels.length === 0) {
empty?.classList.remove('hidden');
return;
}
empty?.classList.add('hidden');
tunnels.forEach(t => {
const tr = document.createElement('tr');
tr.className = `hover:bg-slate-800/40 transition text-xs ${t.disabled ? 'opacity-50' : ''}`;
tr.innerHTML = `
${renderMikroTikFlag(t.running, t.disabled)} |
${t.name} |
${t.tunnel_id} |
${t.ip_address || 'Unassigned'} |
${t.remote_address} |
${t.local_address || '(any)'} |
${t.mac_address || '-'} |
${t.mtu || 1500} |
↓ ${t.rx_human || '0 B'}
↑ ${t.tx_human || '0 B'}
|
${t.comment || '-'} |
|
`;
tbody.appendChild(tr);
});
}
function openAddEoIPModal() {
document.getElementById('eoip-modal-title').innerText = 'Add EoIP Tunnel';
document.getElementById('eoip-modal-id').value = '';
document.getElementById('eoip-modal-name').value = `eoip-tunnel${(state.eoipTunnels || []).length + 1}`;
document.getElementById('eoip-modal-tunnelid').value = (state.eoipTunnels || []).length + 10;
document.getElementById('eoip-modal-remote').value = '';
document.getElementById('eoip-modal-ip').value = '';
document.getElementById('eoip-modal-local').value = '';
document.getElementById('eoip-modal-mac').value = '';
document.getElementById('eoip-modal-mtu').value = '1500';
document.getElementById('eoip-modal-comment').value = '';
document.getElementById('eoip-modal-clampmss').checked = true;
document.getElementById('eoip-modal-disabled').checked = false;
document.getElementById('eoip-modal').classList.remove('hidden');
}
function openEditEoIPModal(id) {
const t = (state.eoipTunnels || []).find(tun => tun.id === id);
if (!t) return;
document.getElementById('eoip-modal-title').innerText = `Edit EoIP Tunnel (${t.name})`;
document.getElementById('eoip-modal-id').value = t.id;
document.getElementById('eoip-modal-name').value = t.name;
document.getElementById('eoip-modal-tunnelid').value = t.tunnel_id;
document.getElementById('eoip-modal-remote').value = t.remote_address;
document.getElementById('eoip-modal-ip').value = t.ip_address || '';
document.getElementById('eoip-modal-local').value = t.local_address || '';
document.getElementById('eoip-modal-mac').value = t.mac_address || '';
document.getElementById('eoip-modal-mtu').value = t.mtu || 1500;
document.getElementById('eoip-modal-comment').value = t.comment || '';
document.getElementById('eoip-modal-clampmss').checked = !!t.clamp_mss;
document.getElementById('eoip-modal-disabled').checked = !!t.disabled;
document.getElementById('eoip-modal').classList.remove('hidden');
}
function closeEoIPModal() {
document.getElementById('eoip-modal').classList.add('hidden');
}
async function submitSaveEoIP(e) {
e.preventDefault();
const id = document.getElementById('eoip-modal-id').value;
const name = document.getElementById('eoip-modal-name').value.trim();
const tunnelId = parseInt(document.getElementById('eoip-modal-tunnelid').value);
const remote = document.getElementById('eoip-modal-remote').value.trim();
const ipAddress = document.getElementById('eoip-modal-ip').value.trim();
const local = document.getElementById('eoip-modal-local').value.trim();
const mac = document.getElementById('eoip-modal-mac').value.trim();
const mtu = parseInt(document.getElementById('eoip-modal-mtu').value) || 1500;
const comment = document.getElementById('eoip-modal-comment').value.trim();
const clampMss = document.getElementById('eoip-modal-clampmss').checked;
const disabled = document.getElementById('eoip-modal-disabled').checked;
try {
const res = await fetch(`${API_BASE}/vpn/eoip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id,
name,
tunnel_id: tunnelId,
remote_address: remote,
ip_address: ipAddress,
local_address: local,
mac_address: mac,
mtu,
clamp_mss: clampMss,
comment,
disabled
})
});
if (res.ok) {
showToast('EoIP Saved', `Tunnel ${name} configured successfully`, 'success');
closeEoIPModal();
loadEoIP(false);
fetchData(false);
} else {
const err = await res.json();
showToast('Error', err.message, 'error');
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function toggleEoIPState(id, disabled) {
try {
const res = await fetch(`${API_BASE}/vpn/eoip/${encodeURIComponent(id)}/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ disabled })
});
if (res.ok) {
showToast(disabled ? 'Disabled' : 'Enabled', 'EoIP tunnel state updated', 'info');
loadEoIP(false);
fetchData(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function deleteEoIP(id) {
if (!confirm('Are you sure you want to delete this EoIP tunnel?')) return;
try {
const res = await fetch(`${API_BASE}/vpn/eoip/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (res.ok) {
showToast('Deleted', 'EoIP tunnel removed', 'success');
loadEoIP(false);
fetchData(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
// ======================== PPP Central Hub & Servers ========================
function switchPPPSubTab(tab) {
state.pppSubTab = tab;
const tabs = ['secrets', 'profiles', 'pppoe', 'pptp', 'l2tp', 'active'];
tabs.forEach(t => {
const panel = document.getElementById(`ppp-subtab-${t}`);
const btn = document.getElementById(`ppp-subtab-btn-${t}`);
if (panel) {
if (t === tab) panel.classList.remove('hidden');
else panel.classList.add('hidden');
}
if (btn) {
if (t === tab) {
btn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-violet-500 text-white transition flex items-center gap-2 whitespace-nowrap';
} else {
btn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2 whitespace-nowrap';
}
}
});
const addBtn = document.getElementById('ppp-primary-add-btn');
if (addBtn) {
if (tab === 'secrets') addBtn.innerHTML = '+ Add User (Secret)';
else if (tab === 'profiles') addBtn.innerHTML = '+ Add Profile';
else if (tab === 'active') addBtn.innerHTML = 'Refresh Sessions';
else addBtn.innerHTML = 'Save Config';
}
if (tab === 'active') loadPPPSessions();
}
function openCurrentPPPModal() {
if (state.pppSubTab === 'secrets') openAddPPPSecretModal();
else if (state.pppSubTab === 'profiles') openAddPPPProfileModal();
else if (state.pppSubTab === 'active') loadPPPSessions();
else if (state.pppSubTab === 'pppoe') document.querySelector('#ppp-subtab-pppoe form')?.requestSubmit();
else if (state.pppSubTab === 'pptp') document.querySelector('#ppp-subtab-pptp form')?.requestSubmit();
else if (state.pppSubTab === 'l2tp') document.querySelector('#ppp-subtab-l2tp form')?.requestSubmit();
}
async function loadPPP(populateDropdowns = true) {
await Promise.all([
loadPPPSecrets(),
loadPPPProfiles(),
loadPPPServers(),
loadPPPSessions()
]);
}
async function loadPPPSecrets() {
try {
const res = await fetch(`${API_BASE}/vpn/ppp/secrets`);
const data = await res.json();
state.pppSecrets = Array.isArray(data) ? data : [];
const b = document.getElementById('ppp-badge-secrets');
if (b) b.innerText = state.pppSecrets.length;
const b2 = document.getElementById('secrets-count-badge');
if (b2) b2.innerText = `${state.pppSecrets.length} Users`;
renderPPPSecrets();
} catch (err) {
console.error('Failed to load PPP secrets:', err);
}
}
function renderPPPSecrets() {
const tbody = document.getElementById('secrets-table-body');
const empty = document.getElementById('secrets-empty');
if (!tbody) return;
tbody.innerHTML = '';
const secrets = state.pppSecrets || [];
if (secrets.length === 0) {
empty?.classList.remove('hidden');
return;
}
empty?.classList.add('hidden');
secrets.forEach(s => {
const tr = document.createElement('tr');
tr.className = `hover:bg-slate-800/40 transition text-xs ${s.disabled ? 'opacity-50' : ''}`;
tr.innerHTML = `
${renderRuleFlag(s.disabled, !!s.comment)} |
${s.name} |
${s.service} |
${s.profile} |
${s.local_address || '(profile)'} |
${s.remote_address || '(profile)'} |
${s.comment || '-'} |
|
`;
tbody.appendChild(tr);
});
}
async function loadPPPProfiles() {
try {
const res = await fetch(`${API_BASE}/vpn/ppp/profiles`);
const data = await res.json();
state.pppProfiles = Array.isArray(data) ? data : [];
const b = document.getElementById('ppp-badge-profiles');
if (b) b.innerText = state.pppProfiles.length;
const b2 = document.getElementById('profiles-count-badge');
if (b2) b2.innerText = `${state.pppProfiles.length} Profiles`;
// Populate profile selects across modals
const selects = ['secret-modal-profile', 'pppoe-server-profile', 'pptp-server-profile', 'l2tp-server-profile'];
selects.forEach(sid => {
const el = document.getElementById(sid);
if (!el) return;
const cur = el.value;
el.innerHTML = '';
state.pppProfiles.forEach(p => {
const opt = document.createElement('option');
opt.value = p.name;
opt.innerText = `${p.name} (${p.remote_address})`;
if (p.name === cur) opt.selected = true;
el.appendChild(opt);
});
});
renderPPPProfiles();
} catch (err) {
console.error('Failed to load PPP profiles:', err);
}
}
function renderPPPProfiles() {
const tbody = document.getElementById('profiles-table-body');
if (!tbody) return;
tbody.innerHTML = '';
(state.pppProfiles || []).forEach(p => {
const isDefault = p.name.startsWith('default');
const tr = document.createElement('tr');
tr.className = 'hover:bg-slate-800/40 transition text-xs font-mono';
tr.innerHTML = `
${p.name} |
${p.local_address} |
${p.remote_address} |
${p.dns_servers || '-'} |
${p.use_encryption} |
${p.only_one ? 'yes' : 'no'} |
${p.comment || '-'} |
${!isDefault ? `` : `Built-in`}
|
`;
tbody.appendChild(tr);
});
}
async function loadPPPServers() {
try {
const [pppoeRes, pptpRes, l2tpRes, ipsecRes] = await Promise.all([
fetch(`${API_BASE}/vpn/servers/pppoe`),
fetch(`${API_BASE}/vpn/servers/pptp`),
fetch(`${API_BASE}/vpn/servers/l2tp`),
fetch(`${API_BASE}/vpn/servers/ipsec`)
]);
if (pppoeRes.ok) {
state.pppoeServer = await pppoeRes.json();
const s = state.pppoeServer;
document.getElementById('pppoe-server-enabled').checked = !!s.enabled;
document.getElementById('pppoe-server-name').value = s.service_name || 'proxmox-pppoe';
populateInterfaceSelect('pppoe-server-iface', s.interface || 'eth0');
document.getElementById('pppoe-server-profile').value = s.default_profile || 'default';
document.getElementById('pppoe-server-mtu').value = s.max_mtu || 1480;
document.getElementById('pppoe-server-onesession').checked = !!s.one_session_per_host;
const pill = document.getElementById('pppoe-server-status-pill');
if (pill) {
pill.innerText = s.running ? `ACTIVE (${s.active_sessions} sess)` : 'STOPPED';
pill.className = s.running ? 'px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-blue-500/20 text-blue-300 border border-blue-500/40' : 'px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-slate-800 text-slate-400';
}
setDotBadge('ppp-badge-pppoe-state', s.running);
}
if (pptpRes.ok) {
state.pptpServer = await pptpRes.json();
const s = state.pptpServer;
document.getElementById('pptp-server-enabled').checked = !!s.enabled;
document.getElementById('pptp-server-port').value = s.port || 1723;
document.getElementById('pptp-server-profile').value = s.default_profile || 'default-encryption';
document.getElementById('pptp-server-mppe').checked = s.require_mppe !== false;
const pill = document.getElementById('pptp-server-status-pill');
if (pill) {
pill.innerText = s.running ? `ACTIVE (${s.active_sessions} sess)` : 'STOPPED';
pill.className = s.running ? 'px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-purple-500/20 text-purple-300 border border-purple-500/40' : 'px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-slate-800 text-slate-400';
}
setDotBadge('ppp-badge-pptp-state', s.running);
}
if (l2tpRes.ok) {
state.l2tpServer = await l2tpRes.json();
const s = state.l2tpServer;
document.getElementById('l2tp-server-enabled').checked = !!s.enabled;
document.getElementById('l2tp-server-port').value = s.port || 1701;
document.getElementById('l2tp-server-profile').value = s.default_profile || 'default';
document.getElementById('l2tp-server-useipsec').value = s.use_ipsec || 'no';
document.getElementById('l2tp-server-ipsecsecret').value = s.ipsec_secret || '';
const pill = document.getElementById('l2tp-server-status-pill');
if (pill) {
pill.innerText = s.running ? `ACTIVE (${s.active_sessions} sess)` : 'STOPPED';
pill.className = s.running ? 'px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-cyan-500/20 text-cyan-300 border border-cyan-500/40' : 'px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-slate-800 text-slate-400';
}
setDotBadge('ppp-badge-l2tp-state', s.running);
}
if (ipsecRes.ok) {
state.ipsecServer = await ipsecRes.json();
const s = state.ipsecServer;
const modalEn = document.getElementById('modal-ipsec-server-enabled');
if (modalEn) modalEn.checked = !!s.enabled;
const modalPsk = document.getElementById('modal-ipsec-server-psk');
if (modalPsk) modalPsk.value = s.preshared_key || '';
const modalPool = document.getElementById('modal-ipsec-server-pool');
if (modalPool) modalPool.value = s.pool_address || '10.10.30.0/24';
const modalDns = document.getElementById('modal-ipsec-server-dns');
if (modalDns) modalDns.value = s.dns_servers || '1.1.1.1,8.8.8.8';
const modalSubnet = document.getElementById('modal-ipsec-server-subnet');
if (modalSubnet) modalSubnet.value = s.local_subnet || '0.0.0.0/0';
const pill = document.getElementById('modal-ipsec-server-status');
if (pill) {
pill.innerText = s.running ? 'ACTIVE RESPONDER' : 'STOPPED';
pill.className = s.running ? 'px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-amber-500/20 text-amber-300 border border-amber-500/40' : 'px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-slate-800 text-slate-400';
}
}
} catch (err) {
console.error('Failed to load VPN servers:', err);
}
}
async function loadPPPSessions() {
try {
const res = await fetch(`${API_BASE}/vpn/ppp/active`);
const data = await res.json();
state.pppActiveSessions = Array.isArray(data) ? data : [];
const b = document.getElementById('ppp-badge-active');
if (b) b.innerText = state.pppActiveSessions.length;
const sideB = document.getElementById('badge-ppp-sessions');
if (sideB) sideB.innerText = state.pppActiveSessions.length;
renderPPPSessions();
} catch (err) {
console.error('Failed to load active PPP sessions:', err);
}
}
function renderPPPSessions() {
const tbody = document.getElementById('active-ppp-table-body');
const empty = document.getElementById('active-ppp-empty');
if (!tbody) return;
tbody.innerHTML = '';
const list = state.pppActiveSessions || [];
if (list.length === 0) {
empty?.classList.remove('hidden');
return;
}
empty?.classList.add('hidden');
list.forEach(s => {
const tr = document.createElement('tr');
tr.className = 'hover:bg-slate-800/40 transition text-xs font-mono';
tr.innerHTML = `
${s.interface} |
${s.username} |
${s.service} |
${s.address || '-'} |
${s.uptime} |
↓ ${s.rx_human || '0 B'}
↑ ${s.tx_human || '0 B'}
|
|
`;
tbody.appendChild(tr);
});
}
async function disconnectPPPSession(iface) {
if (!confirm(`Disconnect session ${iface}?`)) return;
try {
const res = await fetch(`${API_BASE}/vpn/ppp/active/${encodeURIComponent(iface)}`, { method: 'DELETE' });
if (res.ok) {
showToast('Disconnected', `Session ${iface} terminated`, 'info');
loadPPPSessions();
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
// PPP Secret Modal handlers
function openAddPPPSecretModal() {
document.getElementById('secret-modal-title').innerText = 'Add PPP Secret';
document.getElementById('secret-modal-id').value = '';
document.getElementById('secret-modal-name').value = '';
document.getElementById('secret-modal-password').value = '';
document.getElementById('secret-modal-service').value = 'any';
document.getElementById('secret-modal-profile').value = 'default';
document.getElementById('secret-modal-local').value = '';
document.getElementById('secret-modal-remote').value = '';
document.getElementById('secret-modal-comment').value = '';
document.getElementById('secret-modal-disabled').checked = false;
document.getElementById('ppp-secret-modal').classList.remove('hidden');
}
function openEditPPPSecretModal(id) {
const s = (state.pppSecrets || []).find(sec => sec.id === id);
if (!s) return;
document.getElementById('secret-modal-title').innerText = `Edit PPP Secret (${s.name})`;
document.getElementById('secret-modal-id').value = s.id;
document.getElementById('secret-modal-name').value = s.name;
document.getElementById('secret-modal-password').value = s.password;
document.getElementById('secret-modal-service').value = s.service || 'any';
document.getElementById('secret-modal-profile').value = s.profile || 'default';
document.getElementById('secret-modal-local').value = s.local_address || '';
document.getElementById('secret-modal-remote').value = s.remote_address || '';
document.getElementById('secret-modal-comment').value = s.comment || '';
document.getElementById('secret-modal-disabled').checked = !!s.disabled;
document.getElementById('ppp-secret-modal').classList.remove('hidden');
}
function closePPPSecretModal() {
document.getElementById('ppp-secret-modal').classList.add('hidden');
}
async function submitSavePPPSecret(e) {
e.preventDefault();
const id = document.getElementById('secret-modal-id').value;
const name = document.getElementById('secret-modal-name').value.trim();
const password = document.getElementById('secret-modal-password').value.trim();
const service = document.getElementById('secret-modal-service').value;
const profile = document.getElementById('secret-modal-profile').value;
const local = document.getElementById('secret-modal-local').value.trim();
const remote = document.getElementById('secret-modal-remote').value.trim();
const comment = document.getElementById('secret-modal-comment').value.trim();
const disabled = document.getElementById('secret-modal-disabled').checked;
try {
const res = await fetch(`${API_BASE}/vpn/ppp/secrets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id,
name,
password,
service,
profile,
local_address: local,
remote_address: remote,
comment,
disabled
})
});
if (res.ok) {
showToast('Secret Saved', `User ${name} saved to PPP database`, 'success');
closePPPSecretModal();
loadPPPSecrets();
} else {
const err = await res.json();
showToast('Error', err.message, 'error');
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function togglePPPSecretState(id, disabled) {
try {
const res = await fetch(`${API_BASE}/vpn/ppp/secrets/${encodeURIComponent(id)}/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ disabled })
});
if (res.ok) {
showToast(disabled ? 'Disabled' : 'Enabled', 'Secret user state updated', 'info');
loadPPPSecrets();
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function deletePPPSecret(id) {
if (!confirm('Are you sure you want to delete this secret account?')) return;
try {
const res = await fetch(`${API_BASE}/vpn/ppp/secrets/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (res.ok) {
showToast('Deleted', 'Secret account removed', 'success');
loadPPPSecrets();
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
// PPP Profile Modal handlers
function openAddPPPProfileModal() {
document.getElementById('profile-modal-name').value = '';
document.getElementById('profile-modal-local').value = '10.10.10.1';
document.getElementById('profile-modal-remote').value = '10.10.10.10-10.10.10.50';
document.getElementById('profile-modal-dns').value = '1.1.1.1,8.8.8.8';
document.getElementById('profile-modal-encryption').value = 'required';
document.getElementById('profile-modal-onlyone').checked = true;
document.getElementById('profile-modal-comment').value = '';
document.getElementById('ppp-profile-modal').classList.remove('hidden');
}
function closePPPProfileModal() {
document.getElementById('ppp-profile-modal').classList.add('hidden');
}
async function submitSavePPPProfile(e) {
e.preventDefault();
const name = document.getElementById('profile-modal-name').value.trim();
const local = document.getElementById('profile-modal-local').value.trim();
const remote = document.getElementById('profile-modal-remote').value.trim();
const dns = document.getElementById('profile-modal-dns').value.trim();
const enc = document.getElementById('profile-modal-encryption').value;
const onlyone = document.getElementById('profile-modal-onlyone').checked;
const comment = document.getElementById('profile-modal-comment').value.trim();
try {
const res = await fetch(`${API_BASE}/vpn/ppp/profiles`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: name,
name,
local_address: local,
remote_address: remote,
dns_servers: dns,
use_encryption: enc,
only_one: onlyone,
comment
})
});
if (res.ok) {
showToast('Profile Saved', `PPP profile ${name} created`, 'success');
closePPPProfileModal();
loadPPPProfiles();
} else {
const err = await res.json();
showToast('Error', err.message, 'error');
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function deletePPPProfile(name) {
if (!confirm(`Delete PPP profile ${name}?`)) return;
try {
const res = await fetch(`${API_BASE}/vpn/ppp/profiles/${encodeURIComponent(name)}`, { method: 'DELETE' });
if (res.ok) {
showToast('Deleted', 'Profile removed', 'success');
loadPPPProfiles();
} else {
const err = await res.json();
showToast('Error', err.message, 'error');
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
// Server Config submissions
async function savePPPoEServerConfig(e) {
e.preventDefault();
const enabled = document.getElementById('pppoe-server-enabled').checked;
const name = document.getElementById('pppoe-server-name').value.trim();
const iface = document.getElementById('pppoe-server-iface').value;
const profile = document.getElementById('pppoe-server-profile').value;
const mtu = parseInt(document.getElementById('pppoe-server-mtu').value) || 1480;
const onesession = document.getElementById('pppoe-server-onesession').checked;
try {
const res = await fetch(`${API_BASE}/vpn/servers/pppoe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled,
service_name: name,
interface: iface,
default_profile: profile,
max_mtu: mtu,
max_mru: mtu,
one_session_per_host: onesession
})
});
if (res.ok) {
showToast('PPPoE Server', enabled ? 'PPPoE Server started' : 'PPPoE Server stopped', 'success');
loadPPPServers();
fetchData(false);
} else {
const err = await res.json();
showToast('Error', err.message, 'error');
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function savePPTPServerConfig(e) {
e.preventDefault();
const enabled = document.getElementById('pptp-server-enabled').checked;
const port = parseInt(document.getElementById('pptp-server-port').value) || 1723;
const profile = document.getElementById('pptp-server-profile').value;
const mppe = document.getElementById('pptp-server-mppe').checked;
try {
const res = await fetch(`${API_BASE}/vpn/servers/pptp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled,
port,
default_profile: profile,
require_mppe: mppe
})
});
if (res.ok) {
showToast('PPTP Server', enabled ? 'PPTP Server started' : 'PPTP Server stopped', 'success');
loadPPPServers();
fetchData(false);
} else {
const err = await res.json();
showToast('Error', err.message, 'error');
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function saveL2TPServerConfig(e) {
e.preventDefault();
const enabled = document.getElementById('l2tp-server-enabled').checked;
const port = parseInt(document.getElementById('l2tp-server-port').value) || 1701;
const profile = document.getElementById('l2tp-server-profile').value;
const useipsec = document.getElementById('l2tp-server-useipsec').value;
const psk = document.getElementById('l2tp-server-ipsecsecret').value.trim();
try {
const res = await fetch(`${API_BASE}/vpn/servers/l2tp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled,
port,
default_profile: profile,
use_ipsec: useipsec,
ipsec_secret: psk
})
});
if (res.ok) {
showToast('L2TP Server', enabled ? 'L2TP Server started' : 'L2TP Server stopped', 'success');
loadPPPServers();
fetchData(false);
} else {
const err = await res.json();
showToast('Error', err.message, 'error');
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
// IPSec Server Modal
function openIPSecServerModal() {
loadPPPServers();
document.getElementById('ipsec-server-modal').classList.remove('hidden');
}
function closeIPSecServerModal() {
document.getElementById('ipsec-server-modal').classList.add('hidden');
}
async function submitSaveIPSecServer(e) {
e.preventDefault();
const enabled = document.getElementById('modal-ipsec-server-enabled').checked;
const psk = document.getElementById('modal-ipsec-server-psk').value.trim();
const pool = document.getElementById('modal-ipsec-server-pool').value.trim();
const dns = document.getElementById('modal-ipsec-server-dns').value.trim();
const subnet = document.getElementById('modal-ipsec-server-subnet').value.trim();
try {
const res = await fetch(`${API_BASE}/vpn/servers/ipsec`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled,
preshared_key: psk,
pool_address: pool,
dns_servers: dns,
local_subnet: subnet
})
});
if (res.ok) {
showToast('IPSec Server', enabled ? 'IKEv2 Roadwarrior responder active' : 'IPSec Server stopped', 'success');
closeIPSecServerModal();
loadPPPServers();
fetchData(false);
} else {
const err = await res.json();
showToast('Error', err.message, 'error');
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
// WireGuard Client Config Viewer
let currentWGClientConf = '';
async function openWGClientConfModal(iface, clientIP) {
try {
const res = await fetch(`${API_BASE}/vpn/wireguard/client-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
interface: iface,
address: clientIP || '10.10.0.2/32',
dns: '1.1.1.1, 8.8.8.8'
})
});
if (res.ok) {
const data = await res.json();
currentWGClientConf = data.config;
document.getElementById('wg-client-conf-text').value = currentWGClientConf;
document.getElementById('wg-client-conf-modal').classList.remove('hidden');
} else {
const err = await res.json();
showToast('Error', err.message, 'error');
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
function closeWGClientConfModal() {
document.getElementById('wg-client-conf-modal').classList.add('hidden');
}
function copyWGClientConf() {
if (!currentWGClientConf) return;
copyToClipboard(currentWGClientConf, 'WireGuard Client Profile');
}
function downloadWGClientConf() {
if (!currentWGClientConf) return;
const blob = new Blob([currentWGClientConf], { type: 'text/plain;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'wireguard-client.conf';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
showToast('Downloaded', 'wireguard-client.conf saved', 'success');
}
// =========================================================================
// DYNAMIC ROUTING (FRRouting BGP & OSPFv2)
// =========================================================================
state.activeDynSubTab = 'bgp-peers';
state.activeBgpSubTab = 'bgp-peers';
state.activeOspfSubTab = 'ospf-ifaces';
state.bgpInstance = null;
state.bgpPeers = [];
state.bgpNets = [];
state.bgpRoutes = [];
state.ospfInstance = null;
state.ospfInterfaces = [];
state.ospfAreas = [];
state.ospfNeighbors = [];
state.ospfRoutes = [];
function switchBgpSubTab(subTab) {
state.activeBgpSubTab = subTab;
const tabs = ['bgp-peers', 'bgp-nets', 'bgp-routes'];
tabs.forEach(t => {
const el = document.getElementById(`dyn-subtab-${t}`);
const btn = document.getElementById(`bgp-subtab-btn-${t}`);
if (el) el.classList.toggle('hidden', t !== subTab);
if (btn) {
if (t === subTab) {
btn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-purple-500 text-white transition flex items-center gap-2 whitespace-nowrap';
} else {
btn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2 whitespace-nowrap';
}
}
});
}
function switchOspfSubTab(subTab) {
state.activeOspfSubTab = subTab;
const tabs = ['ospf-ifaces', 'ospf-areas', 'ospf-nbrs', 'ospf-routes'];
tabs.forEach(t => {
const el = document.getElementById(`dyn-subtab-${t}`);
const btn = document.getElementById(`ospf-subtab-btn-${t}`);
if (el) el.classList.toggle('hidden', t !== subTab);
if (btn) {
if (t === subTab) {
btn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-indigo-500 text-white transition flex items-center gap-2 whitespace-nowrap';
} else {
btn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2 whitespace-nowrap';
}
}
});
}
function switchDynSubTab(subTab) {
if (subTab.startsWith('bgp')) {
if (state.activeTab !== 'bgp') navigate('bgp');
switchBgpSubTab(subTab);
} else if (subTab.startsWith('ospf')) {
if (state.activeTab !== 'ospf') navigate('ospf');
switchOspfSubTab(subTab);
}
}
async function loadDynamicRouting(showToastMsg = false) {
try {
const [sumRes, bgpInstRes, bgpPeersRes, bgpNetsRes, bgpRoutesRes, ospfInstRes, ospfIfacesRes, ospfAreasRes, ospfNbrsRes, ospfRoutesRes] = await Promise.all([
fetch(`${API_BASE}/routing/dynamic/summary`),
fetch(`${API_BASE}/routing/bgp/instance`),
fetch(`${API_BASE}/routing/bgp/peers`),
fetch(`${API_BASE}/routing/bgp/networks`),
fetch(`${API_BASE}/routing/bgp/routes`),
fetch(`${API_BASE}/routing/ospf/instance`),
fetch(`${API_BASE}/routing/ospf/interfaces`),
fetch(`${API_BASE}/routing/ospf/areas`),
fetch(`${API_BASE}/routing/ospf/neighbors`),
fetch(`${API_BASE}/routing/ospf/routes`)
]);
if (sumRes.ok) state.dynSummary = await sumRes.json();
if (bgpInstRes.ok) state.bgpInstance = await bgpInstRes.json();
if (bgpPeersRes.ok) state.bgpPeers = await bgpPeersRes.json();
if (bgpNetsRes.ok) state.bgpNets = await bgpNetsRes.json();
if (bgpRoutesRes.ok) state.bgpRoutes = await bgpRoutesRes.json();
if (ospfInstRes.ok) state.ospfInstance = await ospfInstRes.json();
if (ospfIfacesRes.ok) state.ospfInterfaces = await ospfIfacesRes.json();
if (ospfAreasRes.ok) state.ospfAreas = await ospfAreasRes.json();
if (ospfNbrsRes.ok) state.ospfNeighbors = await ospfNbrsRes.json();
if (ospfRoutesRes.ok) state.ospfRoutes = await ospfRoutesRes.json();
try { renderBgpStatusPill(); } catch(e) { console.error('renderBgpStatusPill error:', e); }
try { renderBgpPeers(); } catch(e) { console.error('renderBgpPeers error:', e); }
try { renderBgpNetworks(); } catch(e) { console.error('renderBgpNetworks error:', e); }
try { renderBgpRoutes(); } catch(e) { console.error('renderBgpRoutes error:', e); }
try { renderOspfStatusPill(); } catch(e) { console.error('renderOspfStatusPill error:', e); }
try { renderOspfInterfaces(); } catch(e) { console.error('renderOspfInterfaces error:', e); }
try { renderOspfAreas(); } catch(e) { console.error('renderOspfAreas error:', e); }
try { renderOspfNeighbors(); } catch(e) { console.error('renderOspfNeighbors error:', e); }
try { renderOspfRoutes(); } catch(e) { console.error('renderOspfRoutes error:', e); }
try { 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('Error', 'Failed to load dynamic routing telemetry', 'error');
}
}
function renderBgpStatusPill() {
const pill = document.getElementById('bgp-live-status-pill');
if (!pill) return;
const inst = state.bgpInstance;
if (!inst || !inst.enabled) {
pill.className = 'text-[10px] px-2 py-0.5 rounded font-mono bg-slate-800 text-slate-400 border border-slate-700';
pill.innerText = 'BGP DISABLED';
} else if (inst.running) {
pill.className = 'text-[10px] px-2 py-0.5 rounded font-mono bg-purple-950 text-purple-300 border border-purple-800/60 font-semibold';
pill.innerText = `BGP ACTIVE (AS ${inst.as})`;
} else {
pill.className = 'text-[10px] px-2 py-0.5 rounded font-mono bg-amber-950 text-amber-300 border border-amber-800/60 font-semibold';
pill.innerText = 'BGP STARTING...';
}
}
function renderBgpPeers() {
const tbody = document.getElementById('bgp-peers-table-body');
if (!tbody) return;
const empty = document.getElementById('bgp-peers-empty');
const countBadge = document.getElementById('bgp-peers-count-badge');
const navBadge = document.getElementById('dyn-badge-bgp-peers');
const peers = state.bgpPeers || [];
if (countBadge) countBadge.innerText = `${peers.length} Peers`;
if (navBadge) navBadge.innerText = peers.length;
if (peers.length === 0) {
tbody.innerHTML = '';
if (empty) empty.classList.remove('hidden');
return;
}
if (empty) empty.classList.add('hidden');
tbody.innerHTML = peers.map(p => {
let flagA = !p.disabled ? 'A' : '';
let flagE = p.state === 'Established' ? 'E' : '';
let flagX = p.disabled ? 'X' : '';
let stateBadge = '';
if (p.disabled) {
stateBadge = 'Disabled';
} else if (p.state === 'Established') {
stateBadge = 'Established';
} else if (p.state === 'Connect' || p.state === 'Active') {
stateBadge = `${p.state}`;
} else {
stateBadge = `${p.state || 'Idle'}`;
}
return `
| ${flagA}${flagE}${flagX} |
${escapeHtml(p.name)} |
${escapeHtml(p.remote_address)} |
AS ${p.remote_as} |
${stateBadge} |
${escapeHtml(p.uptime || '-')} |
↓ ${p.prefix_received || 0} /
↑ ${p.prefix_sent || 0}
|
${escapeHtml(p.comment || '')} |
|
`;
}).join('');
}
function renderBgpNetworks() {
const tbody = document.getElementById('bgp-nets-table-body');
if (!tbody) return;
const empty = document.getElementById('bgp-nets-empty');
const countBadge = document.getElementById('bgp-nets-count-badge');
const navBadge = document.getElementById('dyn-badge-bgp-nets');
const nets = state.bgpNets || [];
if (countBadge) countBadge.innerText = `${nets.length} Networks`;
if (navBadge) navBadge.innerText = nets.length;
if (nets.length === 0) {
tbody.innerHTML = '';
if (empty) empty.classList.remove('hidden');
return;
}
if (empty) empty.classList.add('hidden');
tbody.innerHTML = nets.map(n => `
| ${escapeHtml(n.network)} |
${n.disabled
? 'Disabled'
: 'Announcing'}
|
${escapeHtml(n.comment || '')} |
|
`).join('');
}
function renderBgpRoutes() {
const tbody = document.getElementById('bgp-routes-table-body');
if (!tbody) return;
const empty = document.getElementById('bgp-routes-empty');
const countBadge = document.getElementById('bgp-routes-count-badge');
const navBadge = document.getElementById('dyn-badge-bgp-routes');
const routes = state.bgpRoutes || [];
if (countBadge) countBadge.innerText = `${routes.length} Routes`;
if (navBadge) navBadge.innerText = routes.length;
if (routes.length === 0) {
tbody.innerHTML = '';
if (empty) empty.classList.remove('hidden');
return;
}
if (empty) empty.classList.add('hidden');
tbody.innerHTML = routes.map(r => `
| ${escapeHtml(r.prefix)} |
${escapeHtml(r.next_hop || '-')} |
${r.metric} |
${r.weight} |
${escapeHtml(r.path || 'i')} |
${escapeHtml(r.origin || 'IGP')} |
`).join('');
}
function renderOspfStatusPill() {
const pill = document.getElementById('ospf-live-status-pill');
if (!pill) return;
const inst = state.ospfInstance;
if (!inst || !inst.enabled) {
pill.className = 'text-[10px] px-2 py-0.5 rounded font-mono bg-slate-800 text-slate-400 border border-slate-700';
pill.innerText = 'OSPF DISABLED';
} else if (inst.running) {
pill.className = 'text-[10px] px-2 py-0.5 rounded font-mono bg-indigo-950 text-indigo-300 border border-indigo-800/60 font-semibold';
pill.innerText = `OSPF ACTIVE (ID: ${inst.router_id || 'Auto'})`;
} else {
pill.className = 'text-[10px] px-2 py-0.5 rounded font-mono bg-amber-950 text-amber-300 border border-amber-800/60 font-semibold';
pill.innerText = 'OSPF STARTING...';
}
}
function renderOspfInterfaces() {
const tbody = document.getElementById('ospf-ifaces-table-body');
if (!tbody) return;
const empty = document.getElementById('ospf-ifaces-empty');
const countBadge = document.getElementById('ospf-ifaces-count-badge');
const navBadge = document.getElementById('dyn-badge-ospf-ifaces');
const ifaces = state.ospfInterfaces || [];
if (countBadge) countBadge.innerText = `${ifaces.length} Interfaces`;
if (navBadge) navBadge.innerText = ifaces.length;
if (ifaces.length === 0) {
tbody.innerHTML = '';
if (empty) empty.classList.remove('hidden');
return;
}
if (empty) empty.classList.add('hidden');
tbody.innerHTML = ifaces.map(i => {
let flagA = !i.disabled ? 'A' : '';
let flagP = i.passive ? 'P' : '';
let flagX = i.disabled ? 'X' : '';
return `
| ${flagA}${flagP}${flagX} |
${escapeHtml(i.network)} |
Area ${escapeHtml(i.area)} |
${escapeHtml(i.interface || 'Any')} |
${i.cost} |
${i.priority} |
${i.hello_interval}s / ${i.dead_interval}s |
${escapeHtml(i.auth_type || 'none')} |
${escapeHtml(i.comment || '')} |
|
`;
}).join('');
}
function renderOspfAreas() {
const tbody = document.getElementById('ospf-areas-table-body');
if (!tbody) return;
const countBadge = document.getElementById('ospf-areas-count-badge');
const navBadge = document.getElementById('dyn-badge-ospf-areas');
const areas = state.ospfAreas || [];
if (countBadge) countBadge.innerText = `${areas.length} Areas`;
if (navBadge) navBadge.innerText = areas.length;
tbody.innerHTML = areas.map(a => `
| ${escapeHtml(a.name)} |
${escapeHtml(a.area_id)} |
${escapeHtml(a.area_type || 'default')} |
${escapeHtml(a.comment || '')} |
|
`).join('');
}
function renderOspfNeighbors() {
const tbody = document.getElementById('ospf-nbrs-table-body');
if (!tbody) return;
const empty = document.getElementById('ospf-nbrs-empty');
const countBadge = document.getElementById('ospf-nbrs-count-badge');
const navBadge = document.getElementById('dyn-badge-ospf-nbrs');
const nbrs = state.ospfNeighbors || [];
if (countBadge) countBadge.innerText = `${nbrs.length} Neighbors`;
if (navBadge) navBadge.innerText = nbrs.length;
if (nbrs.length === 0) {
tbody.innerHTML = '';
if (empty) empty.classList.remove('hidden');
return;
}
if (empty) empty.classList.add('hidden');
tbody.innerHTML = nbrs.map(n => {
let stateBadge = '';
if (n.state && (n.state.startsWith('Full') || n.state.includes('DR') || n.state.includes('Backup'))) {
stateBadge = `${escapeHtml(n.state)}`;
} else {
stateBadge = `${escapeHtml(n.state || 'Unknown')}`;
}
return `
| ${escapeHtml(n.neighbor_id)} |
${escapeHtml(n.address)} |
${escapeHtml(n.interface)} |
${escapeHtml(n.area || '0.0.0.0')} |
${stateBadge} |
${escapeHtml(n.dead_timer || '-')} |
`;
}).join('');
}
function renderOspfRoutes() {
const tbody = document.getElementById('ospf-routes-table-body');
if (!tbody) return;
const empty = document.getElementById('ospf-routes-empty');
const countBadge = document.getElementById('ospf-routes-count-badge');
const navBadge = document.getElementById('dyn-badge-ospf-routes');
const routes = state.ospfRoutes || [];
if (countBadge) countBadge.innerText = `${routes.length} Routes`;
if (navBadge) navBadge.innerText = routes.length;
if (routes.length === 0) {
tbody.innerHTML = '';
if (empty) empty.classList.remove('hidden');
return;
}
if (empty) empty.classList.add('hidden');
tbody.innerHTML = routes.map(r => `
| ${escapeHtml(r.prefix)} |
${escapeHtml(r.route_type || 'Network')} |
${r.cost} |
Area ${escapeHtml(r.area || '0.0.0.0')} |
${escapeHtml(r.next_hop || r.interface || '-')} |
`).join('');
}
// BGP Modals & Actions
function openBgpInstanceModal() {
const inst = state.bgpInstance || { enabled: false, as: 65001, router_id: '192.168.131.56', hold_time: 180, keepalive_time: 60 };
document.getElementById('modal-bgp-enabled').checked = !!inst.enabled;
document.getElementById('modal-bgp-as').value = inst.as || 65001;
document.getElementById('modal-bgp-router-id').value = inst.router_id || '';
document.getElementById('modal-bgp-keepalive').value = inst.keepalive_time || 60;
document.getElementById('modal-bgp-holdtime').value = inst.hold_time || 180;
document.getElementById('modal-bgp-redist-conn').checked = !!inst.redistribute_connected;
document.getElementById('modal-bgp-redist-static').checked = !!inst.redistribute_static;
document.getElementById('modal-bgp-redist-ospf').checked = !!inst.redistribute_ospf;
document.getElementById('bgp-instance-modal').classList.remove('hidden');
}
function closeBgpInstanceModal() {
document.getElementById('bgp-instance-modal').classList.add('hidden');
}
async function submitBgpInstance(e) {
e.preventDefault();
const payload = {
enabled: document.getElementById('modal-bgp-enabled').checked,
as: parseInt(document.getElementById('modal-bgp-as').value, 10),
router_id: document.getElementById('modal-bgp-router-id').value.trim(),
keepalive_time: parseInt(document.getElementById('modal-bgp-keepalive').value, 10),
hold_time: parseInt(document.getElementById('modal-bgp-holdtime').value, 10),
redistribute_connected: document.getElementById('modal-bgp-redist-conn').checked,
redistribute_static: document.getElementById('modal-bgp-redist-static').checked,
redistribute_ospf: document.getElementById('modal-bgp-redist-ospf').checked
};
try {
const res = await fetch(`${API_BASE}/routing/bgp/instance`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
closeBgpInstanceModal();
showToast('Success', 'BGP instance configuration updated', 'success');
loadDynamicRouting();
} else {
const err = await res.json();
showToast('Error', err.error || 'Failed to update BGP instance', 'error');
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
function openAddBgpPeerModal() {
document.getElementById('bgp-peer-form').reset();
document.getElementById('modal-bgp-peer-id').value = '';
document.getElementById('bgp-peer-modal-title').innerText = 'Add BGP Peer Connection';
document.getElementById('modal-bgp-peer-nexthop-self').checked = true;
document.getElementById('bgp-peer-modal').classList.remove('hidden');
}
function openEditBgpPeerModal(id) {
const p = (state.bgpPeers || []).find(item => item.id === id);
if (!p) return;
document.getElementById('modal-bgp-peer-id').value = p.id;
document.getElementById('modal-bgp-peer-name').value = p.name || '';
document.getElementById('modal-bgp-peer-remote-addr').value = p.remote_address || '';
document.getElementById('modal-bgp-peer-remote-as').value = p.remote_as || '';
document.getElementById('modal-bgp-peer-local-addr').value = p.local_address || '';
document.getElementById('modal-bgp-peer-password').value = p.password || '';
document.getElementById('modal-bgp-peer-multihop').checked = !!p.multihop;
document.getElementById('modal-bgp-peer-nexthop-self').checked = !!p.nexthop_self;
document.getElementById('modal-bgp-peer-passive').checked = !!p.passive;
document.getElementById('modal-bgp-peer-disabled').checked = !!p.disabled;
document.getElementById('modal-bgp-peer-comment').value = p.comment || '';
document.getElementById('bgp-peer-modal-title').innerText = 'Edit BGP Peer Connection';
document.getElementById('bgp-peer-modal').classList.remove('hidden');
}
function closeBgpPeerModal() {
document.getElementById('bgp-peer-modal').classList.add('hidden');
}
async function submitBgpPeer(e) {
e.preventDefault();
const payload = {
id: document.getElementById('modal-bgp-peer-id').value,
name: document.getElementById('modal-bgp-peer-name').value.trim(),
remote_address: document.getElementById('modal-bgp-peer-remote-addr').value.trim(),
remote_as: parseInt(document.getElementById('modal-bgp-peer-remote-as').value, 10),
local_address: document.getElementById('modal-bgp-peer-local-addr').value.trim(),
password: document.getElementById('modal-bgp-peer-password').value,
multihop: document.getElementById('modal-bgp-peer-multihop').checked,
nexthop_self: document.getElementById('modal-bgp-peer-nexthop-self').checked,
passive: document.getElementById('modal-bgp-peer-passive').checked,
disabled: document.getElementById('modal-bgp-peer-disabled').checked,
comment: document.getElementById('modal-bgp-peer-comment').value.trim()
};
try {
const res = await fetch(`${API_BASE}/routing/bgp/peers`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
closeBgpPeerModal();
showToast('Success', 'BGP Peer saved', 'success');
loadDynamicRouting();
} else {
const err = await res.json();
showToast('Error', err.error || 'Failed to save peer', 'error');
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function deleteBgpPeer(id) {
if (!confirm('Are you sure you want to delete this BGP peer connection?')) return;
try {
const res = await fetch(`${API_BASE}/routing/bgp/peers/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (res.ok) {
showToast('Deleted', 'BGP peer removed', 'success');
loadDynamicRouting();
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function toggleBgpPeerState(id, disabled) {
try {
const res = await fetch(`${API_BASE}/routing/bgp/peers/${encodeURIComponent(id)}/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ disabled })
});
if (res.ok) {
showToast('Updated', `Peer ${disabled ? 'disabled' : 'enabled'}`, 'success');
loadDynamicRouting();
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
function openAddBgpNetworkModal() {
document.getElementById('bgp-network-form').reset();
document.getElementById('modal-bgp-net-id').value = '';
document.getElementById('bgp-network-modal').classList.remove('hidden');
}
function closeBgpNetworkModal() {
document.getElementById('bgp-network-modal').classList.add('hidden');
}
async function submitBgpNetwork(e) {
e.preventDefault();
const payload = {
id: document.getElementById('modal-bgp-net-id').value,
network: document.getElementById('modal-bgp-net-cidr').value.trim(),
comment: document.getElementById('modal-bgp-net-comment').value.trim(),
disabled: document.getElementById('modal-bgp-net-disabled').checked
};
try {
const res = await fetch(`${API_BASE}/routing/bgp/networks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
closeBgpNetworkModal();
showToast('Success', 'BGP network advertisement saved', 'success');
loadDynamicRouting();
} else {
const err = await res.json();
showToast('Error', err.error || 'Failed to save network', 'error');
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function deleteBgpNetwork(id) {
if (!confirm('Are you sure you want to stop announcing this network?')) return;
try {
const res = await fetch(`${API_BASE}/routing/bgp/networks/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (res.ok) {
showToast('Deleted', 'Network advertisement removed', 'success');
loadDynamicRouting();
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
// OSPF Modals & Actions
function openOspfInstanceModal() {
const inst = state.ospfInstance || { enabled: false, router_id: '192.168.131.56', default_metric: 10 };
document.getElementById('modal-ospf-enabled').checked = !!inst.enabled;
document.getElementById('modal-ospf-router-id').value = inst.router_id || '';
document.getElementById('modal-ospf-metric').value = inst.default_metric || 10;
document.getElementById('modal-ospf-redist-conn').checked = !!inst.redistribute_connected;
document.getElementById('modal-ospf-redist-static').checked = !!inst.redistribute_static;
document.getElementById('modal-ospf-redist-bgp').checked = !!inst.redistribute_bgp;
document.getElementById('ospf-instance-modal').classList.remove('hidden');
}
function closeOspfInstanceModal() {
document.getElementById('ospf-instance-modal').classList.add('hidden');
}
async function submitOspfInstance(e) {
e.preventDefault();
const payload = {
enabled: document.getElementById('modal-ospf-enabled').checked,
router_id: document.getElementById('modal-ospf-router-id').value.trim(),
default_metric: parseInt(document.getElementById('modal-ospf-metric').value, 10),
redistribute_connected: document.getElementById('modal-ospf-redist-conn').checked,
redistribute_static: document.getElementById('modal-ospf-redist-static').checked,
redistribute_bgp: document.getElementById('modal-ospf-redist-bgp').checked
};
try {
const res = await fetch(`${API_BASE}/routing/ospf/instance`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
closeOspfInstanceModal();
showToast('Success', 'OSPF instance configuration updated', 'success');
loadDynamicRouting();
} else {
const err = await res.json();
showToast('Error', err.error || 'Failed to update OSPF instance', 'error');
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
function openAddOspfAreaModal() {
document.getElementById('ospf-area-form').reset();
document.getElementById('modal-ospf-area-id').value = '';
document.getElementById('modal-ospf-area-areaid').value = '0.0.0.0';
document.getElementById('ospf-area-modal').classList.remove('hidden');
}
function closeOspfAreaModal() {
document.getElementById('ospf-area-modal').classList.add('hidden');
}
async function submitOspfArea(e) {
e.preventDefault();
const payload = {
id: document.getElementById('modal-ospf-area-id').value,
name: document.getElementById('modal-ospf-area-name').value.trim(),
area_id: document.getElementById('modal-ospf-area-areaid').value.trim(),
area_type: document.getElementById('modal-ospf-area-type').value,
comment: document.getElementById('modal-ospf-area-comment').value.trim()
};
try {
const res = await fetch(`${API_BASE}/routing/ospf/areas`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
closeOspfAreaModal();
showToast('Success', 'OSPF area saved', 'success');
loadDynamicRouting();
} else {
const err = await res.json();
showToast('Error', err.error || 'Failed to save area', 'error');
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function deleteOspfArea(id) {
if (!confirm('Are you sure you want to delete this OSPF area?')) return;
try {
const res = await fetch(`${API_BASE}/routing/ospf/areas/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (res.ok) {
showToast('Deleted', 'OSPF area removed', 'success');
loadDynamicRouting();
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
function populateOspfInterfaceDevices() {
const select = document.getElementById('modal-ospf-iface-device');
if (!select) return;
const current = select.value;
select.innerHTML = '';
(state.interfaces || []).forEach(iface => {
select.innerHTML += ``;
});
if (current) select.value = current;
}
function openAddOspfInterfaceModal() {
document.getElementById('ospf-interface-form').reset();
document.getElementById('modal-ospf-iface-id').value = '';
document.getElementById('modal-ospf-iface-area').value = '0.0.0.0';
document.getElementById('modal-ospf-iface-cost').value = '10';
document.getElementById('modal-ospf-iface-priority').value = '1';
document.getElementById('modal-ospf-iface-hello').value = '10';
document.getElementById('modal-ospf-iface-dead').value = '40';
document.getElementById('ospf-interface-modal-title').innerText = 'Add OSPF Interface';
populateOspfInterfaceDevices();
document.getElementById('ospf-interface-modal').classList.remove('hidden');
}
function openEditOspfInterfaceModal(id) {
const item = (state.ospfInterfaces || []).find(i => i.id === id);
if (!item) return;
populateOspfInterfaceDevices();
document.getElementById('modal-ospf-iface-id').value = item.id;
document.getElementById('modal-ospf-iface-net').value = item.network || '';
document.getElementById('modal-ospf-iface-area').value = item.area || '0.0.0.0';
document.getElementById('modal-ospf-iface-device').value = item.interface || '';
document.getElementById('modal-ospf-iface-cost').value = item.cost || 10;
document.getElementById('modal-ospf-iface-priority').value = item.priority || 1;
document.getElementById('modal-ospf-iface-hello').value = item.hello_interval || 10;
document.getElementById('modal-ospf-iface-dead').value = item.dead_interval || 40;
document.getElementById('modal-ospf-iface-auth-type').value = item.auth_type || 'none';
document.getElementById('modal-ospf-iface-auth-key').value = item.auth_key || '';
document.getElementById('modal-ospf-iface-passive').checked = !!item.passive;
document.getElementById('modal-ospf-iface-disabled').checked = !!item.disabled;
document.getElementById('modal-ospf-iface-comment').value = item.comment || '';
document.getElementById('ospf-interface-modal-title').innerText = 'Edit OSPF Interface';
document.getElementById('ospf-interface-modal').classList.remove('hidden');
}
function closeOspfInterfaceModal() {
document.getElementById('ospf-interface-modal').classList.add('hidden');
}
async function submitOspfInterface(e) {
e.preventDefault();
const payload = {
id: document.getElementById('modal-ospf-iface-id').value,
network: document.getElementById('modal-ospf-iface-net').value.trim(),
area: document.getElementById('modal-ospf-iface-area').value.trim(),
interface: document.getElementById('modal-ospf-iface-device').value,
cost: parseInt(document.getElementById('modal-ospf-iface-cost').value, 10),
priority: parseInt(document.getElementById('modal-ospf-iface-priority').value, 10),
hello_interval: parseInt(document.getElementById('modal-ospf-iface-hello').value, 10),
dead_interval: parseInt(document.getElementById('modal-ospf-iface-dead').value, 10),
auth_type: document.getElementById('modal-ospf-iface-auth-type').value,
auth_key: document.getElementById('modal-ospf-iface-auth-key').value,
passive: document.getElementById('modal-ospf-iface-passive').checked,
disabled: document.getElementById('modal-ospf-iface-disabled').checked,
comment: document.getElementById('modal-ospf-iface-comment').value.trim()
};
try {
const res = await fetch(`${API_BASE}/routing/ospf/interfaces`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
closeOspfInterfaceModal();
showToast('Success', 'OSPF interface template saved', 'success');
loadDynamicRouting();
} else {
const err = await res.json();
showToast('Error', err.error || 'Failed to save interface', 'error');
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function deleteOspfInterface(id) {
if (!confirm('Are you sure you want to delete this OSPF interface?')) return;
try {
const res = await fetch(`${API_BASE}/routing/ospf/interfaces/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (res.ok) {
showToast('Deleted', 'OSPF interface removed', 'success');
loadDynamicRouting();
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function toggleOspfInterfaceState(id, disabled) {
try {
const res = await fetch(`${API_BASE}/routing/ospf/interfaces/${encodeURIComponent(id)}/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ disabled })
});
if (res.ok) {
showToast('Updated', `Interface ${disabled ? 'disabled' : 'enabled'}`, 'success');
loadDynamicRouting();
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
// ============================================================================
// ==================== MIKROTIK BRIDGE SUBSYSTEM ==============================
// ============================================================================
function switchBridgeSubTab(tab) {
state.bridgeSubTab = tab;
const tabs = ['bridges', 'ports', 'vlans', 'hosts'];
tabs.forEach(t => {
const panel = document.getElementById(`bridge-subtab-${t}`);
const btn = document.getElementById(`bridge-subtab-btn-${t}`);
if (panel) {
if (t === tab) panel.classList.remove('hidden');
else panel.classList.add('hidden');
}
if (btn) {
if (t === tab) {
btn.className = 'px-4 py-2 text-xs font-bold rounded-lg bg-slate-800 text-white shadow-sm transition flex items-center gap-2';
} else {
btn.className = 'px-4 py-2 text-xs font-bold rounded-lg text-slate-400 hover:text-slate-200 transition flex items-center gap-2';
}
}
});
const addBtn = document.getElementById('bridge-top-action-btn');
if (addBtn) {
if (tab === 'bridges') addBtn.innerHTML = '+ Add Bridge';
else if (tab === 'ports') addBtn.innerHTML = '+ Add Port';
else if (tab === 'vlans') addBtn.innerHTML = '+ Add VLAN Entry';
else if (tab === 'hosts') addBtn.innerHTML = '🔄 Refresh MACs';
}
if (tab === 'hosts') loadBridgeHosts();
}
function openCurrentBridgeModal() {
if (state.bridgeSubTab === 'bridges') openAddBridgeModal();
else if (state.bridgeSubTab === 'ports') openAddBridgePortModal();
else if (state.bridgeSubTab === 'vlans') openAddBridgeVLANModal();
else if (state.bridgeSubTab === 'hosts') loadBridgeHosts();
}
async function loadBridge() {
try {
const [bridgesRes, portsRes, vlansRes, summaryRes, eoipRes, vlanIfacesRes] = await Promise.all([
fetch(`${API_BASE}/bridge/bridges`),
fetch(`${API_BASE}/bridge/ports`),
fetch(`${API_BASE}/bridge/vlans`),
fetch(`${API_BASE}/bridge/summary`),
fetch(`${API_BASE}/vpn/eoip`),
fetch(`${API_BASE}/vlans`)
]);
if (bridgesRes.ok) state.bridges = await bridgesRes.json();
if (portsRes.ok) state.bridgePorts = await portsRes.json();
if (vlansRes.ok) state.bridgeVLANs = await vlansRes.json();
if (summaryRes.ok) state.bridgeSummary = await summaryRes.json();
if (eoipRes && eoipRes.ok) state.eoipTunnels = await eoipRes.json();
if (vlanIfacesRes && vlanIfacesRes.ok) state.vlans = await vlanIfacesRes.json();
// Badges & Stats
const bBridges = document.getElementById('bridge-badge-bridges');
if (bBridges) bBridges.innerText = (state.bridges || []).length;
const bPorts = document.getElementById('bridge-badge-ports');
if (bPorts) bPorts.innerText = (state.bridgePorts || []).length;
const bVlans = document.getElementById('bridge-badge-vlans');
if (bVlans) bVlans.innerText = (state.bridgeVLANs || []).length;
const bHosts = document.getElementById('bridge-badge-hosts');
if (bHosts) bHosts.innerText = state.bridgeSummary?.hosts_count || 0;
const sBr = document.getElementById('bridge-stat-bridges');
if (sBr) sBr.innerText = (state.bridges || []).length;
const sPorts = document.getElementById('bridge-stat-ports');
if (sPorts) sPorts.innerText = (state.bridgePorts || []).length;
const sHosts = document.getElementById('bridge-stat-hosts');
if (sHosts) sHosts.innerText = state.bridgeSummary?.hosts_count || 0;
renderBridges();
renderBridgePorts();
renderBridgeVLANs();
if (state.bridgeSubTab === 'hosts') loadBridgeHosts();
} catch (err) {
console.error('Failed to load bridge subsystem:', err);
}
}
// ----------------- Bridges -----------------
function renderBridges() {
const tbody = document.getElementById('bridge-table-body');
const empty = document.getElementById('bridge-empty-bridges');
const countBadge = document.getElementById('bridge-count-badge');
if (!tbody) return;
tbody.innerHTML = '';
const bridges = state.bridges || [];
if (countBadge) countBadge.innerText = `${bridges.length} Bridges`;
if (bridges.length === 0) {
empty?.classList.remove('hidden');
return;
}
empty?.classList.add('hidden');
bridges.forEach((b, idx) => {
const tr = document.createElement('tr');
tr.className = `hover:bg-slate-800/40 transition text-xs ${b.disabled ? 'opacity-50' : ''}`;
// Format IP addresses pill list
let ipsHtml = 'None';
if (b.ip_addresses && b.ip_addresses.length > 0) {
ipsHtml = b.ip_addresses.map(ip => `
${ip}
`).join(' ');
}
const runningFlag = b.running ? 'R' : '-';
const disabledFlag = b.disabled ? 'X' : '';
const flagDisplay = `${runningFlag}${disabledFlag ? ' ' + disabledFlag : ''}`;
tr.innerHTML = `
${idx + 1} |
${flagDisplay} |
🌉${b.name}
|
${b.mtu} |
${b.mac_address || '-'} |
${b.stp}
|
${b.vlan_filtering ? 'yes' : 'no'}
|
${b.pvid || 1} |
${b.ports_count}
|
${ipsHtml}
|
↓ ${b.rx_human || '0 B'} / ↑ ${b.tx_human || '0 B'}
|
${b.comment || '-'} |
|
`;
tbody.appendChild(tr);
});
}
function openAddBridgeModal() {
document.getElementById('bridge-modal-title').innerText = 'Add Bridge Interface';
document.getElementById('bridge-modal-id').value = '';
document.getElementById('bridge-modal-name').value = 'bridge1';
document.getElementById('bridge-modal-name').disabled = false;
document.getElementById('bridge-modal-mtu').value = '1500';
document.getElementById('bridge-modal-mac').value = '';
document.getElementById('bridge-modal-stp').value = 'rstp';
document.getElementById('bridge-modal-priority').value = '32768';
document.getElementById('bridge-modal-vlanfiltering').checked = false;
document.getElementById('bridge-modal-pvid').value = '1';
document.getElementById('bridge-modal-fastforward').checked = true;
document.getElementById('bridge-modal-comment').value = '';
document.getElementById('bridge-modal-disabled').checked = false;
toggleBridgeModalVlanFiltering();
document.getElementById('bridge-modal').classList.remove('hidden');
}
function openEditBridgeModal(id) {
const b = (state.bridges || []).find(x => x.id === id);
if (!b) return;
document.getElementById('bridge-modal-title').innerText = `Edit Bridge (${b.name})`;
document.getElementById('bridge-modal-id').value = b.id;
document.getElementById('bridge-modal-name').value = b.name;
document.getElementById('bridge-modal-name').disabled = true;
document.getElementById('bridge-modal-mtu').value = b.mtu || 1500;
document.getElementById('bridge-modal-mac').value = b.mac_address || '';
document.getElementById('bridge-modal-stp').value = b.stp || 'rstp';
document.getElementById('bridge-modal-priority').value = b.priority || 32768;
document.getElementById('bridge-modal-vlanfiltering').checked = !!b.vlan_filtering;
document.getElementById('bridge-modal-pvid').value = b.pvid || 1;
document.getElementById('bridge-modal-fastforward').checked = b.fast_forward !== false;
document.getElementById('bridge-modal-comment').value = b.comment || '';
document.getElementById('bridge-modal-disabled').checked = !!b.disabled;
toggleBridgeModalVlanFiltering();
document.getElementById('bridge-modal').classList.remove('hidden');
}
function closeBridgeModal() {
document.getElementById('bridge-modal').classList.add('hidden');
}
function toggleBridgeModalVlanFiltering() {
const chk = document.getElementById('bridge-modal-vlanfiltering').checked;
const pvidBox = document.getElementById('bridge-modal-pvid-box');
if (pvidBox) {
if (chk) pvidBox.classList.remove('opacity-40');
else pvidBox.classList.add('opacity-40');
}
}
async function submitSaveBridge(e) {
e.preventDefault();
const id = document.getElementById('bridge-modal-id').value;
const name = document.getElementById('bridge-modal-name').value.trim();
const mtu = parseInt(document.getElementById('bridge-modal-mtu').value, 10) || 1500;
const mac = document.getElementById('bridge-modal-mac').value.trim();
const stp = document.getElementById('bridge-modal-stp').value;
const priority = parseInt(document.getElementById('bridge-modal-priority').value, 10) || 32768;
const vlanFiltering = document.getElementById('bridge-modal-vlanfiltering').checked;
const pvid = parseInt(document.getElementById('bridge-modal-pvid').value, 10) || 1;
const fastForward = document.getElementById('bridge-modal-fastforward').checked;
const comment = document.getElementById('bridge-modal-comment').value.trim();
const disabled = document.getElementById('bridge-modal-disabled').checked;
try {
const res = await fetch(`${API_BASE}/bridge/bridges`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id,
name,
mtu,
mac_address: mac,
stp,
priority,
vlan_filtering: vlanFiltering,
pvid,
fast_forward: fastForward,
comment,
disabled
})
});
if (res.ok) {
showToast('Bridge Saved', `Bridge ${name} configured`, 'success');
closeBridgeModal();
loadBridge();
fetchData(false);
} else {
const err = await res.json();
showToast('Error', err.error || err.message || 'Failed to save bridge', 'error');
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function toggleBridgeState(id, disabled) {
try {
const res = await fetch(`${API_BASE}/bridge/bridges/${encodeURIComponent(id)}/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ disabled })
});
if (res.ok) {
loadBridge();
fetchData(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function deleteBridge(id) {
const b = (state.bridges || []).find(x => x.id === id);
const name = b ? b.name : id;
if (!confirm(`Are you sure you want to delete bridge '${name}'? All member ports will be detached.`)) return;
try {
const res = await fetch(`${API_BASE}/bridge/bridges/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (res.ok) {
showToast('Deleted', `Bridge ${name} deleted`, 'success');
loadBridge();
fetchData(false);
} else {
const err = await res.json();
showToast('Error', err.error || err.message || 'Failed to delete bridge', 'error');
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
// ----------------- Bridge IP Assignments -----------------
function openAddBridgeIPModal(id, name) {
document.getElementById('bridge-ip-modal-id').value = id;
document.getElementById('bridge-ip-modal-sub').innerText = name;
document.getElementById('bridge-ip-modal-cidr').value = '';
document.getElementById('bridge-ip-modal').classList.remove('hidden');
}
function closeBridgeIPModal() {
document.getElementById('bridge-ip-modal').classList.add('hidden');
}
async function submitAddBridgeIP(e) {
e.preventDefault();
const id = document.getElementById('bridge-ip-modal-id').value;
const ip = document.getElementById('bridge-ip-modal-cidr').value.trim();
try {
const res = await fetch(`${API_BASE}/bridge/bridges/${encodeURIComponent(id)}/ip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ip })
});
if (res.ok) {
showToast('IP Assigned', `${ip} assigned to bridge`, 'success');
closeBridgeIPModal();
loadBridge();
fetchData(false);
} else {
const err = await res.json();
showToast('Error', err.error || err.message || 'Failed to add IP', 'error');
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function deleteBridgeIP(id, ip) {
if (!confirm(`Remove IP address ${ip} from bridge?`)) return;
try {
const res = await fetch(`${API_BASE}/bridge/bridges/${encodeURIComponent(id)}/ip`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ip })
});
if (res.ok) {
showToast('IP Removed', `${ip} removed from bridge`, 'success');
loadBridge();
fetchData(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
// ----------------- Bridge Ports -----------------
function renderBridgePorts() {
const tbody = document.getElementById('bridge-ports-table-body');
const empty = document.getElementById('bridge-empty-ports');
const countBadge = document.getElementById('bridge-ports-count-badge');
const filterBridge = document.getElementById('bridge-ports-filter-bridge')?.value || '';
if (!tbody) return;
tbody.innerHTML = '';
// Update filter select
populateBridgeSelect('bridge-ports-filter-bridge', filterBridge, true);
const ports = (state.bridgePorts || []).filter(p => !filterBridge || p.bridge === filterBridge);
if (countBadge) countBadge.innerText = `${ports.length} Ports`;
if (ports.length === 0) {
empty?.classList.remove('hidden');
return;
}
empty?.classList.add('hidden');
ports.forEach((p, idx) => {
const tr = document.createElement('tr');
tr.className = `hover:bg-slate-800/40 transition text-xs ${p.disabled ? 'opacity-50' : ''}`;
const disabledFlag = p.disabled ? 'X' : '-';
tr.innerHTML = `
${idx + 1} |
${disabledFlag} |
🔌${p.interface}
|
${p.bridge} |
${p.pvid || 1} |
${p.frame_types} |
${p.ingress_filtering ? 'yes' : 'no'} |
${p.horizon > 0 ? `domain ${p.horizon}` : 'none'}
|
${p.status}
|
${p.comment || '-'} |
|
`;
tbody.appendChild(tr);
});
}
function openAddBridgePortModal() {
document.getElementById('bridge-port-modal-title').innerText = 'Add Port to Bridge';
document.getElementById('bridge-port-modal-id').value = '';
populateBridgeSelect('bridge-port-modal-bridge');
populateBridgePortCandidateSelect('bridge-port-modal-interface');
document.getElementById('bridge-port-modal-pvid').value = '1';
document.getElementById('bridge-port-modal-frametypes').value = 'admit-all';
document.getElementById('bridge-port-modal-ingressfiltering').checked = false;
document.getElementById('bridge-port-modal-horizon').value = '0';
document.getElementById('bridge-port-modal-pathcost').value = '10';
document.getElementById('bridge-port-modal-priority').value = '128';
document.getElementById('bridge-port-modal-learn').checked = true;
document.getElementById('bridge-port-modal-comment').value = '';
document.getElementById('bridge-port-modal-disabled').checked = false;
document.getElementById('bridge-port-modal').classList.remove('hidden');
}
function openEditBridgePortModal(id) {
const p = (state.bridgePorts || []).find(x => x.id === id);
if (!p) return;
document.getElementById('bridge-port-modal-title').innerText = `Edit Bridge Port (${p.interface})`;
document.getElementById('bridge-port-modal-id').value = p.id;
populateBridgeSelect('bridge-port-modal-bridge', p.bridge);
populateBridgePortCandidateSelect('bridge-port-modal-interface', p.interface);
document.getElementById('bridge-port-modal-pvid').value = p.pvid || 1;
document.getElementById('bridge-port-modal-frametypes').value = p.frame_types || 'admit-all';
document.getElementById('bridge-port-modal-ingressfiltering').checked = !!p.ingress_filtering;
document.getElementById('bridge-port-modal-horizon').value = p.horizon || 0;
document.getElementById('bridge-port-modal-pathcost').value = p.path_cost || 10;
document.getElementById('bridge-port-modal-priority').value = p.priority || 128;
document.getElementById('bridge-port-modal-learn').checked = p.learn !== false;
document.getElementById('bridge-port-modal-comment').value = p.comment || '';
document.getElementById('bridge-port-modal-disabled').checked = !!p.disabled;
document.getElementById('bridge-port-modal').classList.remove('hidden');
}
function closeBridgePortModal() {
document.getElementById('bridge-port-modal').classList.add('hidden');
}
async function submitSaveBridgePort(e) {
e.preventDefault();
const id = document.getElementById('bridge-port-modal-id').value;
const bridge = document.getElementById('bridge-port-modal-bridge').value;
const iface = document.getElementById('bridge-port-modal-interface').value;
const pvid = parseInt(document.getElementById('bridge-port-modal-pvid').value, 10) || 1;
const frameTypes = document.getElementById('bridge-port-modal-frametypes').value;
const ingressFiltering = document.getElementById('bridge-port-modal-ingressfiltering').checked;
const horizon = parseInt(document.getElementById('bridge-port-modal-horizon').value, 10) || 0;
const pathCost = parseInt(document.getElementById('bridge-port-modal-pathcost').value, 10) || 10;
const priority = parseInt(document.getElementById('bridge-port-modal-priority').value, 10) || 128;
const learn = document.getElementById('bridge-port-modal-learn').checked;
const comment = document.getElementById('bridge-port-modal-comment').value.trim();
const disabled = document.getElementById('bridge-port-modal-disabled').checked;
try {
const res = await fetch(`${API_BASE}/bridge/ports`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id,
bridge,
interface: iface,
pvid,
frame_types: frameTypes,
ingress_filtering: ingressFiltering,
horizon,
path_cost: pathCost,
priority,
learn,
comment,
disabled
})
});
if (res.ok) {
showToast('Port Attached', `${iface} added to ${bridge}`, 'success');
closeBridgePortModal();
loadBridge();
fetchData(false);
} else {
const err = await res.json();
showToast('Error', err.error || err.message || 'Failed to save port', 'error');
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function toggleBridgePortState(id, disabled) {
try {
const res = await fetch(`${API_BASE}/bridge/ports/${encodeURIComponent(id)}/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ disabled })
});
if (res.ok) {
loadBridge();
fetchData(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function deleteBridgePort(id) {
const p = (state.bridgePorts || []).find(x => x.id === id);
const iface = p ? p.interface : id;
if (!confirm(`Detach interface '${iface}' from bridge?`)) return;
try {
const res = await fetch(`${API_BASE}/bridge/ports/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (res.ok) {
showToast('Port Detached', `${iface} removed from bridge`, 'success');
loadBridge();
fetchData(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
// ----------------- Bridge VLANs -----------------
function renderBridgeVLANs() {
const tbody = document.getElementById('bridge-vlans-table-body');
const empty = document.getElementById('bridge-empty-vlans');
const countBadge = document.getElementById('bridge-vlans-count-badge');
const filterBridge = document.getElementById('bridge-vlans-filter-bridge')?.value || '';
if (!tbody) return;
tbody.innerHTML = '';
populateBridgeSelect('bridge-vlans-filter-bridge', filterBridge, true);
const vlans = (state.bridgeVLANs || []).filter(v => !filterBridge || v.bridge === filterBridge);
if (countBadge) countBadge.innerText = `${vlans.length} Entries`;
if (vlans.length === 0) {
empty?.classList.remove('hidden');
return;
}
empty?.classList.add('hidden');
vlans.forEach((v, idx) => {
const tr = document.createElement('tr');
tr.className = `hover:bg-slate-800/40 transition text-xs ${v.disabled ? 'opacity-50' : ''}`;
const taggedHtml = (v.tagged && v.tagged.length > 0)
? v.tagged.map(p => `${p}`).join(' ')
: 'none';
const untaggedHtml = (v.untagged && v.untagged.length > 0)
? v.untagged.map(p => `${p}`).join(' ')
: 'none';
const disabledFlag = v.disabled ? 'X' : '-';
tr.innerHTML = `
${idx + 1} |
${disabledFlag} |
${v.bridge} |
${v.vlan_ids} |
${taggedHtml} |
${untaggedHtml} |
${v.comment || '-'} |
|
`;
tbody.appendChild(tr);
});
}
function openAddBridgeVLANModal() {
document.getElementById('bridge-vlan-modal-title').innerText = 'Add Bridge VLAN Entry';
document.getElementById('bridge-vlan-modal-id').value = '';
populateBridgeSelect('bridge-vlan-modal-bridge');
document.getElementById('bridge-vlan-modal-vlanids').value = '';
document.getElementById('bridge-vlan-modal-comment').value = '';
document.getElementById('bridge-vlan-modal-disabled').checked = false;
updateBridgeVlanPortCheckboxes();
document.getElementById('bridge-vlan-modal').classList.remove('hidden');
}
function openEditBridgeVLANModal(id) {
const v = (state.bridgeVLANs || []).find(x => x.id === id);
if (!v) return;
document.getElementById('bridge-vlan-modal-title').innerText = `Edit Bridge VLAN (${v.vlan_ids})`;
document.getElementById('bridge-vlan-modal-id').value = v.id;
populateBridgeSelect('bridge-vlan-modal-bridge', v.bridge);
document.getElementById('bridge-vlan-modal-vlanids').value = v.vlan_ids;
document.getElementById('bridge-vlan-modal-comment').value = v.comment || '';
document.getElementById('bridge-vlan-modal-disabled').checked = !!v.disabled;
updateBridgeVlanPortCheckboxes(v.tagged || [], v.untagged || []);
document.getElementById('bridge-vlan-modal').classList.remove('hidden');
}
function closeBridgeVlanModal() {
document.getElementById('bridge-vlan-modal').classList.add('hidden');
}
function updateBridgeVlanPortCheckboxes(taggedSelected = [], untaggedSelected = []) {
const selBridge = document.getElementById('bridge-vlan-modal-bridge')?.value || '';
const taggedBox = document.getElementById('bridge-vlan-tagged-ports');
const untaggedBox = document.getElementById('bridge-vlan-untagged-ports');
if (!taggedBox || !untaggedBox) return;
// Ports belonging to the selected bridge
const memberPorts = (state.bridgePorts || []).filter(p => !selBridge || p.bridge === selBridge);
if (memberPorts.length === 0) {
taggedBox.innerHTML = 'No member ports found in this bridge';
untaggedBox.innerHTML = 'No member ports found in this bridge';
return;
}
taggedBox.innerHTML = memberPorts.map(p => `
`).join('');
untaggedBox.innerHTML = memberPorts.map(p => `
`).join('');
}
async function submitSaveBridgeVLAN(e) {
e.preventDefault();
const id = document.getElementById('bridge-vlan-modal-id').value;
const bridge = document.getElementById('bridge-vlan-modal-bridge').value;
const vlanIDs = document.getElementById('bridge-vlan-modal-vlanids').value.trim();
const comment = document.getElementById('bridge-vlan-modal-comment').value.trim();
const disabled = document.getElementById('bridge-vlan-modal-disabled').checked;
const tagged = Array.from(document.querySelectorAll('input[name="bvlan-tagged"]:checked')).map(x => x.value);
const untagged = Array.from(document.querySelectorAll('input[name="bvlan-untagged"]:checked')).map(x => x.value);
try {
const res = await fetch(`${API_BASE}/bridge/vlans`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id,
bridge,
vlan_ids: vlanIDs,
tagged,
untagged,
comment,
disabled
})
});
if (res.ok) {
showToast('VLAN Entry Saved', `VLAN ${vlanIDs} configured on ${bridge}`, 'success');
closeBridgeVlanModal();
loadBridge();
} else {
const err = await res.json();
showToast('Error', err.error || err.message || 'Failed to save VLAN entry', 'error');
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function toggleBridgeVLANState(id, disabled) {
try {
const res = await fetch(`${API_BASE}/bridge/vlans/${encodeURIComponent(id)}/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ disabled })
});
if (res.ok) loadBridge();
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function deleteBridgeVLAN(id) {
if (!confirm('Delete this bridge VLAN entry?')) return;
try {
const res = await fetch(`${API_BASE}/bridge/vlans/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (res.ok) {
showToast('Deleted', 'VLAN entry removed from bridge', 'success');
loadBridge();
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
// ----------------- Hosts / MAC Table -----------------
async function loadBridgeHosts() {
const filterBridge = document.getElementById('bridge-hosts-filter-bridge')?.value || '';
populateBridgeSelect('bridge-hosts-filter-bridge', filterBridge, true);
try {
const url = filterBridge ? `${API_BASE}/bridge/hosts?bridge=${encodeURIComponent(filterBridge)}` : `${API_BASE}/bridge/hosts`;
const res = await fetch(url);
if (res.ok) {
state.bridgeHosts = await res.json();
renderBridgeHosts();
}
} catch (err) {
console.error('Failed to load bridge hosts:', err);
}
}
function renderBridgeHosts() {
const tbody = document.getElementById('bridge-hosts-table-body');
const empty = document.getElementById('bridge-empty-hosts');
const countBadge = document.getElementById('bridge-hosts-count-badge');
const search = (document.getElementById('bridge-hosts-search')?.value || '').toLowerCase().trim();
if (!tbody) return;
tbody.innerHTML = '';
const hosts = (state.bridgeHosts || []).filter(h => {
if (!search) return true;
return (h.mac || '').toLowerCase().includes(search) || (h.interface || '').toLowerCase().includes(search) || (h.bridge || '').toLowerCase().includes(search);
});
if (countBadge) countBadge.innerText = `${hosts.length} Hosts`;
if (hosts.length === 0) {
empty?.classList.remove('hidden');
return;
}
empty?.classList.add('hidden');
hosts.forEach(h => {
const tr = document.createElement('tr');
tr.className = 'hover:bg-slate-800/40 transition text-xs';
let flagBadges = '';
if (h.local) flagBadges += 'L';
if (h.static) flagBadges += 'S';
if (!h.local && !h.static) flagBadges += 'D';
tr.innerHTML = `
${h.mac} |
${h.interface || '-'} |
${h.bridge || '-'} |
${h.vlan_id || 1} |
${h.age ? h.age.toFixed(1) + 's' : '-'} |
${flagBadges} |
`;
tbody.appendChild(tr);
});
}
async function flushBridgeHosts() {
const selBridge = document.getElementById('bridge-hosts-filter-bridge')?.value || (state.bridges[0] ? state.bridges[0].name : '');
if (!selBridge) {
showToast('Notice', 'Please select a bridge to flush dynamic MACs', 'info');
return;
}
if (!confirm(`Flush all dynamic learned MAC addresses on bridge '${selBridge}'?`)) return;
try {
const res = await fetch(`${API_BASE}/bridge/hosts/flush`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bridge: selBridge })
});
if (res.ok) {
showToast('FDB Flushed', `Dynamic MACs flushed on ${selBridge}`, 'success');
loadBridgeHosts();
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
// ----------------- Helpers -----------------
function populateBridgeSelect(selectId, currentVal = '', includeAll = false) {
const sel = document.getElementById(selectId);
if (!sel) return;
const prevVal = currentVal || sel.value;
sel.innerHTML = includeAll ? '' : '';
(state.bridges || []).forEach(b => {
const opt = document.createElement('option');
opt.value = b.name;
opt.innerText = `${b.name} (${b.ports_count} ports)`;
if (b.name === prevVal) opt.selected = true;
sel.appendChild(opt);
});
}
function populateBridgePortCandidateSelect(selectId, currentVal = '') {
const sel = document.getElementById(selectId);
if (!sel) return;
sel.innerHTML = '';
// 1. Physical Interfaces
const grpPhys = document.createElement('optgroup');
grpPhys.label = 'Physical Interfaces';
(state.interfaces || []).filter(i => i.type !== 'bridge' && i.type !== 'vlan' && i.type !== 'loopback').forEach(i => {
const opt = document.createElement('option');
opt.value = i.name;
opt.innerText = `${i.name} (${i.role || i.type})`;
if (i.name === currentVal) opt.selected = true;
grpPhys.appendChild(opt);
});
if (grpPhys.children.length > 0) sel.appendChild(grpPhys);
// 2. VLAN Sub-interfaces
const grpVlans = document.createElement('optgroup');
grpVlans.label = 'VLAN Sub-interfaces (802.1Q)';
(state.vlans || []).forEach(v => {
const opt = document.createElement('option');
opt.value = v.name;
opt.innerText = `${v.name} (VLAN ${v.vlan_id})`;
if (v.name === currentVal) opt.selected = true;
grpVlans.appendChild(opt);
});
if (grpVlans.children.length > 0) sel.appendChild(grpVlans);
// 3. EoIP Tunnels (MikroTik EoIP bridging!)
const grpEoip = document.createElement('optgroup');
grpEoip.label = 'EoIP Tunnels (Layer 2 VPN)';
(state.eoipTunnels || []).forEach(t => {
const opt = document.createElement('option');
opt.value = t.name;
opt.innerText = `${t.name} (Tunnel ID: ${t.tunnel_id} -> ${t.remote_address})`;
if (t.name === currentVal) opt.selected = true;
grpEoip.appendChild(opt);
});
if (grpEoip.children.length > 0) sel.appendChild(grpEoip);
}