Compare commits

..

1 Commits

Author SHA1 Message Date
sagit 87722e461c feat(monitor): hop-by-hop latency metrics for forwarding chain (#394) 2026-03-29 18:59:06 +08:00
6 changed files with 195 additions and 21 deletions
@@ -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))
@@ -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)
+1
View File
@@ -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" }
+25
View File
@@ -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.
+12
View File
@@ -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;
}
@@ -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 (
<Card className="border border-divider/60 shadow-sm transition-shadow bg-gradient-to-br from-background to-default-50/50 mt-4">
<CardHeader className="py-3 px-4 flex flex-row items-center justify-between pb-1">
<h3 className="text-sm font-semibold flex items-center gap-1.5 text-default-700">
<Activity className="w-4 h-4 text-primary" />
全链路拓扑状态 (实时)
</h3>
</CardHeader>
<CardBody className="py-2 px-4 pb-4">
<div className="flex items-center overflow-x-auto pb-2 py-2">
{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 (
<React.Fragment key={index}>
{index === 0 && (
<Chip size="sm" variant="flat" className="shrink-0 font-mono shadow-sm">
{hop.fromNodeName}
</Chip>
)}
<div className="flex flex-col items-center justify-center min-w-[70px] mx-1 shrink-0 relative">
<span className={`text-[10px] font-mono leading-none mb-1 ${colorClass}`}>
{hop.latency >= 0 ? `${hop.latency.toFixed(0)}ms` : "超时"}
</span>
<div className={`h-[2px] w-full relative flex items-center justify-end bg-default-200 ${hop.latency < 0 ? "!bg-danger" : ""}`}>
<ArrowRight className={`w-3.5 h-3.5 absolute -right-2 ${colorClass} bg-background rounded-full p-[1px] z-10`} />
</div>
<span className={`text-[10px] font-mono leading-none mt-1.5 ${hop.loss > 0 ? "text-warning" : "text-default-400"}`}>
{hop.loss.toFixed(0)}% 丢包
</span>
</div>
<Chip size="sm" variant="flat" className={`shrink-0 font-mono shadow-sm ${borderColor}`}>
{hop.toNodeName}
</Chip>
</React.Fragment>
);
})}
</div>
</CardBody>
</Card>
);
}
export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) {
const [tunnels, setTunnels] = useState<MonitorTunnelApiItem[]>([]);
const [tunnelsLoading, setTunnelsLoading] = useState(false);
@@ -778,6 +837,11 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
)}
</div>
{/* ====== Chain Topology ====== */}
{monitorTunnelQualityEnabled && quality?.chainDetails && (
<ForwardingChainTopology hopsStr={quality.chainDetails} />
)}
{/* ====== Quality History Chart — isolated with React.memo ====== */}
<QualityChartCard
rangeMs={qualityRangeMs}