|
|
@@ -135,6 +135,7 @@ function navigate(tabName) {
|
|
|
ipsec: { title: 'IPSec (IKEv2) strongSwan', sub: 'Enterprise Site-to-Site & Roadwarrior IPSec controller' },
|
|
|
l2tp: { title: 'L2TP VPN Client', sub: 'Layer 2 Tunneling Protocol via xl2tpd' },
|
|
|
pptp: { title: 'PPTP VPN Client', sub: 'Point-to-Point Tunneling Protocol with MPPE-128 encryption' },
|
|
|
+ vrrp: { title: 'VRRP High Availability', sub: 'Virtual Router Redundancy Protocol, active-backup gateway failover & Virtual IP (VIP) routing' },
|
|
|
tools: { title: 'Diagnostic & Troubleshooting Tools', sub: 'Network latency analysis, ICMP/MTR telemetry, path tracing, DNS resolution and TCP probing' },
|
|
|
persistence: { title: 'Debian Configuration', sub: 'Native /etc/network/interfaces.d/ persistence manager' }
|
|
|
};
|
|
|
@@ -179,6 +180,7 @@ function navigate(tabName) {
|
|
|
if (tabName === 'tools') loadToolsView();
|
|
|
if (tabName === 'dhcp-client') loadDHCPClients();
|
|
|
if (tabName === 'dhcp-server') loadDHCPServer();
|
|
|
+ if (tabName === 'vrrp') loadVRRP();
|
|
|
setTimeout(initTableSorting, 50);
|
|
|
}
|
|
|
|
|
|
@@ -188,7 +190,7 @@ async function fetchData(isManual = false) {
|
|
|
if (isManual && spinner) spinner.classList.add('animate-spin');
|
|
|
|
|
|
try {
|
|
|
- const [statusRes, ifacesRes, vlansRes, vpnRes, fwRes, dynRes, brSummaryRes, dhcpSumRes, dhcpClientsRes] = await Promise.all([
|
|
|
+ const [statusRes, ifacesRes, vlansRes, vpnRes, fwRes, dynRes, brSummaryRes, dhcpSumRes, dhcpClientsRes, vrrpSumRes] = await Promise.all([
|
|
|
fetch(`${API_BASE}/system/status`),
|
|
|
fetch(`${API_BASE}/interfaces`),
|
|
|
fetch(`${API_BASE}/vlans`),
|
|
|
@@ -197,9 +199,15 @@ async function fetchData(isManual = false) {
|
|
|
fetch(`${API_BASE}/routing/dynamic/summary`),
|
|
|
fetch(`${API_BASE}/bridge/summary`),
|
|
|
fetch(`${API_BASE}/dhcp/clients/summary`),
|
|
|
- fetch(`${API_BASE}/dhcp/clients`)
|
|
|
+ fetch(`${API_BASE}/dhcp/clients`),
|
|
|
+ fetch(`${API_BASE}/vrrp/summary`).catch(() => null)
|
|
|
]);
|
|
|
|
|
|
+ if (vrrpSumRes && vrrpSumRes.ok) {
|
|
|
+ state.vrrpSummary = await vrrpSumRes.json();
|
|
|
+ renderVRRPSummary();
|
|
|
+ }
|
|
|
+
|
|
|
if (dhcpSumRes && dhcpSumRes.ok) {
|
|
|
state.dhcpSummary = await dhcpSumRes.json();
|
|
|
const bDhcp = document.getElementById('badge-dhcp-client-count');
|
|
|
@@ -259,6 +267,7 @@ async function fetchData(isManual = false) {
|
|
|
if (state.activeTab === 'pptp') loadPPTP(false);
|
|
|
if (state.activeTab === 'dhcp-client') loadDHCPClients(false);
|
|
|
if (state.activeTab === 'dhcp-server') loadDHCPServer(false);
|
|
|
+ if (state.activeTab === 'vrrp') loadVRRP(false);
|
|
|
|
|
|
} catch (err) {
|
|
|
console.error('Failed to fetch router state:', err);
|
|
|
@@ -13595,6 +13604,264 @@ function compareTableCells(cellA, cellB) {
|
|
|
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
|
|
|
}
|
|
|
|
|
|
+// ==================== VRRP HIGH AVAILABILITY CONTROLLER ====================
|
|
|
+async function loadVRRP(showToastNotification = false) {
|
|
|
+ try {
|
|
|
+ const [instRes, sumRes] = await Promise.all([
|
|
|
+ fetch(`${API_BASE}/vrrp/instances`),
|
|
|
+ fetch(`${API_BASE}/vrrp/summary`)
|
|
|
+ ]);
|
|
|
+
|
|
|
+ if (instRes.ok) {
|
|
|
+ state.vrrpInstances = await instRes.json();
|
|
|
+ renderVRRP();
|
|
|
+ }
|
|
|
+ if (sumRes.ok) {
|
|
|
+ state.vrrpSummary = await sumRes.json();
|
|
|
+ renderVRRPSummary();
|
|
|
+ }
|
|
|
+ if (showToastNotification) {
|
|
|
+ showToast('Refreshed', 'VRRP telemetry refreshed', 'info');
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ console.error('Failed to load VRRP:', err);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function renderVRRPSummary() {
|
|
|
+ const s = state.vrrpSummary || { total_instances: 0, master_instances: 0, backup_instances: 0 };
|
|
|
+ const elTotal = document.getElementById('vrrp-stat-total');
|
|
|
+ if (elTotal) elTotal.innerText = s.total_instances || 0;
|
|
|
+ const elMaster = document.getElementById('vrrp-stat-master');
|
|
|
+ if (elMaster) elMaster.innerText = s.master_instances || 0;
|
|
|
+ const elBackup = document.getElementById('vrrp-stat-backup');
|
|
|
+ if (elBackup) elBackup.innerText = s.backup_instances || 0;
|
|
|
+
|
|
|
+ const bCount = document.getElementById('badge-vrrp-count');
|
|
|
+ if (bCount) bCount.innerText = s.total_instances || 0;
|
|
|
+ const dot = document.getElementById('badge-vrrp-dot');
|
|
|
+ if (dot) {
|
|
|
+ if (s.master_instances > 0) {
|
|
|
+ dot.className = 'w-2 h-2 rounded-full bg-emerald-400 animate-pulse';
|
|
|
+ } else if (s.backup_instances > 0) {
|
|
|
+ dot.className = 'w-2 h-2 rounded-full bg-sky-400';
|
|
|
+ } else {
|
|
|
+ dot.className = 'w-2 h-2 rounded-full bg-slate-600';
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function renderVRRP() {
|
|
|
+ const tbody = document.getElementById('vrrp-table-tbody');
|
|
|
+ const empty = document.getElementById('vrrp-table-empty');
|
|
|
+ if (!tbody) return;
|
|
|
+ tbody.innerHTML = '';
|
|
|
+
|
|
|
+ const instances = state.vrrpInstances || [];
|
|
|
+ if (instances.length === 0) {
|
|
|
+ empty?.classList.remove('hidden');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ empty?.classList.add('hidden');
|
|
|
+
|
|
|
+ instances.forEach(inst => {
|
|
|
+ const tr = document.createElement('tr');
|
|
|
+ tr.className = `hover:bg-slate-800/40 transition text-xs ${inst.disabled ? 'opacity-50' : ''}`;
|
|
|
+
|
|
|
+ // Render Flag: M (Master), B (Backup), F (Fault), X (Disabled)
|
|
|
+ let flagHtml = `<span class="inline-flex items-center justify-center w-5 h-5 rounded-md font-mono font-bold text-[11px] bg-slate-800 text-slate-500 border border-slate-700">-</span>`;
|
|
|
+ if (inst.disabled) {
|
|
|
+ flagHtml = `<span class="inline-flex items-center justify-center w-5 h-5 rounded-md font-mono font-bold text-[11px] bg-rose-500/20 text-rose-400 border border-rose-500/40 shadow-sm" title="Disabled">X</span>`;
|
|
|
+ } else if (inst.state === 'MASTER') {
|
|
|
+ flagHtml = `<span class="inline-flex items-center justify-center w-5 h-5 rounded-md font-mono font-bold text-[11px] bg-emerald-500/20 text-emerald-400 border border-emerald-500/40 shadow-sm" title="Master (Holding VIP)">M</span>`;
|
|
|
+ } else if (inst.state === 'BACKUP') {
|
|
|
+ flagHtml = `<span class="inline-flex items-center justify-center w-5 h-5 rounded-md font-mono font-bold text-[11px] bg-sky-500/20 text-sky-400 border border-sky-500/40 shadow-sm" title="Backup (Standby)">B</span>`;
|
|
|
+ } else if (inst.state === 'FAULT') {
|
|
|
+ flagHtml = `<span class="inline-flex items-center justify-center w-5 h-5 rounded-md font-mono font-bold text-[11px] bg-amber-500/20 text-amber-400 border border-amber-500/40 shadow-sm" title="Fault (Interface Down)">F</span>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Render State Badge
|
|
|
+ let stateBadge = `<span class="px-2 py-0.5 rounded text-[10px] bg-slate-800 text-slate-400 border border-slate-700">DISABLED</span>`;
|
|
|
+ if (!inst.disabled) {
|
|
|
+ if (inst.state === 'MASTER') {
|
|
|
+ stateBadge = `<span class="px-2 py-0.5 rounded text-[10px] bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 font-bold inline-flex items-center gap-1.5"><span class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>MASTER</span>`;
|
|
|
+ } else if (inst.state === 'BACKUP') {
|
|
|
+ stateBadge = `<span class="px-2 py-0.5 rounded text-[10px] bg-sky-500/20 text-sky-300 border border-sky-500/40 font-bold inline-flex items-center gap-1.5"><span class="w-1.5 h-1.5 rounded-full bg-sky-400"></span>BACKUP</span>`;
|
|
|
+ } else if (inst.state === 'FAULT') {
|
|
|
+ stateBadge = `<span class="px-2 py-0.5 rounded text-[10px] bg-amber-500/20 text-amber-300 border border-amber-500/40 font-bold inline-flex items-center gap-1.5"><span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>FAULT</span>`;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const vipsStr = (inst.virtual_ips || []).join(', ') || '-';
|
|
|
+
|
|
|
+ tr.innerHTML = `
|
|
|
+ <td class="py-2.5 px-3 text-center">${flagHtml}</td>
|
|
|
+ <td class="py-2.5 px-3 font-bold text-white font-mono">${escapeHtml(inst.name)}</td>
|
|
|
+ <td class="py-2.5 px-3 font-mono text-cyan-400 font-semibold">${escapeHtml(inst.interface)}</td>
|
|
|
+ <td class="py-2.5 px-3 font-mono font-bold text-center text-white">${inst.vrid}</td>
|
|
|
+ <td class="py-2.5 px-3 font-mono text-center text-slate-300 font-semibold">${inst.priority}</td>
|
|
|
+ <td class="py-2.5 px-3 font-mono text-emerald-400 font-bold"><span class="px-2 py-0.5 rounded bg-emerald-500/10 border border-emerald-500/20">${escapeHtml(vipsStr)}</span></td>
|
|
|
+ <td class="py-2.5 px-3 font-mono text-center text-slate-300">${inst.preempt ? '<span class="text-emerald-400 font-semibold">yes</span>' : '<span class="text-slate-500">no</span>'}</td>
|
|
|
+ <td class="py-2.5 px-3 font-mono text-slate-400 text-[11px]">${escapeHtml(inst.virtual_mac || '-')}</td>
|
|
|
+ <td class="py-2.5 px-3 text-center">${stateBadge}</td>
|
|
|
+ <td class="py-2.5 px-3 text-slate-400 italic truncate max-w-[140px]" title="${escapeHtml(inst.comment || '')}">${escapeHtml(inst.comment || '-')}</td>
|
|
|
+ <td class="py-2.5 px-3 text-right space-x-1.5 font-sans whitespace-nowrap">
|
|
|
+ <button onclick="toggleVRRPState('${inst.id}', ${!inst.disabled})" class="text-xs ${inst.disabled ? 'text-emerald-400' : 'text-slate-400'} hover:underline">${inst.disabled ? 'Enable' : 'Disable'}</button>
|
|
|
+ <button onclick="openEditVRRPModal('${inst.id}')" class="text-xs text-slate-400 hover:text-white">Edit</button>
|
|
|
+ <button onclick="deleteVRRP('${inst.id}', '${escapeHtml(inst.name)}')" class="text-xs text-rose-400 hover:text-rose-300">Delete</button>
|
|
|
+ </td>
|
|
|
+ `;
|
|
|
+ tbody.appendChild(tr);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function populateVRRPIfaceSelect(elemId, selectedIface) {
|
|
|
+ const sel = document.getElementById(elemId);
|
|
|
+ if (!sel) return;
|
|
|
+ sel.innerHTML = '<option value="">Select interface...</option>';
|
|
|
+ const ifaces = state.interfaces || [];
|
|
|
+ ifaces.forEach(i => {
|
|
|
+ const opt = document.createElement('option');
|
|
|
+ opt.value = i.name;
|
|
|
+ opt.innerText = `${i.name} (${i.type || 'net'}${i.ip ? ' - ' + i.ip : ''})`;
|
|
|
+ if (selectedIface && selectedIface === i.name) opt.selected = true;
|
|
|
+ sel.appendChild(opt);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function openAddVRRPModal() {
|
|
|
+ document.getElementById('modal-vrrp-title').innerText = 'Add VRRP Instance';
|
|
|
+ document.getElementById('modal-vrrp-id').value = '';
|
|
|
+ document.getElementById('modal-vrrp-name').value = `vrrp${(state.vrrpInstances || []).length + 1}`;
|
|
|
+ document.getElementById('modal-vrrp-vrid').value = 10 + (state.vrrpInstances || []).length;
|
|
|
+ document.getElementById('modal-vrrp-priority').value = 100;
|
|
|
+ document.getElementById('modal-vrrp-vip').value = '192.168.88.254/24';
|
|
|
+ document.getElementById('modal-vrrp-version').value = '3';
|
|
|
+ document.getElementById('modal-vrrp-interval').value = 1;
|
|
|
+ document.getElementById('modal-vrrp-authtype').value = 'none';
|
|
|
+ document.getElementById('modal-vrrp-authpass').value = '';
|
|
|
+ document.getElementById('modal-vrrp-comment').value = '';
|
|
|
+ document.getElementById('modal-vrrp-preempt').checked = true;
|
|
|
+ document.getElementById('modal-vrrp-disabled').checked = false;
|
|
|
+
|
|
|
+ populateVRRPIfaceSelect('modal-vrrp-iface', '');
|
|
|
+ document.getElementById('modal-vrrp-instance').classList.remove('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function openEditVRRPModal(id) {
|
|
|
+ const inst = (state.vrrpInstances || []).find(item => item.id === id);
|
|
|
+ if (!inst) return;
|
|
|
+ document.getElementById('modal-vrrp-title').innerText = `Edit VRRP Instance (${inst.name})`;
|
|
|
+ document.getElementById('modal-vrrp-id').value = inst.id;
|
|
|
+ document.getElementById('modal-vrrp-name').value = inst.name;
|
|
|
+ document.getElementById('modal-vrrp-vrid').value = inst.vrid;
|
|
|
+ document.getElementById('modal-vrrp-priority').value = inst.priority;
|
|
|
+ document.getElementById('modal-vrrp-vip').value = (inst.virtual_ips || []).join(', ');
|
|
|
+ document.getElementById('modal-vrrp-version').value = String(inst.version || 3);
|
|
|
+ document.getElementById('modal-vrrp-interval').value = inst.interval || 1;
|
|
|
+ document.getElementById('modal-vrrp-authtype').value = inst.auth_type || 'none';
|
|
|
+ document.getElementById('modal-vrrp-authpass').value = inst.auth_password || '';
|
|
|
+ document.getElementById('modal-vrrp-comment').value = inst.comment || '';
|
|
|
+ document.getElementById('modal-vrrp-preempt').checked = inst.preempt !== false;
|
|
|
+ document.getElementById('modal-vrrp-disabled').checked = !!inst.disabled;
|
|
|
+
|
|
|
+ populateVRRPIfaceSelect('modal-vrrp-iface', inst.interface);
|
|
|
+ document.getElementById('modal-vrrp-instance').classList.remove('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function closeVRRPModal() {
|
|
|
+ document.getElementById('modal-vrrp-instance').classList.add('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+async function submitSaveVRRP(e) {
|
|
|
+ e.preventDefault();
|
|
|
+ const id = document.getElementById('modal-vrrp-id').value;
|
|
|
+ const name = document.getElementById('modal-vrrp-name').value.trim();
|
|
|
+ const iface = document.getElementById('modal-vrrp-iface').value;
|
|
|
+ const vrid = parseInt(document.getElementById('modal-vrrp-vrid').value, 10) || 1;
|
|
|
+ const priority = parseInt(document.getElementById('modal-vrrp-priority').value, 10) || 100;
|
|
|
+ const vipRaw = document.getElementById('modal-vrrp-vip').value.trim();
|
|
|
+ const version = parseInt(document.getElementById('modal-vrrp-version').value, 10) || 3;
|
|
|
+ const interval = parseInt(document.getElementById('modal-vrrp-interval').value, 10) || 1;
|
|
|
+ const authType = document.getElementById('modal-vrrp-authtype').value;
|
|
|
+ const authPass = document.getElementById('modal-vrrp-authpass').value.trim();
|
|
|
+ const comment = document.getElementById('modal-vrrp-comment').value.trim();
|
|
|
+ const preempt = document.getElementById('modal-vrrp-preempt').checked;
|
|
|
+ const disabled = document.getElementById('modal-vrrp-disabled').checked;
|
|
|
+
|
|
|
+ const vips = vipRaw.split(',').map(s => s.trim()).filter(Boolean);
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/vrrp/instances`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({
|
|
|
+ id,
|
|
|
+ name,
|
|
|
+ interface: iface,
|
|
|
+ vrid,
|
|
|
+ priority,
|
|
|
+ virtual_ips: vips,
|
|
|
+ version,
|
|
|
+ interval,
|
|
|
+ auth_type: authType,
|
|
|
+ auth_password: authPass,
|
|
|
+ comment,
|
|
|
+ preempt,
|
|
|
+ disabled
|
|
|
+ })
|
|
|
+ });
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('VRRP Saved', `VRRP instance ${name} configured`, 'success');
|
|
|
+ closeVRRPModal();
|
|
|
+ loadVRRP(false);
|
|
|
+ fetchData(false);
|
|
|
+ } else {
|
|
|
+ const err = await res.text();
|
|
|
+ showToast('Error', err, 'error');
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function deleteVRRP(id, name) {
|
|
|
+ if (!confirm(`Delete VRRP instance "${name}"?`)) return;
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/vrrp/instances/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('Deleted', `VRRP instance ${name} removed`, 'info');
|
|
|
+ loadVRRP(false);
|
|
|
+ fetchData(false);
|
|
|
+ } else {
|
|
|
+ const err = await res.text();
|
|
|
+ showToast('Error', err, 'error');
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function toggleVRRPState(id, disabled) {
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/vrrp/instances/${encodeURIComponent(id)}/state`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ disabled })
|
|
|
+ });
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('State Updated', disabled ? 'VRRP disabled' : 'VRRP enabled', 'info');
|
|
|
+ loadVRRP(false);
|
|
|
+ fetchData(false);
|
|
|
+ } else {
|
|
|
+ const err = await res.text();
|
|
|
+ showToast('Error', err, 'error');
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
// Global observer for dynamically injected tables (e.g. inside modals or drawers)
|
|
|
if (typeof MutationObserver !== 'undefined' && typeof document !== 'undefined') {
|
|
|
const docTableObserver = new MutationObserver((mutations) => {
|