mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
feat(monitor): hop-by-hop latency metrics for forwarding chain (#394)
This commit is contained in:
@@ -245,6 +245,7 @@ func (h *Handler) monitorTunnelQualityHandler(w http.ResponseWriter, r *http.Req
|
|||||||
Success: q.Success == 1,
|
Success: q.Success == 1,
|
||||||
ErrorMessage: q.ErrorMessage,
|
ErrorMessage: q.ErrorMessage,
|
||||||
Timestamp: q.Timestamp,
|
Timestamp: q.Timestamp,
|
||||||
|
ChainDetails: q.ChainDetails,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
response.WriteJSON(w, response.OK(snapshots))
|
response.WriteJSON(w, response.OK(snapshots))
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -19,6 +20,17 @@ const (
|
|||||||
tunnelQualityReportInterval = 30 * time.Second // DB save interval
|
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.
|
// tunnelQualitySnapshot is the in-memory latest probe result for a tunnel.
|
||||||
type tunnelQualitySnapshot struct {
|
type tunnelQualitySnapshot struct {
|
||||||
TunnelID int64 `json:"tunnelId"`
|
TunnelID int64 `json:"tunnelId"`
|
||||||
@@ -29,6 +41,7 @@ type tunnelQualitySnapshot struct {
|
|||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
ErrorMessage string `json:"errorMessage,omitempty"`
|
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||||
Timestamp int64 `json:"timestamp"`
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
ChainDetails string `json:"chainDetails,omitempty"`
|
||||||
|
|
||||||
// internal fields for db reporting
|
// internal fields for db reporting
|
||||||
lastDBWrite int64 `json:"-"`
|
lastDBWrite int64 `json:"-"`
|
||||||
@@ -215,7 +228,7 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ipPreference := h.repo.GetTunnelIPPreference(tunnelID)
|
ipPreference := h.repo.GetTunnelIPPreference(tunnelID)
|
||||||
inNodes, _, outNodes := splitChainNodeGroups(chainRows)
|
inNodes, midNodesGrouped, outNodes := splitChainNodeGroups(chainRows)
|
||||||
|
|
||||||
options := diagnosisExecOptions{
|
options := diagnosisExecOptions{
|
||||||
commandTimeout: tunnelQualityProbeTimeout,
|
commandTimeout: tunnelQualityProbeTimeout,
|
||||||
@@ -241,28 +254,85 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
|
|||||||
probeOK := true
|
probeOK := true
|
||||||
|
|
||||||
if len(inNodes) > 0 && len(outNodes) > 0 {
|
if len(inNodes) > 0 && len(outNodes) > 0 {
|
||||||
// Entry → Exit
|
var hops []TunnelQualityHop
|
||||||
targetNode, nodeErr := h.getNodeRecord(outNodes[0].NodeID)
|
var totalLat float64
|
||||||
if nodeErr == nil && targetNode != nil {
|
remainingSuccessProb := 1.0
|
||||||
fromNode, _ := h.getNodeRecord(inNodes[0].NodeID)
|
|
||||||
targetIP, targetPort, resolveErr := resolveChainProbeTarget(fromNode, targetNode, outNodes[0].Port, ipPreference, outNodes[0].ConnectIP)
|
nodesInPath := make([]chainNodeRecord, 0, 2+len(midNodesGrouped))
|
||||||
if resolveErr == nil {
|
nodesInPath = append(nodesInPath, inNodes[0])
|
||||||
lat, loss, err := p.tcpPingNode(inNodes[0].NodeID, targetIP, targetPort, options)
|
for _, midGroup := range midNodesGrouped {
|
||||||
if err == nil {
|
if len(midGroup) > 0 {
|
||||||
snap.EntryToExitLatency = lat
|
nodesInPath = append(nodesInPath, midGroup[0])
|
||||||
snap.EntryToExitLoss = loss
|
|
||||||
} else {
|
|
||||||
snap.EntryToExitLatency = -1
|
|
||||||
snap.EntryToExitLoss = 100
|
|
||||||
probeOK = false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
snap.ErrorMessage = resolveErr.Error()
|
|
||||||
probeOK = false
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
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 {
|
} else {
|
||||||
snap.ErrorMessage = "出口节点不可用"
|
snap.EntryToExitLatency = -1
|
||||||
probeOK = false
|
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,
|
Success: successInt,
|
||||||
ErrorMessage: snap.ErrorMessage,
|
ErrorMessage: snap.ErrorMessage,
|
||||||
Timestamp: snap.Timestamp,
|
Timestamp: snap.Timestamp,
|
||||||
|
ChainDetails: snap.ChainDetails,
|
||||||
}
|
}
|
||||||
if err := h.repo.InsertTunnelQuality(q); err != nil {
|
if err := h.repo.InsertTunnelQuality(q); err != nil {
|
||||||
log.Printf("tunnel_quality_prober: insert db err=%v tunnel_id=%d", err, snap.TunnelID)
|
log.Printf("tunnel_quality_prober: insert db err=%v tunnel_id=%d", err, snap.TunnelID)
|
||||||
|
|||||||
@@ -728,6 +728,7 @@ type TunnelQuality struct {
|
|||||||
Success int `gorm:"not null;default:1" json:"success"`
|
Success int `gorm:"not null;default:1" json:"success"`
|
||||||
ErrorMessage string `gorm:"column:error_message;type:text" json:"errorMessage,omitempty"`
|
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"`
|
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" }
|
func (TunnelQuality) TableName() string { return "tunnel_quality" }
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -489,6 +489,17 @@ export interface MonitorAccessApiData {
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TunnelQualityHopApiItem {
|
||||||
|
fromNodeId: number;
|
||||||
|
fromNodeName: string;
|
||||||
|
toNodeId: number;
|
||||||
|
toNodeName: string;
|
||||||
|
latency: number;
|
||||||
|
loss: number;
|
||||||
|
targetIp?: string;
|
||||||
|
targetPort?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TunnelQualityApiItem {
|
export interface TunnelQualityApiItem {
|
||||||
tunnelId: number;
|
tunnelId: number;
|
||||||
entryToExitLatency: number;
|
entryToExitLatency: number;
|
||||||
@@ -498,4 +509,5 @@ export interface TunnelQualityApiItem {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
|
chainDetails?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
MonitorTunnelApiItem,
|
MonitorTunnelApiItem,
|
||||||
TunnelMetricApiItem,
|
TunnelMetricApiItem,
|
||||||
TunnelQualityApiItem,
|
TunnelQualityApiItem,
|
||||||
|
TunnelQualityHopApiItem,
|
||||||
} from "@/api/types";
|
} from "@/api/types";
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
ArrowRightLeft,
|
ArrowRightLeft,
|
||||||
Wifi,
|
Wifi,
|
||||||
WifiOff,
|
WifiOff,
|
||||||
|
ArrowRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import toast from "react-hot-toast";
|
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) {
|
export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) {
|
||||||
const [tunnels, setTunnels] = useState<MonitorTunnelApiItem[]>([]);
|
const [tunnels, setTunnels] = useState<MonitorTunnelApiItem[]>([]);
|
||||||
const [tunnelsLoading, setTunnelsLoading] = useState(false);
|
const [tunnelsLoading, setTunnelsLoading] = useState(false);
|
||||||
@@ -778,6 +837,11 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ====== Chain Topology ====== */}
|
||||||
|
{monitorTunnelQualityEnabled && quality?.chainDetails && (
|
||||||
|
<ForwardingChainTopology hopsStr={quality.chainDetails} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ====== Quality History Chart — isolated with React.memo ====== */}
|
{/* ====== Quality History Chart — isolated with React.memo ====== */}
|
||||||
<QualityChartCard
|
<QualityChartCard
|
||||||
rangeMs={qualityRangeMs}
|
rangeMs={qualityRangeMs}
|
||||||
|
|||||||
Reference in New Issue
Block a user