feat(monitor): show backup tunnel latencies (#535)

Closes #508
This commit is contained in:
sagit
2026-08-03 10:15:22 +08:00
committed by GitHub
parent ae370382d3
commit cbe2fc492e
6 changed files with 488 additions and 76 deletions
@@ -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
+14
View File
@@ -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({