From 87722e461c603db53292a0a207e798858d76e4c2 Mon Sep 17 00:00:00 2001 From: sagit <36596628+Sagit-chu@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:59:06 +0800 Subject: [PATCH] feat(monitor): hop-by-hop latency metrics for forwarding chain (#394) --- .../internal/http/handler/monitoring.go | 1 + .../http/handler/tunnel_quality_prober.go | 113 ++++++++++++++---- go-backend/internal/store/model/model.go | 1 + plans/068-tunnel-quality-chain-probe.md | 25 ++++ vite-frontend/src/api/types.ts | 12 ++ .../src/pages/node/tunnel-monitor-view.tsx | 64 ++++++++++ 6 files changed, 195 insertions(+), 21 deletions(-) create mode 100644 plans/068-tunnel-quality-chain-probe.md diff --git a/go-backend/internal/http/handler/monitoring.go b/go-backend/internal/http/handler/monitoring.go index 5ca8a34..96c0687 100644 --- a/go-backend/internal/http/handler/monitoring.go +++ b/go-backend/internal/http/handler/monitoring.go @@ -245,6 +245,7 @@ func (h *Handler) monitorTunnelQualityHandler(w http.ResponseWriter, r *http.Req Success: q.Success == 1, ErrorMessage: q.ErrorMessage, Timestamp: q.Timestamp, + ChainDetails: q.ChainDetails, }) } response.WriteJSON(w, response.OK(snapshots)) diff --git a/go-backend/internal/http/handler/tunnel_quality_prober.go b/go-backend/internal/http/handler/tunnel_quality_prober.go index 9cf76d9..adb31fd 100644 --- a/go-backend/internal/http/handler/tunnel_quality_prober.go +++ b/go-backend/internal/http/handler/tunnel_quality_prober.go @@ -2,6 +2,7 @@ package handler import ( "context" + "encoding/json" "log" "sync" "sync/atomic" @@ -19,6 +20,17 @@ const ( tunnelQualityReportInterval = 30 * time.Second // DB save interval ) +type TunnelQualityHop struct { + FromNodeID int64 `json:"fromNodeId"` + FromNodeName string `json:"fromNodeName"` + ToNodeID int64 `json:"toNodeId"` + ToNodeName string `json:"toNodeName"` + Latency float64 `json:"latency"` + Loss float64 `json:"loss"` + TargetIP string `json:"targetIp,omitempty"` + TargetPort int `json:"targetPort,omitempty"` +} + // tunnelQualitySnapshot is the in-memory latest probe result for a tunnel. type tunnelQualitySnapshot struct { TunnelID int64 `json:"tunnelId"` @@ -29,6 +41,7 @@ type tunnelQualitySnapshot struct { Success bool `json:"success"` ErrorMessage string `json:"errorMessage,omitempty"` Timestamp int64 `json:"timestamp"` + ChainDetails string `json:"chainDetails,omitempty"` // internal fields for db reporting lastDBWrite int64 `json:"-"` @@ -215,7 +228,7 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) { } ipPreference := h.repo.GetTunnelIPPreference(tunnelID) - inNodes, _, outNodes := splitChainNodeGroups(chainRows) + inNodes, midNodesGrouped, outNodes := splitChainNodeGroups(chainRows) options := diagnosisExecOptions{ commandTimeout: tunnelQualityProbeTimeout, @@ -241,28 +254,85 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) { probeOK := true if len(inNodes) > 0 && len(outNodes) > 0 { - // Entry → Exit - targetNode, nodeErr := h.getNodeRecord(outNodes[0].NodeID) - if nodeErr == nil && targetNode != nil { - fromNode, _ := h.getNodeRecord(inNodes[0].NodeID) - targetIP, targetPort, resolveErr := resolveChainProbeTarget(fromNode, targetNode, outNodes[0].Port, ipPreference, outNodes[0].ConnectIP) - if resolveErr == nil { - lat, loss, err := p.tcpPingNode(inNodes[0].NodeID, targetIP, targetPort, options) - if err == nil { - snap.EntryToExitLatency = lat - snap.EntryToExitLoss = loss - } else { - snap.EntryToExitLatency = -1 - snap.EntryToExitLoss = 100 - probeOK = false - } - } else { - snap.ErrorMessage = resolveErr.Error() - probeOK = false + var hops []TunnelQualityHop + var totalLat float64 + remainingSuccessProb := 1.0 + + nodesInPath := make([]chainNodeRecord, 0, 2+len(midNodesGrouped)) + nodesInPath = append(nodesInPath, inNodes[0]) + for _, midGroup := range midNodesGrouped { + if len(midGroup) > 0 { + nodesInPath = append(nodesInPath, midGroup[0]) } + } + nodesInPath = append(nodesInPath, outNodes[0]) + + for i := 0; i < len(nodesInPath)-1; i++ { + source := nodesInPath[i] + target := nodesInPath[i+1] + + hop := TunnelQualityHop{ + FromNodeID: source.NodeID, + FromNodeName: source.NodeName, + ToNodeID: target.NodeID, + ToNodeName: target.NodeName, + } + + targetNode, nodeErr := h.getNodeRecord(target.NodeID) + if nodeErr != nil || targetNode == nil { + snap.ErrorMessage = "节点 " + target.NodeName + " 不可用" + probeOK = false + hop.Latency = -1 + hop.Loss = 100 + hops = append(hops, hop) + break + } + + fromNode, _ := h.getNodeRecord(source.NodeID) + targetIP, targetPort, resolveErr := resolveChainProbeTarget(fromNode, targetNode, target.Port, ipPreference, target.ConnectIP) + if resolveErr != nil { + snap.ErrorMessage = "解析节点 " + target.NodeName + " 失败: " + resolveErr.Error() + probeOK = false + hop.Latency = -1 + hop.Loss = 100 + hops = append(hops, hop) + break + } + + hop.TargetIP = targetIP + hop.TargetPort = targetPort + + lat, loss, err := p.tcpPingNode(source.NodeID, targetIP, targetPort, options) + if err == nil { + hop.Latency = lat + hop.Loss = loss + totalLat += lat + remainingSuccessProb *= (1.0 - loss/100.0) + hops = append(hops, hop) + } else { + probeOK = false + hop.Latency = -1 + hop.Loss = 100 + hops = append(hops, hop) + if snap.ErrorMessage == "" { + snap.ErrorMessage = err.Error() + } + break + } + } + + if probeOK { + snap.EntryToExitLatency = totalLat + snap.EntryToExitLoss = (1.0 - remainingSuccessProb) * 100.0 } else { - snap.ErrorMessage = "出口节点不可用" - probeOK = false + snap.EntryToExitLatency = -1 + snap.EntryToExitLoss = 100 + } + + if len(hops) > 0 { + if b, err := json.Marshal(hops); err == nil { + snap.ChainDetails = string(b) + } } } @@ -374,6 +444,7 @@ func (p *tunnelQualityProber) storeResult(snap *tunnelQualitySnapshot) { Success: successInt, ErrorMessage: snap.ErrorMessage, Timestamp: snap.Timestamp, + ChainDetails: snap.ChainDetails, } if err := h.repo.InsertTunnelQuality(q); err != nil { log.Printf("tunnel_quality_prober: insert db err=%v tunnel_id=%d", err, snap.TunnelID) diff --git a/go-backend/internal/store/model/model.go b/go-backend/internal/store/model/model.go index 7e47838..3111963 100644 --- a/go-backend/internal/store/model/model.go +++ b/go-backend/internal/store/model/model.go @@ -728,6 +728,7 @@ type TunnelQuality struct { Success int `gorm:"not null;default:1" json:"success"` ErrorMessage string `gorm:"column:error_message;type:text" json:"errorMessage,omitempty"` Timestamp int64 `gorm:"not null;index:idx_tunnel_quality_tunnel_time,priority:2;index:idx_tunnel_quality_time" json:"timestamp"` + ChainDetails string `gorm:"column:chain_details;type:text" json:"chainDetails,omitempty"` } func (TunnelQuality) TableName() string { return "tunnel_quality" } diff --git a/plans/068-tunnel-quality-chain-probe.md b/plans/068-tunnel-quality-chain-probe.md new file mode 100644 index 0000000..b55bc09 --- /dev/null +++ b/plans/068-tunnel-quality-chain-probe.md @@ -0,0 +1,25 @@ +# PLAN: Detailed Hop-by-Hop Tunnel Quality Probing (Option B) + +## Objective +Enhance the tunnel quality monitoring to correctly execute and record hop-by-hop latency and loss through the entire forwarding chain (Entry -> Mids -> Exit), rather than directly forcing Entry to ping Exit. Expose these details in the UI for advanced troubleshooting. + +## Tasks + +- [ ] **1. DB Schema & Model Updates** + - Update `model.TunnelQuality` in `model.go` with `ChainDetails string` (`gorm:"column:chain_details;type:text"`). + - GORM AutoMigrate will handle adding the column to SQLite/PostgreSQL automatically on backend restart. +- [ ] **2. Backend Data Structures (`tunnel_quality_prober.go`)** + - Define `TunnelQualityHop` to store `FromNodeID`, `FromNodeName`, `ToNodeID`, `ToNodeName`, `Latency`, `Loss`. + - Update `tunnelQualitySnapshot` to include `ChainDetails []TunnelQualityHop` (`json:"chainDetails,omitempty"`). + - Update DB query models to pass `ChainDetails` back to the frontend. +- [ ] **3. Prober Logic Restructuring** + - In `tunnel_quality_prober.go:probeTunnel()`, handle `Type 2` (Forwarding Chain) properly. + - Extract the intermediate nodes using `splitChainNodeGroups`. + - Form the hop pairs: `in[0]->mid[0]`, `mid[i]->mid[i+1]`, `mid[last]->out[0]`. + - Probe each hop sequentially. Resolve target IPs via `resolveChainProbeTarget` using `connect_ip` fields and node preferences. + - Cumulative metrics: `EntryToExitLatency` = `sum(latency)`. `EntryToExitLoss` = $1 - \prod (1 - loss\_i)$. +- [ ] **4. Frontend API & Component** + - Add `chainDetails?: string;` to `TunnelQualityApiItem` in `vite-frontend/src/api/types.ts`. + - In `TunnelMonitorView`, parse the JSON string back into an array of hops if it exists. + - Design a horizontal topology diagram (e.g., using `heroui/chip` and `lucide-react` arrows) to show `[上海入口] --25ms--> [香港跳板] --15ms--> [落地出口]`. + - Highlight bottlenecks (e.g., > 100ms or loss > 0%) in yellow or red. diff --git a/vite-frontend/src/api/types.ts b/vite-frontend/src/api/types.ts index 77330d7..7e92b67 100644 --- a/vite-frontend/src/api/types.ts +++ b/vite-frontend/src/api/types.ts @@ -489,6 +489,17 @@ export interface MonitorAccessApiData { reason?: string; } +export interface TunnelQualityHopApiItem { + fromNodeId: number; + fromNodeName: string; + toNodeId: number; + toNodeName: string; + latency: number; + loss: number; + targetIp?: string; + targetPort?: number; +} + export interface TunnelQualityApiItem { tunnelId: number; entryToExitLatency: number; @@ -498,4 +509,5 @@ export interface TunnelQualityApiItem { success: boolean; errorMessage?: string; timestamp: number; + chainDetails?: string; } diff --git a/vite-frontend/src/pages/node/tunnel-monitor-view.tsx b/vite-frontend/src/pages/node/tunnel-monitor-view.tsx index fec11b4..3b8f888 100644 --- a/vite-frontend/src/pages/node/tunnel-monitor-view.tsx +++ b/vite-frontend/src/pages/node/tunnel-monitor-view.tsx @@ -2,6 +2,7 @@ import type { MonitorTunnelApiItem, TunnelMetricApiItem, TunnelQualityApiItem, + TunnelQualityHopApiItem, } from "@/api/types"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -23,6 +24,7 @@ import { ArrowRightLeft, Wifi, WifiOff, + ArrowRight, } from "lucide-react"; import toast from "react-hot-toast"; @@ -327,6 +329,63 @@ const TrafficChartCard = React.memo(function TrafficChartCard({ ); }); +function ForwardingChainTopology({ hopsStr }: { hopsStr?: string }) { + if (!hopsStr) return null; + + let hops: TunnelQualityHopApiItem[] = []; + try { + hops = JSON.parse(hopsStr); + } catch { + return null; + } + + if (!Array.isArray(hops) || hops.length === 0) return null; + + return ( + + +

+ + 全链路拓扑状态 (实时) +

+
+ +
+ {hops.map((hop, index) => { + const hasError = hop.latency < 0 || hop.loss > 0; + const colorClass = hop.latency < 0 ? "text-danger" : (hop.loss > 0 ? "text-warning" : "text-success"); + const borderColor = hasError ? "border-danger" : ""; + + return ( + + {index === 0 && ( + + {hop.fromNodeName} + + )} +
+ + {hop.latency >= 0 ? `${hop.latency.toFixed(0)}ms` : "超时"} + +
+ +
+ 0 ? "text-warning" : "text-default-400"}`}> + {hop.loss.toFixed(0)}% 丢包 + +
+ + {hop.toNodeName} + +
+ ); + })} +
+
+
+ ); +} + export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) { const [tunnels, setTunnels] = useState([]); const [tunnelsLoading, setTunnelsLoading] = useState(false); @@ -778,6 +837,11 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) )} + {/* ====== Chain Topology ====== */} + {monitorTunnelQualityEnabled && quality?.chainDetails && ( + + )} + {/* ====== Quality History Chart — isolated with React.memo ====== */}