mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -31,6 +32,26 @@ type TunnelQualityHop struct {
|
|||||||
TargetPort int `json:"targetPort,omitempty"`
|
TargetPort int `json:"targetPort,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TunnelQualityCandidateHop struct {
|
||||||
|
TunnelQualityHop
|
||||||
|
FromRole string `json:"fromRole"`
|
||||||
|
ToRole string `json:"toRole"`
|
||||||
|
HopIndex int `json:"hopIndex"`
|
||||||
|
Selected bool `json:"selected"`
|
||||||
|
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tunnelQualityChainDetails struct {
|
||||||
|
PrimaryPath []TunnelQualityHop `json:"primaryPath,omitempty"`
|
||||||
|
CandidateHops []TunnelQualityCandidateHop `json:"candidateHops,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tunnelQualityCandidateGroup struct {
|
||||||
|
role string
|
||||||
|
roleIndex int
|
||||||
|
nodes []chainNodeRecord
|
||||||
|
}
|
||||||
|
|
||||||
// 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"`
|
||||||
@@ -283,16 +304,25 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
|
|||||||
pingCount: 1,
|
pingCount: 1,
|
||||||
timeoutMessage: "探测超时",
|
timeoutMessage: "探测超时",
|
||||||
}
|
}
|
||||||
p.probeBestExitOwners(tunnelID, inNodes, midNodesGrouped, outNodes, ipPreference, options, probeTarget)
|
roundPinger := newBestExitRoundPinger(p.pingNode)
|
||||||
|
p.probeBestExitOwners(tunnelID, inNodes, midNodesGrouped, outNodes, ipPreference, options, probeTarget, roundPinger)
|
||||||
|
|
||||||
entry, _, entryOnline := p.firstOnlineChainNode(inNodes)
|
entry, _, entryOnline := p.firstOnlineChainNode(inNodes)
|
||||||
exit, _, exitOnline := p.firstOnlineChainNode(outNodes)
|
exit, _, exitOnline := p.firstOnlineChainNode(outNodes)
|
||||||
|
selectedNodeIDs := make(map[string]int64, 2+len(midNodesGrouped))
|
||||||
|
if entryOnline {
|
||||||
|
selectedNodeIDs[tunnelQualityGroupKey("entry", 0)] = entry.NodeID
|
||||||
|
}
|
||||||
|
if exitOnline {
|
||||||
|
selectedNodeIDs[tunnelQualityGroupKey("exit", 0)] = exit.NodeID
|
||||||
|
}
|
||||||
|
var primaryHops []TunnelQualityHop
|
||||||
|
|
||||||
switch tunnel.Type {
|
switch tunnel.Type {
|
||||||
case 1:
|
case 1:
|
||||||
// Port forwarding: entry → public probe target only.
|
// Port forwarding: entry → public probe target only.
|
||||||
if entryOnline {
|
if entryOnline {
|
||||||
lat, loss, err := p.pingNode(entry.NodeID, probeTarget.Host, probeTarget.Port, options)
|
lat, loss, err := roundPinger(entry.NodeID, probeTarget.Host, probeTarget.Port, options)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
snap.ExitToBingLatency = lat
|
snap.ExitToBingLatency = lat
|
||||||
snap.ExitToBingLoss = loss
|
snap.ExitToBingLoss = loss
|
||||||
@@ -318,13 +348,12 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
|
|||||||
snap.EntryToExitLatency = -1
|
snap.EntryToExitLatency = -1
|
||||||
snap.EntryToExitLoss = 100
|
snap.EntryToExitLoss = 100
|
||||||
} else {
|
} else {
|
||||||
var hops []TunnelQualityHop
|
|
||||||
var totalLat float64
|
var totalLat float64
|
||||||
remainingSuccessProb := 1.0
|
remainingSuccessProb := 1.0
|
||||||
|
|
||||||
nodesInPath := make([]chainNodeRecord, 0, 2+len(midNodesGrouped))
|
nodesInPath := make([]chainNodeRecord, 0, 2+len(midNodesGrouped))
|
||||||
nodesInPath = append(nodesInPath, entry)
|
nodesInPath = append(nodesInPath, entry)
|
||||||
for _, midGroup := range midNodesGrouped {
|
for midIndex, midGroup := range midNodesGrouped {
|
||||||
mid, _, online := p.firstOnlineChainNode(midGroup)
|
mid, _, online := p.firstOnlineChainNode(midGroup)
|
||||||
if !online {
|
if !online {
|
||||||
probeOK = false
|
probeOK = false
|
||||||
@@ -332,6 +361,7 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
nodesInPath = append(nodesInPath, mid)
|
nodesInPath = append(nodesInPath, mid)
|
||||||
|
selectedNodeIDs[tunnelQualityGroupKey("middle", midIndex)] = mid.NodeID
|
||||||
}
|
}
|
||||||
if probeOK {
|
if probeOK {
|
||||||
nodesInPath = append(nodesInPath, exit)
|
nodesInPath = append(nodesInPath, exit)
|
||||||
@@ -354,7 +384,7 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
|
|||||||
probeOK = false
|
probeOK = false
|
||||||
hop.Latency = -1
|
hop.Latency = -1
|
||||||
hop.Loss = 100
|
hop.Loss = 100
|
||||||
hops = append(hops, hop)
|
primaryHops = append(primaryHops, hop)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,25 +395,25 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
|
|||||||
probeOK = false
|
probeOK = false
|
||||||
hop.Latency = -1
|
hop.Latency = -1
|
||||||
hop.Loss = 100
|
hop.Loss = 100
|
||||||
hops = append(hops, hop)
|
primaryHops = append(primaryHops, hop)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
hop.TargetIP = targetIP
|
hop.TargetIP = targetIP
|
||||||
hop.TargetPort = targetPort
|
hop.TargetPort = targetPort
|
||||||
|
|
||||||
lat, loss, err := p.pingNode(source.NodeID, targetIP, targetPort, options)
|
lat, loss, err := roundPinger(source.NodeID, targetIP, targetPort, options)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
hop.Latency = lat
|
hop.Latency = lat
|
||||||
hop.Loss = loss
|
hop.Loss = loss
|
||||||
totalLat += lat
|
totalLat += lat
|
||||||
remainingSuccessProb *= (1.0 - loss/100.0)
|
remainingSuccessProb *= (1.0 - loss/100.0)
|
||||||
hops = append(hops, hop)
|
primaryHops = append(primaryHops, hop)
|
||||||
} else {
|
} else {
|
||||||
probeOK = false
|
probeOK = false
|
||||||
hop.Latency = -1
|
hop.Latency = -1
|
||||||
hop.Loss = 100
|
hop.Loss = 100
|
||||||
hops = append(hops, hop)
|
primaryHops = append(primaryHops, hop)
|
||||||
if snap.ErrorMessage == "" {
|
if snap.ErrorMessage == "" {
|
||||||
snap.ErrorMessage = err.Error()
|
snap.ErrorMessage = err.Error()
|
||||||
}
|
}
|
||||||
@@ -398,17 +428,11 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
|
|||||||
snap.EntryToExitLatency = -1
|
snap.EntryToExitLatency = -1
|
||||||
snap.EntryToExitLoss = 100
|
snap.EntryToExitLoss = 100
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(hops) > 0 {
|
|
||||||
if b, err := json.Marshal(hops); err == nil {
|
|
||||||
snap.ChainDetails = string(b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exit → Bing
|
// Exit → Bing
|
||||||
if exitOnline {
|
if exitOnline {
|
||||||
lat, loss, err := p.pingNode(exit.NodeID, probeTarget.Host, probeTarget.Port, options)
|
lat, loss, err := roundPinger(exit.NodeID, probeTarget.Host, probeTarget.Port, options)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
snap.ExitToBingLatency = lat
|
snap.ExitToBingLatency = lat
|
||||||
snap.ExitToBingLoss = loss
|
snap.ExitToBingLoss = loss
|
||||||
@@ -424,7 +448,7 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
|
|||||||
default:
|
default:
|
||||||
// Unknown type: entry → public probe target.
|
// Unknown type: entry → public probe target.
|
||||||
if entryOnline {
|
if entryOnline {
|
||||||
lat, loss, err := p.pingNode(entry.NodeID, probeTarget.Host, probeTarget.Port, options)
|
lat, loss, err := roundPinger(entry.NodeID, probeTarget.Host, probeTarget.Port, options)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
snap.ExitToBingLatency = lat
|
snap.ExitToBingLatency = lat
|
||||||
snap.ExitToBingLoss = loss
|
snap.ExitToBingLoss = loss
|
||||||
@@ -437,9 +461,192 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
candidateHops := p.probeTunnelCandidateHops(
|
||||||
|
tunnel.Type,
|
||||||
|
inNodes,
|
||||||
|
midNodesGrouped,
|
||||||
|
outNodes,
|
||||||
|
selectedNodeIDs,
|
||||||
|
ipPreference,
|
||||||
|
options,
|
||||||
|
probeTarget,
|
||||||
|
roundPinger,
|
||||||
|
)
|
||||||
|
if len(primaryHops) > 0 || len(candidateHops) > 0 {
|
||||||
|
details := tunnelQualityChainDetails{
|
||||||
|
PrimaryPath: primaryHops,
|
||||||
|
CandidateHops: candidateHops,
|
||||||
|
}
|
||||||
|
if b, err := json.Marshal(details); err == nil {
|
||||||
|
snap.ChainDetails = string(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
p.storeResult(snap)
|
p.storeResult(snap)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func tunnelQualityGroupKey(role string, index int) string {
|
||||||
|
return fmt.Sprintf("%s:%d", role, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *tunnelQualityProber) probeTunnelCandidateHops(
|
||||||
|
tunnelType int,
|
||||||
|
inNodes []chainNodeRecord,
|
||||||
|
chainHops [][]chainNodeRecord,
|
||||||
|
outNodes []chainNodeRecord,
|
||||||
|
selectedNodeIDs map[string]int64,
|
||||||
|
ipPreference string,
|
||||||
|
options diagnosisExecOptions,
|
||||||
|
probeTarget tunnelProbeTarget,
|
||||||
|
ping bestExitProbeFunc,
|
||||||
|
) []TunnelQualityCandidateHop {
|
||||||
|
if p == nil || p.handler == nil || ping == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if tunnelType != 2 {
|
||||||
|
return p.probePublicTargetCandidates("entry", 0, inNodes, selectedNodeIDs, options, probeTarget, ping)
|
||||||
|
}
|
||||||
|
|
||||||
|
groups := make([]tunnelQualityCandidateGroup, 0, 2+len(chainHops))
|
||||||
|
groups = append(groups, tunnelQualityCandidateGroup{role: "entry", roleIndex: 0, nodes: inNodes})
|
||||||
|
for i, hop := range chainHops {
|
||||||
|
groups = append(groups, tunnelQualityCandidateGroup{role: "middle", roleIndex: i, nodes: hop})
|
||||||
|
}
|
||||||
|
groups = append(groups, tunnelQualityCandidateGroup{role: "exit", roleIndex: 0, nodes: outNodes})
|
||||||
|
|
||||||
|
var items []TunnelQualityCandidateHop
|
||||||
|
for i := 0; i < len(groups)-1; i++ {
|
||||||
|
items = append(items, p.probeCandidateGroupLinks(
|
||||||
|
groups[i],
|
||||||
|
groups[i+1],
|
||||||
|
i,
|
||||||
|
selectedNodeIDs,
|
||||||
|
ipPreference,
|
||||||
|
options,
|
||||||
|
ping,
|
||||||
|
)...)
|
||||||
|
}
|
||||||
|
items = append(items, p.probePublicTargetCandidates(
|
||||||
|
"exit",
|
||||||
|
0,
|
||||||
|
outNodes,
|
||||||
|
selectedNodeIDs,
|
||||||
|
options,
|
||||||
|
probeTarget,
|
||||||
|
ping,
|
||||||
|
)...)
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *tunnelQualityProber) probeCandidateGroupLinks(
|
||||||
|
fromGroup tunnelQualityCandidateGroup,
|
||||||
|
toGroup tunnelQualityCandidateGroup,
|
||||||
|
hopIndex int,
|
||||||
|
selectedNodeIDs map[string]int64,
|
||||||
|
ipPreference string,
|
||||||
|
options diagnosisExecOptions,
|
||||||
|
ping bestExitProbeFunc,
|
||||||
|
) []TunnelQualityCandidateHop {
|
||||||
|
items := make([]TunnelQualityCandidateHop, 0, len(fromGroup.nodes)*len(toGroup.nodes))
|
||||||
|
for _, source := range fromGroup.nodes {
|
||||||
|
for _, target := range toGroup.nodes {
|
||||||
|
item := TunnelQualityCandidateHop{
|
||||||
|
TunnelQualityHop: TunnelQualityHop{
|
||||||
|
FromNodeID: source.NodeID,
|
||||||
|
FromNodeName: source.NodeName,
|
||||||
|
ToNodeID: target.NodeID,
|
||||||
|
ToNodeName: target.NodeName,
|
||||||
|
Latency: -1,
|
||||||
|
Loss: 100,
|
||||||
|
},
|
||||||
|
FromRole: fromGroup.role,
|
||||||
|
ToRole: toGroup.role,
|
||||||
|
HopIndex: hopIndex,
|
||||||
|
Selected: selectedNodeIDs[tunnelQualityGroupKey(fromGroup.role, fromGroup.roleIndex)] == source.NodeID &&
|
||||||
|
selectedNodeIDs[tunnelQualityGroupKey(toGroup.role, toGroup.roleIndex)] == target.NodeID,
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceNode, sourceErr := p.handler.getNodeRecord(source.NodeID)
|
||||||
|
if sourceErr != nil || !isTunnelProbeNodeOnline(sourceNode) {
|
||||||
|
item.ErrorMessage = "来源节点不在线"
|
||||||
|
items = append(items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
targetNode, targetErr := p.handler.getNodeRecord(target.NodeID)
|
||||||
|
if targetErr != nil || !isTunnelProbeNodeOnline(targetNode) {
|
||||||
|
item.ErrorMessage = "目标节点不在线"
|
||||||
|
items = append(items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
targetIP, targetPort, resolveErr := resolveChainProbeTarget(sourceNode, targetNode, target.Port, ipPreference, target.ConnectIP)
|
||||||
|
if resolveErr != nil {
|
||||||
|
item.ErrorMessage = resolveErr.Error()
|
||||||
|
items = append(items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item.TargetIP = targetIP
|
||||||
|
item.TargetPort = targetPort
|
||||||
|
latency, loss, probeErr := ping(source.NodeID, targetIP, targetPort, options)
|
||||||
|
if probeErr != nil {
|
||||||
|
item.ErrorMessage = probeErr.Error()
|
||||||
|
items = append(items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item.Latency = latency
|
||||||
|
item.Loss = loss
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *tunnelQualityProber) probePublicTargetCandidates(
|
||||||
|
fromRole string,
|
||||||
|
fromIndex int,
|
||||||
|
nodes []chainNodeRecord,
|
||||||
|
selectedNodeIDs map[string]int64,
|
||||||
|
options diagnosisExecOptions,
|
||||||
|
probeTarget tunnelProbeTarget,
|
||||||
|
ping bestExitProbeFunc,
|
||||||
|
) []TunnelQualityCandidateHop {
|
||||||
|
items := make([]TunnelQualityCandidateHop, 0, len(nodes))
|
||||||
|
for _, source := range nodes {
|
||||||
|
item := TunnelQualityCandidateHop{
|
||||||
|
TunnelQualityHop: TunnelQualityHop{
|
||||||
|
FromNodeID: source.NodeID,
|
||||||
|
FromNodeName: source.NodeName,
|
||||||
|
ToNodeName: formatTunnelProbeTarget(probeTarget),
|
||||||
|
Latency: -1,
|
||||||
|
Loss: 100,
|
||||||
|
TargetIP: probeTarget.Host,
|
||||||
|
TargetPort: probeTarget.Port,
|
||||||
|
},
|
||||||
|
FromRole: fromRole,
|
||||||
|
ToRole: "target",
|
||||||
|
HopIndex: fromIndex,
|
||||||
|
Selected: selectedNodeIDs[tunnelQualityGroupKey(fromRole, fromIndex)] == source.NodeID,
|
||||||
|
}
|
||||||
|
sourceNode, sourceErr := p.handler.getNodeRecord(source.NodeID)
|
||||||
|
if sourceErr != nil || !isTunnelProbeNodeOnline(sourceNode) {
|
||||||
|
item.ErrorMessage = "来源节点不在线"
|
||||||
|
items = append(items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
latency, loss, probeErr := ping(source.NodeID, probeTarget.Host, probeTarget.Port, options)
|
||||||
|
if probeErr != nil {
|
||||||
|
item.ErrorMessage = probeErr.Error()
|
||||||
|
items = append(items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item.Latency = latency
|
||||||
|
item.Loss = loss
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
func isTunnelProbeNodeOnline(node *nodeRecord) bool {
|
func isTunnelProbeNodeOnline(node *nodeRecord) bool {
|
||||||
return node != nil && (node.IsRemote == 1 || node.Status == 1)
|
return node != nil && (node.IsRemote == 1 || node.Status == 1)
|
||||||
}
|
}
|
||||||
@@ -457,7 +664,7 @@ func (p *tunnelQualityProber) firstOnlineChainNode(nodes []chainNodeRecord) (cha
|
|||||||
return chainNodeRecord{}, nil, false
|
return chainNodeRecord{}, nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *tunnelQualityProber) probeBestExitOwners(tunnelID int64, inNodes []chainNodeRecord, chainHops [][]chainNodeRecord, outNodes []chainNodeRecord, ipPreference string, options diagnosisExecOptions, probeTarget tunnelProbeTarget) {
|
func (p *tunnelQualityProber) probeBestExitOwners(tunnelID int64, inNodes []chainNodeRecord, chainHops [][]chainNodeRecord, outNodes []chainNodeRecord, ipPreference string, options diagnosisExecOptions, probeTarget tunnelProbeTarget, roundPinger bestExitProbeFunc) {
|
||||||
if p == nil || p.handler == nil || p.handler.bestExit == nil || len(outNodes) <= 1 {
|
if p == nil || p.handler == nil || p.handler.bestExit == nil || len(outNodes) <= 1 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -479,9 +686,6 @@ func (p *tunnelQualityProber) probeBestExitOwners(tunnelID int64, inNodes []chai
|
|||||||
nodeMap[exit.NodeID] = node
|
nodeMap[exit.NodeID] = node
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// This best-exit decision cache is per decision round; the display-oriented
|
|
||||||
// tunnel quality snapshot may still collect its own first-exit public probe.
|
|
||||||
roundPinger := newBestExitRoundPinger(p.pingNode)
|
|
||||||
for _, owner := range owners {
|
for _, owner := range owners {
|
||||||
if nodeMap[owner.NodeID] == nil {
|
if nodeMap[owner.NodeID] == nil {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -106,6 +107,57 @@ func TestTunnelQualityProberUsesOnlineBackupExit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTunnelQualityProberReportsAllExitCandidateLatencies(t *testing.T) {
|
||||||
|
h := setupProbeTargetTunnelHandler(t)
|
||||||
|
seedQualityForwardTunnel(t, h, 83, []int{1, 1})
|
||||||
|
|
||||||
|
p := newTunnelQualityProber(h)
|
||||||
|
p.probeNode = func(nodeID int64, ip string, port int, options diagnosisExecOptions) (float64, float64, error) {
|
||||||
|
switch fmt.Sprintf("%d|%s|%d", nodeID, ip, port) {
|
||||||
|
case "10|10.0.0.30|30030":
|
||||||
|
return 20, 0, nil
|
||||||
|
case "10|10.0.0.31|30031":
|
||||||
|
return 35, 0, nil
|
||||||
|
case "30|www.bing.com|443":
|
||||||
|
return 50, 0, nil
|
||||||
|
case "31|www.bing.com|443":
|
||||||
|
return 65, 0, nil
|
||||||
|
default:
|
||||||
|
return 0, 100, fmt.Errorf("unexpected probe node=%d target=%s:%d", nodeID, ip, port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.probeTunnel(83)
|
||||||
|
|
||||||
|
snaps := p.GetAll()
|
||||||
|
if len(snaps) != 1 {
|
||||||
|
t.Fatalf("expected one quality snapshot, got %+v", snaps)
|
||||||
|
}
|
||||||
|
if snaps[0].EntryToExitLatency != 20 || snaps[0].ExitToBingLatency != 50 {
|
||||||
|
t.Fatalf("expected primary path metrics to remain unchanged, got %+v", snaps[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
var details tunnelQualityChainDetails
|
||||||
|
if err := json.Unmarshal([]byte(snaps[0].ChainDetails), &details); err != nil {
|
||||||
|
t.Fatalf("decode chain details: %v", err)
|
||||||
|
}
|
||||||
|
assertCandidateHop := func(fromID, toID int64, latency float64, selected bool) {
|
||||||
|
t.Helper()
|
||||||
|
for _, hop := range details.CandidateHops {
|
||||||
|
if hop.FromNodeID == fromID && hop.ToNodeID == toID {
|
||||||
|
if hop.Latency != latency || hop.Selected != selected || hop.ErrorMessage != "" {
|
||||||
|
t.Fatalf("unexpected candidate hop: %+v", hop)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("candidate hop %d -> %d not found in %+v", fromID, toID, details.CandidateHops)
|
||||||
|
}
|
||||||
|
assertCandidateHop(10, 30, 20, true)
|
||||||
|
assertCandidateHop(10, 31, 35, false)
|
||||||
|
assertCandidateHop(30, 0, 50, true)
|
||||||
|
assertCandidateHop(31, 0, 65, false)
|
||||||
|
}
|
||||||
|
|
||||||
func TestTunnelQualityProberStoresProbeTargetWhenChainIncomplete(t *testing.T) {
|
func TestTunnelQualityProberStoresProbeTargetWhenChainIncomplete(t *testing.T) {
|
||||||
h := setupProbeTargetTunnelHandler(t)
|
h := setupProbeTargetTunnelHandler(t)
|
||||||
seedProbeTargetTunnel(t, h, 78, "quality-target-incomplete", "speed.example.com", 8443)
|
seedProbeTargetTunnel(t, h, 78, "quality-target-incomplete", "speed.example.com", 8443)
|
||||||
|
|||||||
@@ -132,3 +132,31 @@ func TestUpsertTunnelMetricBucketsIsSafeUnderConcurrency(t *testing.T) {
|
|||||||
t.Fatalf("expected bytesOut %d, got %d", wantOut, rows[0].BytesOut)
|
t.Fatalf("expected bytesOut %d, got %d", wantOut, rows[0].BytesOut)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetLatestTunnelQualitiesIncludesChainDetails(t *testing.T) {
|
||||||
|
r, err := Open(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open repo: %v", err)
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
if err := r.InsertTunnelQuality(&model.TunnelQuality{
|
||||||
|
TunnelID: 7,
|
||||||
|
Timestamp: time.Now().UnixMilli(),
|
||||||
|
Success: 1,
|
||||||
|
ChainDetails: `{"primaryPath":[],"candidateHops":[{"fromNodeId":10,"toNodeId":31}]}`,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("insert tunnel quality: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := r.GetLatestTunnelQualities()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get latest tunnel qualities: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("expected one latest tunnel quality, got %+v", items)
|
||||||
|
}
|
||||||
|
if items[0].ChainDetails == "" {
|
||||||
|
t.Fatalf("expected chain details in latest quality row, got %+v", items[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ func (r *Repository) GetLatestTunnelQualities() ([]model.TunnelQuality, error) {
|
|||||||
// Use window function (works on modern SQLite 3.25+ and PostgreSQL).
|
// Use window function (works on modern SQLite 3.25+ and PostgreSQL).
|
||||||
q := `
|
q := `
|
||||||
SELECT id, tunnel_id, entry_to_exit_latency, exit_to_bing_latency,
|
SELECT id, tunnel_id, entry_to_exit_latency, exit_to_bing_latency,
|
||||||
entry_to_exit_loss, exit_to_bing_loss, success, error_message, timestamp
|
entry_to_exit_loss, exit_to_bing_loss, success, error_message, timestamp,
|
||||||
|
chain_details
|
||||||
FROM (
|
FROM (
|
||||||
SELECT *, ROW_NUMBER() OVER (PARTITION BY tunnel_id ORDER BY timestamp DESC, id DESC) AS rn
|
SELECT *, ROW_NUMBER() OVER (PARTITION BY tunnel_id ORDER BY timestamp DESC, id DESC) AS rn
|
||||||
FROM tunnel_quality
|
FROM tunnel_quality
|
||||||
|
|||||||
@@ -595,6 +595,20 @@ export interface TunnelQualityHopApiItem {
|
|||||||
targetPort?: number;
|
targetPort?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TunnelQualityCandidateHopApiItem
|
||||||
|
extends TunnelQualityHopApiItem {
|
||||||
|
fromRole: "entry" | "middle" | "exit";
|
||||||
|
toRole: "middle" | "exit" | "target";
|
||||||
|
hopIndex: number;
|
||||||
|
selected: boolean;
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TunnelQualityChainDetailsApiItem {
|
||||||
|
primaryPath?: TunnelQualityHopApiItem[];
|
||||||
|
candidateHops?: TunnelQualityCandidateHopApiItem[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface TunnelQualityApiItem {
|
export interface TunnelQualityApiItem {
|
||||||
tunnelId: number;
|
tunnelId: number;
|
||||||
entryToExitLatency: number;
|
entryToExitLatency: number;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import type {
|
|||||||
MonitorTunnelApiItem,
|
MonitorTunnelApiItem,
|
||||||
TunnelMetricApiItem,
|
TunnelMetricApiItem,
|
||||||
TunnelQualityApiItem,
|
TunnelQualityApiItem,
|
||||||
|
TunnelQualityCandidateHopApiItem,
|
||||||
|
TunnelQualityChainDetailsApiItem,
|
||||||
TunnelQualityHopApiItem,
|
TunnelQualityHopApiItem,
|
||||||
} from "@/api/types";
|
} from "@/api/types";
|
||||||
|
|
||||||
@@ -468,18 +470,59 @@ const TrafficChartCard = React.memo(function TrafficChartCard({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
function ForwardingChainTopology({ hopsStr }: { hopsStr?: string }) {
|
const parseTunnelQualityChainDetails = (
|
||||||
if (!hopsStr) return null;
|
raw?: string,
|
||||||
|
): TunnelQualityChainDetailsApiItem => {
|
||||||
let hops: TunnelQualityHopApiItem[] = [];
|
if (!raw) return {};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
hops = JSON.parse(hopsStr);
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
|
||||||
|
// Backward-compatible with historical rows that stored the primary path
|
||||||
|
// directly as a JSON array.
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return { primaryPath: parsed as TunnelQualityHopApiItem[] };
|
||||||
|
}
|
||||||
|
if (parsed && typeof parsed === "object") {
|
||||||
|
return parsed as TunnelQualityChainDetailsApiItem;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.isArray(hops) || hops.length === 0) return null;
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
const candidateRoleLabel = (role: string) => {
|
||||||
|
switch (role) {
|
||||||
|
case "entry":
|
||||||
|
return "入口";
|
||||||
|
case "middle":
|
||||||
|
return "中转";
|
||||||
|
case "exit":
|
||||||
|
return "出口";
|
||||||
|
case "target":
|
||||||
|
return "测试目标";
|
||||||
|
default:
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ForwardingChainTopology = React.memo(function ForwardingChainTopology({
|
||||||
|
hopsStr,
|
||||||
|
}: {
|
||||||
|
hopsStr?: string;
|
||||||
|
}) {
|
||||||
|
const details = useMemo(
|
||||||
|
() => parseTunnelQualityChainDetails(hopsStr),
|
||||||
|
[hopsStr],
|
||||||
|
);
|
||||||
|
const primaryHops = details.primaryPath ?? [];
|
||||||
|
const alternativeHops = useMemo(
|
||||||
|
() => (details.candidateHops ?? []).filter((hop) => !hop.selected),
|
||||||
|
[details.candidateHops],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (primaryHops.length === 0 && alternativeHops.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border border-divider/60 shadow-sm transition-shadow bg-gradient-to-br from-background to-default-50/50 mt-4">
|
<Card className="border border-divider/60 shadow-sm transition-shadow bg-gradient-to-br from-background to-default-50/50 mt-4">
|
||||||
@@ -490,61 +533,131 @@ function ForwardingChainTopology({ hopsStr }: { hopsStr?: string }) {
|
|||||||
</h3>
|
</h3>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardBody className="py-2 px-4 pb-4">
|
<CardBody className="py-2 px-4 pb-4">
|
||||||
<div className="flex items-center overflow-x-auto pb-2 py-2">
|
{primaryHops.length > 0 ? (
|
||||||
{hops.map((hop, index) => {
|
<div className="flex items-center overflow-x-auto pb-2 py-2">
|
||||||
const hasError = hop.latency < 0 || hop.loss > 0;
|
{primaryHops.map((hop, index) => {
|
||||||
const colorClass =
|
const hasError = hop.latency < 0 || hop.loss > 0;
|
||||||
hop.latency < 0
|
const colorClass =
|
||||||
? "text-danger"
|
hop.latency < 0
|
||||||
: hop.loss > 0
|
? "text-danger"
|
||||||
? "text-warning"
|
: hop.loss > 0
|
||||||
: "text-success";
|
? "text-warning"
|
||||||
const borderColor = hasError ? "border-danger" : "";
|
: "text-success";
|
||||||
|
const borderColor = hasError ? "border-danger" : "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={index}>
|
<React.Fragment
|
||||||
{index === 0 && (
|
key={`${hop.fromNodeId}-${hop.toNodeId}-${index}`}
|
||||||
|
>
|
||||||
|
{index === 0 ? (
|
||||||
|
<Chip
|
||||||
|
className="shrink-0 font-mono shadow-sm"
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
{hop.fromNodeName}
|
||||||
|
</Chip>
|
||||||
|
) : null}
|
||||||
|
<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
|
<Chip
|
||||||
className="shrink-0 font-mono shadow-sm"
|
className={`shrink-0 font-mono shadow-sm ${borderColor}`}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
>
|
>
|
||||||
{hop.fromNodeName}
|
{hop.toNodeName}
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
</React.Fragment>
|
||||||
<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}`}
|
</div>
|
||||||
>
|
) : null}
|
||||||
{hop.latency >= 0 ? `${hop.latency.toFixed(0)}ms` : "超时"}
|
|
||||||
</span>
|
{alternativeHops.length > 0 ? (
|
||||||
<div
|
<div
|
||||||
className={`h-[2px] w-full relative flex items-center justify-end bg-default-200 ${hop.latency < 0 ? "!bg-danger" : ""}`}
|
className={`${primaryHops.length > 0 ? "mt-3 border-t border-divider/60 pt-3" : ""}`}
|
||||||
>
|
>
|
||||||
<ArrowRight
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
className={`w-3.5 h-3.5 absolute -right-2 ${colorClass} bg-background rounded-full p-[1px] z-10`}
|
<span className="text-xs font-semibold text-default-600">
|
||||||
/>
|
备选节点实时延迟
|
||||||
</div>
|
</span>
|
||||||
<span
|
<span className="text-[10px] text-default-400">
|
||||||
className={`text-[10px] font-mono leading-none mt-1.5 ${hop.loss > 0 ? "text-warning" : "text-default-400"}`}
|
{alternativeHops.length} 条候选链路
|
||||||
>
|
</span>
|
||||||
{hop.loss.toFixed(0)}% 丢包
|
</div>
|
||||||
</span>
|
<div className="grid max-h-72 grid-cols-1 gap-2 overflow-y-auto pr-1 md:grid-cols-2">
|
||||||
</div>
|
{alternativeHops.map((hop) => (
|
||||||
<Chip
|
<CandidateHopLatency
|
||||||
className={`shrink-0 font-mono shadow-sm ${borderColor}`}
|
key={`${hop.hopIndex}-${hop.fromNodeId}-${hop.toRole}-${hop.toNodeId || hop.toNodeName}`}
|
||||||
size="sm"
|
hop={hop}
|
||||||
variant="flat"
|
/>
|
||||||
>
|
))}
|
||||||
{hop.toNodeName}
|
</div>
|
||||||
</Chip>
|
</div>
|
||||||
</React.Fragment>
|
) : null}
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function CandidateHopLatency({
|
||||||
|
hop,
|
||||||
|
}: {
|
||||||
|
hop: TunnelQualityCandidateHopApiItem;
|
||||||
|
}) {
|
||||||
|
const hasError = Boolean(hop.errorMessage) || hop.latency < 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-divider/60 bg-default-50/50 px-3 py-2.5">
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="min-w-0 truncate font-medium text-foreground">
|
||||||
|
{hop.fromNodeName}
|
||||||
|
</span>
|
||||||
|
<ArrowRight className="h-3.5 w-3.5 shrink-0 text-default-400" />
|
||||||
|
<span className="min-w-0 truncate font-medium text-foreground">
|
||||||
|
{hop.toNodeName}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto shrink-0">
|
||||||
|
<LatencyDisplay value={hop.latency} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex items-center gap-2 text-[10px] text-default-400">
|
||||||
|
<span>
|
||||||
|
{candidateRoleLabel(hop.fromRole)} → {candidateRoleLabel(hop.toRole)}
|
||||||
|
</span>
|
||||||
|
{hasError ? (
|
||||||
|
<span className="ml-auto truncate text-danger">
|
||||||
|
{hop.errorMessage || "探测失败"}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={`ml-auto font-mono ${hop.loss > 0 ? "text-warning" : "text-default-500"}`}
|
||||||
|
>
|
||||||
|
{hop.loss.toFixed(0)}% 丢包
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TunnelMonitorView({
|
export function TunnelMonitorView({
|
||||||
|
|||||||
Reference in New Issue
Block a user