|
|
@@ -7,6 +7,12 @@ let state = {
|
|
|
routes: [],
|
|
|
activeTab: 'dashboard',
|
|
|
fwSubTab: 'filter',
|
|
|
+ bridgeSubTab: 'bridges',
|
|
|
+ bridges: [],
|
|
|
+ bridgePorts: [],
|
|
|
+ bridgeVLANs: [],
|
|
|
+ bridgeHosts: [],
|
|
|
+ bridgeSummary: null,
|
|
|
routesSubTab: 'routes',
|
|
|
pppSubTab: 'secrets',
|
|
|
eoipTunnels: [],
|
|
|
@@ -71,6 +77,7 @@ function navigate(tabName) {
|
|
|
const titles = {
|
|
|
dashboard: { title: 'Dashboard Overview', sub: 'Live router performance and network telemetry' },
|
|
|
interfaces: { title: 'Network Interfaces', sub: 'Hierarchical view of physical interfaces and nested 802.1Q VLANs' },
|
|
|
+ bridge: { title: 'Bridge Interfaces', sub: 'MikroTik-style IEEE 802.1D / 802.1Q VLAN Filtering Bridge with RSTP & FDB' },
|
|
|
vlans: { title: 'VLAN Virtual Networks', sub: 'IEEE 802.1Q sub-interface segmentation and routing' },
|
|
|
firewall: { title: 'MikroTik Firewall', sub: 'Filter rules, NAT, Mangle, Address Lists & Active Connections tracking' },
|
|
|
routes: { title: 'Routing & Policy', sub: 'Routing tables, static routes (DAS/DAC), and policy routing rules' },
|
|
|
@@ -100,6 +107,7 @@ function navigate(tabName) {
|
|
|
}
|
|
|
});
|
|
|
|
|
|
+ if (tabName === 'bridge') loadBridge();
|
|
|
if (tabName === 'firewall') loadFirewall();
|
|
|
if (tabName === 'routes') loadRoutes();
|
|
|
if (tabName === 'dynamic-routing') loadDynamicRouting();
|
|
|
@@ -119,15 +127,28 @@ async function fetchData(isManual = false) {
|
|
|
if (isManual && spinner) spinner.classList.add('animate-spin');
|
|
|
|
|
|
try {
|
|
|
- const [statusRes, ifacesRes, vlansRes, vpnRes, fwRes, dynRes] = await Promise.all([
|
|
|
+ const [statusRes, ifacesRes, vlansRes, vpnRes, fwRes, dynRes, brSummaryRes] = await Promise.all([
|
|
|
fetch(`${API_BASE}/system/status`),
|
|
|
fetch(`${API_BASE}/interfaces`),
|
|
|
fetch(`${API_BASE}/vlans`),
|
|
|
fetch(`${API_BASE}/vpn/status`),
|
|
|
fetch(`${API_BASE}/firewall/summary`),
|
|
|
- fetch(`${API_BASE}/routing/dynamic/summary`)
|
|
|
+ fetch(`${API_BASE}/routing/dynamic/summary`),
|
|
|
+ fetch(`${API_BASE}/bridge/summary`)
|
|
|
]);
|
|
|
|
|
|
+ if (brSummaryRes.ok) {
|
|
|
+ state.bridgeSummary = await brSummaryRes.json();
|
|
|
+ const bBridge = document.getElementById('badge-bridge-count');
|
|
|
+ if (bBridge) bBridge.innerText = state.bridgeSummary?.bridges_count || 0;
|
|
|
+ const sBr = document.getElementById('bridge-stat-bridges');
|
|
|
+ if (sBr) sBr.innerText = state.bridgeSummary?.bridges_count || 0;
|
|
|
+ const sPorts = document.getElementById('bridge-stat-ports');
|
|
|
+ if (sPorts) sPorts.innerText = state.bridgeSummary?.ports_count || 0;
|
|
|
+ const sHosts = document.getElementById('bridge-stat-hosts');
|
|
|
+ if (sHosts) sHosts.innerText = state.bridgeSummary?.hosts_count || 0;
|
|
|
+ }
|
|
|
+
|
|
|
if (statusRes.ok) state.systemStatus = await statusRes.json();
|
|
|
if (ifacesRes.ok) {
|
|
|
const ifacesData = await ifacesRes.json();
|
|
|
@@ -5737,3 +5758,850 @@ async function toggleOspfInterfaceState(id, disabled) {
|
|
|
showToast('Error', err.message, 'error');
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+// ============================================================================
|
|
|
+// ==================== MIKROTIK BRIDGE SUBSYSTEM ==============================
|
|
|
+// ============================================================================
|
|
|
+
|
|
|
+function switchBridgeSubTab(tab) {
|
|
|
+ state.bridgeSubTab = tab;
|
|
|
+ const tabs = ['bridges', 'ports', 'vlans', 'hosts'];
|
|
|
+ tabs.forEach(t => {
|
|
|
+ const panel = document.getElementById(`bridge-subtab-${t}`);
|
|
|
+ const btn = document.getElementById(`bridge-subtab-btn-${t}`);
|
|
|
+ if (panel) {
|
|
|
+ if (t === tab) panel.classList.remove('hidden');
|
|
|
+ else panel.classList.add('hidden');
|
|
|
+ }
|
|
|
+ if (btn) {
|
|
|
+ if (t === tab) {
|
|
|
+ btn.className = 'px-4 py-2 text-xs font-bold rounded-lg bg-slate-800 text-white shadow-sm transition flex items-center gap-2';
|
|
|
+ } else {
|
|
|
+ btn.className = 'px-4 py-2 text-xs font-bold rounded-lg text-slate-400 hover:text-slate-200 transition flex items-center gap-2';
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ const addBtn = document.getElementById('bridge-top-action-btn');
|
|
|
+ if (addBtn) {
|
|
|
+ if (tab === 'bridges') addBtn.innerHTML = '<span>+ Add Bridge</span>';
|
|
|
+ else if (tab === 'ports') addBtn.innerHTML = '<span>+ Add Port</span>';
|
|
|
+ else if (tab === 'vlans') addBtn.innerHTML = '<span>+ Add VLAN Entry</span>';
|
|
|
+ else if (tab === 'hosts') addBtn.innerHTML = '<span>🔄 Refresh MACs</span>';
|
|
|
+ }
|
|
|
+
|
|
|
+ if (tab === 'hosts') loadBridgeHosts();
|
|
|
+}
|
|
|
+
|
|
|
+function openCurrentBridgeModal() {
|
|
|
+ if (state.bridgeSubTab === 'bridges') openAddBridgeModal();
|
|
|
+ else if (state.bridgeSubTab === 'ports') openAddBridgePortModal();
|
|
|
+ else if (state.bridgeSubTab === 'vlans') openAddBridgeVLANModal();
|
|
|
+ else if (state.bridgeSubTab === 'hosts') loadBridgeHosts();
|
|
|
+}
|
|
|
+
|
|
|
+async function loadBridge() {
|
|
|
+ try {
|
|
|
+ const [bridgesRes, portsRes, vlansRes, summaryRes, eoipRes, vlanIfacesRes] = await Promise.all([
|
|
|
+ fetch(`${API_BASE}/bridge/bridges`),
|
|
|
+ fetch(`${API_BASE}/bridge/ports`),
|
|
|
+ fetch(`${API_BASE}/bridge/vlans`),
|
|
|
+ fetch(`${API_BASE}/bridge/summary`),
|
|
|
+ fetch(`${API_BASE}/vpn/eoip`),
|
|
|
+ fetch(`${API_BASE}/vlans`)
|
|
|
+ ]);
|
|
|
+
|
|
|
+ if (bridgesRes.ok) state.bridges = await bridgesRes.json();
|
|
|
+ if (portsRes.ok) state.bridgePorts = await portsRes.json();
|
|
|
+ if (vlansRes.ok) state.bridgeVLANs = await vlansRes.json();
|
|
|
+ if (summaryRes.ok) state.bridgeSummary = await summaryRes.json();
|
|
|
+ if (eoipRes && eoipRes.ok) state.eoipTunnels = await eoipRes.json();
|
|
|
+ if (vlanIfacesRes && vlanIfacesRes.ok) state.vlans = await vlanIfacesRes.json();
|
|
|
+
|
|
|
+ // Badges & Stats
|
|
|
+ const bBridges = document.getElementById('bridge-badge-bridges');
|
|
|
+ if (bBridges) bBridges.innerText = (state.bridges || []).length;
|
|
|
+ const bPorts = document.getElementById('bridge-badge-ports');
|
|
|
+ if (bPorts) bPorts.innerText = (state.bridgePorts || []).length;
|
|
|
+ const bVlans = document.getElementById('bridge-badge-vlans');
|
|
|
+ if (bVlans) bVlans.innerText = (state.bridgeVLANs || []).length;
|
|
|
+ const bHosts = document.getElementById('bridge-badge-hosts');
|
|
|
+ if (bHosts) bHosts.innerText = state.bridgeSummary?.hosts_count || 0;
|
|
|
+
|
|
|
+ const sBr = document.getElementById('bridge-stat-bridges');
|
|
|
+ if (sBr) sBr.innerText = (state.bridges || []).length;
|
|
|
+ const sPorts = document.getElementById('bridge-stat-ports');
|
|
|
+ if (sPorts) sPorts.innerText = (state.bridgePorts || []).length;
|
|
|
+ const sHosts = document.getElementById('bridge-stat-hosts');
|
|
|
+ if (sHosts) sHosts.innerText = state.bridgeSummary?.hosts_count || 0;
|
|
|
+
|
|
|
+ renderBridges();
|
|
|
+ renderBridgePorts();
|
|
|
+ renderBridgeVLANs();
|
|
|
+
|
|
|
+ if (state.bridgeSubTab === 'hosts') loadBridgeHosts();
|
|
|
+ } catch (err) {
|
|
|
+ console.error('Failed to load bridge subsystem:', err);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ----------------- Bridges -----------------
|
|
|
+
|
|
|
+function renderBridges() {
|
|
|
+ const tbody = document.getElementById('bridge-table-body');
|
|
|
+ const empty = document.getElementById('bridge-empty-bridges');
|
|
|
+ const countBadge = document.getElementById('bridge-count-badge');
|
|
|
+ if (!tbody) return;
|
|
|
+ tbody.innerHTML = '';
|
|
|
+
|
|
|
+ const bridges = state.bridges || [];
|
|
|
+ if (countBadge) countBadge.innerText = `${bridges.length} Bridges`;
|
|
|
+
|
|
|
+ if (bridges.length === 0) {
|
|
|
+ empty?.classList.remove('hidden');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ empty?.classList.add('hidden');
|
|
|
+
|
|
|
+ bridges.forEach((b, idx) => {
|
|
|
+ const tr = document.createElement('tr');
|
|
|
+ tr.className = `hover:bg-slate-800/40 transition text-xs ${b.disabled ? 'opacity-50' : ''}`;
|
|
|
+
|
|
|
+ // Format IP addresses pill list
|
|
|
+ let ipsHtml = '<span class="text-slate-600 font-sans">None</span>';
|
|
|
+ if (b.ip_addresses && b.ip_addresses.length > 0) {
|
|
|
+ ipsHtml = b.ip_addresses.map(ip => `
|
|
|
+ <span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-mono bg-sky-950/80 border border-sky-800/40 text-sky-300">
|
|
|
+ <span>${ip}</span>
|
|
|
+ <button onclick="deleteBridgeIP('${b.id}', '${ip}')" class="text-slate-500 hover:text-rose-400 font-bold ml-0.5">×</button>
|
|
|
+ </span>
|
|
|
+ `).join(' ');
|
|
|
+ }
|
|
|
+
|
|
|
+ const runningFlag = b.running ? '<span class="text-emerald-400 font-bold" title="Running (R)">R</span>' : '<span class="text-slate-600">-</span>';
|
|
|
+ const disabledFlag = b.disabled ? '<span class="text-rose-400 font-bold" title="Disabled (X)">X</span>' : '';
|
|
|
+ const flagDisplay = `${runningFlag}${disabledFlag ? ' ' + disabledFlag : ''}`;
|
|
|
+
|
|
|
+ tr.innerHTML = `
|
|
|
+ <td class="py-2.5 px-3 text-center text-slate-500 font-bold">${idx + 1}</td>
|
|
|
+ <td class="py-2.5 px-3 text-center font-mono">${flagDisplay}</td>
|
|
|
+ <td class="py-2.5 px-3 font-bold text-white text-sm">
|
|
|
+ <span class="text-sky-400 mr-1.5">🌉</span>${b.name}
|
|
|
+ </td>
|
|
|
+ <td class="py-2.5 px-3 text-slate-300">${b.mtu}</td>
|
|
|
+ <td class="py-2.5 px-3 font-mono text-slate-300 text-[11px]">${b.mac_address || '-'}</td>
|
|
|
+ <td class="py-2.5 px-3">
|
|
|
+ <span class="px-1.5 py-0.5 rounded text-[10px] font-bold ${b.stp === 'none' ? 'bg-slate-800 text-slate-400' : 'bg-sky-950 text-sky-300 border border-sky-800/40'} uppercase">${b.stp}</span>
|
|
|
+ </td>
|
|
|
+ <td class="py-2.5 px-3 text-center">
|
|
|
+ ${b.vlan_filtering ? '<span class="text-emerald-400 font-bold">yes</span>' : '<span class="text-slate-500">no</span>'}
|
|
|
+ </td>
|
|
|
+ <td class="py-2.5 px-3 font-mono">${b.pvid || 1}</td>
|
|
|
+ <td class="py-2.5 px-3">
|
|
|
+ <span class="px-2 py-0.5 rounded-full text-[10px] font-bold ${b.ports_count > 0 ? 'bg-sky-950 text-sky-400 border border-sky-800/40' : 'bg-slate-800 text-slate-500'} font-mono">${b.ports_count}</span>
|
|
|
+ </td>
|
|
|
+ <td class="py-2.5 px-3">
|
|
|
+ <div class="flex items-center gap-1.5 flex-wrap">
|
|
|
+ ${ipsHtml}
|
|
|
+ <button onclick="openAddBridgeIPModal('${b.id}', '${b.name}')" class="px-1.5 py-0.2 rounded text-[10px] bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 font-sans" title="Add IP to Bridge">+ IP</button>
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
+ <td class="py-2.5 px-3 text-[11px] text-slate-400">
|
|
|
+ <span class="text-emerald-400">↓ ${b.rx_human || '0 B'}</span> / <span class="text-cyan-400">↑ ${b.tx_human || '0 B'}</span>
|
|
|
+ </td>
|
|
|
+ <td class="py-2.5 px-3 text-slate-400 italic truncate max-w-[120px]" title="${b.comment || ''}">${b.comment || '-'}</td>
|
|
|
+ <td class="py-2.5 px-3 text-right space-x-1.5 font-sans whitespace-nowrap">
|
|
|
+ <button onclick="toggleBridgeState('${b.id}', ${!b.disabled})" class="text-xs ${b.disabled ? 'text-emerald-400' : 'text-slate-400'} hover:underline font-semibold">${b.disabled ? 'Enable' : 'Disable'}</button>
|
|
|
+ <button onclick="openEditBridgeModal('${b.id}')" class="text-xs text-slate-400 hover:text-white">Edit</button>
|
|
|
+ <button onclick="deleteBridge('${b.id}')" class="text-xs text-slate-500 hover:text-rose-400">Delete</button>
|
|
|
+ </td>
|
|
|
+ `;
|
|
|
+ tbody.appendChild(tr);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function openAddBridgeModal() {
|
|
|
+ document.getElementById('bridge-modal-title').innerText = 'Add Bridge Interface';
|
|
|
+ document.getElementById('bridge-modal-id').value = '';
|
|
|
+ document.getElementById('bridge-modal-name').value = 'bridge1';
|
|
|
+ document.getElementById('bridge-modal-name').disabled = false;
|
|
|
+ document.getElementById('bridge-modal-mtu').value = '1500';
|
|
|
+ document.getElementById('bridge-modal-mac').value = '';
|
|
|
+ document.getElementById('bridge-modal-stp').value = 'rstp';
|
|
|
+ document.getElementById('bridge-modal-priority').value = '32768';
|
|
|
+ document.getElementById('bridge-modal-vlanfiltering').checked = false;
|
|
|
+ document.getElementById('bridge-modal-pvid').value = '1';
|
|
|
+ document.getElementById('bridge-modal-fastforward').checked = true;
|
|
|
+ document.getElementById('bridge-modal-comment').value = '';
|
|
|
+ document.getElementById('bridge-modal-disabled').checked = false;
|
|
|
+ toggleBridgeModalVlanFiltering();
|
|
|
+ document.getElementById('bridge-modal').classList.remove('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function openEditBridgeModal(id) {
|
|
|
+ const b = (state.bridges || []).find(x => x.id === id);
|
|
|
+ if (!b) return;
|
|
|
+
|
|
|
+ document.getElementById('bridge-modal-title').innerText = `Edit Bridge (${b.name})`;
|
|
|
+ document.getElementById('bridge-modal-id').value = b.id;
|
|
|
+ document.getElementById('bridge-modal-name').value = b.name;
|
|
|
+ document.getElementById('bridge-modal-name').disabled = true;
|
|
|
+ document.getElementById('bridge-modal-mtu').value = b.mtu || 1500;
|
|
|
+ document.getElementById('bridge-modal-mac').value = b.mac_address || '';
|
|
|
+ document.getElementById('bridge-modal-stp').value = b.stp || 'rstp';
|
|
|
+ document.getElementById('bridge-modal-priority').value = b.priority || 32768;
|
|
|
+ document.getElementById('bridge-modal-vlanfiltering').checked = !!b.vlan_filtering;
|
|
|
+ document.getElementById('bridge-modal-pvid').value = b.pvid || 1;
|
|
|
+ document.getElementById('bridge-modal-fastforward').checked = b.fast_forward !== false;
|
|
|
+ document.getElementById('bridge-modal-comment').value = b.comment || '';
|
|
|
+ document.getElementById('bridge-modal-disabled').checked = !!b.disabled;
|
|
|
+ toggleBridgeModalVlanFiltering();
|
|
|
+ document.getElementById('bridge-modal').classList.remove('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function closeBridgeModal() {
|
|
|
+ document.getElementById('bridge-modal').classList.add('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function toggleBridgeModalVlanFiltering() {
|
|
|
+ const chk = document.getElementById('bridge-modal-vlanfiltering').checked;
|
|
|
+ const pvidBox = document.getElementById('bridge-modal-pvid-box');
|
|
|
+ if (pvidBox) {
|
|
|
+ if (chk) pvidBox.classList.remove('opacity-40');
|
|
|
+ else pvidBox.classList.add('opacity-40');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function submitSaveBridge(e) {
|
|
|
+ e.preventDefault();
|
|
|
+ const id = document.getElementById('bridge-modal-id').value;
|
|
|
+ const name = document.getElementById('bridge-modal-name').value.trim();
|
|
|
+ const mtu = parseInt(document.getElementById('bridge-modal-mtu').value, 10) || 1500;
|
|
|
+ const mac = document.getElementById('bridge-modal-mac').value.trim();
|
|
|
+ const stp = document.getElementById('bridge-modal-stp').value;
|
|
|
+ const priority = parseInt(document.getElementById('bridge-modal-priority').value, 10) || 32768;
|
|
|
+ const vlanFiltering = document.getElementById('bridge-modal-vlanfiltering').checked;
|
|
|
+ const pvid = parseInt(document.getElementById('bridge-modal-pvid').value, 10) || 1;
|
|
|
+ const fastForward = document.getElementById('bridge-modal-fastforward').checked;
|
|
|
+ const comment = document.getElementById('bridge-modal-comment').value.trim();
|
|
|
+ const disabled = document.getElementById('bridge-modal-disabled').checked;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/bridge/bridges`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({
|
|
|
+ id,
|
|
|
+ name,
|
|
|
+ mtu,
|
|
|
+ mac_address: mac,
|
|
|
+ stp,
|
|
|
+ priority,
|
|
|
+ vlan_filtering: vlanFiltering,
|
|
|
+ pvid,
|
|
|
+ fast_forward: fastForward,
|
|
|
+ comment,
|
|
|
+ disabled
|
|
|
+ })
|
|
|
+ });
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('Bridge Saved', `Bridge ${name} configured`, 'success');
|
|
|
+ closeBridgeModal();
|
|
|
+ loadBridge();
|
|
|
+ fetchData(false);
|
|
|
+ } else {
|
|
|
+ const err = await res.json();
|
|
|
+ showToast('Error', err.error || err.message || 'Failed to save bridge', 'error');
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function toggleBridgeState(id, disabled) {
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/bridge/bridges/${encodeURIComponent(id)}/state`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ disabled })
|
|
|
+ });
|
|
|
+ if (res.ok) {
|
|
|
+ loadBridge();
|
|
|
+ fetchData(false);
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function deleteBridge(id) {
|
|
|
+ const b = (state.bridges || []).find(x => x.id === id);
|
|
|
+ const name = b ? b.name : id;
|
|
|
+ if (!confirm(`Are you sure you want to delete bridge '${name}'? All member ports will be detached.`)) return;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/bridge/bridges/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('Deleted', `Bridge ${name} deleted`, 'success');
|
|
|
+ loadBridge();
|
|
|
+ fetchData(false);
|
|
|
+ } else {
|
|
|
+ const err = await res.json();
|
|
|
+ showToast('Error', err.error || err.message || 'Failed to delete bridge', 'error');
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ----------------- Bridge IP Assignments -----------------
|
|
|
+
|
|
|
+function openAddBridgeIPModal(id, name) {
|
|
|
+ document.getElementById('bridge-ip-modal-id').value = id;
|
|
|
+ document.getElementById('bridge-ip-modal-sub').innerText = name;
|
|
|
+ document.getElementById('bridge-ip-modal-cidr').value = '';
|
|
|
+ document.getElementById('bridge-ip-modal').classList.remove('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function closeBridgeIPModal() {
|
|
|
+ document.getElementById('bridge-ip-modal').classList.add('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+async function submitAddBridgeIP(e) {
|
|
|
+ e.preventDefault();
|
|
|
+ const id = document.getElementById('bridge-ip-modal-id').value;
|
|
|
+ const ip = document.getElementById('bridge-ip-modal-cidr').value.trim();
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/bridge/bridges/${encodeURIComponent(id)}/ip`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ ip })
|
|
|
+ });
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('IP Assigned', `${ip} assigned to bridge`, 'success');
|
|
|
+ closeBridgeIPModal();
|
|
|
+ loadBridge();
|
|
|
+ fetchData(false);
|
|
|
+ } else {
|
|
|
+ const err = await res.json();
|
|
|
+ showToast('Error', err.error || err.message || 'Failed to add IP', 'error');
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function deleteBridgeIP(id, ip) {
|
|
|
+ if (!confirm(`Remove IP address ${ip} from bridge?`)) return;
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/bridge/bridges/${encodeURIComponent(id)}/ip`, {
|
|
|
+ method: 'DELETE',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ ip })
|
|
|
+ });
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('IP Removed', `${ip} removed from bridge`, 'success');
|
|
|
+ loadBridge();
|
|
|
+ fetchData(false);
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ----------------- Bridge Ports -----------------
|
|
|
+
|
|
|
+function renderBridgePorts() {
|
|
|
+ const tbody = document.getElementById('bridge-ports-table-body');
|
|
|
+ const empty = document.getElementById('bridge-empty-ports');
|
|
|
+ const countBadge = document.getElementById('bridge-ports-count-badge');
|
|
|
+ const filterBridge = document.getElementById('bridge-ports-filter-bridge')?.value || '';
|
|
|
+ if (!tbody) return;
|
|
|
+ tbody.innerHTML = '';
|
|
|
+
|
|
|
+ // Update filter select
|
|
|
+ populateBridgeSelect('bridge-ports-filter-bridge', filterBridge, true);
|
|
|
+
|
|
|
+ const ports = (state.bridgePorts || []).filter(p => !filterBridge || p.bridge === filterBridge);
|
|
|
+ if (countBadge) countBadge.innerText = `${ports.length} Ports`;
|
|
|
+
|
|
|
+ if (ports.length === 0) {
|
|
|
+ empty?.classList.remove('hidden');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ empty?.classList.add('hidden');
|
|
|
+
|
|
|
+ ports.forEach((p, idx) => {
|
|
|
+ const tr = document.createElement('tr');
|
|
|
+ tr.className = `hover:bg-slate-800/40 transition text-xs ${p.disabled ? 'opacity-50' : ''}`;
|
|
|
+
|
|
|
+ const disabledFlag = p.disabled ? '<span class="text-rose-400 font-bold" title="Disabled (X)">X</span>' : '<span class="text-slate-600">-</span>';
|
|
|
+
|
|
|
+ tr.innerHTML = `
|
|
|
+ <td class="py-2.5 px-3 text-center text-slate-500 font-bold">${idx + 1}</td>
|
|
|
+ <td class="py-2.5 px-3 text-center font-mono">${disabledFlag}</td>
|
|
|
+ <td class="py-2.5 px-3 font-bold text-white text-sm">
|
|
|
+ <span class="text-emerald-400 mr-1.5">🔌</span>${p.interface}
|
|
|
+ </td>
|
|
|
+ <td class="py-2.5 px-3 font-semibold text-sky-400">${p.bridge}</td>
|
|
|
+ <td class="py-2.5 px-3 font-mono">${p.pvid || 1}</td>
|
|
|
+ <td class="py-2.5 px-3 text-slate-300">${p.frame_types}</td>
|
|
|
+ <td class="py-2.5 px-3 text-center">${p.ingress_filtering ? '<span class="text-amber-400 font-bold">yes</span>' : '<span class="text-slate-500">no</span>'}</td>
|
|
|
+ <td class="py-2.5 px-3">
|
|
|
+ ${p.horizon > 0 ? `<span class="px-1.5 py-0.5 rounded text-[10px] bg-purple-950 text-purple-300 border border-purple-800/40 font-mono">domain ${p.horizon}</span>` : '<span class="text-slate-500">none</span>'}
|
|
|
+ </td>
|
|
|
+ <td class="py-2.5 px-3">
|
|
|
+ <span class="px-2 py-0.5 rounded text-[10px] font-bold ${p.status === 'forwarding' ? 'bg-emerald-950 text-emerald-400 border border-emerald-800/40' : 'bg-slate-800 text-slate-400'} uppercase font-mono">${p.status}</span>
|
|
|
+ </td>
|
|
|
+ <td class="py-2.5 px-3 text-slate-400 italic truncate max-w-[120px]" title="${p.comment || ''}">${p.comment || '-'}</td>
|
|
|
+ <td class="py-2.5 px-3 text-right space-x-1.5 font-sans whitespace-nowrap">
|
|
|
+ <button onclick="toggleBridgePortState('${p.id}', ${!p.disabled})" class="text-xs ${p.disabled ? 'text-emerald-400' : 'text-slate-400'} hover:underline font-semibold">${p.disabled ? 'Enable' : 'Disable'}</button>
|
|
|
+ <button onclick="openEditBridgePortModal('${p.id}')" class="text-xs text-slate-400 hover:text-white">Edit</button>
|
|
|
+ <button onclick="deleteBridgePort('${p.id}')" class="text-xs text-slate-500 hover:text-rose-400">Delete</button>
|
|
|
+ </td>
|
|
|
+ `;
|
|
|
+ tbody.appendChild(tr);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function openAddBridgePortModal() {
|
|
|
+ document.getElementById('bridge-port-modal-title').innerText = 'Add Port to Bridge';
|
|
|
+ document.getElementById('bridge-port-modal-id').value = '';
|
|
|
+ populateBridgeSelect('bridge-port-modal-bridge');
|
|
|
+ populateBridgePortCandidateSelect('bridge-port-modal-interface');
|
|
|
+ document.getElementById('bridge-port-modal-pvid').value = '1';
|
|
|
+ document.getElementById('bridge-port-modal-frametypes').value = 'admit-all';
|
|
|
+ document.getElementById('bridge-port-modal-ingressfiltering').checked = false;
|
|
|
+ document.getElementById('bridge-port-modal-horizon').value = '0';
|
|
|
+ document.getElementById('bridge-port-modal-pathcost').value = '10';
|
|
|
+ document.getElementById('bridge-port-modal-priority').value = '128';
|
|
|
+ document.getElementById('bridge-port-modal-learn').checked = true;
|
|
|
+ document.getElementById('bridge-port-modal-comment').value = '';
|
|
|
+ document.getElementById('bridge-port-modal-disabled').checked = false;
|
|
|
+ document.getElementById('bridge-port-modal').classList.remove('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function openEditBridgePortModal(id) {
|
|
|
+ const p = (state.bridgePorts || []).find(x => x.id === id);
|
|
|
+ if (!p) return;
|
|
|
+
|
|
|
+ document.getElementById('bridge-port-modal-title').innerText = `Edit Bridge Port (${p.interface})`;
|
|
|
+ document.getElementById('bridge-port-modal-id').value = p.id;
|
|
|
+ populateBridgeSelect('bridge-port-modal-bridge', p.bridge);
|
|
|
+ populateBridgePortCandidateSelect('bridge-port-modal-interface', p.interface);
|
|
|
+ document.getElementById('bridge-port-modal-pvid').value = p.pvid || 1;
|
|
|
+ document.getElementById('bridge-port-modal-frametypes').value = p.frame_types || 'admit-all';
|
|
|
+ document.getElementById('bridge-port-modal-ingressfiltering').checked = !!p.ingress_filtering;
|
|
|
+ document.getElementById('bridge-port-modal-horizon').value = p.horizon || 0;
|
|
|
+ document.getElementById('bridge-port-modal-pathcost').value = p.path_cost || 10;
|
|
|
+ document.getElementById('bridge-port-modal-priority').value = p.priority || 128;
|
|
|
+ document.getElementById('bridge-port-modal-learn').checked = p.learn !== false;
|
|
|
+ document.getElementById('bridge-port-modal-comment').value = p.comment || '';
|
|
|
+ document.getElementById('bridge-port-modal-disabled').checked = !!p.disabled;
|
|
|
+ document.getElementById('bridge-port-modal').classList.remove('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function closeBridgePortModal() {
|
|
|
+ document.getElementById('bridge-port-modal').classList.add('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+async function submitSaveBridgePort(e) {
|
|
|
+ e.preventDefault();
|
|
|
+ const id = document.getElementById('bridge-port-modal-id').value;
|
|
|
+ const bridge = document.getElementById('bridge-port-modal-bridge').value;
|
|
|
+ const iface = document.getElementById('bridge-port-modal-interface').value;
|
|
|
+ const pvid = parseInt(document.getElementById('bridge-port-modal-pvid').value, 10) || 1;
|
|
|
+ const frameTypes = document.getElementById('bridge-port-modal-frametypes').value;
|
|
|
+ const ingressFiltering = document.getElementById('bridge-port-modal-ingressfiltering').checked;
|
|
|
+ const horizon = parseInt(document.getElementById('bridge-port-modal-horizon').value, 10) || 0;
|
|
|
+ const pathCost = parseInt(document.getElementById('bridge-port-modal-pathcost').value, 10) || 10;
|
|
|
+ const priority = parseInt(document.getElementById('bridge-port-modal-priority').value, 10) || 128;
|
|
|
+ const learn = document.getElementById('bridge-port-modal-learn').checked;
|
|
|
+ const comment = document.getElementById('bridge-port-modal-comment').value.trim();
|
|
|
+ const disabled = document.getElementById('bridge-port-modal-disabled').checked;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/bridge/ports`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({
|
|
|
+ id,
|
|
|
+ bridge,
|
|
|
+ interface: iface,
|
|
|
+ pvid,
|
|
|
+ frame_types: frameTypes,
|
|
|
+ ingress_filtering: ingressFiltering,
|
|
|
+ horizon,
|
|
|
+ path_cost: pathCost,
|
|
|
+ priority,
|
|
|
+ learn,
|
|
|
+ comment,
|
|
|
+ disabled
|
|
|
+ })
|
|
|
+ });
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('Port Attached', `${iface} added to ${bridge}`, 'success');
|
|
|
+ closeBridgePortModal();
|
|
|
+ loadBridge();
|
|
|
+ fetchData(false);
|
|
|
+ } else {
|
|
|
+ const err = await res.json();
|
|
|
+ showToast('Error', err.error || err.message || 'Failed to save port', 'error');
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function toggleBridgePortState(id, disabled) {
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/bridge/ports/${encodeURIComponent(id)}/state`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ disabled })
|
|
|
+ });
|
|
|
+ if (res.ok) {
|
|
|
+ loadBridge();
|
|
|
+ fetchData(false);
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function deleteBridgePort(id) {
|
|
|
+ const p = (state.bridgePorts || []).find(x => x.id === id);
|
|
|
+ const iface = p ? p.interface : id;
|
|
|
+ if (!confirm(`Detach interface '${iface}' from bridge?`)) return;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/bridge/ports/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('Port Detached', `${iface} removed from bridge`, 'success');
|
|
|
+ loadBridge();
|
|
|
+ fetchData(false);
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ----------------- Bridge VLANs -----------------
|
|
|
+
|
|
|
+function renderBridgeVLANs() {
|
|
|
+ const tbody = document.getElementById('bridge-vlans-table-body');
|
|
|
+ const empty = document.getElementById('bridge-empty-vlans');
|
|
|
+ const countBadge = document.getElementById('bridge-vlans-count-badge');
|
|
|
+ const filterBridge = document.getElementById('bridge-vlans-filter-bridge')?.value || '';
|
|
|
+ if (!tbody) return;
|
|
|
+ tbody.innerHTML = '';
|
|
|
+
|
|
|
+ populateBridgeSelect('bridge-vlans-filter-bridge', filterBridge, true);
|
|
|
+
|
|
|
+ const vlans = (state.bridgeVLANs || []).filter(v => !filterBridge || v.bridge === filterBridge);
|
|
|
+ if (countBadge) countBadge.innerText = `${vlans.length} Entries`;
|
|
|
+
|
|
|
+ if (vlans.length === 0) {
|
|
|
+ empty?.classList.remove('hidden');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ empty?.classList.add('hidden');
|
|
|
+
|
|
|
+ vlans.forEach((v, idx) => {
|
|
|
+ const tr = document.createElement('tr');
|
|
|
+ tr.className = `hover:bg-slate-800/40 transition text-xs ${v.disabled ? 'opacity-50' : ''}`;
|
|
|
+
|
|
|
+ const taggedHtml = (v.tagged && v.tagged.length > 0)
|
|
|
+ ? v.tagged.map(p => `<span class="px-1.5 py-0.5 rounded text-[10px] bg-cyan-950/80 border border-cyan-800/40 text-cyan-300 font-mono">${p}</span>`).join(' ')
|
|
|
+ : '<span class="text-slate-600">none</span>';
|
|
|
+
|
|
|
+ const untaggedHtml = (v.untagged && v.untagged.length > 0)
|
|
|
+ ? v.untagged.map(p => `<span class="px-1.5 py-0.5 rounded text-[10px] bg-slate-800 border border-slate-700/60 text-slate-300 font-mono">${p}</span>`).join(' ')
|
|
|
+ : '<span class="text-slate-600">none</span>';
|
|
|
+
|
|
|
+ const disabledFlag = v.disabled ? '<span class="text-rose-400 font-bold" title="Disabled (X)">X</span>' : '<span class="text-slate-600">-</span>';
|
|
|
+
|
|
|
+ tr.innerHTML = `
|
|
|
+ <td class="py-2.5 px-3 text-center text-slate-500 font-bold">${idx + 1}</td>
|
|
|
+ <td class="py-2.5 px-3 text-center font-mono">${disabledFlag}</td>
|
|
|
+ <td class="py-2.5 px-3 font-semibold text-sky-400">${v.bridge}</td>
|
|
|
+ <td class="py-2.5 px-3 font-bold text-white font-mono text-sm">${v.vlan_ids}</td>
|
|
|
+ <td class="py-2.5 px-3"><div class="flex items-center gap-1.5 flex-wrap">${taggedHtml}</div></td>
|
|
|
+ <td class="py-2.5 px-3"><div class="flex items-center gap-1.5 flex-wrap">${untaggedHtml}</div></td>
|
|
|
+ <td class="py-2.5 px-3 text-slate-400 italic truncate max-w-[120px]" title="${v.comment || ''}">${v.comment || '-'}</td>
|
|
|
+ <td class="py-2.5 px-3 text-right space-x-1.5 font-sans whitespace-nowrap">
|
|
|
+ <button onclick="toggleBridgeVLANState('${v.id}', ${!v.disabled})" class="text-xs ${v.disabled ? 'text-emerald-400' : 'text-slate-400'} hover:underline font-semibold">${v.disabled ? 'Enable' : 'Disable'}</button>
|
|
|
+ <button onclick="openEditBridgeVLANModal('${v.id}')" class="text-xs text-slate-400 hover:text-white">Edit</button>
|
|
|
+ <button onclick="deleteBridgeVLAN('${v.id}')" class="text-xs text-slate-500 hover:text-rose-400">Delete</button>
|
|
|
+ </td>
|
|
|
+ `;
|
|
|
+ tbody.appendChild(tr);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function openAddBridgeVLANModal() {
|
|
|
+ document.getElementById('bridge-vlan-modal-title').innerText = 'Add Bridge VLAN Entry';
|
|
|
+ document.getElementById('bridge-vlan-modal-id').value = '';
|
|
|
+ populateBridgeSelect('bridge-vlan-modal-bridge');
|
|
|
+ document.getElementById('bridge-vlan-modal-vlanids').value = '';
|
|
|
+ document.getElementById('bridge-vlan-modal-comment').value = '';
|
|
|
+ document.getElementById('bridge-vlan-modal-disabled').checked = false;
|
|
|
+ updateBridgeVlanPortCheckboxes();
|
|
|
+ document.getElementById('bridge-vlan-modal').classList.remove('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function openEditBridgeVLANModal(id) {
|
|
|
+ const v = (state.bridgeVLANs || []).find(x => x.id === id);
|
|
|
+ if (!v) return;
|
|
|
+
|
|
|
+ document.getElementById('bridge-vlan-modal-title').innerText = `Edit Bridge VLAN (${v.vlan_ids})`;
|
|
|
+ document.getElementById('bridge-vlan-modal-id').value = v.id;
|
|
|
+ populateBridgeSelect('bridge-vlan-modal-bridge', v.bridge);
|
|
|
+ document.getElementById('bridge-vlan-modal-vlanids').value = v.vlan_ids;
|
|
|
+ document.getElementById('bridge-vlan-modal-comment').value = v.comment || '';
|
|
|
+ document.getElementById('bridge-vlan-modal-disabled').checked = !!v.disabled;
|
|
|
+ updateBridgeVlanPortCheckboxes(v.tagged || [], v.untagged || []);
|
|
|
+ document.getElementById('bridge-vlan-modal').classList.remove('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function closeBridgeVlanModal() {
|
|
|
+ document.getElementById('bridge-vlan-modal').classList.add('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function updateBridgeVlanPortCheckboxes(taggedSelected = [], untaggedSelected = []) {
|
|
|
+ const selBridge = document.getElementById('bridge-vlan-modal-bridge')?.value || '';
|
|
|
+ const taggedBox = document.getElementById('bridge-vlan-tagged-ports');
|
|
|
+ const untaggedBox = document.getElementById('bridge-vlan-untagged-ports');
|
|
|
+ if (!taggedBox || !untaggedBox) return;
|
|
|
+
|
|
|
+ // Ports belonging to the selected bridge
|
|
|
+ const memberPorts = (state.bridgePorts || []).filter(p => !selBridge || p.bridge === selBridge);
|
|
|
+
|
|
|
+ if (memberPorts.length === 0) {
|
|
|
+ taggedBox.innerHTML = '<span class="text-slate-500 text-xs font-sans">No member ports found in this bridge</span>';
|
|
|
+ untaggedBox.innerHTML = '<span class="text-slate-500 text-xs font-sans">No member ports found in this bridge</span>';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ taggedBox.innerHTML = memberPorts.map(p => `
|
|
|
+ <label class="flex items-center space-x-2 text-xs cursor-pointer hover:bg-slate-900/60 p-1 rounded">
|
|
|
+ <input type="checkbox" name="bvlan-tagged" value="${p.interface}" ${taggedSelected.includes(p.interface) ? 'checked' : ''} class="rounded bg-slate-800 border-slate-700 text-cyan-500">
|
|
|
+ <span>${p.interface}</span>
|
|
|
+ </label>
|
|
|
+ `).join('');
|
|
|
+
|
|
|
+ untaggedBox.innerHTML = memberPorts.map(p => `
|
|
|
+ <label class="flex items-center space-x-2 text-xs cursor-pointer hover:bg-slate-900/60 p-1 rounded">
|
|
|
+ <input type="checkbox" name="bvlan-untagged" value="${p.interface}" ${untaggedSelected.includes(p.interface) ? 'checked' : ''} class="rounded bg-slate-800 border-slate-700 text-slate-400">
|
|
|
+ <span>${p.interface}</span>
|
|
|
+ </label>
|
|
|
+ `).join('');
|
|
|
+}
|
|
|
+
|
|
|
+async function submitSaveBridgeVLAN(e) {
|
|
|
+ e.preventDefault();
|
|
|
+ const id = document.getElementById('bridge-vlan-modal-id').value;
|
|
|
+ const bridge = document.getElementById('bridge-vlan-modal-bridge').value;
|
|
|
+ const vlanIDs = document.getElementById('bridge-vlan-modal-vlanids').value.trim();
|
|
|
+ const comment = document.getElementById('bridge-vlan-modal-comment').value.trim();
|
|
|
+ const disabled = document.getElementById('bridge-vlan-modal-disabled').checked;
|
|
|
+
|
|
|
+ const tagged = Array.from(document.querySelectorAll('input[name="bvlan-tagged"]:checked')).map(x => x.value);
|
|
|
+ const untagged = Array.from(document.querySelectorAll('input[name="bvlan-untagged"]:checked')).map(x => x.value);
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/bridge/vlans`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({
|
|
|
+ id,
|
|
|
+ bridge,
|
|
|
+ vlan_ids: vlanIDs,
|
|
|
+ tagged,
|
|
|
+ untagged,
|
|
|
+ comment,
|
|
|
+ disabled
|
|
|
+ })
|
|
|
+ });
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('VLAN Entry Saved', `VLAN ${vlanIDs} configured on ${bridge}`, 'success');
|
|
|
+ closeBridgeVlanModal();
|
|
|
+ loadBridge();
|
|
|
+ } else {
|
|
|
+ const err = await res.json();
|
|
|
+ showToast('Error', err.error || err.message || 'Failed to save VLAN entry', 'error');
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function toggleBridgeVLANState(id, disabled) {
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/bridge/vlans/${encodeURIComponent(id)}/state`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ disabled })
|
|
|
+ });
|
|
|
+ if (res.ok) loadBridge();
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function deleteBridgeVLAN(id) {
|
|
|
+ if (!confirm('Delete this bridge VLAN entry?')) return;
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/bridge/vlans/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('Deleted', 'VLAN entry removed from bridge', 'success');
|
|
|
+ loadBridge();
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ----------------- Hosts / MAC Table -----------------
|
|
|
+
|
|
|
+async function loadBridgeHosts() {
|
|
|
+ const filterBridge = document.getElementById('bridge-hosts-filter-bridge')?.value || '';
|
|
|
+ populateBridgeSelect('bridge-hosts-filter-bridge', filterBridge, true);
|
|
|
+
|
|
|
+ try {
|
|
|
+ const url = filterBridge ? `${API_BASE}/bridge/hosts?bridge=${encodeURIComponent(filterBridge)}` : `${API_BASE}/bridge/hosts`;
|
|
|
+ const res = await fetch(url);
|
|
|
+ if (res.ok) {
|
|
|
+ state.bridgeHosts = await res.json();
|
|
|
+ renderBridgeHosts();
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ console.error('Failed to load bridge hosts:', err);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function renderBridgeHosts() {
|
|
|
+ const tbody = document.getElementById('bridge-hosts-table-body');
|
|
|
+ const empty = document.getElementById('bridge-empty-hosts');
|
|
|
+ const countBadge = document.getElementById('bridge-hosts-count-badge');
|
|
|
+ const search = (document.getElementById('bridge-hosts-search')?.value || '').toLowerCase().trim();
|
|
|
+ if (!tbody) return;
|
|
|
+ tbody.innerHTML = '';
|
|
|
+
|
|
|
+ const hosts = (state.bridgeHosts || []).filter(h => {
|
|
|
+ if (!search) return true;
|
|
|
+ return (h.mac || '').toLowerCase().includes(search) || (h.interface || '').toLowerCase().includes(search) || (h.bridge || '').toLowerCase().includes(search);
|
|
|
+ });
|
|
|
+
|
|
|
+ if (countBadge) countBadge.innerText = `${hosts.length} Hosts`;
|
|
|
+
|
|
|
+ if (hosts.length === 0) {
|
|
|
+ empty?.classList.remove('hidden');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ empty?.classList.add('hidden');
|
|
|
+
|
|
|
+ hosts.forEach(h => {
|
|
|
+ const tr = document.createElement('tr');
|
|
|
+ tr.className = 'hover:bg-slate-800/40 transition text-xs';
|
|
|
+
|
|
|
+ let flagBadges = '';
|
|
|
+ if (h.local) flagBadges += '<span class="px-1.5 py-0.2 rounded text-[10px] bg-sky-950 text-sky-400 border border-sky-800/40 font-mono font-bold mr-1" title="Local router interface MAC">L</span>';
|
|
|
+ if (h.static) flagBadges += '<span class="px-1.5 py-0.2 rounded text-[10px] bg-purple-950 text-purple-400 border border-purple-800/40 font-mono font-bold mr-1" title="Static entry">S</span>';
|
|
|
+ if (!h.local && !h.static) flagBadges += '<span class="px-1.5 py-0.2 rounded text-[10px] bg-emerald-950 text-emerald-400 border border-emerald-800/40 font-mono font-bold mr-1" title="Dynamically learned">D</span>';
|
|
|
+
|
|
|
+ tr.innerHTML = `
|
|
|
+ <td class="py-2.5 px-3 font-mono font-bold text-white text-sm">${h.mac}</td>
|
|
|
+ <td class="py-2.5 px-3 font-semibold text-emerald-400 font-mono">${h.interface || '-'}</td>
|
|
|
+ <td class="py-2.5 px-3 font-mono text-sky-400">${h.bridge || '-'}</td>
|
|
|
+ <td class="py-2.5 px-3 font-mono">${h.vlan_id || 1}</td>
|
|
|
+ <td class="py-2.5 px-3 font-mono text-slate-400">${h.age ? h.age.toFixed(1) + 's' : '-'}</td>
|
|
|
+ <td class="py-2.5 px-3">${flagBadges}</td>
|
|
|
+ `;
|
|
|
+ tbody.appendChild(tr);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+async function flushBridgeHosts() {
|
|
|
+ const selBridge = document.getElementById('bridge-hosts-filter-bridge')?.value || (state.bridges[0] ? state.bridges[0].name : '');
|
|
|
+ if (!selBridge) {
|
|
|
+ showToast('Notice', 'Please select a bridge to flush dynamic MACs', 'info');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (!confirm(`Flush all dynamic learned MAC addresses on bridge '${selBridge}'?`)) return;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/bridge/hosts/flush`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ bridge: selBridge })
|
|
|
+ });
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('FDB Flushed', `Dynamic MACs flushed on ${selBridge}`, 'success');
|
|
|
+ loadBridgeHosts();
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ----------------- Helpers -----------------
|
|
|
+
|
|
|
+function populateBridgeSelect(selectId, currentVal = '', includeAll = false) {
|
|
|
+ const sel = document.getElementById(selectId);
|
|
|
+ if (!sel) return;
|
|
|
+ const prevVal = currentVal || sel.value;
|
|
|
+ sel.innerHTML = includeAll ? '<option value="">All Bridges</option>' : '';
|
|
|
+
|
|
|
+ (state.bridges || []).forEach(b => {
|
|
|
+ const opt = document.createElement('option');
|
|
|
+ opt.value = b.name;
|
|
|
+ opt.innerText = `${b.name} (${b.ports_count} ports)`;
|
|
|
+ if (b.name === prevVal) opt.selected = true;
|
|
|
+ sel.appendChild(opt);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function populateBridgePortCandidateSelect(selectId, currentVal = '') {
|
|
|
+ const sel = document.getElementById(selectId);
|
|
|
+ if (!sel) return;
|
|
|
+ sel.innerHTML = '<option value="">(Select Interface...)</option>';
|
|
|
+
|
|
|
+ // 1. Physical Interfaces
|
|
|
+ const grpPhys = document.createElement('optgroup');
|
|
|
+ grpPhys.label = 'Physical Interfaces';
|
|
|
+ (state.interfaces || []).filter(i => i.type !== 'bridge' && i.type !== 'vlan' && i.type !== 'loopback').forEach(i => {
|
|
|
+ const opt = document.createElement('option');
|
|
|
+ opt.value = i.name;
|
|
|
+ opt.innerText = `${i.name} (${i.role || i.type})`;
|
|
|
+ if (i.name === currentVal) opt.selected = true;
|
|
|
+ grpPhys.appendChild(opt);
|
|
|
+ });
|
|
|
+ if (grpPhys.children.length > 0) sel.appendChild(grpPhys);
|
|
|
+
|
|
|
+ // 2. VLAN Sub-interfaces
|
|
|
+ const grpVlans = document.createElement('optgroup');
|
|
|
+ grpVlans.label = 'VLAN Sub-interfaces (802.1Q)';
|
|
|
+ (state.vlans || []).forEach(v => {
|
|
|
+ const opt = document.createElement('option');
|
|
|
+ opt.value = v.name;
|
|
|
+ opt.innerText = `${v.name} (VLAN ${v.vlan_id})`;
|
|
|
+ if (v.name === currentVal) opt.selected = true;
|
|
|
+ grpVlans.appendChild(opt);
|
|
|
+ });
|
|
|
+ if (grpVlans.children.length > 0) sel.appendChild(grpVlans);
|
|
|
+
|
|
|
+ // 3. EoIP Tunnels (MikroTik EoIP bridging!)
|
|
|
+ const grpEoip = document.createElement('optgroup');
|
|
|
+ grpEoip.label = 'EoIP Tunnels (Layer 2 VPN)';
|
|
|
+ (state.eoipTunnels || []).forEach(t => {
|
|
|
+ const opt = document.createElement('option');
|
|
|
+ opt.value = t.name;
|
|
|
+ opt.innerText = `${t.name} (Tunnel ID: ${t.tunnel_id} -> ${t.remote_address})`;
|
|
|
+ if (t.name === currentVal) opt.selected = true;
|
|
|
+ grpEoip.appendChild(opt);
|
|
|
+ });
|
|
|
+ if (grpEoip.children.length > 0) sel.appendChild(grpEoip);
|
|
|
+}
|
|
|
+
|