Просмотр исходного кода

fix(sdwan): resolve virtual SD-WAN groups to healthy members in static routes & health prober (v1.8.23)

- Resolve SD-WAN group interfaces to active healthy member gateways and devices
  preventing routing blackholes into Linux dummy devices
- Prevent static route health probers from probing dummy interfaces, avoiding
  false-positive offline states and route withdrawal
- Implement bidirectional state change synchronization: SD-WAN health monitor
  triggers kernel static route updates on failover
- Filter virtual SD-WAN dummy devices in UI interface selector dropdowns and
  add visual SD-WAN badge in route table
- Bump version to v1.8.23 and rebuild AMD64/ARM64 release binaries
Proxmox Router 12 часов назад
Родитель
Сommit
30169f45b0

+ 1 - 1
VERSION

@@ -1 +1 @@
-1.8.22
+1.8.23


BIN
bin/debtik-arm64


BIN
bin/debtik-cli


BIN
bin/debtik-cli-arm64


+ 13 - 0
internal/routing/health.go

@@ -11,6 +11,7 @@ import (
 	"strings"
 	"time"
 
+	"debtik/internal/sdwan"
 	"github.com/vishvananda/netlink"
 )
 
@@ -169,6 +170,18 @@ func (m *RoutingManager) checkAllRoutesHealth(now time.Time) {
 			iface = resolveGatewayInterface(r.Gateway)
 		}
 
+		// If this route is bound to an SD-WAN group, resolve egress interface to the group's active member
+		if sdwan.IsSDWANGroup(iface) || sdwan.IsSDWANGroup(r.Gateway) {
+			targetSDWAN := iface
+			if !sdwan.IsSDWANGroup(targetSDWAN) {
+				targetSDWAN = r.Gateway
+			}
+			resolved, ok := sdwan.ResolveGroupForRouting(targetSDWAN)
+			if ok && len(resolved) > 0 {
+				iface = resolved[0].Interface
+			}
+		}
+
 		// Threshold
 		threshold := r.CheckThreshold
 		if threshold < 5 {

+ 159 - 50
internal/routing/manager.go

@@ -15,6 +15,7 @@ import (
 	"sync"
 	"time"
 
+	"debtik/internal/sdwan"
 	"github.com/vishvananda/netlink"
 )
 
@@ -46,6 +47,11 @@ func NewRoutingManager() *RoutingManager {
 	m.applyAll()
 	m.startHealthCheckWorker()
 	m.startRouteSyncWorker()
+
+	sdwan.RegisterStateChangeCallback(func() {
+		m.SyncSDWANRoutes()
+	})
+
 	return m
 }
 
@@ -516,6 +522,20 @@ func (m *RoutingManager) ListRoutes() []RouteItem {
 				continue
 			}
 			gwMatches := sr.Gateway == "" || lr.Gateway == sr.Gateway || lr.Interface == sr.Gateway || (sr.Interface != "" && lr.Interface == sr.Interface)
+			if !gwMatches && (sdwan.IsSDWANGroup(sr.Interface) || sdwan.IsSDWANGroup(sr.Gateway)) {
+				targetSDWAN := sr.Interface
+				if !sdwan.IsSDWANGroup(targetSDWAN) {
+					targetSDWAN = sr.Gateway
+				}
+				if resolved, ok := sdwan.ResolveGroupForRouting(targetSDWAN); ok {
+					for _, nh := range resolved {
+						if nh.Interface == lr.Interface || (nh.Gateway != "" && nh.Gateway == lr.Gateway) {
+							gwMatches = true
+							break
+						}
+					}
+				}
+			}
 			if gwMatches {
 				matchedLive[i] = true
 				liveRoutes[i].Comment = sr.Comment
@@ -523,6 +543,9 @@ func (m *RoutingManager) ListRoutes() []RouteItem {
 				liveRoutes[i].Dynamic = false
 				liveRoutes[i].Protocol = "static"
 				liveRoutes[i].ID = sr.ID
+				if sr.Interface != "" {
+					liveRoutes[i].Interface = sr.Interface
+				}
 				if liveRoutes[i].Gateway == "" && sr.Gateway != "" {
 					liveRoutes[i].Gateway = sr.Gateway
 				}
@@ -1064,42 +1087,94 @@ func (m *RoutingManager) syncRouteGroupToKernel(dstAddress, routingTable string,
 		return nil
 	}
 
-	if len(activeRoutes) == 1 {
-		r := activeRoutes[0]
+	type expandedNexthop struct {
+		Gateway   string
+		Interface string
+		Weight    int
+	}
+
+	var allNexthops []expandedNexthop
+	isBlackhole := false
+
+	for _, r := range activeRoutes {
 		gw := strings.TrimSpace(r.Gateway)
-		isBlackhole := strings.EqualFold(gw, "blackhole") || strings.EqualFold(r.Action, "blackhole")
+		iface := strings.TrimSpace(r.Interface)
 
-		var args []string
-		if isBlackhole {
-			args = []string{"route", "replace", "blackhole", normDst}
-		} else {
-			args = []string{"route", "replace", normDst}
-			iface := strings.TrimSpace(r.Interface)
-			hasVia := false
-			if gw != "" {
-				if net.ParseIP(gw) != nil {
-					args = append(args, "via", gw)
-					hasVia = true
-				} else {
-					// Gateway field contains an interface name
-					targetDev := gw
-					if iface == "" {
-						iface = targetDev
-					}
-					if detectedGw := detectGatewayForInterface(targetDev); detectedGw != "" {
-						args = append(args, "via", detectedGw)
-						hasVia = true
-					} else {
-						args = append(args, "dev", targetDev)
-					}
+		if strings.EqualFold(gw, "blackhole") || strings.EqualFold(r.Action, "blackhole") {
+			isBlackhole = true
+			break
+		}
+
+		// Check if interface or gateway specifies an SD-WAN virtual group
+		targetSDWAN := ""
+		if sdwan.IsSDWANGroup(iface) {
+			targetSDWAN = iface
+		} else if sdwan.IsSDWANGroup(gw) {
+			targetSDWAN = gw
+		}
+
+		if targetSDWAN != "" {
+			resolved, ok := sdwan.ResolveGroupForRouting(targetSDWAN)
+			if ok && len(resolved) > 0 {
+				for _, nh := range resolved {
+					allNexthops = append(allNexthops, expandedNexthop{
+						Gateway:   nh.Gateway,
+						Interface: nh.Interface,
+						Weight:    nh.Weight,
+					})
 				}
+				continue
 			}
-			if iface != "" && (gw == "" || net.ParseIP(gw) != nil || (gw != "" && detectGatewayForInterface(gw) != "")) {
-				args = append(args, "dev", iface)
-			}
-			if hasVia && iface != "" {
-				args = append(args, "onlink")
-			}
+		}
+
+		// Standard static route
+		if gw != "" && net.ParseIP(gw) == nil && iface == "" {
+			iface = gw
+			gw = ""
+		}
+		if gw == "" && iface != "" {
+			gw = detectGatewayForInterface(iface)
+		}
+
+		allNexthops = append(allNexthops, expandedNexthop{
+			Gateway:   gw,
+			Interface: iface,
+			Weight:    1,
+		})
+	}
+
+	if isBlackhole {
+		args := []string{"route", "replace", "blackhole", normDst}
+		if distance > 0 {
+			args = append(args, "metric", strconv.Itoa(distance))
+		}
+		if tbl != "main" {
+			args = append(args, "table", tbl)
+		}
+		args = append(args, "proto", "static")
+		if out, err := exec.Command("ip", args...).CombinedOutput(); err != nil {
+			return fmt.Errorf("failed to apply blackhole route: %s: %w", string(out), err)
+		}
+		return nil
+	}
+
+	if len(allNexthops) == 0 {
+		return nil
+	}
+
+	if len(allNexthops) == 1 {
+		nh := allNexthops[0]
+		args := []string{"route", "replace", normDst}
+		hasVia := false
+		if nh.Gateway != "" && net.ParseIP(nh.Gateway) != nil {
+			args = append(args, "via", nh.Gateway)
+			hasVia = true
+		}
+		if nh.Interface != "" {
+			args = append(args, "dev", nh.Interface)
+		}
+		if hasVia && nh.Interface != "" {
+			args = append(args, "onlink")
 		}
 		if distance > 0 {
 			args = append(args, "metric", strconv.Itoa(distance))
@@ -1127,7 +1202,7 @@ func (m *RoutingManager) syncRouteGroupToKernel(dstAddress, routingTable string,
 		return nil
 	}
 
-	// Multiple routes: ECMP multipath route
+	// Multiple nexthops: ECMP multipath route
 	args := []string{"route", "replace", normDst, "scope", "global"}
 	if distance > 0 {
 		args = append(args, "metric", strconv.Itoa(distance))
@@ -1137,30 +1212,24 @@ func (m *RoutingManager) syncRouteGroupToKernel(dstAddress, routingTable string,
 	}
 	args = append(args, "proto", "static")
 
-	for _, r := range activeRoutes {
+	for _, nh := range allNexthops {
 		args = append(args, "nexthop")
-		gw := strings.TrimSpace(r.Gateway)
-		iface := strings.TrimSpace(r.Interface)
-		if iface == "" && gw != "" && net.ParseIP(gw) == nil {
-			iface = gw
-		}
 		hasVia := false
-		if gw != "" && net.ParseIP(gw) != nil {
-			args = append(args, "via", gw)
+		if nh.Gateway != "" && net.ParseIP(nh.Gateway) != nil {
+			args = append(args, "via", nh.Gateway)
 			hasVia = true
-		} else if iface != "" {
-			if detectedGw := detectGatewayForInterface(iface); detectedGw != "" {
-				args = append(args, "via", detectedGw)
-				hasVia = true
-			}
 		}
-		if iface != "" {
-			args = append(args, "dev", iface)
+		if nh.Interface != "" {
+			args = append(args, "dev", nh.Interface)
 		}
-		if hasVia && iface != "" {
+		if hasVia && nh.Interface != "" {
 			args = append(args, "onlink")
 		}
-		args = append(args, "weight", "1")
+		w := nh.Weight
+		if w <= 0 {
+			w = 1
+		}
+		args = append(args, "weight", strconv.Itoa(w))
 	}
 
 	cmd := exec.Command("ip", args...)
@@ -1181,6 +1250,46 @@ func (m *RoutingManager) syncRouteGroupToKernel(dstAddress, routingTable string,
 	return nil
 }
 
+// SyncSDWANRoutes reapplies kernel routes for static routes referencing SD-WAN groups when group state changes.
+func (m *RoutingManager) SyncSDWANRoutes() {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	type groupKey struct {
+		dst   string
+		table string
+		dist  int
+	}
+	seen := make(map[groupKey]bool)
+	var groupsToSync []groupKey
+
+	for _, r := range m.staticRoutes {
+		if r.Disabled {
+			continue
+		}
+		if sdwan.IsSDWANGroup(r.Interface) || sdwan.IsSDWANGroup(r.Gateway) {
+			tbl := strings.TrimSpace(r.RoutingTable)
+			if tbl == "" {
+				tbl = "main"
+			}
+			normDst := normalizeDst(r.DstAddress)
+			k := groupKey{dst: normDst, table: tbl, dist: r.Distance}
+			if !seen[k] {
+				seen[k] = true
+				groupsToSync = append(groupsToSync, groupKey{dst: r.DstAddress, table: tbl, dist: r.Distance})
+			}
+		}
+	}
+
+	for _, g := range groupsToSync {
+		if err := m.syncRouteGroupToKernel(g.dst, g.table, g.dist); err != nil {
+			log.Printf("[RouteManager] SD-WAN failover route sync failed for %s (%s metric %d): %v", g.dst, g.table, g.dist, err)
+		} else {
+			log.Printf("[RouteManager] SD-WAN failover synced route %s (%s metric %d) successfully", g.dst, g.table, g.dist)
+		}
+	}
+}
+
 func (m *RoutingManager) applyRouteToKernel(r RouteItem) error {
 	return m.syncRouteGroupToKernel(r.DstAddress, r.RoutingTable, r.Distance)
 }

+ 98 - 0
internal/routing/manager_test.go

@@ -5,6 +5,8 @@ import (
 	"os"
 	"path/filepath"
 	"testing"
+
+	"debtik/internal/sdwan"
 )
 
 func TestDeduplicationOnLoad(t *testing.T) {
@@ -388,3 +390,99 @@ func TestMultipathMixedNexthopFlags(t *testing.T) {
 	_ = mgr.DeleteRoute(r1.ID)
 	_ = mgr.DeleteRoute(r2.ID)
 }
+
+func TestSDWANRouteGroupResolution(t *testing.T) {
+	tmpDir := t.TempDir()
+	sdwanPath := filepath.Join(tmpDir, "sdwan.json")
+	sdwanMgr := sdwan.NewManager(sdwanPath)
+	defer sdwanMgr.Stop()
+
+	// 1. Create failover group
+	group := &sdwan.SDWANGroup{
+		ID:   "sdwan-grp-test",
+		Name: "sdwan-test",
+		Mode: sdwan.ModeFailover,
+		Members: []*sdwan.SDWANMember{
+			{
+				ID:        "mem-1",
+				Interface: "Fibergrid",
+				Gateway:   "100.68.0.1",
+				Priority:  1,
+				Status:    sdwan.StatusHealthy,
+			},
+			{
+				ID:        "mem-2",
+				Interface: "wgx-Space",
+				Gateway:   "",
+				Priority:  2,
+				Status:    sdwan.StatusHealthy,
+			},
+		},
+	}
+	if err := sdwanMgr.Save(group); err != nil {
+		t.Fatalf("failed to save sdwan test group: %v", err)
+	}
+
+	// 2. Test resolution when primary is healthy
+	nhList, ok := sdwan.ResolveGroupForRouting("sdwan-test")
+	if !ok || len(nhList) != 1 {
+		t.Fatalf("expected 1 resolved nexthop, got %d (ok=%v)", len(nhList), ok)
+	}
+	if nhList[0].Interface != "Fibergrid" || nhList[0].Gateway != "100.68.0.1" {
+		t.Fatalf("expected Fibergrid via 100.68.0.1, got %s via %s", nhList[0].Interface, nhList[0].Gateway)
+	}
+
+	// 3. Mark primary as DOWN and verify automatic failover to wgx-Space
+	group.Members[0].Status = sdwan.StatusDown
+	if err := sdwanMgr.Save(group); err != nil {
+		t.Fatalf("failed to update sdwan group: %v", err)
+	}
+
+	nhListAfter, ok := sdwan.ResolveGroupForRouting("sdwan-test")
+	if !ok || len(nhListAfter) != 1 {
+		t.Fatalf("expected 1 resolved nexthop after failover, got %d", len(nhListAfter))
+	}
+	if nhListAfter[0].Interface != "wgx-Space" {
+		t.Fatalf("expected failover to wgx-Space, got %s", nhListAfter[0].Interface)
+	}
+
+	// 4. Test RoutingManager saving a static route mapped to SD-WAN group
+	routesPath := filepath.Join(tmpDir, "static_routes.json")
+	rulesPath := filepath.Join(tmpDir, "policy_rules.json")
+	customPath := filepath.Join(tmpDir, "custom_tables.json")
+
+	StaticRoutesStorePath = routesPath
+	PolicyRulesStorePath = rulesPath
+	CustomTablesStorePath = customPath
+
+	mgr := NewRoutingManager()
+	defer mgr.Stop()
+
+	sdwanRoute := RouteItem{
+		DstAddress:   "198.51.200.0/24",
+		Interface:    "sdwan-test",
+		RoutingTable: "main",
+		Distance:     5,
+	}
+	if err := mgr.SaveRoute(&sdwanRoute); err != nil {
+		t.Fatalf("failed to save static route pointing to SD-WAN group: %v", err)
+	}
+
+	// Verify route is present and active
+	routes := mgr.ListRoutes()
+	found := false
+	for _, r := range routes {
+		if r.DstAddress == "198.51.200.0/24" {
+			found = true
+			if r.Interface != "sdwan-test" {
+				t.Errorf("expected route interface sdwan-test, got %s", r.Interface)
+			}
+			break
+		}
+	}
+	if !found {
+		t.Fatalf("SD-WAN static route not found in ListRoutes")
+	}
+
+	_ = mgr.DeleteRoute(sdwanRoute.ID)
+}

+ 211 - 0
internal/sdwan/manager.go

@@ -16,6 +16,40 @@ const DefaultStorePath = "/etc/debtik/sdwan.json"
 // Global resolver registered for firewall integration
 var GlobalManager *Manager
 
+// ResolvedRouteNexthop represents a target egress interface/gateway for routing.
+type ResolvedRouteNexthop struct {
+	Gateway   string `json:"gateway"`
+	Interface string `json:"interface"`
+	Weight    int    `json:"weight"`
+}
+
+var (
+	stateChangeCallbacks []func()
+	stateChangeMu        sync.Mutex
+)
+
+// RegisterStateChangeCallback registers a function to be invoked when SD-WAN link health or topology changes.
+func RegisterStateChangeCallback(cb func()) {
+	if cb == nil {
+		return
+	}
+	stateChangeMu.Lock()
+	defer stateChangeMu.Unlock()
+	stateChangeCallbacks = append(stateChangeCallbacks, cb)
+}
+
+// NotifyStateChange invokes all registered state change callbacks.
+func NotifyStateChange() {
+	stateChangeMu.Lock()
+	cbs := make([]func(), len(stateChangeCallbacks))
+	copy(cbs, stateChangeCallbacks)
+	stateChangeMu.Unlock()
+
+	for _, cb := range cbs {
+		go cb()
+	}
+}
+
 // Manager coordinates SD-WAN group configurations, kernel routing, and SLA health monitoring.
 type Manager struct {
 	mu        sync.RWMutex
@@ -43,6 +77,7 @@ func NewManager(customStore ...string) *Manager {
 		groups := mgr.listLocked()
 		mgr.mu.RUnlock()
 		_ = mgr.engine.Sync(groups)
+		NotifyStateChange()
 	})
 
 	mgr.load()
@@ -201,6 +236,7 @@ func (m *Manager) Save(g *SDWANGroup) error {
 func (m *Manager) syncLocked() error {
 	err := m.engine.Sync(m.listLocked())
 	_ = m.save()
+	NotifyStateChange()
 	return err
 }
 
@@ -486,5 +522,180 @@ func (m *Manager) RenameInterface(oldName, newName string) {
 	if modified {
 		_ = m.save()
 		_ = m.engine.Sync(m.listLocked())
+		NotifyStateChange()
+	}
+}
+
+// ResolveGroupForRouting resolves an SD-WAN group name or ID to active egress nexthops for kernel routing.
+func (m *Manager) ResolveGroupForRouting(groupNameOrID string) ([]ResolvedRouteNexthop, bool) {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	g := m.findLocked(groupNameOrID)
+	if g == nil || g.Disabled || len(g.Members) == 0 {
+		return nil, false
+	}
+
+	var candidates []*SDWANMember
+	for _, mem := range g.Members {
+		if !mem.Disabled && mem.Interface != "" {
+			candidates = append(candidates, mem)
+		}
+	}
+	if len(candidates) == 0 {
+		return nil, false
+	}
+
+	var online []*SDWANMember
+	for _, mem := range candidates {
+		if mem.Status != StatusDown {
+			online = append(online, mem)
+		}
+	}
+	// Fallback to all enabled candidates if all are down so traffic isn't completely blackholed
+	if len(online) == 0 {
+		online = candidates
+	}
+
+	switch g.Mode {
+	case ModeFailover:
+		// Sort by priority (1 is highest priority)
+		sort.Slice(online, func(i, j int) bool {
+			p1, p2 := online[i].Priority, online[j].Priority
+			if p1 <= 0 {
+				p1 = 100
+			}
+			if p2 <= 0 {
+				p2 = 100
+			}
+			return p1 < p2
+		})
+		primary := online[0]
+		gw := primary.Gateway
+		if gw == "" {
+			gw = m.engine.detectInterfaceGateway(primary.Interface)
+		}
+		return []ResolvedRouteNexthop{{
+			Gateway:   gw,
+			Interface: primary.Interface,
+			Weight:    1,
+		}}, true
+
+	case ModeLowestLatency:
+		best := online[0]
+		for _, mem := range online[1:] {
+			if mem.LiveLatency > 0 && (best.LiveLatency <= 0 || mem.LiveLatency < best.LiveLatency) {
+				best = mem
+			}
+		}
+		gw := best.Gateway
+		if gw == "" {
+			gw = m.engine.detectInterfaceGateway(best.Interface)
+		}
+		return []ResolvedRouteNexthop{{
+			Gateway:   gw,
+			Interface: best.Interface,
+			Weight:    1,
+		}}, true
+
+	case ModeLowestLoss:
+		best := online[0]
+		for _, mem := range online[1:] {
+			if mem.PacketLoss < best.PacketLoss {
+				best = mem
+			}
+		}
+		gw := best.Gateway
+		if gw == "" {
+			gw = m.engine.detectInterfaceGateway(best.Interface)
+		}
+		return []ResolvedRouteNexthop{{
+			Gateway:   gw,
+			Interface: best.Interface,
+			Weight:    1,
+		}}, true
+
+	case ModeWeighted, ModePCC:
+		fallthrough
+	default:
+		var res []ResolvedRouteNexthop
+		for _, mem := range online {
+			gw := mem.Gateway
+			if gw == "" {
+				gw = m.engine.detectInterfaceGateway(mem.Interface)
+			}
+			w := mem.Weight
+			if w <= 0 {
+				w = 1
+			}
+			res = append(res, ResolvedRouteNexthop{
+				Gateway:   gw,
+				Interface: mem.Interface,
+				Weight:    w,
+			})
+		}
+		return res, true
+	}
+}
+
+// ResolveGroupForRouting resolves an SD-WAN group name or ID via GlobalManager or file fallback.
+func ResolveGroupForRouting(groupNameOrID string) ([]ResolvedRouteNexthop, bool) {
+	if GlobalManager != nil {
+		return GlobalManager.ResolveGroupForRouting(groupNameOrID)
+	}
+
+	// Fallback to reading file if GlobalManager is not yet initialized
+	data, err := os.ReadFile(DefaultStorePath)
+	if err != nil {
+		return nil, false
+	}
+	var groups []*SDWANGroup
+	if err := json.Unmarshal(data, &groups); err != nil {
+		return nil, false
+	}
+	for _, g := range groups {
+		if strings.EqualFold(g.Name, groupNameOrID) || g.ID == groupNameOrID {
+			if g.Disabled || len(g.Members) == 0 {
+				return nil, false
+			}
+			var res []ResolvedRouteNexthop
+			for _, m := range g.Members {
+				if !m.Disabled && m.Interface != "" {
+					res = append(res, ResolvedRouteNexthop{
+						Gateway:   m.Gateway,
+						Interface: m.Interface,
+						Weight:    m.Weight,
+					})
+				}
+			}
+			return res, len(res) > 0
+		}
+	}
+	return nil, false
+}
+
+// IsSDWANGroup reports whether the given name corresponds to an active or configured SD-WAN group.
+func IsSDWANGroup(name string) bool {
+	clean := strings.TrimSpace(name)
+	if clean == "" {
+		return false
+	}
+	if GlobalManager != nil {
+		return GlobalManager.Has(clean)
+	}
+
+	data, err := os.ReadFile(DefaultStorePath)
+	if err != nil {
+		return false
+	}
+	var groups []*SDWANGroup
+	if err := json.Unmarshal(data, &groups); err != nil {
+		return false
+	}
+	for _, g := range groups {
+		if strings.EqualFold(g.Name, clean) || g.ID == clean {
+			return true
+		}
 	}
+	return false
 }

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

@@ -4027,7 +4027,8 @@ function populateInterfaceSelect(selectId, currentVal = '') {
   const sel = document.getElementById(selectId);
   if (!sel) return;
   sel.innerHTML = '<option value="">(Any Interface)</option>';
-  (state.interfaces || []).forEach(i => {
+  const sdwanNames = new Set((state.sdwanGroups || []).map(g => g.name));
+  (state.interfaces || []).filter(i => !sdwanNames.has(i.name)).forEach(i => {
     const opt = document.createElement('option');
     opt.value = i.name;
     opt.innerText = `${i.name} (${i.role || i.type})`;
@@ -7527,7 +7528,11 @@ function renderRoutesList() {
       <td class="py-2.5 px-3 text-slate-300 font-mono">${r.gateway || '<span class="text-slate-600 font-sans">Direct link</span>'}</td>
       <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 text-emerald-400 font-mono">${
+        (state.sdwanGroups || []).some(g => g.name === r.interface)
+          ? `<span class="px-1.5 py-0.5 rounded text-[10px] bg-cyan-950/70 text-cyan-300 border border-cyan-700/50 font-bold inline-flex items-center gap-1"><span>⚡</span><span>${escapeHtml(r.interface)}</span></span>`
+          : (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">