Explorar o código

feat(routing): implement Gateway Health Check & Behind-NAT Internet Failover prober for static routes

Proxmox Router hai 1 semana
pai
achega
78ef29f119

+ 1 - 0
cmd/server/main.go

@@ -37,6 +37,7 @@ func main() {
 	vpnMgr := vpn.NewManager()
 	fwMgr := firewall.NewFirewallManager()
 	routingMgr := routing.NewRoutingManager()
+	defer routingMgr.Stop()
 	dynRoutingMgr := dynamicrouting.NewManager("")
 	bridgeMgr := bridge.NewBridgeManager()
 	toolsMgr := tools.NewManager()

+ 214 - 0
internal/routing/health.go

@@ -0,0 +1,214 @@
+package routing
+
+import (
+	"bytes"
+	"fmt"
+	"log"
+	"net"
+	"os/exec"
+	"regexp"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/vishvananda/netlink"
+)
+
+var (
+	pingLossRegex = regexp.MustCompile(`(\d+)% packet loss`)
+	pingRttRegex  = regexp.MustCompile(`rtt min/avg/max/mdev = [\d.]+/([\d.]+)/`)
+)
+
+// resolveGatewayInterface determines which network device routes to the gateway IP
+func resolveGatewayInterface(gwIP string) string {
+	ip := net.ParseIP(strings.TrimSpace(gwIP))
+	if ip == nil {
+		return ""
+	}
+
+	routes, err := netlink.RouteGet(ip)
+	if err == nil && len(routes) > 0 {
+		link, err := netlink.LinkByIndex(routes[0].LinkIndex)
+		if err == nil && link != nil {
+			return link.Attrs().Name
+		}
+	}
+
+	// Fallback using `ip route get`
+	out, err := exec.Command("ip", "route", "get", gwIP).Output()
+	if err == nil {
+		fields := strings.Fields(string(out))
+		for i, f := range fields {
+			if f == "dev" && i+1 < len(fields) {
+				return fields[i+1]
+			}
+		}
+	}
+
+	return ""
+}
+
+// probeRouteHealth tests reachability of a gateway or public internet host bound to a specific interface
+func probeRouteHealth(target string, iface string) (bool, float64, float64, error) {
+	target = strings.TrimSpace(target)
+	if target == "" {
+		return false, 0, 100, fmt.Errorf("empty probe target")
+	}
+
+	var args []string
+	if iface != "" {
+		args = []string{"-c", "2", "-W", "1", "-I", iface, target}
+	} else {
+		args = []string{"-c", "2", "-W", "1", target}
+	}
+
+	cmd := exec.Command("ping", args...)
+	var outBuf bytes.Buffer
+	cmd.Stdout = &outBuf
+	cmd.Stderr = &outBuf
+
+	err := cmd.Run()
+	output := outBuf.String()
+
+	// Parse packet loss
+	lossPct := 100.0
+	if match := pingLossRegex.FindStringSubmatch(output); len(match) > 1 {
+		if val, err := strconv.ParseFloat(match[1], 64); err == nil {
+			lossPct = val
+		}
+	}
+
+	// Parse average RTT
+	var avgRtt float64
+	if match := pingRttRegex.FindStringSubmatch(output); len(match) > 1 {
+		if val, err := strconv.ParseFloat(match[1], 64); err == nil {
+			avgRtt = val
+		}
+	}
+
+	isUp := (lossPct < 100.0) && (err == nil || lossPct < 50.0)
+	return isUp, avgRtt, lossPct, nil
+}
+
+// startHealthCheckWorker initiates background internet connectivity probing for static routes
+func (m *RoutingManager) startHealthCheckWorker() {
+	ticker := time.NewTicker(2 * time.Second)
+	go func() {
+		for {
+			select {
+			case <-m.stopChan:
+				ticker.Stop()
+				return
+			case now := <-ticker.C:
+				m.checkAllRoutesHealth(now)
+			}
+		}
+	}()
+}
+
+func (m *RoutingManager) checkAllRoutesHealth(now time.Time) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	for i := range m.staticRoutes {
+		r := &m.staticRoutes[i]
+		if r.Disabled {
+			continue
+		}
+
+		mode := strings.ToLower(strings.TrimSpace(r.CheckGateway))
+		if mode != "ping" && mode != "internet" {
+			continue
+		}
+
+		interval := r.CheckInterval
+		if interval <= 0 {
+			interval = 5
+		}
+
+		// Check if it's time to probe
+		if r.HealthLastCheck != "" {
+			if lastT, err := time.Parse(time.RFC3339, r.HealthLastCheck); err == nil {
+				if now.Sub(lastT) < time.Duration(interval)*time.Second {
+					continue
+				}
+			}
+		}
+
+		// Determine probe target
+		target := r.CheckTarget
+		if mode == "ping" {
+			target = r.Gateway
+		} else if mode == "internet" {
+			if target == "" {
+				target = "1.1.1.1"
+			}
+		}
+
+		// Determine egress interface
+		iface := r.Interface
+		if iface == "" && r.Gateway != "" {
+			iface = resolveGatewayInterface(r.Gateway)
+		}
+
+		// Threshold
+		threshold := r.CheckThreshold
+		if threshold <= 0 {
+			threshold = 3
+		}
+
+		// Execute probe asynchronously with timeout to prevent blocking routing manager lock
+		go func(routeID string, probeTarget string, probeIface string, checkThreshold int) {
+			isUp, rtt, loss, _ := probeRouteHealth(probeTarget, probeIface)
+
+			m.mu.Lock()
+			defer m.mu.Unlock()
+
+			// Find route by ID again
+			var targetRoute *RouteItem
+			for idx := range m.staticRoutes {
+				if m.staticRoutes[idx].ID == routeID {
+					targetRoute = &m.staticRoutes[idx]
+					break
+				}
+			}
+			if targetRoute == nil || targetRoute.Disabled {
+				return
+			}
+
+			targetRoute.HealthRTT = rtt
+			targetRoute.HealthLossPct = loss
+			targetRoute.HealthLastCheck = time.Now().Format(time.RFC3339)
+
+			if isUp {
+				wasOffline := targetRoute.HealthStatus == "offline"
+				targetRoute.HealthStatus = "online"
+				targetRoute.HealthFailCount = 0
+
+				// If it was offline, restore route in kernel
+				if wasOffline || !targetRoute.Active {
+					_ = m.applyRouteToKernel(*targetRoute)
+					targetRoute.Active = true
+					log.Printf("[RouteHealth] Route %s via %s (target %s) Internet RESTORED (RTT: %.1fms) -> Re-installed in kernel",
+						targetRoute.DstAddress, targetRoute.Gateway, probeTarget, rtt)
+					_ = m.save()
+				}
+			} else {
+				targetRoute.HealthFailCount++
+				if targetRoute.HealthFailCount >= checkThreshold {
+					wasOnline := targetRoute.HealthStatus != "offline"
+					targetRoute.HealthStatus = "offline"
+
+					// If it was online, withdraw route from kernel to allow failover
+					if wasOnline || targetRoute.Active {
+						_ = m.removeRouteFromKernel(*targetRoute)
+						targetRoute.Active = false
+						log.Printf("[RouteHealth] Route %s via %s (target %s) Internet DOWN (threshold %d reached) -> Removed from kernel for failover",
+							targetRoute.DstAddress, targetRoute.Gateway, probeTarget, checkThreshold)
+						_ = m.save()
+					}
+				}
+			}
+		}(r.ID, target, iface, threshold)
+	}
+}

+ 46 - 0
internal/routing/manager.go

@@ -29,6 +29,7 @@ type RoutingManager struct {
 	staticRoutes []RouteItem
 	policyRules  []PolicyRuleItem
 	customTables []RoutingTableItem
+	stopChan     chan struct{}
 }
 
 func NewRoutingManager() *RoutingManager {
@@ -36,11 +37,13 @@ func NewRoutingManager() *RoutingManager {
 		staticRoutes: make([]RouteItem, 0),
 		policyRules:  make([]PolicyRuleItem, 0),
 		customTables: make([]RoutingTableItem, 0),
+		stopChan:     make(chan struct{}),
 	}
 	m.initRTTableFile()
 	m.load()
 	m.syncCustomTablesToSystem()
 	m.applyAll()
+	m.startHealthCheckWorker()
 	return m
 }
 
@@ -211,6 +214,15 @@ func (m *RoutingManager) ListRoutes() []RouteItem {
 				if sr.Distance > 0 && liveRoutes[i].Distance == 0 {
 					liveRoutes[i].Distance = sr.Distance
 				}
+				liveRoutes[i].CheckGateway = sr.CheckGateway
+				liveRoutes[i].CheckTarget = sr.CheckTarget
+				liveRoutes[i].CheckInterval = sr.CheckInterval
+				liveRoutes[i].CheckThreshold = sr.CheckThreshold
+				liveRoutes[i].HealthStatus = sr.HealthStatus
+				liveRoutes[i].HealthRTT = sr.HealthRTT
+				liveRoutes[i].HealthLossPct = sr.HealthLossPct
+				liveRoutes[i].HealthFailCount = sr.HealthFailCount
+				liveRoutes[i].HealthLastCheck = sr.HealthLastCheck
 				found = true
 				break
 			}
@@ -294,7 +306,31 @@ func (m *RoutingManager) SaveRoute(r RouteItem) error {
 		r.ID = fmt.Sprintf("route-%d", time.Now().UnixNano())
 	}
 
+	if r.CheckInterval <= 0 {
+		r.CheckInterval = 5
+	}
+	if r.CheckThreshold <= 0 {
+		r.CheckThreshold = 3
+	}
+	if r.CheckGateway == "internet" && strings.TrimSpace(r.CheckTarget) == "" {
+		r.CheckTarget = "1.1.1.1"
+	}
+	if r.CheckGateway != "" && r.CheckGateway != "none" {
+		if r.HealthStatus == "" {
+			r.HealthStatus = "checking"
+		}
+	} else {
+		r.CheckGateway = "none"
+		r.HealthStatus = "none"
+	}
+
 	if foundIndex >= 0 {
+		if r.HealthStatus == "checking" && m.staticRoutes[foundIndex].HealthStatus != "" && m.staticRoutes[foundIndex].CheckGateway == r.CheckGateway {
+			r.HealthStatus = m.staticRoutes[foundIndex].HealthStatus
+			r.HealthRTT = m.staticRoutes[foundIndex].HealthRTT
+			r.HealthLossPct = m.staticRoutes[foundIndex].HealthLossPct
+			r.HealthLastCheck = m.staticRoutes[foundIndex].HealthLastCheck
+		}
 		m.staticRoutes[foundIndex] = r
 	} else {
 		m.staticRoutes = append(m.staticRoutes, r)
@@ -713,3 +749,13 @@ func (m *RoutingManager) getTableMap() map[int]string {
 
 	return tableMap
 }
+
+func (m *RoutingManager) Stop() {
+	if m.stopChan != nil {
+		select {
+		case <-m.stopChan:
+		default:
+			close(m.stopChan)
+		}
+	}
+}

+ 11 - 0
internal/routing/types.go

@@ -16,6 +16,17 @@ type RouteItem struct {
 	Disabled     bool   `json:"disabled"`      // MikroTik 'X' flag
 	Comment      string `json:"comment,omitempty"`
 
+	// Gateway Health Check & Behind-NAT Internet Prober
+	CheckGateway    string `json:"check_gateway,omitempty"`     // "none", "ping", "internet"
+	CheckTarget     string `json:"check_target,omitempty"`      // Target to probe (e.g. "1.1.1.1", "8.8.8.8")
+	CheckInterval   int    `json:"check_interval,omitempty"`    // Seconds between probes (default 5)
+	CheckThreshold  int    `json:"check_threshold,omitempty"`   // Consecutive failures before failover (default 3)
+	HealthStatus    string `json:"health_status,omitempty"`     // "online", "offline", "checking", "none"
+	HealthRTT       float64 `json:"health_rtt_ms,omitempty"`    // Round trip latency in ms
+	HealthLossPct   float64 `json:"health_loss_pct,omitempty"`  // Packet loss percentage
+	HealthFailCount int    `json:"health_fail_count,omitempty"` // Consecutive failures count
+	HealthLastCheck string `json:"health_last_check,omitempty"` // Formatted timestamp of last probe
+
 	// Old values for modifying existing routes
 	OldDstAddress   string `json:"old_dst_address,omitempty"`
 	OldGateway      string `json:"old_gateway,omitempty"`

+ 101 - 2
internal/web/static/app.js

@@ -5948,9 +5948,52 @@ function renderRoutesList() {
 
     const isDefault = r.dst_address === 'default' || r.dst_address === '0.0.0.0/0';
 
+    let healthCell = `<span class="text-slate-600 font-mono text-[11px]">-</span>`;
+    if (r.check_gateway === 'internet') {
+      if (r.health_status === 'online') {
+        const ms = (r.health_rtt_ms || 0).toFixed(0);
+        healthCell = `
+          <span class="px-2 py-0.5 rounded text-[10px] font-mono bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 font-semibold inline-flex items-center gap-1.5" title="Internet Verified via ${escapeHtml(r.check_target || '1.1.1.1')} (${r.health_loss_pct || 0}% loss)">
+            <span class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
+            <span>Internet OK (${ms}ms)</span>
+          </span>
+        `;
+      } else if (r.health_status === 'offline') {
+        healthCell = `
+          <span class="px-2 py-0.5 rounded text-[10px] font-mono bg-rose-500/20 text-rose-300 border border-rose-500/40 font-bold inline-flex items-center gap-1.5 animate-pulse" title="Internet probe to ${escapeHtml(r.check_target || '1.1.1.1')} failed! Route withdrawn for failover.">
+            <span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>
+            <span>Internet Down</span>
+          </span>
+        `;
+      } else {
+        healthCell = `
+          <span class="px-2 py-0.5 rounded text-[10px] font-mono bg-slate-800 text-slate-400 border border-slate-700 inline-flex items-center gap-1.5">
+            <span class="w-1.5 h-1.5 rounded-full bg-amber-400 animate-ping"></span>
+            <span>Checking...</span>
+          </span>
+        `;
+      }
+    } else if (r.check_gateway === 'ping') {
+      if (r.health_status === 'online') {
+        healthCell = `
+          <span class="px-2 py-0.5 rounded text-[10px] font-mono bg-cyan-500/20 text-cyan-300 border border-cyan-500/30 font-semibold inline-flex items-center gap-1.5">
+            <span class="w-1.5 h-1.5 rounded-full bg-cyan-400"></span>
+            <span>Gateway OK (${(r.health_rtt_ms || 0).toFixed(0)}ms)</span>
+          </span>
+        `;
+      } else if (r.health_status === 'offline') {
+        healthCell = `
+          <span class="px-2 py-0.5 rounded text-[10px] font-mono bg-rose-500/20 text-rose-300 border border-rose-500/40 font-bold inline-flex items-center gap-1.5">
+            <span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>
+            <span>Gateway Dead</span>
+          </span>
+        `;
+      }
+    }
+
     tr.innerHTML = `
       <td class="py-2.5 px-3 text-center">
-        <span class="px-1.5 py-0.5 rounded text-[10px] font-mono font-bold bg-slate-800 ${r.active ? 'text-cyan-400 border border-cyan-800/40' : 'text-slate-500'}">
+        <span class="px-1.5 py-0.5 rounded text-[10px] font-mono font-bold bg-slate-800 ${r.active ? 'text-cyan-400 border border-cyan-800/40' : (r.health_status === 'offline' ? 'text-rose-400 border border-rose-800/40' : 'text-slate-500')}">
           ${flags || '-'}
         </span>
       </td>
@@ -5959,6 +6002,7 @@ function renderRoutesList() {
       <td class="py-2.5 px-3 text-slate-400 font-mono">${(r.distance !== undefined && r.distance !== null) ? r.distance : 0}</td>
       <td class="py-2.5 px-3 font-mono font-semibold text-purple-300">${r.routing_table || 'main'}</td>
       <td class="py-2.5 px-3 text-emerald-400 font-mono">${r.interface || '-'}</td>
+      <td class="py-2.5 px-3">${healthCell}</td>
       <td class="py-2.5 px-3 text-slate-400 italic truncate max-w-[140px]">${r.comment || '-'}</td>
       <td class="py-2.5 px-3 text-right space-x-2 font-sans whitespace-nowrap">
         ${r.static ? `
@@ -6006,6 +6050,16 @@ function openAddRouteModal() {
   disCheck.checked = false;
   if (saveBtn) saveBtn.classList.remove('hidden');
 
+  const checkGw = document.getElementById('route-modal-check-gw');
+  if (checkGw) checkGw.value = 'internet';
+  const checkTarget = document.getElementById('route-modal-check-target');
+  if (checkTarget) checkTarget.value = '1.1.1.1';
+  const checkInterval = document.getElementById('route-modal-check-interval');
+  if (checkInterval) checkInterval.value = '5';
+  const checkThresh = document.getElementById('route-modal-check-threshold');
+  if (checkThresh) checkThresh.value = '3';
+  toggleRouteCheckModeUI();
+
   populateInterfaceSelect('route-modal-iface');
 
   // Populate tables
@@ -6052,6 +6106,16 @@ function openEditRouteModal(id) {
   commentInput.value = r.comment || '';
   disCheck.checked = !!r.disabled;
 
+  const checkGw = document.getElementById('route-modal-check-gw');
+  if (checkGw) checkGw.value = r.check_gateway || 'none';
+  const checkTarget = document.getElementById('route-modal-check-target');
+  if (checkTarget) checkTarget.value = r.check_target || '1.1.1.1';
+  const checkInterval = document.getElementById('route-modal-check-interval');
+  if (checkInterval) checkInterval.value = r.check_interval || 5;
+  const checkThresh = document.getElementById('route-modal-check-threshold');
+  if (checkThresh) checkThresh.value = r.check_threshold || 3;
+  toggleRouteCheckModeUI();
+
   dstInput.disabled = !isStatic;
   gwInput.disabled = !isStatic;
   distInput.disabled = !isStatic;
@@ -6059,6 +6123,10 @@ function openEditRouteModal(id) {
   tableSelect.disabled = !isStatic;
   commentInput.disabled = !isStatic;
   disCheck.disabled = !isStatic;
+  if (checkGw) checkGw.disabled = !isStatic;
+  if (checkTarget) checkTarget.disabled = !isStatic;
+  if (checkInterval) checkInterval.disabled = !isStatic;
+  if (checkThresh) checkThresh.disabled = !isStatic;
 
   if (saveBtn) {
     if (isStatic) {
@@ -6086,6 +6154,28 @@ function closeRouteModal() {
   document.getElementById('route-modal').classList.add('hidden');
 }
 
+function setRouteCheckTarget(ip) {
+  const inp = document.getElementById('route-modal-check-target');
+  if (inp) inp.value = ip;
+}
+
+function toggleRouteCheckModeUI() {
+  const mode = document.getElementById('route-modal-check-gw')?.value || 'none';
+  const targetRow = document.getElementById('route-row-check-target');
+  const paramsRow = document.getElementById('route-row-check-params');
+
+  if (mode === 'none') {
+    if (targetRow) targetRow.classList.add('opacity-40', 'pointer-events-none');
+    if (paramsRow) paramsRow.classList.add('opacity-40', 'pointer-events-none');
+  } else if (mode === 'ping') {
+    if (targetRow) targetRow.classList.add('opacity-40', 'pointer-events-none');
+    if (paramsRow) paramsRow.classList.remove('opacity-40', 'pointer-events-none');
+  } else { // internet
+    if (targetRow) targetRow.classList.remove('opacity-40', 'pointer-events-none');
+    if (paramsRow) paramsRow.classList.remove('opacity-40', 'pointer-events-none');
+  }
+}
+
 async function submitSaveRoute(e) {
   e.preventDefault();
   const id = document.getElementById('route-modal-id').value;
@@ -6103,6 +6193,11 @@ async function submitSaveRoute(e) {
   const comment = document.getElementById('route-modal-comment').value.trim();
   const disabled = document.getElementById('route-modal-disabled').checked;
 
+  const check_gateway = document.getElementById('route-modal-check-gw')?.value || 'none';
+  const check_target = document.getElementById('route-modal-check-target')?.value.trim() || '1.1.1.1';
+  const check_interval = parseInt(document.getElementById('route-modal-check-interval')?.value, 10) || 5;
+  const check_threshold = parseInt(document.getElementById('route-modal-check-threshold')?.value, 10) || 3;
+
   if (!dst) {
     showToast('Error', 'Destination address is required', 'error');
     return;
@@ -6124,7 +6219,11 @@ async function submitSaveRoute(e) {
     distance,
     interface: iface,
     comment,
-    disabled
+    disabled,
+    check_gateway,
+    check_target,
+    check_interval,
+    check_threshold
   };
 
   if (oldDistStr !== undefined && oldDistStr !== '') {

+ 49 - 1
internal/web/static/index.html

@@ -2329,6 +2329,7 @@
                     <th class="py-2.5 px-3">Distance</th>
                     <th class="py-2.5 px-3">Table / Mark</th>
                     <th class="py-2.5 px-3">Interface</th>
+                    <th class="py-2.5 px-3">Internet / Gateway Health</th>
                     <th class="py-2.5 px-3">Comment</th>
                     <th class="py-2.5 px-3 text-right font-sans">Actions</th>
                   </tr>
@@ -7995,6 +7996,53 @@
           </div>
         </div>
 
+        <!-- Gateway Health Check & Behind-NAT Internet Prober -->
+        <div class="bg-slate-950/80 border border-slate-800 rounded-xl p-4 space-y-3">
+          <div class="flex items-center justify-between">
+            <label class="font-bold text-white flex items-center gap-2">
+              <svg class="w-4 h-4 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
+              <span>Gateway Health & Behind-NAT Internet Prober</span>
+            </label>
+            <span class="text-[10px] px-2 py-0.5 rounded bg-emerald-500/10 text-emerald-300 border border-emerald-500/20 font-mono">Failover Prober</span>
+          </div>
+          <p class="text-[11px] text-slate-400 leading-relaxed">
+            When your router is behind a local modem/ONT/NAT (e.g. 192.168.1.1), the local link stays UP even if the ISP line drops. Enable Internet Check to continuously probe public targets through this gateway and automatically failover if Internet dies.
+          </p>
+
+          <div class="grid grid-cols-2 gap-3 pt-1">
+            <div>
+              <label class="block font-semibold text-slate-300 mb-1">Check Mode</label>
+              <select id="route-modal-check-gw" onchange="toggleRouteCheckModeUI()" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-cyan-500">
+                <option value="none">None (Standard static route)</option>
+                <option value="internet" selected>Internet Check (Detects Behind-NAT Outages)</option>
+                <option value="ping">Direct Gateway Ping (Local next-hop only)</option>
+              </select>
+            </div>
+            <div id="route-row-check-target">
+              <div class="flex justify-between items-center mb-1">
+                <label class="block font-semibold text-slate-300">Probe Target IP</label>
+                <div class="flex gap-1 text-[10px] font-mono">
+                  <button type="button" onclick="setRouteCheckTarget('1.1.1.1')" class="px-1.5 py-0.2 bg-slate-800 hover:bg-slate-700 text-cyan-400 rounded border border-slate-700">1.1.1.1</button>
+                  <button type="button" onclick="setRouteCheckTarget('8.8.8.8')" class="px-1.5 py-0.2 bg-slate-800 hover:bg-slate-700 text-emerald-400 rounded border border-slate-700">8.8.8.8</button>
+                  <button type="button" onclick="setRouteCheckTarget('9.9.9.9')" class="px-1.5 py-0.2 bg-slate-800 hover:bg-slate-700 text-purple-400 rounded border border-slate-700">9.9.9.9</button>
+                </div>
+              </div>
+              <input type="text" id="route-modal-check-target" value="1.1.1.1" placeholder="e.g. 1.1.1.1 or 8.8.8.8" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-cyan-500">
+            </div>
+          </div>
+
+          <div id="route-row-check-params" class="grid grid-cols-2 gap-3 pt-1">
+            <div>
+              <label class="block font-semibold text-slate-300 mb-1">Check Interval (Seconds)</label>
+              <input type="number" id="route-modal-check-interval" value="5" min="2" max="60" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-cyan-500">
+            </div>
+            <div>
+              <label class="block font-semibold text-slate-300 mb-1">Failure Threshold (Drops)</label>
+              <input type="number" id="route-modal-check-threshold" value="3" min="1" max="10" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-cyan-500" title="Consecutive timeouts before withdrawing route for failover">
+            </div>
+          </div>
+        </div>
+
         <div>
           <label class="block font-semibold text-slate-300 mb-1.5">Comment</label>
           <input type="text" id="route-modal-comment" placeholder="e.g. Primary WAN Default Gateway" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white">
@@ -10765,6 +10813,6 @@
   </div>
 
   <datalist id="fw-addrlist-datalist"></datalist>
-  <script src="app.js?v=14.0"></script>
+  <script src="app.js?v=15.0"></script>
 </body>
 </html>