Bladeren bron

feat(traffic): add real-time 1s SSE interface traffic streaming and eliminate UI table flickers

Proxmox Router 1 week geleden
bovenliggende
commit
162cf5e575
4 gewijzigde bestanden met toevoegingen van 509 en 41 verwijderingen
  1. 55 0
      internal/api/api.go
  2. 197 23
      internal/network/manager.go
  3. 27 0
      internal/network/types.go
  4. 230 18
      internal/web/static/app.js

+ 55 - 0
internal/api/api.go

@@ -2,8 +2,10 @@ package api
 
 import (
 	"encoding/json"
+	"fmt"
 	"net/http"
 	"strings"
+	"time"
 
 	"proxmox-router/internal/bridge"
 	"proxmox-router/internal/bwtest"
@@ -124,6 +126,9 @@ func (s *Server) registerRoutes() {
 	s.mux.HandleFunc("POST /api/firewall/connections/kill", s.handleKillConnection)
 
 	s.mux.HandleFunc("GET /api/interfaces", s.handleListInterfaces)
+	s.mux.HandleFunc("GET /api/interfaces/traffic/stream", s.handleInterfacesTrafficStream)
+	s.mux.HandleFunc("GET /api/interfaces/traffic/rates", s.handleInterfacesTrafficRates)
+	s.mux.HandleFunc("GET /api/interfaces/rates", s.handleInterfacesTrafficRates)
 	s.mux.HandleFunc("GET /api/interfaces/{name}", s.handleGetInterface)
 	s.mux.HandleFunc("POST /api/interfaces/{name}/state", s.handleSetInterfaceState)
 	s.mux.HandleFunc("POST /api/interfaces/{name}/mtu", s.handleSetInterfaceMTU)
@@ -465,6 +470,56 @@ func (s *Server) handleListInterfaces(w http.ResponseWriter, r *http.Request) {
 	writeJSON(w, http.StatusOK, ifaces)
 }
 
+func (s *Server) handleInterfacesTrafficStream(w http.ResponseWriter, r *http.Request) {
+	flusher, ok := w.(http.Flusher)
+	if !ok {
+		http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
+		return
+	}
+
+	w.Header().Set("Content-Type", "text/event-stream")
+	w.Header().Set("Cache-Control", "no-cache")
+	w.Header().Set("Connection", "keep-alive")
+	w.Header().Set("Access-Control-Allow-Origin", "*")
+
+	ch := s.netMgr.SubscribeTraffic()
+	defer s.netMgr.UnsubscribeTraffic(ch)
+
+	// Send immediate initial snapshot
+	initSnap := s.netMgr.GetLatestTrafficSnapshot()
+	if initSnap != nil {
+		data, _ := json.Marshal(initSnap)
+		fmt.Fprintf(w, "data: %s\n\n", data)
+		flusher.Flush()
+	}
+
+	notify := r.Context().Done()
+	for {
+		select {
+		case <-notify:
+			return
+		case snap, ok := <-ch:
+			if !ok {
+				return
+			}
+			data, _ := json.Marshal(snap)
+			fmt.Fprintf(w, "data: %s\n\n", data)
+			flusher.Flush()
+		}
+	}
+}
+
+func (s *Server) handleInterfacesTrafficRates(w http.ResponseWriter, r *http.Request) {
+	snap := s.netMgr.GetLatestTrafficSnapshot()
+	if snap == nil {
+		snap = &network.TrafficSnapshot{
+			Timestamp:  time.Now().Unix(),
+			Interfaces: make(map[string]network.InterfaceLiveRate),
+		}
+	}
+	writeJSON(w, http.StatusOK, snap)
+}
+
 func (s *Server) handleGetInterface(w http.ResponseWriter, r *http.Request) {
 	name := r.PathValue("name")
 	iface, err := s.netMgr.GetInterface(name)

+ 197 - 23
internal/network/manager.go

@@ -4,7 +4,9 @@ import (
 	"fmt"
 	"log"
 	"net"
+	"os"
 	"regexp"
+	"strconv"
 	"strings"
 	"sync"
 	"time"
@@ -18,19 +20,37 @@ type prevStat struct {
 	time    time.Time
 }
 
+type rawNetDevStat struct {
+	rxBytes   uint64
+	rxPackets uint64
+	rxErrors  uint64
+	rxDropped uint64
+	txBytes   uint64
+	txPackets uint64
+	txErrors  uint64
+	txDropped uint64
+	time      time.Time
+}
+
 // Manager handles all Linux network interactions via Netlink.
 type Manager struct {
-	mu        sync.Mutex
-	metadata  *MetadataManager
-	prevStats map[string]prevStat
+	mu             sync.Mutex
+	metadata       *MetadataManager
+	prevStats      map[string]prevStat
+	snapMu         sync.RWMutex
+	latestSnapshot *TrafficSnapshot
+	subMu          sync.Mutex
+	subscribers    map[chan *TrafficSnapshot]bool
 }
 
 func NewManager(mm *MetadataManager) *Manager {
 	mgr := &Manager{
-		metadata:  mm,
-		prevStats: make(map[string]prevStat),
+		metadata:    mm,
+		prevStats:   make(map[string]prevStat),
+		subscribers: make(map[chan *TrafficSnapshot]bool),
 	}
 	mgr.startIPSyncWorker()
+	mgr.startTrafficSampler()
 	return mgr
 }
 
@@ -109,10 +129,15 @@ func (m *Manager) ListInterfaces() ([]InterfaceInfo, error) {
 		linkMap[l.Attrs().Index] = l
 	}
 
+	latestSnap := m.GetLatestTrafficSnapshot()
+	var latestRates map[string]InterfaceLiveRate
+	if latestSnap != nil {
+		latestRates = latestSnap.Interfaces
+	}
+
 	m.mu.Lock()
 	defer m.mu.Unlock()
 
-	now := time.Now()
 	result := make([]InterfaceInfo, 0)
 
 	for _, link := range links {
@@ -203,25 +228,26 @@ func (m *Manager) ListInterfaces() ([]InterfaceInfo, error) {
 				RxDropped: attrs.Statistics.RxDropped,
 				TxDropped: attrs.Statistics.TxDropped,
 			}
+		}
 
-			if prev, ok := m.prevStats[attrs.Name]; ok {
-				dt := now.Sub(prev.time).Seconds()
-				if dt > 0.1 && attrs.Statistics.RxBytes >= prev.rxBytes && attrs.Statistics.TxBytes >= prev.txBytes {
-					rxRate := float64(attrs.Statistics.RxBytes-prev.rxBytes) / dt
-					txRate := float64(attrs.Statistics.TxBytes-prev.txBytes) / dt
-					info.Rate = TrafficRate{
-						RxBytesPerSec: rxRate,
-						TxBytesPerSec: txRate,
-						RxHuman:       formatBytes(rxRate) + "/s",
-						TxHuman:       formatBytes(txRate) + "/s",
-					}
-				}
+		if lr, ok := latestRates[attrs.Name]; ok {
+			info.Rate = TrafficRate{
+				RxBytesPerSec: lr.RxBytesPerSec,
+				TxBytesPerSec: lr.TxBytesPerSec,
+				RxHuman:       lr.RxHuman,
+				TxHuman:       lr.TxHuman,
 			}
-
-			m.prevStats[attrs.Name] = prevStat{
-				rxBytes: attrs.Statistics.RxBytes,
-				txBytes: attrs.Statistics.TxBytes,
-				time:    now,
+			if attrs.Statistics == nil || attrs.Statistics.RxBytes == 0 {
+				info.Stats = NetStats{
+					RxBytes:   lr.RxBytes,
+					TxBytes:   lr.TxBytes,
+					RxPackets: lr.RxPackets,
+					TxPackets: lr.TxPackets,
+					RxErrors:  lr.RxErrors,
+					TxErrors:  lr.TxErrors,
+					RxDropped: lr.RxDropped,
+					TxDropped: lr.TxDropped,
+				}
 			}
 		}
 
@@ -513,3 +539,151 @@ func formatBytes(b float64) string {
 	}
 	return fmt.Sprintf("%.1f %cB", b/float64(div), "KMGTPE"[exp])
 }
+
+func readProcNetDev() (map[string]rawNetDevStat, error) {
+	data, err := os.ReadFile("/proc/net/dev")
+	if err != nil {
+		return nil, err
+	}
+	lines := strings.Split(string(data), "\n")
+	result := make(map[string]rawNetDevStat)
+	now := time.Now()
+
+	for _, line := range lines {
+		colonIdx := strings.Index(line, ":")
+		if colonIdx == -1 {
+			continue
+		}
+		ifaceName := strings.TrimSpace(line[:colonIdx])
+		fields := strings.Fields(line[colonIdx+1:])
+		if len(fields) < 16 {
+			continue
+		}
+
+		rxBytes, _ := strconv.ParseUint(fields[0], 10, 64)
+		rxPackets, _ := strconv.ParseUint(fields[1], 10, 64)
+		rxErrors, _ := strconv.ParseUint(fields[2], 10, 64)
+		rxDropped, _ := strconv.ParseUint(fields[3], 10, 64)
+
+		txBytes, _ := strconv.ParseUint(fields[8], 10, 64)
+		txPackets, _ := strconv.ParseUint(fields[9], 10, 64)
+		txErrors, _ := strconv.ParseUint(fields[10], 10, 64)
+		txDropped, _ := strconv.ParseUint(fields[11], 10, 64)
+
+		result[ifaceName] = rawNetDevStat{
+			rxBytes:   rxBytes,
+			rxPackets: rxPackets,
+			rxErrors:  rxErrors,
+			rxDropped: rxDropped,
+			txBytes:   txBytes,
+			txPackets: txPackets,
+			txErrors:  txErrors,
+			txDropped: txDropped,
+			time:      now,
+		}
+	}
+	return result, nil
+}
+
+func (m *Manager) startTrafficSampler() {
+	go func() {
+		// Initial sample
+		prev, _ := readProcNetDev()
+		ticker := time.NewTicker(1 * time.Second)
+		defer ticker.Stop()
+
+		for range ticker.C {
+			curr, err := readProcNetDev()
+			if err != nil {
+				continue
+			}
+
+			now := time.Now()
+			snap := &TrafficSnapshot{
+				Timestamp:  now.Unix(),
+				Interfaces: make(map[string]InterfaceLiveRate),
+			}
+
+			var totalRxRate, totalTxRate float64
+
+			for name, c := range curr {
+				p, exists := prev[name]
+				var rxRate, txRate float64
+				if exists {
+					dt := c.time.Sub(p.time).Seconds()
+					if dt > 0.05 && c.rxBytes >= p.rxBytes && c.txBytes >= p.txBytes {
+						rxRate = float64(c.rxBytes-p.rxBytes) / dt
+						txRate = float64(c.txBytes-p.txBytes) / dt
+					}
+				}
+
+				lr := InterfaceLiveRate{
+					Name:          name,
+					RxBytesPerSec: rxRate,
+					TxBytesPerSec: txRate,
+					RxHuman:       formatBytes(rxRate) + "/s",
+					TxHuman:       formatBytes(txRate) + "/s",
+					RxBytes:       c.rxBytes,
+					TxBytes:       c.txBytes,
+					RxPackets:     c.rxPackets,
+					TxPackets:     c.txPackets,
+					RxErrors:      c.rxErrors,
+					TxErrors:      c.txErrors,
+					RxDropped:     c.rxDropped,
+					TxDropped:     c.txDropped,
+				}
+
+				snap.Interfaces[name] = lr
+
+				// Exclude loopback from global WAN/LAN totals
+				if name != "lo" && !strings.HasPrefix(name, "dummy") {
+					totalRxRate += rxRate
+					totalTxRate += txRate
+				}
+			}
+
+			snap.TotalRxRate = totalRxRate
+			snap.TotalTxRate = totalTxRate
+			snap.TotalRxHuman = formatBytes(totalRxRate) + "/s"
+			snap.TotalTxHuman = formatBytes(totalTxRate) + "/s"
+
+			m.snapMu.Lock()
+			m.latestSnapshot = snap
+			m.snapMu.Unlock()
+
+			prev = curr
+
+			// Fan out to SSE subscribers
+			m.subMu.Lock()
+			for ch := range m.subscribers {
+				select {
+				case ch <- snap:
+				default:
+					// drop frame if client is slow
+				}
+			}
+			m.subMu.Unlock()
+		}
+	}()
+}
+
+func (m *Manager) GetLatestTrafficSnapshot() *TrafficSnapshot {
+	m.snapMu.RLock()
+	defer m.snapMu.RUnlock()
+	return m.latestSnapshot
+}
+
+func (m *Manager) SubscribeTraffic() chan *TrafficSnapshot {
+	m.subMu.Lock()
+	defer m.subMu.Unlock()
+	ch := make(chan *TrafficSnapshot, 4)
+	m.subscribers[ch] = true
+	return ch
+}
+
+func (m *Manager) UnsubscribeTraffic(ch chan *TrafficSnapshot) {
+	m.subMu.Lock()
+	defer m.subMu.Unlock()
+	delete(m.subscribers, ch)
+	close(ch)
+}

+ 27 - 0
internal/network/types.go

@@ -42,6 +42,33 @@ type TrafficRate struct {
 	TxHuman       string  `json:"tx_human"`
 }
 
+// InterfaceLiveRate holds real-time throughput and cumulative counters for an interface.
+type InterfaceLiveRate struct {
+	Name          string  `json:"name"`
+	RxBytesPerSec float64 `json:"rx_bytes_sec"`
+	TxBytesPerSec float64 `json:"tx_bytes_sec"`
+	RxHuman       string  `json:"rx_human"`
+	TxHuman       string  `json:"tx_human"`
+	RxBytes       uint64  `json:"rx_bytes"`
+	TxBytes       uint64  `json:"tx_bytes"`
+	RxPackets     uint64  `json:"rx_packets"`
+	TxPackets     uint64  `json:"tx_packets"`
+	RxErrors      uint64  `json:"rx_errors"`
+	TxErrors      uint64  `json:"tx_errors"`
+	RxDropped     uint64  `json:"rx_dropped"`
+	TxDropped     uint64  `json:"tx_dropped"`
+}
+
+// TrafficSnapshot represents an instantaneous router-wide live network traffic snapshot.
+type TrafficSnapshot struct {
+	Timestamp    int64                        `json:"timestamp"`
+	Interfaces   map[string]InterfaceLiveRate `json:"interfaces"`
+	TotalRxRate  float64                      `json:"total_rx_bytes_sec"`
+	TotalTxRate  float64                      `json:"total_tx_bytes_sec"`
+	TotalRxHuman string                       `json:"total_rx_human"`
+	TotalTxHuman string                       `json:"total_tx_human"`
+}
+
 // CreateVLANRequest specifies parameters for creating an 802.1Q VLAN interface.
 type CreateVLANRequest struct {
 	ParentName  string `json:"parent_name"` // e.g. "eth0"

+ 230 - 18
internal/web/static/app.js

@@ -81,14 +81,145 @@ document.addEventListener('DOMContentLoaded', () => {
   initTableSorting();
 
   fetchData();
-  // Live polling every 2.5 seconds
+  initLiveTrafficStream();
+
+  // Full state telemetry polling every 5 seconds (smooth & flicker-free)
   setInterval(() => {
     if (state.isPolling) {
       fetchData(false);
     }
-  }, 2500);
+  }, 5000);
 });
 
+let trafficEventSource = null;
+let trafficPollFallback = null;
+
+function initLiveTrafficStream() {
+  if (trafficEventSource) {
+    try { trafficEventSource.close(); } catch(e) {}
+    trafficEventSource = null;
+  }
+  if (trafficPollFallback) {
+    clearInterval(trafficPollFallback);
+    trafficPollFallback = null;
+  }
+
+  if (window.EventSource) {
+    try {
+      trafficEventSource = new EventSource(`${API_BASE}/interfaces/traffic/stream`);
+      trafficEventSource.onmessage = (event) => {
+        try {
+          const data = JSON.parse(event.data);
+          updateLiveInterfaceTraffic(data);
+        } catch (e) {
+          console.error('Error parsing live traffic event:', e);
+        }
+      };
+      trafficEventSource.onerror = () => {
+        // Fallback polling if SSE disconnects
+        if (!trafficPollFallback) {
+          trafficPollFallback = setInterval(pollLiveTrafficSnapshot, 1000);
+        }
+      };
+      trafficEventSource.onopen = () => {
+        if (trafficPollFallback) {
+          clearInterval(trafficPollFallback);
+          trafficPollFallback = null;
+        }
+      };
+      return;
+    } catch (e) {
+      console.warn('EventSource failed, using 1s polling fallback:', e);
+    }
+  }
+
+  // Fallback if EventSource is unavailable
+  trafficPollFallback = setInterval(pollLiveTrafficSnapshot, 1000);
+}
+
+async function pollLiveTrafficSnapshot() {
+  try {
+    const res = await fetch(`${API_BASE}/interfaces/traffic/rates`);
+    if (res.ok) {
+      const data = await res.json();
+      updateLiveInterfaceTraffic(data);
+    }
+  } catch (e) {}
+}
+
+function updateLiveInterfaceTraffic(data) {
+  if (!data || !data.interfaces) return;
+  state.latestTraffic = data;
+
+  // 1. Update Top & Dashboard Total Bandwidth Indicators
+  const rxHuman = data.total_rx_human || '0 B/s';
+  const txHuman = data.total_tx_human || '0 B/s';
+  const statRx = document.getElementById('stat-traffic-rx');
+  if (statRx && statRx.innerText !== rxHuman) statRx.innerText = rxHuman;
+  const statTx = document.getElementById('stat-traffic-tx');
+  if (statTx && statTx.innerText !== txHuman) statTx.innerText = txHuman;
+
+  // 2. Update state.interfaces rates in-memory
+  if (Array.isArray(state.interfaces)) {
+    state.interfaces.forEach(i => {
+      const live = data.interfaces[i.name];
+      if (live) {
+        i.rate = {
+          rx_bytes_sec: live.rx_bytes_sec,
+          tx_bytes_sec: live.tx_bytes_sec,
+          rx_human: live.rx_human,
+          tx_human: live.tx_human
+        };
+        i.stats = {
+          rx_bytes: live.rx_bytes,
+          tx_bytes: live.tx_bytes,
+          rx_packets: live.rx_packets,
+          tx_packets: live.tx_packets,
+          rx_errors: live.rx_errors,
+          tx_errors: live.tx_errors,
+          rx_dropped: live.rx_dropped,
+          tx_dropped: live.tx_dropped
+        };
+      }
+    });
+  }
+
+  // 3. Update all live UI rate spans in-place (ZERO flicker)
+  Object.keys(data.interfaces).forEach(name => {
+    const live = data.interfaces[name];
+
+    // Dashboard Port Matrix Card
+    const cardRate = document.getElementById(`port-card-rate-${name}`);
+    if (cardRate && cardRate.innerText !== live.rx_human) {
+      cardRate.innerText = live.rx_human;
+    }
+
+    // Dashboard Quick Interfaces Table
+    const quickRx = document.getElementById(`quick-rx-${name}`);
+    if (quickRx) quickRx.innerText = `↓ ${live.rx_human}`;
+    const quickTx = document.getElementById(`quick-tx-${name}`);
+    if (quickTx) quickTx.innerText = `↑ ${live.tx_human}`;
+
+    // Main Interfaces Tree Table
+    const ifaceRx = document.getElementById(`iface-rate-rx-${name}`);
+    if (ifaceRx) ifaceRx.innerText = `↓ ${live.rx_human}`;
+    const ifaceTx = document.getElementById(`iface-rate-tx-${name}`);
+    if (ifaceTx) ifaceTx.innerText = `↑ ${live.tx_human}`;
+
+    // VLAN Sub-interfaces Table
+    const vlanRx = document.getElementById(`vlan-rate-rx-${name}`);
+    if (vlanRx) vlanRx.innerText = `↓ ${live.rx_human}`;
+    const vlanTx = document.getElementById(`vlan-rate-tx-${name}`);
+    if (vlanTx) vlanTx.innerText = `↑ ${live.tx_human}`;
+
+    // VLAN Page Table
+    const vlanPageRx = document.getElementById(`vlan-page-rate-rx-${name}`);
+    if (vlanPageRx) vlanPageRx.innerText = `↓ ${live.rx_human}`;
+    const vlanPageTx = document.getElementById(`vlan-page-rate-tx-${name}`);
+    if (vlanPageTx) vlanPageTx.innerText = `↑ ${live.tx_human}`;
+  });
+}
+
 // Navigation Handler
 function navigate(tabName) {
   if (tabName === 'dynamic-routing' || tabName === 'routing') {
@@ -508,6 +639,14 @@ function updateBadges() {
 // Render Dashboard View
 function renderDashboard() {
   const matrix = document.getElementById('port-matrix');
+  if (!matrix) return;
+
+  const ifaces = state.interfaces || [];
+  const sig = ifaces.map(i => `${i.name}:${i.state}:${(i.ipv4 || []).join(',')}:${i.type}:${i.is_vlan}`).join(';');
+  if (matrix.dataset.sig === sig) {
+    return; // Structure & link states haven't changed! Do not rebuild or flicker!
+  }
+  matrix.dataset.sig = sig;
   matrix.innerHTML = '';
 
   (state.interfaces || []).forEach(iface => {
@@ -533,7 +672,7 @@ function renderDashboard() {
       <div class="text-[11px] font-mono text-slate-300 truncate">${iface.ipv4 && iface.ipv4.length > 0 ? iface.ipv4[0] : (iface.mac || 'No IP')}</div>
       <div class="mt-2 flex items-center justify-between text-[10px] text-slate-400">
         <span class="uppercase tracking-wider font-semibold">${isVlan ? `VLAN ${iface.vlan_id}` : iface.type}</span>
-        <span>${iface.rate?.rx_human || '0 B/s'}</span>
+        <span id="port-card-rate-${iface.name}">${iface.rate?.rx_human || '0 B/s'}</span>
       </div>
     `;
     matrix.appendChild(portCard);
@@ -560,7 +699,7 @@ function renderDashboard() {
         <td class="py-2.5 text-slate-400 text-[11px] font-mono">${i.mac || '-'}</td>
         <td class="py-2.5 text-slate-200 font-semibold">${i.ipv4 && i.ipv4.length > 0 ? i.ipv4.join(', ') : '<span class="text-slate-600 font-normal">Unassigned</span>'}</td>
         <td class="py-2.5 text-slate-300 text-[11px] font-mono">
-          <span class="text-emerald-400">↓ ${rxStr}</span> <span class="text-slate-600">•</span> <span class="text-cyan-400">↑ ${txStr}</span>
+          <span id="quick-rx-${i.name}" class="text-emerald-400">↓ ${rxStr}</span> <span class="text-slate-600">•</span> <span id="quick-tx-${i.name}" class="text-cyan-400">↑ ${txStr}</span>
         </td>
         <td class="py-2.5 text-right font-sans font-semibold">
           <span class="px-2 py-0.5 rounded text-[10px] ${isUp ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20' : 'bg-rose-500/10 text-rose-400 border border-rose-500/20'}">${i.state}</span>
@@ -656,8 +795,6 @@ function renderInterfacesList() {
   const summary = document.getElementById('ifaces-list-summary');
   const query = (document.getElementById('iface-search')?.value || '').trim().toLowerCase();
 
-  tbody.innerHTML = '';
-
   const allIfaces = state.interfaces || [];
   const allVlans = state.vlans || [];
 
@@ -692,10 +829,22 @@ function renderInterfacesList() {
 
   if (filteredParents.length === 0) {
     emptyState?.classList.remove('hidden');
+    tbody.innerHTML = '';
     return;
   }
   emptyState?.classList.add('hidden');
 
+  const sig = `${state.ifaceFilter}|${query}|` + filteredParents.map(p => {
+    const cVlans = allVlans.filter(v => v.parent_name === p.name || v.name.startsWith(p.name + '.'));
+    return `${p.name}:${p.state}:${(p.ipv4||[]).join(',')}:${p.mtu}:${cVlans.length}:${state.expandedParents.has(p.name)}`;
+  }).join(';');
+
+  if (tbody.dataset.sig === sig) {
+    return; // ZERO changes to structure, links, IPs or VLANs! Do not rebuild DOM!
+  }
+  tbody.dataset.sig = sig;
+  tbody.innerHTML = '';
+
   filteredParents.forEach(parent => {
     const isUp = parent.state === 'UP';
     const childVlans = allVlans.filter(v => v.parent_name === parent.name || v.name.startsWith(parent.name + '.'));
@@ -766,8 +915,8 @@ function renderInterfacesList() {
       </td>
       <td class="py-3 px-4 text-[11px]">
         <div class="flex items-center space-x-2">
-          <span class="text-emerald-400">↓ ${parent.rate?.rx_human || '0 B/s'}</span>
-          <span class="text-cyan-400">↑ ${parent.rate?.tx_human || '0 B/s'}</span>
+          <span id="iface-rate-rx-${parent.name}" class="text-emerald-400">↓ ${parent.rate?.rx_human || '0 B/s'}</span>
+          <span id="iface-rate-tx-${parent.name}" class="text-cyan-400">↑ ${parent.rate?.tx_human || '0 B/s'}</span>
         </div>
       </td>
       <td class="py-3 px-4 font-sans">
@@ -880,8 +1029,8 @@ function renderInterfacesList() {
                         </td>
                         <td class="py-2.5 px-4 text-slate-400">${v.mtu}</td>
                         <td class="py-2.5 px-4 text-[11px]">
-                          <span class="text-emerald-400">↓ ${v.rate?.rx_human || '0 B/s'}</span>
-                          <span class="text-cyan-400 ml-1.5">↑ ${v.rate?.tx_human || '0 B/s'}</span>
+                          <span id="vlan-rate-rx-${v.name}" class="text-emerald-400">↓ ${v.rate?.rx_human || '0 B/s'}</span>
+                          <span id="vlan-rate-tx-${v.name}" class="text-cyan-400 ml-1.5">↑ ${v.rate?.tx_human || '0 B/s'}</span>
                         </td>
                         <td class="py-2.5 px-4 font-sans">
                           <button onclick="toggleInterfaceState('${v.name}', '${isVlanUp ? 'down' : 'up'}')"
@@ -926,7 +1075,6 @@ function renderVlansList() {
   const countBadge = document.getElementById('vlans-table-count');
   const query = (document.getElementById('vlan-search')?.value || '').toLowerCase();
 
-  tbody.innerHTML = '';
   const allVlans = state.vlans || [];
   if (countBadge) countBadge.innerText = allVlans.length;
 
@@ -940,10 +1088,16 @@ function renderVlansList() {
 
   if (allVlans.length === 0) {
     emptyState.classList.remove('hidden');
+    tbody.innerHTML = '';
     return;
   }
   emptyState.classList.add('hidden');
 
+  const sig = `${query}|` + filtered.map(v => `${v.name}:${v.state}:${(v.ipv4||[]).join(',')}:${v.vlan_id}:${v.mtu}`).join(';');
+  if (tbody.dataset.sig === sig) return;
+  tbody.dataset.sig = sig;
+  tbody.innerHTML = '';
+
   filtered.forEach(v => {
     const isUp = v.state === 'UP';
     const tr = document.createElement('tr');
@@ -3207,10 +3361,31 @@ async function loadFirewall(populateDropdowns = true) {
     const bGeo = document.getElementById('fw-badge-geo');
     if (bGeo) bGeo.innerText = state.geoStatus?.enabled_count || 0;
 
-    renderFilterRules();
-    renderNATRules();
-    renderMangleRules();
-    renderAddressLists();
+    const sigFilter = (state.filterRules || []).map(r => `${r.id}:${r.chain}:${r.action}:${r.disabled}`).join(';');
+    const sigNat = (state.natRules || []).map(r => `${r.id}:${r.chain}:${r.action}:${r.disabled}`).join(';');
+    const sigMangle = (state.mangleRules || []).map(r => `${r.id}:${r.chain}:${r.action}:${r.disabled}`).join(';');
+    const sigAddr = (state.addressLists || []).map(a => `${a.id}:${a.name}:${a.address}:${a.disabled}`).join(';');
+
+    const tbodyFilter = document.getElementById('filter-rules-table-body');
+    if (tbodyFilter && (populateDropdowns || tbodyFilter.dataset.sig !== sigFilter)) {
+      tbodyFilter.dataset.sig = sigFilter;
+      renderFilterRules();
+    }
+    const tbodyNat = document.getElementById('nat-rules-table-body');
+    if (tbodyNat && (populateDropdowns || tbodyNat.dataset.sig !== sigNat)) {
+      tbodyNat.dataset.sig = sigNat;
+      renderNATRules();
+    }
+    const tbodyMangle = document.getElementById('mangle-rules-table-body');
+    if (tbodyMangle && (populateDropdowns || tbodyMangle.dataset.sig !== sigMangle)) {
+      tbodyMangle.dataset.sig = sigMangle;
+      renderMangleRules();
+    }
+    const tbodyAddr = document.getElementById('addr-list-table-body');
+    if (tbodyAddr && (populateDropdowns || tbodyAddr.dataset.sig !== sigAddr)) {
+      tbodyAddr.dataset.sig = sigAddr;
+      renderAddressLists();
+    }
     renderGeoCountries();
 
     if (state.fwSubTab === 'connections') {
@@ -6100,7 +6275,6 @@ function renderRoutesList() {
   const tbody = document.getElementById('routes-table-body');
   const empty = document.getElementById('routes-empty');
   if (!tbody) return;
-  tbody.innerHTML = '';
 
   // Update table filter select
   const sel = document.getElementById('routes-filter-table');
@@ -6139,10 +6313,18 @@ function renderRoutesList() {
 
   if (routes.length === 0) {
     empty?.classList.remove('hidden');
+    tbody.innerHTML = '';
     return;
   }
   empty?.classList.add('hidden');
 
+  const sig = `${tableFilter}|${typeFilter}|` + routes.map(r => `${r.id}:${r.active}:${r.disabled}:${r.distance}:${r.protocol}:${r.health_status}:${(r.health_rtt_ms || 0).toFixed(0)}`).join(';');
+  if (tbody.dataset.sig === sig) {
+    return; // Routes table unchanged! Do not wipe DOM or flicker!
+  }
+  tbody.dataset.sig = sig;
+  tbody.innerHTML = '';
+
   routes.forEach(r => {
     const tr = document.createElement('tr');
     tr.className = `hover:bg-slate-800/40 transition text-xs cursor-pointer ${r.disabled ? 'opacity-50' : ''}`;
@@ -6518,7 +6700,6 @@ function renderPolicyRules() {
   const tbody = document.getElementById('rules-table-body');
   const empty = document.getElementById('rules-empty');
   if (!tbody) return;
-  tbody.innerHTML = '';
 
   const showSystem = document.getElementById('prule-show-system')?.checked;
   const filtered = (state.policyRules || []).filter(pr => showSystem || (!pr.is_default && !pr.is_system));
@@ -6530,10 +6711,16 @@ function renderPolicyRules() {
 
   if (filtered.length === 0) {
     empty?.classList.remove('hidden');
+    tbody.innerHTML = '';
     return;
   }
   empty?.classList.add('hidden');
 
+  const sig = `${showSystem}|` + filtered.map(pr => `${pr.id}:${pr.priority}:${pr.src_address}:${pr.dst_address}:${pr.table}:${pr.routing_mark}`).join(';');
+  if (tbody.dataset.sig === sig) return;
+  tbody.dataset.sig = sig;
+  tbody.innerHTML = '';
+
   filtered.forEach(pr => {
     const isSys = pr.is_default || pr.is_system;
     const tr = document.createElement('tr');
@@ -6623,6 +6810,10 @@ async function deletePolicyRule(id) {
 function renderRoutingTables() {
   const tbody = document.getElementById('tables-table-body');
   if (!tbody) return;
+  const tables = state.routingTables || [];
+  const sig = tables.map(t => `${t.table_id}:${t.name}:${t.comment}`).join(';');
+  if (tbody.dataset.sig === sig) return;
+  tbody.dataset.sig = sig;
   tbody.innerHTML = '';
 
   (state.routingTables || []).forEach(t => {
@@ -8111,6 +8302,10 @@ function renderBgpPeers() {
   }
   if (empty) empty.classList.add('hidden');
 
+  const sig = peers.map(p => `${p.name}:${p.state}:${p.uptime}:${p.prefix_received}:${p.prefix_sent}:${p.disabled}`).join(';');
+  if (tbody.dataset.sig === sig) return;
+  tbody.dataset.sig = sig;
+
   tbody.innerHTML = peers.map(p => {
     let flagA = !p.disabled ? '<span class="text-cyan-400 font-bold" title="Active">A</span>' : '';
     let flagE = p.state === 'Established' ? '<span class="text-emerald-400 font-bold ml-1" title="Established">E</span>' : '';
@@ -8182,6 +8377,10 @@ function renderBgpNetworks() {
   }
   if (empty) empty.classList.add('hidden');
 
+  const sig = nets.map(n => `${n.network}:${n.disabled}:${n.comment}`).join(';');
+  if (tbody.dataset.sig === sig) return;
+  tbody.dataset.sig = sig;
+
   tbody.innerHTML = nets.map(n => `
     <tr class="hover:bg-slate-800/40 transition">
       <td class="py-2.5 px-3 font-semibold text-purple-300 font-mono">${escapeHtml(n.network)}</td>
@@ -8218,6 +8417,10 @@ function renderBgpRoutes() {
   }
   if (empty) empty.classList.add('hidden');
 
+  const sig = routes.map(r => `${r.prefix}:${r.next_hop}:${r.metric}:${r.path}`).join(';');
+  if (tbody.dataset.sig === sig) return;
+  tbody.dataset.sig = sig;
+
   tbody.innerHTML = routes.map(r => `
     <tr class="hover:bg-slate-800/40 transition">
       <td class="py-2.5 px-3 font-semibold text-white">${escapeHtml(r.prefix)}</td>
@@ -12591,6 +12794,10 @@ function renderDHCPClients() {
   }
   if (emptyState) emptyState.classList.add('hidden');
 
+  const sig = clients.map(c => `${c.id}:${c.status}:${c.ip_address}:${c.gateway}:${c.enabled}`).join(';');
+  if (tbody.dataset.sig === sig) return;
+  tbody.dataset.sig = sig;
+
   tbody.innerHTML = clients.map(c => {
     let statusBadge = '';
     if (!c.enabled) {
@@ -13003,15 +13210,20 @@ function renderDHCPServers() {
   const tbody = document.getElementById('dhcp-server-tbody');
   const empty = document.getElementById('dhcp-server-empty');
   if (!tbody) return;
-  tbody.innerHTML = '';
 
   const servers = state.dhcpServers || [];
   if (servers.length === 0) {
+    tbody.innerHTML = '';
     empty?.classList.remove('hidden');
     return;
   }
   empty?.classList.add('hidden');
 
+  const sig = servers.map(s => `${s.id}:${s.running}:${s.disabled}:${s.active_leases}:${s.interface}`).join(';');
+  if (tbody.dataset.sig === sig) return;
+  tbody.dataset.sig = sig;
+  tbody.innerHTML = '';
+
   servers.forEach(s => {
     const tr = document.createElement('tr');
     tr.className = `hover:bg-slate-800/40 transition text-xs ${s.disabled ? 'opacity-50' : ''}`;