// Proxmox RouterOS Frontend Application Logic
let state = {
interfaces: [],
vlans: [],
systemStatus: null,
vpnStatus: null,
routes: [],
activeTab: 'dashboard',
fwSubTab: 'filter',
routesSubTab: 'routes',
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';
// 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) {
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' },
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' },
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 === 'firewall') loadFirewall();
if (tabName === 'routes') loadRoutes();
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] = 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`)
]);
if (statusRes.ok) state.systemStatus = await statusRes.json();
if (ifacesRes.ok) {
const ifacesData = await ifacesRes.json();
state.interfaces = Array.isArray(ifacesData) ? ifacesData : [];
}
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();
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 === '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_load && s.cpu_load.length > 0) {
document.getElementById('side-cpu-val').innerText = s.cpu_load[0];
const cpuPct = Math.min(100, Math.round(parseFloat(s.cpu_load[0]) * 50));
document.getElementById('side-cpu-bar').style.width = `${Math.max(5, cpuPct)}%`;
}
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() {
document.getElementById('badge-ifaces-count').innerText = (state.interfaces || []).length;
document.getElementById('badge-vlans-count').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;
}
}
// 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);
}
} 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', '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 === 'connections') addBtn.innerHTML = 'Refresh Flows';
}
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 === 'connections') loadConnections();
}
async function loadFirewall(populateDropdowns = true) {
try {
const [summaryRes, filterRes, natRes, mangleRes, addrlistRes] = 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`)
]);
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();
// 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;
renderFilterRules();
renderNATRules();
renderMangleRules();
renderAddressLists();
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} |
${r.src_address || r.src_address_list ? (r.src_address || `list:${r.src_address_list}`) : '0.0.0.0/0'} |
${r.dst_address || r.dst_address_list ? (r.dst_address || `list:${r.dst_address_list}`) : '0.0.0.0/0'} |
${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 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 set = new Set((state.addressLists || []).map(a => a.name));
set.forEach(name => {
const opt = document.createElement('option');
opt.value = name;
opt.innerText = name;
if (name === currentVal) opt.selected = true;
sel.appendChild(opt);
});
}
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');
populateAddressListSelect('filter-modal-srclist');
populateAddressListSelect('filter-modal-dstlist');
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);
populateAddressListSelect('filter-modal-srclist', r.src_address_list);
populateAddressListSelect('filter-modal-dstlist', r.dst_address_list);
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();
const srclist = document.getElementById('filter-modal-srclist').value;
const dstlist = document.getElementById('filter-modal-dstlist').value;
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,
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} |
${r.src_address || '0.0.0.0/0'} |
${r.dst_address || '0.0.0.0/0'} |
${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');
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);
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;
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,
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} |
${r.src_address || '0.0.0.0/0'} |
${r.dst_address || '0.0.0.0/0'} |
${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');
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);
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;
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,
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 ${e.disabled ? 'opacity-50' : ''}`;
tr.innerHTML = `
${idx + 1} |
${e.disabled ? 'X' : '-'} |
${e.name} |
${e.address} |
${e.comment || '-'} |
|
`;
tbody.appendChild(tr);
});
}
function openAddAddrListModal() {
document.getElementById('addrlist-modal-name').value = '';
document.getElementById('addrlist-modal-addr').value = '';
document.getElementById('addrlist-modal-comment').value = '';
document.getElementById('addrlist-modal').classList.remove('hidden');
}
function closeAddrListModal() {
document.getElementById('addrlist-modal').classList.add('hidden');
}
async function submitSaveAddrList(e) {
e.preventDefault();
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();
try {
const res = await fetch(`${API_BASE}/firewall/address-lists`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, address: addr, comment, disabled: false })
});
if (res.ok) {
showToast('Address List Item Added', `Added ${addr} to ${name}`, 'success');
closeAddrListModal();
loadFirewall(false);
}
} catch (err) {
showToast('Error', err.message, 'error');
}
}
async function deleteAddrList(id) {
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');
}
}
// ----------------- 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 ${r.disabled ? 'opacity-50' : ''}`;
// 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 || 1} |
${r.routing_table || 'main'} |
${r.interface || '-'} |
${r.comment || '-'} |
${r.static ? `
` : `Kernel`}
|
`;
tbody.appendChild(tr);
});
}
function openAddRouteModal() {
document.getElementById('route-modal-title').innerText = 'Add Static Route';
document.getElementById('route-modal-id').value = '';
document.getElementById('route-modal-dst').value = '0.0.0.0/0';
document.getElementById('route-modal-gw').value = '';
document.getElementById('route-modal-distance').value = '1';
document.getElementById('route-modal-comment').value = '';
document.getElementById('route-modal-disabled').checked = false;
populateInterfaceSelect('route-modal-iface');
// Populate tables
const sel = document.getElementById('route-modal-table');
sel.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;
sel.appendChild(opt);
});
document.getElementById('route-modal').classList.remove('hidden');
}
function openEditRouteModal(id) {
const r = (state.routes || []).find(route => route.id === id);
if (!r) return;
document.getElementById('route-modal-title').innerText = `Edit Route: ${r.dst_address}`;
document.getElementById('route-modal-id').value = r.id;
document.getElementById('route-modal-dst').value = r.dst_address;
document.getElementById('route-modal-gw').value = r.gateway || '';
document.getElementById('route-modal-distance').value = r.distance || 1;
document.getElementById('route-modal-comment').value = r.comment || '';
document.getElementById('route-modal-disabled').checked = !!r.disabled;
populateInterfaceSelect('route-modal-iface', r.interface);
const sel = document.getElementById('route-modal-table');
sel.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) opt.selected = true;
sel.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 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) || 1;
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;
try {
const res = await fetch(`${API_BASE}/routes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id,
dst_address: dst,
gateway: gw,
routing_table: table,
distance,
interface: iface,
comment,
disabled
})
});
if (res.ok) {
showToast('Route Saved', `Route to ${dst} installed`, 'success');
closeRouteModal();
loadRoutes();
} else {
const err = await res.json();
showToast('Error', err.message, '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');
}