|
|
@@ -110,6 +110,8 @@ let state = {
|
|
|
routesSubTab: 'routes',
|
|
|
pppSubTab: 'secrets',
|
|
|
eoipTunnels: [],
|
|
|
+ vxlanTunnels: [],
|
|
|
+ vxlanVteps: [],
|
|
|
pppSecrets: [],
|
|
|
pppProfiles: [],
|
|
|
pppActiveSessions: [],
|
|
|
@@ -365,6 +367,7 @@ function navigate(tabName) {
|
|
|
'dynamic-routing': { title: 'BGP Dynamic Routing', sub: 'Dynamic routing engine and peering sessions' },
|
|
|
ppp: { title: 'PPP Servers & Secrets', sub: 'Subscriber authentication, dynamic IP pools and VPN access concentrators' },
|
|
|
eoip: { title: 'EoIP Tunnels', sub: 'Ethernet over IP (EoIP / GRETAP) Layer 2 encapsulation & transparent network bridging' },
|
|
|
+ vxlan: { title: 'VXLAN Tunnels', sub: 'Virtual Extensible LAN (RFC 7348) Layer 2 overlay networks and static VTEPs' },
|
|
|
aerotun: { title: 'AeroTUN High-Speed VPN', sub: 'Layer 3 encrypted tunnel with XChaCha20-Poly1305 and adaptive LZ4 compression' },
|
|
|
pppoe: { title: 'PPPoE Client WAN', sub: 'Point-to-Point Protocol over Ethernet WAN access' },
|
|
|
wireguard: { title: 'WireGuard VPN', sub: 'High-performance cryptographic Layer 3 tunnels with ChaCha20-Poly1305 key exchange' },
|
|
|
@@ -418,6 +421,7 @@ function navigate(tabName) {
|
|
|
if (tabName === 'dynamic-routing') loadDynamicRouting();
|
|
|
if (tabName === 'ppp') loadPPP();
|
|
|
if (tabName === 'eoip') loadEoIP();
|
|
|
+ if (tabName === 'vxlan') loadVXLAN();
|
|
|
if (tabName === 'aerotun') loadAeroTUN();
|
|
|
if (tabName === 'pppoe') loadPPPoE();
|
|
|
if (tabName === 'wireguard') loadWireGuard();
|
|
|
@@ -534,6 +538,7 @@ async function refreshCoreData(isManual = false) {
|
|
|
if (state.activeTab === 'dynamic-routing' || state.activeTab === 'bgp' || state.activeTab === 'ospf') await loadDynamicRouting(false);
|
|
|
if (state.activeTab === 'ppp') await loadPPP(false);
|
|
|
if (state.activeTab === 'eoip') await loadEoIP(false);
|
|
|
+ if (state.activeTab === 'vxlan') await loadVXLAN(false);
|
|
|
if (state.activeTab === 'aerotun') await loadAeroTUN(false);
|
|
|
if (state.activeTab === 'pppoe') await loadPPPoE(false);
|
|
|
if (state.activeTab === 'wireguard') await loadWireGuard(false);
|
|
|
@@ -754,6 +759,8 @@ function updateBadges() {
|
|
|
const v = state.vpnStatus;
|
|
|
const bEoIP = document.getElementById('badge-eoip-count');
|
|
|
if (bEoIP) bEoIP.innerText = v.total_eoip || 0;
|
|
|
+ const bVXLAN = document.getElementById('badge-vxlan-count');
|
|
|
+ if (bVXLAN) bVXLAN.innerText = v.total_vxlan || 0;
|
|
|
const bAero = document.getElementById('badge-aerotun-count');
|
|
|
if (bAero) bAero.innerText = v.total_aerotun || 0;
|
|
|
const bPPPSess = document.getElementById('badge-ppp-sessions');
|
|
|
@@ -8133,6 +8140,325 @@ async function deleteEoIP(id) {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+// ======================== VXLAN (Virtual Extensible LAN) ========================
|
|
|
+async function loadVXLAN(populate = true) {
|
|
|
+ try {
|
|
|
+ const [tunnelsRes, vtepsRes] = await Promise.all([
|
|
|
+ fetch(`${API_BASE}/vpn/vxlan`),
|
|
|
+ fetch(`${API_BASE}/vpn/vxlan/vteps`)
|
|
|
+ ]);
|
|
|
+
|
|
|
+ if (tunnelsRes.ok) {
|
|
|
+ state.vxlanTunnels = await tunnelsRes.json() || [];
|
|
|
+ }
|
|
|
+ if (vtepsRes.ok) {
|
|
|
+ state.vxlanVteps = await vtepsRes.json() || [];
|
|
|
+ }
|
|
|
+
|
|
|
+ renderVXLANTable();
|
|
|
+ renderVXLANVtepsTable();
|
|
|
+
|
|
|
+ const countBadge = document.getElementById('vxlan-count-badge');
|
|
|
+ if (countBadge) countBadge.innerText = `${(state.vxlanTunnels || []).length} Tunnels`;
|
|
|
+
|
|
|
+ const vtepsCountBadge = document.getElementById('vxlan-vteps-count-badge');
|
|
|
+ if (vtepsCountBadge) vtepsCountBadge.innerText = `${(state.vxlanVteps || []).length} VTEPs`;
|
|
|
+
|
|
|
+ const navBadge = document.getElementById('badge-vxlan-count');
|
|
|
+ if (navBadge) navBadge.innerText = (state.vxlanTunnels || []).length;
|
|
|
+ } catch (err) {
|
|
|
+ console.error('Error loading VXLAN:', err);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function renderVXLANTable() {
|
|
|
+ const tbody = document.getElementById('vxlan-table-body');
|
|
|
+ const empty = document.getElementById('vxlan-empty');
|
|
|
+ if (!tbody || !empty) return;
|
|
|
+
|
|
|
+ tbody.innerHTML = '';
|
|
|
+ const tunnels = state.vxlanTunnels || [];
|
|
|
+
|
|
|
+ if (tunnels.length === 0) {
|
|
|
+ empty.classList.remove('hidden');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ empty.classList.add('hidden');
|
|
|
+
|
|
|
+ tunnels.forEach(t => {
|
|
|
+ const tr = document.createElement('tr');
|
|
|
+ tr.className = 'hover:bg-slate-800/40 transition border-b border-slate-800/60';
|
|
|
+
|
|
|
+ let flag = '';
|
|
|
+ if (t.disabled) {
|
|
|
+ flag = '<span class="px-1.5 py-0.5 rounded text-[10px] bg-slate-800 text-slate-400 font-bold border border-slate-700" title="Disabled">X</span>';
|
|
|
+ } else if (t.running) {
|
|
|
+ flag = '<span class="px-1.5 py-0.5 rounded text-[10px] bg-amber-950 text-amber-300 font-bold border border-amber-800/50" title="Running">R</span>';
|
|
|
+ } else {
|
|
|
+ flag = '<span class="px-1.5 py-0.5 rounded text-[10px] bg-rose-950 text-rose-300 font-bold border border-rose-800/50" title="Down">!</span>';
|
|
|
+ }
|
|
|
+
|
|
|
+ const vtepCount = (t.vteps || []).length;
|
|
|
+ const underlayDesc = t.parent_interface || t.local_address ? `${t.parent_interface || '-'}${t.local_address ? ' / ' + t.local_address : ''}` : '(any)';
|
|
|
+
|
|
|
+ tr.innerHTML = `
|
|
|
+ <td class="py-2.5 px-3 text-center">${flag}</td>
|
|
|
+ <td class="py-2.5 px-3 font-bold text-white font-mono">${escapeHtml(t.name)}</td>
|
|
|
+ <td class="py-2.5 px-3 font-bold text-amber-400 font-mono">${t.vni}</td>
|
|
|
+ <td class="py-2.5 px-3 text-slate-300 font-mono">${t.port || 4789}</td>
|
|
|
+ <td class="py-2.5 px-3 font-semibold text-emerald-400 font-mono">${escapeHtml(t.ip_address || '') || '<span class="text-slate-500 font-normal">Unassigned</span>'}</td>
|
|
|
+ <td class="py-2.5 px-3 text-slate-300 font-mono text-[11px]">${escapeHtml(underlayDesc)}</td>
|
|
|
+ <td class="py-2.5 px-3 text-slate-400 font-mono text-[11px]">${escapeHtml(t.mac_address || '-')}</td>
|
|
|
+ <td class="py-2.5 px-3 text-slate-400 font-mono">${t.mtu || 1500}</td>
|
|
|
+ <td class="py-2.5 px-3 font-mono">
|
|
|
+ <span class="px-2 py-0.5 rounded-full text-[10px] bg-slate-800 text-amber-300 border border-slate-700 cursor-pointer" onclick="openAddVXLANVtepModal('${escapeHtml(t.name)}')" title="Click to add VTEP peer">${vtepCount} VTEP${vtepCount === 1 ? '' : 's'}</span>
|
|
|
+ </td>
|
|
|
+ <td class="py-2.5 px-3 font-mono text-[11px]">
|
|
|
+ <span class="text-emerald-400">↓ ${t.rx_human || '0 B'}</span>
|
|
|
+ <span class="text-amber-400 ml-1">↑ ${t.tx_human || '0 B'}</span>
|
|
|
+ </td>
|
|
|
+ <td class="py-2.5 px-3 text-slate-400 italic truncate max-w-[120px]" title="${escapeHtml(t.comment || '')}">${escapeHtml(t.comment || '-')}</td>
|
|
|
+ <td class="py-2.5 px-3 text-right space-x-1.5 font-sans whitespace-nowrap">
|
|
|
+ <button onclick="toggleVXLANState('${t.id}', ${!t.disabled})" class="text-xs ${t.disabled ? 'text-amber-400' : 'text-slate-400'} hover:underline">${t.disabled ? 'Enable' : 'Disable'}</button>
|
|
|
+ <button onclick="openEditVXLANModal('${t.id}')" class="text-xs text-slate-400 hover:text-white">Edit</button>
|
|
|
+ <button onclick="deleteVXLAN('${t.id}')" class="text-xs text-rose-400 hover:text-rose-300">Delete</button>
|
|
|
+ </td>
|
|
|
+ `;
|
|
|
+ tbody.appendChild(tr);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function renderVXLANVtepsTable() {
|
|
|
+ const tbody = document.getElementById('vxlan-vteps-table-body');
|
|
|
+ const empty = document.getElementById('vxlan-vteps-empty');
|
|
|
+ if (!tbody || !empty) return;
|
|
|
+
|
|
|
+ tbody.innerHTML = '';
|
|
|
+ const vteps = state.vxlanVteps || [];
|
|
|
+
|
|
|
+ if (vteps.length === 0) {
|
|
|
+ empty.classList.remove('hidden');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ empty.classList.add('hidden');
|
|
|
+
|
|
|
+ vteps.forEach(v => {
|
|
|
+ const tr = document.createElement('tr');
|
|
|
+ tr.className = 'hover:bg-slate-800/40 transition border-b border-slate-800/60';
|
|
|
+
|
|
|
+ tr.innerHTML = `
|
|
|
+ <td class="py-2.5 px-3 font-bold text-white font-mono">${escapeHtml(v.interface)}</td>
|
|
|
+ <td class="py-2.5 px-3 text-amber-400 font-mono font-semibold">${escapeHtml(v.remote_ip)}</td>
|
|
|
+ <td class="py-2.5 px-3 text-slate-300 font-mono">${v.port > 0 ? v.port : 'default'}</td>
|
|
|
+ <td class="py-2.5 px-3 text-slate-400 italic truncate max-w-[150px]" title="${escapeHtml(v.comment || '')}">${escapeHtml(v.comment || '-')}</td>
|
|
|
+ <td class="py-2.5 px-3 text-right font-sans whitespace-nowrap">
|
|
|
+ <button onclick="deleteVXLANVtep('${escapeHtml(v.interface)}', '${escapeHtml(v.id || v.remote_ip)}')" class="text-xs text-rose-400 hover:text-rose-300">Delete</button>
|
|
|
+ </td>
|
|
|
+ `;
|
|
|
+ tbody.appendChild(tr);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function openAddVXLANModal() {
|
|
|
+ document.getElementById('vxlan-modal-title').innerText = 'Add VXLAN Interface';
|
|
|
+ document.getElementById('vxlan-modal-id').value = '';
|
|
|
+ document.getElementById('vxlan-modal-name').value = `vxlan${(state.vxlanTunnels || []).length + 100}`;
|
|
|
+ document.getElementById('vxlan-modal-vni').value = (state.vxlanTunnels || []).length + 100;
|
|
|
+ document.getElementById('vxlan-modal-port').value = '4789';
|
|
|
+ document.getElementById('vxlan-modal-ip').value = '';
|
|
|
+ document.getElementById('vxlan-modal-parent').value = '';
|
|
|
+ document.getElementById('vxlan-modal-local').value = '';
|
|
|
+ document.getElementById('vxlan-modal-remote').value = '';
|
|
|
+ document.getElementById('vxlan-modal-group').value = '';
|
|
|
+ document.getElementById('vxlan-modal-mac').value = '';
|
|
|
+ document.getElementById('vxlan-modal-mtu').value = '1500';
|
|
|
+ document.getElementById('vxlan-modal-comment').value = '';
|
|
|
+ document.getElementById('vxlan-modal-learning').checked = true;
|
|
|
+ document.getElementById('vxlan-modal-disabled').checked = false;
|
|
|
+ document.getElementById('vxlan-modal').classList.remove('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function openEditVXLANModal(id) {
|
|
|
+ const t = (state.vxlanTunnels || []).find(tun => tun.id === id);
|
|
|
+ if (!t) return;
|
|
|
+ document.getElementById('vxlan-modal-title').innerText = `Edit VXLAN Interface (${t.name})`;
|
|
|
+ document.getElementById('vxlan-modal-id').value = t.id;
|
|
|
+ document.getElementById('vxlan-modal-name').value = t.name;
|
|
|
+ document.getElementById('vxlan-modal-vni').value = t.vni;
|
|
|
+ document.getElementById('vxlan-modal-port').value = t.port || 4789;
|
|
|
+ document.getElementById('vxlan-modal-ip').value = t.ip_address || '';
|
|
|
+ document.getElementById('vxlan-modal-parent').value = t.parent_interface || '';
|
|
|
+ document.getElementById('vxlan-modal-local').value = t.local_address || '';
|
|
|
+ document.getElementById('vxlan-modal-remote').value = t.remote_address || '';
|
|
|
+ document.getElementById('vxlan-modal-group').value = t.group || '';
|
|
|
+ document.getElementById('vxlan-modal-mac').value = t.mac_address || '';
|
|
|
+ document.getElementById('vxlan-modal-mtu').value = t.mtu || 1500;
|
|
|
+ document.getElementById('vxlan-modal-comment').value = t.comment || '';
|
|
|
+ document.getElementById('vxlan-modal-learning').checked = t.learning !== false;
|
|
|
+ document.getElementById('vxlan-modal-disabled').checked = !!t.disabled;
|
|
|
+ document.getElementById('vxlan-modal').classList.remove('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function closeVXLANModal() {
|
|
|
+ document.getElementById('vxlan-modal').classList.add('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+async function submitSaveVXLAN(e) {
|
|
|
+ e.preventDefault();
|
|
|
+ const id = document.getElementById('vxlan-modal-id').value;
|
|
|
+ const name = document.getElementById('vxlan-modal-name').value.trim();
|
|
|
+ const vni = parseInt(document.getElementById('vxlan-modal-vni').value);
|
|
|
+ const port = parseInt(document.getElementById('vxlan-modal-port').value) || 4789;
|
|
|
+ const ipAddress = document.getElementById('vxlan-modal-ip').value.trim();
|
|
|
+ const parentInterface = document.getElementById('vxlan-modal-parent').value.trim();
|
|
|
+ const local = document.getElementById('vxlan-modal-local').value.trim();
|
|
|
+ const remote = document.getElementById('vxlan-modal-remote').value.trim();
|
|
|
+ const group = document.getElementById('vxlan-modal-group').value.trim();
|
|
|
+ const mac = document.getElementById('vxlan-modal-mac').value.trim();
|
|
|
+ const mtu = parseInt(document.getElementById('vxlan-modal-mtu').value) || 1500;
|
|
|
+ const comment = document.getElementById('vxlan-modal-comment').value.trim();
|
|
|
+ const learning = document.getElementById('vxlan-modal-learning').checked;
|
|
|
+ const disabled = document.getElementById('vxlan-modal-disabled').checked;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/vpn/vxlan`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({
|
|
|
+ id,
|
|
|
+ name,
|
|
|
+ vni,
|
|
|
+ port,
|
|
|
+ ip_address: ipAddress,
|
|
|
+ parent_interface: parentInterface,
|
|
|
+ local_address: local,
|
|
|
+ remote_address: remote,
|
|
|
+ group,
|
|
|
+ mac_address: mac,
|
|
|
+ mtu,
|
|
|
+ learning,
|
|
|
+ comment,
|
|
|
+ disabled
|
|
|
+ })
|
|
|
+ });
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('VXLAN Saved', `Interface ${name} configured successfully`, 'success');
|
|
|
+ closeVXLANModal();
|
|
|
+ loadVXLAN(false);
|
|
|
+ fetchData(false);
|
|
|
+ } else {
|
|
|
+ const err = await res.json();
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function toggleVXLANState(id, disabled) {
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/vpn/vxlan/${encodeURIComponent(id)}/state`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ disabled })
|
|
|
+ });
|
|
|
+ if (res.ok) {
|
|
|
+ showToast(disabled ? 'Disabled' : 'Enabled', 'VXLAN interface state updated', 'info');
|
|
|
+ loadVXLAN(false);
|
|
|
+ fetchData(false);
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function deleteVXLAN(id) {
|
|
|
+ if (!confirm('Are you sure you want to delete this VXLAN interface?')) return;
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/vpn/vxlan/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('Deleted', 'VXLAN interface removed', 'success');
|
|
|
+ loadVXLAN(false);
|
|
|
+ fetchData(false);
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function openAddVXLANVtepModal(defaultIface = '') {
|
|
|
+ const sel = document.getElementById('vxlan-vtep-modal-interface');
|
|
|
+ if (sel) {
|
|
|
+ sel.innerHTML = '';
|
|
|
+ const tunnels = state.vxlanTunnels || [];
|
|
|
+ tunnels.forEach(t => {
|
|
|
+ const opt = document.createElement('option');
|
|
|
+ opt.value = t.name;
|
|
|
+ opt.innerText = `${t.name} (VNI ${t.vni})`;
|
|
|
+ if (defaultIface && t.name === defaultIface) {
|
|
|
+ opt.selected = true;
|
|
|
+ }
|
|
|
+ sel.appendChild(opt);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ document.getElementById('vxlan-vtep-modal-remote').value = '';
|
|
|
+ document.getElementById('vxlan-vtep-modal-port').value = '';
|
|
|
+ document.getElementById('vxlan-vtep-modal-comment').value = '';
|
|
|
+ document.getElementById('vxlan-vtep-modal').classList.remove('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function closeVXLANVtepModal() {
|
|
|
+ document.getElementById('vxlan-vtep-modal').classList.add('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+async function submitSaveVXLANVtep(e) {
|
|
|
+ e.preventDefault();
|
|
|
+ const iface = document.getElementById('vxlan-vtep-modal-interface').value;
|
|
|
+ const remote = document.getElementById('vxlan-vtep-modal-remote').value.trim();
|
|
|
+ const port = parseInt(document.getElementById('vxlan-vtep-modal-port').value) || 0;
|
|
|
+ const comment = document.getElementById('vxlan-vtep-modal-comment').value.trim();
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/vpn/vxlan/vteps`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({
|
|
|
+ interface: iface,
|
|
|
+ remote_ip: remote,
|
|
|
+ port,
|
|
|
+ comment
|
|
|
+ })
|
|
|
+ });
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('VTEP Added', `Peer ${remote} added to ${iface}`, 'success');
|
|
|
+ closeVXLANVtepModal();
|
|
|
+ loadVXLAN(false);
|
|
|
+ fetchData(false);
|
|
|
+ } else {
|
|
|
+ const err = await res.json();
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function deleteVXLANVtep(iface, vtepIdOrIp) {
|
|
|
+ if (!confirm(`Are you sure you want to remove VTEP ${vtepIdOrIp} from ${iface}?`)) return;
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/vpn/vxlan/${encodeURIComponent(iface)}/vteps/${encodeURIComponent(vtepIdOrIp)}`, {
|
|
|
+ method: 'DELETE'
|
|
|
+ });
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('Deleted', 'Remote VTEP removed', 'success');
|
|
|
+ loadVXLAN(false);
|
|
|
+ fetchData(false);
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
// ======================== AeroTUN (Custom Encrypted VPN with Adaptive LZ4) ========================
|
|
|
const aeroRateSamples = new Map();
|
|
|
const aeroLiveRates = new Map();
|
|
|
@@ -11359,12 +11685,13 @@ function openCurrentBridgeModal() {
|
|
|
|
|
|
async function loadBridge() {
|
|
|
try {
|
|
|
- const [bridgesRes, portsRes, vlansRes, summaryRes, eoipRes, vlanIfacesRes] = await Promise.all([
|
|
|
+ const [bridgesRes, portsRes, vlansRes, summaryRes, eoipRes, vxlanRes, vlanIfacesRes] = await Promise.all([
|
|
|
fetch(`${API_BASE}/bridge/bridges`),
|
|
|
fetch(`${API_BASE}/bridge/ports`),
|
|
|
fetch(`${API_BASE}/bridge/vlans`),
|
|
|
fetch(`${API_BASE}/bridge/summary`),
|
|
|
fetch(`${API_BASE}/vpn/eoip`),
|
|
|
+ fetch(`${API_BASE}/vpn/vxlan`),
|
|
|
fetch(`${API_BASE}/vlans`)
|
|
|
]);
|
|
|
|
|
|
@@ -11373,6 +11700,7 @@ async function loadBridge() {
|
|
|
if (vlansRes.ok) state.bridgeVLANs = await vlansRes.json();
|
|
|
if (summaryRes.ok) state.bridgeSummary = await summaryRes.json();
|
|
|
if (eoipRes && eoipRes.ok) state.eoipTunnels = await eoipRes.json();
|
|
|
+ if (vxlanRes && vxlanRes.ok) state.vxlanTunnels = await vxlanRes.json();
|
|
|
if (vlanIfacesRes && vlanIfacesRes.ok) state.vlans = await vlanIfacesRes.json();
|
|
|
|
|
|
// Badges & Stats
|
|
|
@@ -12160,6 +12488,18 @@ function populateBridgePortCandidateSelect(selectId, currentVal = '') {
|
|
|
grpEoip.appendChild(opt);
|
|
|
});
|
|
|
if (grpEoip.children.length > 0) sel.appendChild(grpEoip);
|
|
|
+
|
|
|
+ // 4. VXLAN Tunnels (MikroTik VXLAN bridging!)
|
|
|
+ const grpVxlan = document.createElement('optgroup');
|
|
|
+ grpVxlan.label = 'VXLAN Tunnels (Layer 2 Overlay)';
|
|
|
+ (state.vxlanTunnels || []).forEach(t => {
|
|
|
+ const opt = document.createElement('option');
|
|
|
+ opt.value = t.name;
|
|
|
+ opt.innerText = `${t.name} (VNI: ${t.vni}, Port: ${t.port})`;
|
|
|
+ if (t.name === currentVal) opt.selected = true;
|
|
|
+ grpVxlan.appendChild(opt);
|
|
|
+ });
|
|
|
+ if (grpVxlan.children.length > 0) sel.appendChild(grpVxlan);
|
|
|
}
|
|
|
|
|
|
// -------------------------------------------------------------
|