|
|
@@ -146,7 +146,9 @@ let state = {
|
|
|
dhcpNetworks: [],
|
|
|
dhcpLeases: [],
|
|
|
dhcpOptions: [],
|
|
|
- dhcpServerSummary: null
|
|
|
+ dhcpServerSummary: null,
|
|
|
+ sdwanGroups: [],
|
|
|
+ sdwanTelemetry: null
|
|
|
};
|
|
|
|
|
|
const API_BASE = '/api';
|
|
|
@@ -361,6 +363,7 @@ function navigate(tabName) {
|
|
|
vlans: { title: 'VLAN Virtual Networks', sub: 'IEEE 802.1Q sub-interface segmentation and routing' },
|
|
|
firewall: { title: 'Network Firewall', sub: 'Stateful packet inspection, NAT, traffic mangle, address lists & connection tracking' },
|
|
|
routes: { title: 'Routing & Policy', sub: 'Routing tables, static routes (DAS/DAC), and policy routing rules' },
|
|
|
+ sdwan: { title: 'Software-Defined WAN (SD-WAN)', sub: 'Multi-link aggregation, traffic load balancing & quality SLA health monitoring' },
|
|
|
bgp: { title: 'BGP Dynamic Routing', sub: 'BGP multi-instance peering sessions, advertised networks, and routing table (RIB)' },
|
|
|
ospf: { title: 'OSPF Dynamic Routing', sub: 'OSPFv2 link-state routing, dynamic neighbor adjacencies, and multi-area topology' },
|
|
|
'routing-filters': { title: 'Routing Policy Filters', sub: 'Route filter chains, prefix matching, BGP community maps & policy-based path control' },
|
|
|
@@ -409,6 +412,7 @@ function navigate(tabName) {
|
|
|
if (tabName === 'bridge') loadBridge();
|
|
|
if (tabName === 'firewall') loadFirewall();
|
|
|
if (tabName === 'routes') loadRoutes();
|
|
|
+ if (tabName === 'sdwan') loadSDWAN();
|
|
|
if (tabName === 'bgp') {
|
|
|
loadDynamicRouting();
|
|
|
switchBgpSubTab(state.activeBgpSubTab || 'bgp-peers');
|
|
|
@@ -552,6 +556,7 @@ async function refreshCoreData(isManual = false) {
|
|
|
if (state.activeTab === 'users') await loadUsers(false);
|
|
|
if (state.activeTab === 'certificates') await loadCertificates(false);
|
|
|
if (state.activeTab === 'reverse_proxy') await loadReverseProxy(false);
|
|
|
+ if (state.activeTab === 'sdwan') await loadSDWAN(false);
|
|
|
if (state.activeTab === 'services') await loadServices(false);
|
|
|
if (state.activeTab === 'updates') await loadUpdateStatus(false);
|
|
|
if (state.activeTab === 'bwtest') await fetchBwTestServerStatus();
|
|
|
@@ -3988,6 +3993,18 @@ function populateInterfaceSelect(selectId, currentVal = '') {
|
|
|
if (i.name === currentVal) opt.selected = true;
|
|
|
sel.appendChild(opt);
|
|
|
});
|
|
|
+ if (state.sdwanGroups && state.sdwanGroups.length > 0) {
|
|
|
+ const optgroup = document.createElement('optgroup');
|
|
|
+ optgroup.label = 'SD-WAN Groups';
|
|
|
+ state.sdwanGroups.forEach(g => {
|
|
|
+ const opt = document.createElement('option');
|
|
|
+ opt.value = g.name;
|
|
|
+ opt.innerText = `${g.name} [SD-WAN: ${g.mode}]`;
|
|
|
+ if (g.name === currentVal) opt.selected = true;
|
|
|
+ optgroup.appendChild(opt);
|
|
|
+ });
|
|
|
+ sel.appendChild(optgroup);
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
function populateAddressListSelect(selectId, currentVal = '') {
|
|
|
@@ -18884,3 +18901,546 @@ async function saveProxyConfig(event) {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+// ==================== SD-WAN Controller ====================
|
|
|
+
|
|
|
+async function loadSDWAN(manual = false) {
|
|
|
+ try {
|
|
|
+ const [groupsRes, telemetryRes] = await Promise.all([
|
|
|
+ fetch(`${API_BASE}/sdwan/groups`),
|
|
|
+ fetch(`${API_BASE}/sdwan/telemetry`)
|
|
|
+ ]);
|
|
|
+
|
|
|
+ if (!groupsRes.ok) throw new Error('Failed to load SD-WAN groups');
|
|
|
+ state.sdwanGroups = await groupsRes.json() || [];
|
|
|
+
|
|
|
+ if (telemetryRes.ok) {
|
|
|
+ state.sdwanTelemetry = await telemetryRes.json() || {};
|
|
|
+ }
|
|
|
+
|
|
|
+ renderSDWANGroups(state.sdwanGroups);
|
|
|
+ renderSDWANMembers(state.sdwanGroups);
|
|
|
+ updateSDWANMetrics(state.sdwanTelemetry);
|
|
|
+
|
|
|
+ if (manual) showToast('SD-WAN', 'SD-WAN groups and live telemetry reloaded', 'success');
|
|
|
+ } catch (err) {
|
|
|
+ console.error('loadSDWAN error:', err);
|
|
|
+ if (manual) showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function updateSDWANMetrics(telemetry) {
|
|
|
+ const groups = state.sdwanGroups || [];
|
|
|
+ const t = telemetry || {};
|
|
|
+
|
|
|
+ const badge = document.getElementById('badge-sdwan-count');
|
|
|
+ if (badge) badge.textContent = groups.length;
|
|
|
+
|
|
|
+ const metricGroups = document.getElementById('sdwan-metric-groups');
|
|
|
+ if (metricGroups) metricGroups.textContent = `${t.active_groups || 0} / ${t.total_groups || groups.length}`;
|
|
|
+
|
|
|
+ const metricHealthy = document.getElementById('sdwan-metric-healthy');
|
|
|
+ if (metricHealthy) metricHealthy.textContent = t.healthy_members || 0;
|
|
|
+
|
|
|
+ const metricDegraded = document.getElementById('sdwan-metric-degraded');
|
|
|
+ if (metricDegraded) metricDegraded.textContent = t.degraded_members || 0;
|
|
|
+
|
|
|
+ const metricDown = document.getElementById('sdwan-metric-down');
|
|
|
+ if (metricDown) metricDown.textContent = t.down_members || 0;
|
|
|
+}
|
|
|
+
|
|
|
+function renderSDWANGroups(groups) {
|
|
|
+ const tbody = document.getElementById('sdwan-groups-table-body');
|
|
|
+ if (!tbody) return;
|
|
|
+
|
|
|
+ if (!groups || groups.length === 0) {
|
|
|
+ tbody.innerHTML = '<tr><td colspan="8" class="py-8 text-center text-slate-500 text-xs font-sans">No SD-WAN groups configured yet. Click "Create SD-WAN Group" to begin.</td></tr>';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ tbody.innerHTML = groups.map(g => {
|
|
|
+ const isEnabled = g.disabled !== true;
|
|
|
+ const members = g.members || [];
|
|
|
+ const totalWeight = members.filter(m => !m.disabled).reduce((sum, m) => sum + (m.weight || 50), 0) || 1;
|
|
|
+
|
|
|
+ // Build distribution progress bar
|
|
|
+ let distHtml = '';
|
|
|
+ if (members.length === 0) {
|
|
|
+ distHtml = '<span class="text-slate-500 text-[11px] font-sans">No links attached</span>';
|
|
|
+ } else if (g.mode === 'weighted' || g.mode === 'pcc') {
|
|
|
+ const colors = ['bg-amber-500', 'bg-cyan-500', 'bg-emerald-500', 'bg-purple-500', 'bg-rose-500'];
|
|
|
+ const segments = members.map((m, idx) => {
|
|
|
+ const pct = m.disabled ? 0 : Math.round(((m.weight || 50) / totalWeight) * 100);
|
|
|
+ const col = colors[idx % colors.length];
|
|
|
+ return `<div class="${col} h-2 transition-all relative group" style="width: ${pct}%" title="${escapeHtml(m.interface)}: ${pct}%"></div>`;
|
|
|
+ }).join('');
|
|
|
+
|
|
|
+ const labels = members.map((m, idx) => {
|
|
|
+ const pct = m.disabled ? '0%' : `${Math.round(((m.weight || 50) / totalWeight) * 100)}%`;
|
|
|
+ const textCol = ['text-amber-400', 'text-cyan-400', 'text-emerald-400', 'text-purple-400', 'text-rose-400'][idx % 5];
|
|
|
+ return `<span class="${textCol} text-[10px] font-mono mr-2">${escapeHtml(m.interface)}: ${pct}</span>`;
|
|
|
+ }).join('');
|
|
|
+
|
|
|
+ distHtml = `
|
|
|
+ <div class="w-48 bg-slate-800 rounded-full h-2 overflow-hidden flex mb-1 border border-slate-700/50">${segments}</div>
|
|
|
+ <div class="flex flex-wrap">${labels}</div>
|
|
|
+ `;
|
|
|
+ } else if (g.mode === 'lowest-latency') {
|
|
|
+ distHtml = '<span class="text-emerald-400 text-[11px] font-sans">⚡ Auto: lowest RTT probe</span>';
|
|
|
+ } else if (g.mode === 'lowest-loss') {
|
|
|
+ distHtml = '<span class="text-cyan-400 text-[11px] font-sans">🛡 Auto: minimum packet loss</span>';
|
|
|
+ } else if (g.mode === 'failover') {
|
|
|
+ distHtml = '<span class="text-amber-400 text-[11px] font-sans">🔄 Priority Failover (Active-Backup)</span>';
|
|
|
+ }
|
|
|
+
|
|
|
+ let modeBadge = '';
|
|
|
+ switch (g.mode) {
|
|
|
+ case 'weighted':
|
|
|
+ modeBadge = '<span class="px-2 py-0.5 rounded bg-amber-500/20 text-amber-300 border border-amber-500/30 text-[10px] font-bold">Weighted LB</span>';
|
|
|
+ break;
|
|
|
+ case 'pcc':
|
|
|
+ modeBadge = '<span class="px-2 py-0.5 rounded bg-purple-500/20 text-purple-300 border border-purple-500/30 text-[10px] font-bold">PCC Hashing</span>';
|
|
|
+ break;
|
|
|
+ case 'lowest-latency':
|
|
|
+ modeBadge = '<span class="px-2 py-0.5 rounded bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 text-[10px] font-bold">Lowest Latency SLA</span>';
|
|
|
+ break;
|
|
|
+ case 'lowest-loss':
|
|
|
+ modeBadge = '<span class="px-2 py-0.5 rounded bg-cyan-500/20 text-cyan-300 border border-cyan-500/30 text-[10px] font-bold">Lowest Loss SLA</span>';
|
|
|
+ break;
|
|
|
+ case 'failover':
|
|
|
+ modeBadge = '<span class="px-2 py-0.5 rounded bg-rose-500/20 text-rose-300 border border-rose-500/30 text-[10px] font-bold">Failover</span>';
|
|
|
+ break;
|
|
|
+ default:
|
|
|
+ modeBadge = `<span class="px-2 py-0.5 rounded bg-slate-800 text-slate-300 text-[10px] font-bold">${escapeHtml(g.mode)}</span>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ const autoNatBadge = g.auto_nat
|
|
|
+ ? '<span class="text-emerald-400 text-[11px]">Enabled</span>'
|
|
|
+ : '<span class="text-slate-500 text-[11px]">Disabled</span>';
|
|
|
+
|
|
|
+ return `
|
|
|
+ <tr class="hover:bg-slate-800/40 transition">
|
|
|
+ <td class="py-3 px-4">
|
|
|
+ <button onclick="toggleSDWANGroupState('${escapeHtml(g.id)}', ${isEnabled})" class="relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${isEnabled ? 'bg-amber-500' : 'bg-slate-700'}" title="${isEnabled ? 'Click to disable' : 'Click to enable'}">
|
|
|
+ <span class="inline-block h-4 w-4 transform rounded-full bg-white shadow transition duration-200 ease-in-out ${isEnabled ? 'translate-x-4' : 'translate-x-0'}"></span>
|
|
|
+ </button>
|
|
|
+ </td>
|
|
|
+ <td class="py-3 px-4">
|
|
|
+ <div class="font-bold text-white text-xs flex items-center gap-1.5">
|
|
|
+ <span class="px-2 py-0.5 rounded bg-amber-950/60 text-amber-300 border border-amber-800/40 font-mono text-[11px] font-bold">${escapeHtml(g.name)}</span>
|
|
|
+ ${g.comment ? `<span class="text-slate-400 text-[10px] font-sans truncate max-w-[150px]" title="${escapeHtml(g.comment)}">(${escapeHtml(g.comment)})</span>` : ''}
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
+ <td class="py-3 px-4">${modeBadge}</td>
|
|
|
+ <td class="py-3 px-4">${distHtml}</td>
|
|
|
+ <td class="py-3 px-4">
|
|
|
+ <span class="px-2 py-0.5 rounded bg-slate-800 text-slate-200 border border-slate-700 font-mono text-[11px]">${members.length} links</span>
|
|
|
+ </td>
|
|
|
+ <td class="py-3 px-4 text-slate-300 font-mono text-xs">${g.probe_interval || 3}s</td>
|
|
|
+ <td class="py-3 px-4">${autoNatBadge}</td>
|
|
|
+ <td class="py-3 px-4 text-right">
|
|
|
+ <div class="flex items-center justify-end gap-1.5">
|
|
|
+ <button onclick="openAddSDWANMemberModal('${escapeHtml(g.id)}')" class="p-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-amber-300 transition" title="Add Member Link">
|
|
|
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
|
|
+ </button>
|
|
|
+ <button onclick="openEditSDWANGroupModal('${escapeHtml(g.id)}')" class="p-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 transition" title="Edit Group">
|
|
|
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
|
|
+ </button>
|
|
|
+ <button onclick="deleteSDWANGroup('${escapeHtml(g.id)}')" class="p-1.5 rounded-lg bg-rose-950/40 hover:bg-rose-900/60 text-rose-400 border border-rose-800/40 transition" title="Delete Group">
|
|
|
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ `;
|
|
|
+ }).join('');
|
|
|
+}
|
|
|
+
|
|
|
+function renderSDWANMembers(groups) {
|
|
|
+ const tbody = document.getElementById('sdwan-members-table-body');
|
|
|
+ if (!tbody) return;
|
|
|
+
|
|
|
+ const allMembers = [];
|
|
|
+ (groups || []).forEach(g => {
|
|
|
+ (g.members || []).forEach(m => {
|
|
|
+ allMembers.push({ ...m, group_id: g.id, group_name: g.name });
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ if (allMembers.length === 0) {
|
|
|
+ tbody.innerHTML = '<tr><td colspan="11" class="py-8 text-center text-slate-500 text-xs font-sans">No member WAN links configured. Click "Add Member Link" above.</td></tr>';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ tbody.innerHTML = allMembers.map(m => {
|
|
|
+ const isEnabled = m.disabled !== true;
|
|
|
+
|
|
|
+ let statusBadge = '<span class="px-2 py-0.5 rounded bg-emerald-500/20 text-emerald-400 border border-emerald-500/30 text-[10px] font-bold">HEALTHY</span>';
|
|
|
+ if (!isEnabled) {
|
|
|
+ statusBadge = '<span class="px-2 py-0.5 rounded bg-slate-800 text-slate-400 border border-slate-700 text-[10px]">DISABLED</span>';
|
|
|
+ } else if (m.status === 'DEGRADED') {
|
|
|
+ statusBadge = '<span class="px-2 py-0.5 rounded bg-amber-500/20 text-amber-400 border border-amber-500/30 text-[10px] font-bold">DEGRADED</span>';
|
|
|
+ } else if (m.status === 'DOWN') {
|
|
|
+ statusBadge = '<span class="px-2 py-0.5 rounded bg-rose-500/20 text-rose-400 border border-rose-500/30 text-[10px] font-bold animate-pulse">DOWN</span>';
|
|
|
+ }
|
|
|
+
|
|
|
+ const latColor = m.live_latency > (m.max_latency || 250) ? 'text-amber-400' : 'text-emerald-400';
|
|
|
+ const lossColor = m.packet_loss > (m.max_loss || 20) ? 'text-rose-400' : 'text-slate-300';
|
|
|
+
|
|
|
+ return `
|
|
|
+ <tr class="hover:bg-slate-800/40 transition">
|
|
|
+ <td class="py-3 px-4">
|
|
|
+ <button onclick="toggleSDWANMemberState('${escapeHtml(m.group_id)}', '${escapeHtml(m.id)}', ${isEnabled})" class="relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${isEnabled ? 'bg-amber-500' : 'bg-slate-700'}" title="${isEnabled ? 'Click to disable link' : 'Click to enable link'}">
|
|
|
+ <span class="inline-block h-4 w-4 transform rounded-full bg-white shadow transition duration-200 ease-in-out ${isEnabled ? 'translate-x-4' : 'translate-x-0'}"></span>
|
|
|
+ </button>
|
|
|
+ </td>
|
|
|
+ <td class="py-3 px-4">
|
|
|
+ <span class="px-2 py-0.5 rounded bg-amber-950/60 text-amber-300 border border-amber-800/40 font-mono text-[11px] font-bold">${escapeHtml(m.group_name)}</span>
|
|
|
+ </td>
|
|
|
+ <td class="py-3 px-4">
|
|
|
+ <div class="font-bold text-white text-xs font-mono">${escapeHtml(m.interface)}</div>
|
|
|
+ ${m.comment ? `<div class="text-[10px] text-slate-500 font-sans">${escapeHtml(m.comment)}</div>` : ''}
|
|
|
+ </td>
|
|
|
+ <td class="py-3 px-4 text-slate-300 font-mono text-xs">${escapeHtml(m.gateway || '(auto)')}</td>
|
|
|
+ <td class="py-3 px-4">
|
|
|
+ <div class="text-xs font-mono text-white">W: ${m.weight || 50}</div>
|
|
|
+ <div class="text-[10px] text-slate-500">Prio: ${m.priority || 1}</div>
|
|
|
+ </td>
|
|
|
+ <td class="py-3 px-4">${statusBadge}</td>
|
|
|
+ <td class="py-3 px-4 text-cyan-400 font-mono text-xs">${escapeHtml(m.target_probe || '1.1.1.1')}</td>
|
|
|
+ <td class="py-3 px-4 font-mono text-xs ${latColor}">
|
|
|
+ ${m.live_latency ? `${m.live_latency.toFixed(1)} ms` : '<span class="text-slate-500">--</span>'}
|
|
|
+ </td>
|
|
|
+ <td class="py-3 px-4 font-mono text-xs ${lossColor}">
|
|
|
+ ${m.packet_loss !== undefined ? `${m.packet_loss.toFixed(1)}%` : '<span class="text-slate-500">--</span>'}
|
|
|
+ </td>
|
|
|
+ <td class="py-3 px-4 font-mono text-xs text-slate-400">
|
|
|
+ ${m.jitter ? `${m.jitter.toFixed(1)} ms` : '<span class="text-slate-500">--</span>'}
|
|
|
+ </td>
|
|
|
+ <td class="py-3 px-4 text-right">
|
|
|
+ <div class="flex items-center justify-end gap-1.5">
|
|
|
+ <button onclick="openEditSDWANMemberModal('${escapeHtml(m.group_id)}', '${escapeHtml(m.id)}')" class="p-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 transition" title="Edit Member">
|
|
|
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
|
|
+ </button>
|
|
|
+ <button onclick="deleteSDWANMember('${escapeHtml(m.group_id)}', '${escapeHtml(m.interface)}')" class="p-1.5 rounded-lg bg-rose-950/40 hover:bg-rose-900/60 text-rose-400 border border-rose-800/40 transition" title="Remove Link">
|
|
|
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ `;
|
|
|
+ }).join('');
|
|
|
+}
|
|
|
+
|
|
|
+// Modal handling for SD-WAN Groups
|
|
|
+function openAddSDWANGroupModal() {
|
|
|
+ const modal = document.getElementById('modal-sdwan-group');
|
|
|
+ if (!modal) return;
|
|
|
+
|
|
|
+ document.getElementById('sdwan-group-modal-title').querySelector('span').textContent = 'Create SD-WAN Group';
|
|
|
+ document.getElementById('sdwan-group-id').value = '';
|
|
|
+ document.getElementById('sdwan-group-name').value = `sdwan${(state.sdwanGroups || []).length}`;
|
|
|
+ document.getElementById('sdwan-group-name').disabled = false;
|
|
|
+ document.getElementById('sdwan-group-mode').value = 'weighted';
|
|
|
+ document.getElementById('sdwan-group-pcc-container').classList.add('hidden');
|
|
|
+ document.getElementById('sdwan-group-hash-fields').value = 'both-addresses-and-ports';
|
|
|
+ document.getElementById('sdwan-group-probe-interval').value = 3;
|
|
|
+ document.getElementById('sdwan-group-auto-nat').checked = true;
|
|
|
+ document.getElementById('sdwan-group-comment').value = '';
|
|
|
+
|
|
|
+ modal.classList.remove('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function openEditSDWANGroupModal(id) {
|
|
|
+ const group = (state.sdwanGroups || []).find(g => g.id === id);
|
|
|
+ if (!group) return;
|
|
|
+
|
|
|
+ const modal = document.getElementById('modal-sdwan-group');
|
|
|
+ if (!modal) return;
|
|
|
+
|
|
|
+ document.getElementById('sdwan-group-modal-title').querySelector('span').textContent = `Edit SD-WAN Group (${group.name})`;
|
|
|
+ document.getElementById('sdwan-group-id').value = group.id;
|
|
|
+ document.getElementById('sdwan-group-name').value = group.name;
|
|
|
+ document.getElementById('sdwan-group-name').disabled = true;
|
|
|
+ document.getElementById('sdwan-group-mode').value = group.mode || 'weighted';
|
|
|
+ if (group.mode === 'pcc') {
|
|
|
+ document.getElementById('sdwan-group-pcc-container').classList.remove('hidden');
|
|
|
+ } else {
|
|
|
+ document.getElementById('sdwan-group-pcc-container').classList.add('hidden');
|
|
|
+ }
|
|
|
+ document.getElementById('sdwan-group-hash-fields').value = group.hash_fields || 'both-addresses-and-ports';
|
|
|
+ document.getElementById('sdwan-group-probe-interval').value = group.probe_interval || 3;
|
|
|
+ document.getElementById('sdwan-group-auto-nat').checked = group.auto_nat !== false;
|
|
|
+ document.getElementById('sdwan-group-comment').value = group.comment || '';
|
|
|
+
|
|
|
+ modal.classList.remove('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function closeSDWANGroupModal() {
|
|
|
+ const modal = document.getElementById('modal-sdwan-group');
|
|
|
+ if (modal) modal.classList.add('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function onSDWANModeChange() {
|
|
|
+ const mode = document.getElementById('sdwan-group-mode').value;
|
|
|
+ const pccContainer = document.getElementById('sdwan-group-pcc-container');
|
|
|
+ if (pccContainer) {
|
|
|
+ if (mode === 'pcc') pccContainer.classList.remove('hidden');
|
|
|
+ else pccContainer.classList.add('hidden');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function saveSDWANGroup(event) {
|
|
|
+ event.preventDefault();
|
|
|
+
|
|
|
+ const id = document.getElementById('sdwan-group-id').value;
|
|
|
+ const name = document.getElementById('sdwan-group-name').value.trim();
|
|
|
+ const mode = document.getElementById('sdwan-group-mode').value;
|
|
|
+ const hashFields = document.getElementById('sdwan-group-hash-fields').value;
|
|
|
+ const probeInterval = parseInt(document.getElementById('sdwan-group-probe-interval').value, 10) || 3;
|
|
|
+ const autoNAT = document.getElementById('sdwan-group-auto-nat').checked;
|
|
|
+ const comment = document.getElementById('sdwan-group-comment').value.trim();
|
|
|
+
|
|
|
+ const payload = {
|
|
|
+ id: id || undefined,
|
|
|
+ name,
|
|
|
+ mode,
|
|
|
+ hash_fields: mode === 'pcc' ? hashFields : undefined,
|
|
|
+ probe_interval: probeInterval,
|
|
|
+ auto_nat: autoNAT,
|
|
|
+ comment
|
|
|
+ };
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/sdwan/groups`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify(payload)
|
|
|
+ });
|
|
|
+
|
|
|
+ if (!res.ok) {
|
|
|
+ const err = await res.json().catch(() => ({}));
|
|
|
+ throw new Error(err.message || 'Failed to save SD-WAN group');
|
|
|
+ }
|
|
|
+
|
|
|
+ closeSDWANGroupModal();
|
|
|
+ showToast('Success', `SD-WAN group ${name} saved successfully`, 'success');
|
|
|
+ await loadSDWAN(false);
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function toggleSDWANGroupState(id, currentState) {
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/sdwan/groups/${id}/state`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ disabled: currentState })
|
|
|
+ });
|
|
|
+
|
|
|
+ if (!res.ok) throw new Error('Failed to update group state');
|
|
|
+ await loadSDWAN(false);
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function deleteSDWANGroup(id) {
|
|
|
+ const group = (state.sdwanGroups || []).find(g => g.id === id);
|
|
|
+ const name = group ? group.name : id;
|
|
|
+ if (!confirm(`Are you sure you want to delete SD-WAN group "${name}"? Member routing tables will be cleared.`)) return;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/sdwan/groups/${id}`, {
|
|
|
+ method: 'DELETE'
|
|
|
+ });
|
|
|
+
|
|
|
+ if (!res.ok) {
|
|
|
+ const err = await res.json().catch(() => ({}));
|
|
|
+ throw new Error(err.message || 'Failed to delete SD-WAN group');
|
|
|
+ }
|
|
|
+
|
|
|
+ showToast('Deleted', `SD-WAN group ${name} removed`, 'success');
|
|
|
+ await loadSDWAN(false);
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// Modal handling for SD-WAN Members
|
|
|
+function populateSDWANGroupsSelect(selectedId = '') {
|
|
|
+ const sel = document.getElementById('sdwan-member-group-id');
|
|
|
+ if (!sel) return;
|
|
|
+ sel.innerHTML = '';
|
|
|
+ (state.sdwanGroups || []).forEach(g => {
|
|
|
+ const opt = document.createElement('option');
|
|
|
+ opt.value = g.id;
|
|
|
+ opt.textContent = `${g.name} (${g.mode})`;
|
|
|
+ if (g.id === selectedId) opt.selected = true;
|
|
|
+ sel.appendChild(opt);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function populateSDWANMemberInterfaceSelect(selectedVal = '') {
|
|
|
+ const sel = document.getElementById('sdwan-member-interface');
|
|
|
+ if (!sel) return;
|
|
|
+ sel.innerHTML = '';
|
|
|
+ (state.interfaces || []).forEach(i => {
|
|
|
+ const opt = document.createElement('option');
|
|
|
+ opt.value = i.name;
|
|
|
+ opt.textContent = `${i.name} (${i.role || i.type})`;
|
|
|
+ if (i.name === selectedVal) opt.selected = true;
|
|
|
+ sel.appendChild(opt);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function openAddSDWANMemberModal(defaultGroupId = null) {
|
|
|
+ const modal = document.getElementById('modal-sdwan-member');
|
|
|
+ if (!modal) return;
|
|
|
+
|
|
|
+ if (!state.sdwanGroups || state.sdwanGroups.length === 0) {
|
|
|
+ showToast('Notice', 'Please create an SD-WAN group first before adding member links', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ document.getElementById('sdwan-member-modal-title').querySelector('span').textContent = 'Add Member Link';
|
|
|
+ document.getElementById('sdwan-member-id').value = '';
|
|
|
+ populateSDWANGroupsSelect(defaultGroupId || (state.sdwanGroups[0] && state.sdwanGroups[0].id));
|
|
|
+ populateSDWANMemberInterfaceSelect();
|
|
|
+ document.getElementById('sdwan-member-gateway').value = '';
|
|
|
+ document.getElementById('sdwan-member-weight').value = 50;
|
|
|
+ document.getElementById('sdwan-member-priority').value = 1;
|
|
|
+ document.getElementById('sdwan-member-probe').value = '1.1.1.1';
|
|
|
+ document.getElementById('sdwan-member-max-lat').value = 250;
|
|
|
+ document.getElementById('sdwan-member-max-loss').value = 20;
|
|
|
+ document.getElementById('sdwan-member-comment').value = '';
|
|
|
+
|
|
|
+ modal.classList.remove('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function openEditSDWANMemberModal(groupId, memberId) {
|
|
|
+ const group = (state.sdwanGroups || []).find(g => g.id === groupId);
|
|
|
+ if (!group) return;
|
|
|
+ const member = (group.members || []).find(m => m.id === memberId);
|
|
|
+ if (!member) return;
|
|
|
+
|
|
|
+ const modal = document.getElementById('modal-sdwan-member');
|
|
|
+ if (!modal) return;
|
|
|
+
|
|
|
+ document.getElementById('sdwan-member-modal-title').querySelector('span').textContent = `Edit Member Link (${member.interface})`;
|
|
|
+ document.getElementById('sdwan-member-id').value = member.id;
|
|
|
+ populateSDWANGroupsSelect(group.id);
|
|
|
+ document.getElementById('sdwan-member-group-id').disabled = true;
|
|
|
+ populateSDWANMemberInterfaceSelect(member.interface);
|
|
|
+ document.getElementById('sdwan-member-interface').disabled = true;
|
|
|
+ document.getElementById('sdwan-member-gateway').value = member.gateway || '';
|
|
|
+ document.getElementById('sdwan-member-weight').value = member.weight || 50;
|
|
|
+ document.getElementById('sdwan-member-priority').value = member.priority || 1;
|
|
|
+ document.getElementById('sdwan-member-probe').value = member.target_probe || '1.1.1.1';
|
|
|
+ document.getElementById('sdwan-member-max-lat').value = member.max_latency || 250;
|
|
|
+ document.getElementById('sdwan-member-max-loss').value = member.max_loss || 20;
|
|
|
+ document.getElementById('sdwan-member-comment').value = member.comment || '';
|
|
|
+
|
|
|
+ modal.classList.remove('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function closeSDWANMemberModal() {
|
|
|
+ const modal = document.getElementById('modal-sdwan-member');
|
|
|
+ if (modal) {
|
|
|
+ document.getElementById('sdwan-member-group-id').disabled = false;
|
|
|
+ document.getElementById('sdwan-member-interface').disabled = false;
|
|
|
+ modal.classList.add('hidden');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function saveSDWANMember(event) {
|
|
|
+ event.preventDefault();
|
|
|
+
|
|
|
+ const id = document.getElementById('sdwan-member-id').value;
|
|
|
+ const groupId = document.getElementById('sdwan-member-group-id').value;
|
|
|
+ const iface = document.getElementById('sdwan-member-interface').value;
|
|
|
+ const gateway = document.getElementById('sdwan-member-gateway').value.trim();
|
|
|
+ const weight = parseInt(document.getElementById('sdwan-member-weight').value, 10) || 50;
|
|
|
+ const priority = parseInt(document.getElementById('sdwan-member-priority').value, 10) || 1;
|
|
|
+ const targetProbe = document.getElementById('sdwan-member-probe').value.trim() || '1.1.1.1';
|
|
|
+ const maxLatency = parseInt(document.getElementById('sdwan-member-max-lat').value, 10) || 250;
|
|
|
+ const maxLoss = parseInt(document.getElementById('sdwan-member-max-loss').value, 10) || 20;
|
|
|
+ const comment = document.getElementById('sdwan-member-comment').value.trim();
|
|
|
+
|
|
|
+ const payload = {
|
|
|
+ id: id || undefined,
|
|
|
+ interface: iface,
|
|
|
+ gateway,
|
|
|
+ weight,
|
|
|
+ priority,
|
|
|
+ target_probe: targetProbe,
|
|
|
+ max_latency: maxLatency,
|
|
|
+ max_loss: maxLoss,
|
|
|
+ comment
|
|
|
+ };
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/sdwan/groups/${groupId}/members`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify(payload)
|
|
|
+ });
|
|
|
+
|
|
|
+ if (!res.ok) {
|
|
|
+ const err = await res.json().catch(() => ({}));
|
|
|
+ throw new Error(err.message || 'Failed to save member link');
|
|
|
+ }
|
|
|
+
|
|
|
+ closeSDWANMemberModal();
|
|
|
+ showToast('Success', `Link ${iface} saved to SD-WAN group`, 'success');
|
|
|
+ await loadSDWAN(false);
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function toggleSDWANMemberState(groupId, memberId, currentState) {
|
|
|
+ const group = (state.sdwanGroups || []).find(g => g.id === groupId);
|
|
|
+ if (!group) return;
|
|
|
+ const member = (group.members || []).find(m => m.id === memberId);
|
|
|
+ if (!member) return;
|
|
|
+
|
|
|
+ const payload = {
|
|
|
+ ...member,
|
|
|
+ disabled: currentState
|
|
|
+ };
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/sdwan/groups/${groupId}/members`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify(payload)
|
|
|
+ });
|
|
|
+
|
|
|
+ if (!res.ok) throw new Error('Failed to update member state');
|
|
|
+ await loadSDWAN(false);
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function deleteSDWANMember(groupId, iface) {
|
|
|
+ if (!confirm(`Are you sure you want to remove link "${iface}" from this SD-WAN group?`)) return;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/sdwan/groups/${groupId}/members/${iface}`, {
|
|
|
+ method: 'DELETE'
|
|
|
+ });
|
|
|
+
|
|
|
+ if (!res.ok) {
|
|
|
+ const err = await res.json().catch(() => ({}));
|
|
|
+ throw new Error(err.message || 'Failed to remove member link');
|
|
|
+ }
|
|
|
+
|
|
|
+ showToast('Removed', `Link ${iface} removed from SD-WAN group`, 'success');
|
|
|
+ await loadSDWAN(false);
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|