feat(frontend): group forwards by user with admin priority sorting (#227)

## Summary
- Add user grouping for forwards in both grouped table and card views
- Sort user groups with admin's own group first, then alphabetically by
name
- Restrict drag-and-drop reordering to same user group only to prevent
cross-user data mixing
- Remove redundant user column from grouped table view (user shown in
group header)
- Add user group headers with forward count badges and "管理员本人" chip for
admin's own group
This commit is contained in:
sagit
2026-02-28 20:11:18 +08:00
committed by GitHub
12 changed files with 1880 additions and 258 deletions
+1 -1
View File
@@ -49,7 +49,7 @@ func New(cfg config.Config) (*App, error) {
Handler: router,
ReadTimeout: 30 * time.Second,
ReadHeaderTimeout: 5 * time.Second,
WriteTimeout: 30 * time.Second,
WriteTimeout: 2 * time.Minute,
IdleTimeout: 60 * time.Second,
}
+424 -92
View File
@@ -1,6 +1,7 @@
package handler
import (
"context"
"errors"
"fmt"
"net"
@@ -8,6 +9,7 @@ import (
"sort"
"strconv"
"strings"
"sync"
"time"
"go-backend/internal/http/client"
@@ -30,6 +32,40 @@ type diagnosisTarget struct {
Port int
}
type diagnosisWorkItem struct {
fromNodeID int64
targetIP string
targetPort int
description string
metadata map[string]interface{}
toNode chainNodeRecord
hasChainHop bool
ipPreference string
}
type diagnosisExecOptions struct {
commandTimeout time.Duration
pingTimeoutMS int
timeoutMessage string
}
type diagnosisProgress struct {
Total int `json:"total"`
Completed int `json:"completed"`
Success int `json:"success"`
Failed int `json:"failed"`
}
type diagnosisItemEmitter func(index int, item map[string]interface{}, progress diagnosisProgress)
const diagnosisMaxConcurrency = 8
const (
defaultNodeCommandTimeout = 6 * time.Second
diagnosisCommandTimeout = 2 * time.Minute
diagnosisRequestTimeout = 2 * time.Minute
)
func (h *Handler) resolveForwardAccess(r *http.Request, forwardID int64) (*forwardRecord, int64, int, error) {
userID, roleID, err := userRoleFromRequest(r)
if err != nil {
@@ -295,16 +331,23 @@ func (h *Handler) applyNodeProtocolChange(nodeID int64, httpVal, tlsVal, socksVa
}
func (h *Handler) sendNodeCommand(nodeID int64, commandType string, data interface{}, tolerateExists bool, tolerateNotFound bool) (ws.CommandResult, error) {
return h.sendNodeCommandWithTimeout(nodeID, commandType, data, defaultNodeCommandTimeout, tolerateExists, tolerateNotFound)
}
func (h *Handler) sendNodeCommandWithTimeout(nodeID int64, commandType string, data interface{}, timeout time.Duration, tolerateExists bool, tolerateNotFound bool) (ws.CommandResult, error) {
var (
result ws.CommandResult
err error
)
if timeout <= 0 {
timeout = defaultNodeCommandTimeout
}
node, nodeErr := h.getNodeRecord(nodeID)
if nodeErr == nil && node != nil && node.IsRemote == 1 {
result, err = h.sendRemoteNodeCommand(node, commandType, data)
result, err = h.sendRemoteNodeCommandWithTimeout(node, commandType, data, timeout)
} else {
result, err = h.wsServer.SendCommand(nodeID, commandType, data, 12*time.Second)
result, err = h.wsServer.SendCommand(nodeID, commandType, data, timeout)
}
if err == nil {
return result, nil
@@ -324,6 +367,10 @@ func (h *Handler) sendNodeCommand(nodeID int64, commandType string, data interfa
}
func (h *Handler) sendRemoteNodeCommand(node *nodeRecord, commandType string, data interface{}) (ws.CommandResult, error) {
return h.sendRemoteNodeCommandWithTimeout(node, commandType, data, 0)
}
func (h *Handler) sendRemoteNodeCommandWithTimeout(node *nodeRecord, commandType string, data interface{}, timeout time.Duration) (ws.CommandResult, error) {
if node == nil {
return ws.CommandResult{}, errors.New("节点不存在")
}
@@ -334,6 +381,9 @@ func (h *Handler) sendRemoteNodeCommand(node *nodeRecord, commandType string, da
}
fc := client.NewFederationClient()
if timeout > 0 {
fc = client.NewFederationClientWithTimeout(timeout)
}
res, err := fc.Command(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeNodeCommandRequest{
CommandType: commandType,
Data: data,
@@ -361,41 +411,65 @@ func (h *Handler) sendRemoteNodeCommand(node *nodeRecord, commandType string, da
return result, nil
}
func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]interface{}, error) {
func (h *Handler) diagnoseForwardRuntime(ctx context.Context, forward *forwardRecord) (map[string]interface{}, error) {
if ctx == nil {
ctx = context.Background()
}
forwardName, workItems, err := h.prepareForwardDiagnosis(forward)
if err != nil {
return nil, err
}
results := h.runDiagnosisWorkItems(ctx, workItems, nil)
payload := map[string]interface{}{
"forwardName": forwardName,
"timestamp": time.Now().UnixMilli(),
"results": results,
}
return payload, nil
}
func (h *Handler) prepareForwardDiagnosis(forward *forwardRecord) (string, []diagnosisWorkItem, error) {
if forward == nil {
return nil, errForwardNotFound
return "", nil, errForwardNotFound
}
targets, err := resolveDiagnosisTargets(forward.RemoteAddr)
if err != nil {
return nil, err
return "", nil, err
}
tunnel, err := h.getTunnelRecord(forward.TunnelID)
if err != nil {
return nil, err
return "", nil, err
}
chainRows, err := h.listChainNodesForTunnel(forward.TunnelID)
if err != nil {
return nil, err
return "", nil, err
}
if len(chainRows) == 0 {
return nil, errors.New("隧道配置不完整")
return "", nil, errors.New("隧道配置不完整")
}
ipPreference := h.repo.GetTunnelIPPreference(forward.TunnelID)
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
results := make([]map[string]interface{}, 0, len(chainRows)*2+len(targets))
nodeCache := map[int64]*nodeRecord{}
workItems := make([]diagnosisWorkItem, 0, len(chainRows)*2+len(targets))
switch tunnel.Type {
case 1:
for _, inNode := range inNodes {
for _, target := range targets {
description := fmt.Sprintf("入口(%s)->目标(%s)", inNode.NodeName, target.Address)
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, target.IP, target.Port, description, map[string]interface{}{
"fromChainType": 1,
workItems = append(workItems, diagnosisWorkItem{
fromNodeID: inNode.NodeID,
targetIP: target.IP,
targetPort: target.Port,
description: description,
metadata: map[string]interface{}{
"fromChainType": 1,
},
})
}
}
@@ -404,19 +478,33 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
if len(chainHops) > 0 {
for _, firstNode := range chainHops[0] {
description := fmt.Sprintf("入口(%s)->第1跳(%s)", inNode.NodeName, firstNode.NodeName)
h.appendChainHopDiagnosis(&results, nodeCache, inNode.NodeID, firstNode, description, map[string]interface{}{
"fromChainType": 1,
"toChainType": 2,
"toInx": firstNode.Inx,
}, ipPreference)
workItems = append(workItems, diagnosisWorkItem{
fromNodeID: inNode.NodeID,
toNode: firstNode,
hasChainHop: true,
ipPreference: ipPreference,
description: description,
metadata: map[string]interface{}{
"fromChainType": 1,
"toChainType": 2,
"toInx": firstNode.Inx,
},
})
}
} else {
for _, outNode := range outNodes {
description := fmt.Sprintf("入口(%s)->出口(%s)", inNode.NodeName, outNode.NodeName)
h.appendChainHopDiagnosis(&results, nodeCache, inNode.NodeID, outNode, description, map[string]interface{}{
"fromChainType": 1,
"toChainType": 3,
}, ipPreference)
workItems = append(workItems, diagnosisWorkItem{
fromNodeID: inNode.NodeID,
toNode: outNode,
hasChainHop: true,
ipPreference: ipPreference,
description: description,
metadata: map[string]interface{}{
"fromChainType": 1,
"toChainType": 3,
},
})
}
}
}
@@ -426,21 +514,35 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
if i+1 < len(chainHops) {
for _, nextNode := range chainHops[i+1] {
description := fmt.Sprintf("第%d跳(%s)->第%d跳(%s)", i+1, currentNode.NodeName, i+2, nextNode.NodeName)
h.appendChainHopDiagnosis(&results, nodeCache, currentNode.NodeID, nextNode, description, map[string]interface{}{
"fromChainType": 2,
"fromInx": currentNode.Inx,
"toChainType": 2,
"toInx": nextNode.Inx,
}, ipPreference)
workItems = append(workItems, diagnosisWorkItem{
fromNodeID: currentNode.NodeID,
toNode: nextNode,
hasChainHop: true,
ipPreference: ipPreference,
description: description,
metadata: map[string]interface{}{
"fromChainType": 2,
"fromInx": currentNode.Inx,
"toChainType": 2,
"toInx": nextNode.Inx,
},
})
}
} else {
for _, outNode := range outNodes {
description := fmt.Sprintf("第%d跳(%s)->出口(%s)", i+1, currentNode.NodeName, outNode.NodeName)
h.appendChainHopDiagnosis(&results, nodeCache, currentNode.NodeID, outNode, description, map[string]interface{}{
"fromChainType": 2,
"fromInx": currentNode.Inx,
"toChainType": 3,
}, ipPreference)
workItems = append(workItems, diagnosisWorkItem{
fromNodeID: currentNode.NodeID,
toNode: outNode,
hasChainHop: true,
ipPreference: ipPreference,
description: description,
metadata: map[string]interface{}{
"fromChainType": 2,
"fromInx": currentNode.Inx,
"toChainType": 3,
},
})
}
}
}
@@ -449,8 +551,14 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
for _, outNode := range outNodes {
for _, target := range targets {
description := fmt.Sprintf("出口(%s)->目标(%s)", outNode.NodeName, target.Address)
h.appendPathDiagnosis(&results, nodeCache, outNode.NodeID, target.IP, target.Port, description, map[string]interface{}{
"fromChainType": 3,
workItems = append(workItems, diagnosisWorkItem{
fromNodeID: outNode.NodeID,
targetIP: target.IP,
targetPort: target.Port,
description: description,
metadata: map[string]interface{}{
"fromChainType": 3,
},
})
}
}
@@ -458,54 +566,80 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
for _, inNode := range inNodes {
for _, target := range targets {
description := fmt.Sprintf("入口(%s)->目标(%s)", inNode.NodeName, target.Address)
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, target.IP, target.Port, description, map[string]interface{}{
"fromChainType": 1,
workItems = append(workItems, diagnosisWorkItem{
fromNodeID: inNode.NodeID,
targetIP: target.IP,
targetPort: target.Port,
description: description,
metadata: map[string]interface{}{
"fromChainType": 1,
},
})
}
}
}
return forward.Name, workItems, nil
}
func (h *Handler) diagnoseTunnelRuntime(ctx context.Context, tunnelID int64) (map[string]interface{}, error) {
if ctx == nil {
ctx = context.Background()
}
tunnelName, tunnelType, workItems, err := h.prepareTunnelDiagnosis(tunnelID)
if err != nil {
return nil, err
}
results := h.runDiagnosisWorkItems(ctx, workItems, nil)
payload := map[string]interface{}{
"forwardName": forward.Name,
"timestamp": time.Now().UnixMilli(),
"results": results,
"tunnelName": tunnelName,
"tunnelType": tunnelType,
"timestamp": time.Now().UnixMilli(),
"results": results,
}
return payload, nil
}
func (h *Handler) diagnoseTunnelRuntime(tunnelID int64) (map[string]interface{}, error) {
func (h *Handler) prepareTunnelDiagnosis(tunnelID int64) (string, string, []diagnosisWorkItem, error) {
tunnel, err := h.getTunnelRecord(tunnelID)
if err != nil {
return nil, err
return "", "", nil, err
}
tunnelName, err := h.repo.GetTunnelName(tunnelID)
if err != nil {
return nil, err
return "", "", nil, err
}
if tunnelName == "" {
return nil, errors.New("隧道不存在")
return "", "", nil, errors.New("隧道不存在")
}
chainRows, err := h.listChainNodesForTunnel(tunnelID)
if err != nil {
return nil, err
return "", "", nil, err
}
if len(chainRows) == 0 {
return nil, errors.New("隧道配置不完整")
return "", "", nil, errors.New("隧道配置不完整")
}
ipPreference := h.repo.GetTunnelIPPreference(tunnelID)
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
results := make([]map[string]interface{}, 0, len(chainRows)*2)
nodeCache := map[int64]*nodeRecord{}
workItems := make([]diagnosisWorkItem, 0, len(chainRows)*2)
switch tunnel.Type {
case 1:
for _, inNode := range inNodes {
description := fmt.Sprintf("入口(%s)->外网", inNode.NodeName)
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.bing.com", 443, description, map[string]interface{}{
"fromChainType": 1,
workItems = append(workItems, diagnosisWorkItem{
fromNodeID: inNode.NodeID,
targetIP: "www.bing.com",
targetPort: 443,
description: description,
metadata: map[string]interface{}{
"fromChainType": 1,
},
})
}
case 2:
@@ -513,19 +647,33 @@ func (h *Handler) diagnoseTunnelRuntime(tunnelID int64) (map[string]interface{},
if len(chainHops) > 0 {
for _, firstNode := range chainHops[0] {
description := fmt.Sprintf("入口(%s)->第1跳(%s)", inNode.NodeName, firstNode.NodeName)
h.appendChainHopDiagnosis(&results, nodeCache, inNode.NodeID, firstNode, description, map[string]interface{}{
"fromChainType": 1,
"toChainType": 2,
"toInx": firstNode.Inx,
}, ipPreference)
workItems = append(workItems, diagnosisWorkItem{
fromNodeID: inNode.NodeID,
toNode: firstNode,
hasChainHop: true,
ipPreference: ipPreference,
description: description,
metadata: map[string]interface{}{
"fromChainType": 1,
"toChainType": 2,
"toInx": firstNode.Inx,
},
})
}
} else {
for _, outNode := range outNodes {
description := fmt.Sprintf("入口(%s)->出口(%s)", inNode.NodeName, outNode.NodeName)
h.appendChainHopDiagnosis(&results, nodeCache, inNode.NodeID, outNode, description, map[string]interface{}{
"fromChainType": 1,
"toChainType": 3,
}, ipPreference)
workItems = append(workItems, diagnosisWorkItem{
fromNodeID: inNode.NodeID,
toNode: outNode,
hasChainHop: true,
ipPreference: ipPreference,
description: description,
metadata: map[string]interface{}{
"fromChainType": 1,
"toChainType": 3,
},
})
}
}
}
@@ -535,21 +683,35 @@ func (h *Handler) diagnoseTunnelRuntime(tunnelID int64) (map[string]interface{},
if i+1 < len(chainHops) {
for _, nextNode := range chainHops[i+1] {
description := fmt.Sprintf("第%d跳(%s)->第%d跳(%s)", i+1, currentNode.NodeName, i+2, nextNode.NodeName)
h.appendChainHopDiagnosis(&results, nodeCache, currentNode.NodeID, nextNode, description, map[string]interface{}{
"fromChainType": 2,
"fromInx": currentNode.Inx,
"toChainType": 2,
"toInx": nextNode.Inx,
}, ipPreference)
workItems = append(workItems, diagnosisWorkItem{
fromNodeID: currentNode.NodeID,
toNode: nextNode,
hasChainHop: true,
ipPreference: ipPreference,
description: description,
metadata: map[string]interface{}{
"fromChainType": 2,
"fromInx": currentNode.Inx,
"toChainType": 2,
"toInx": nextNode.Inx,
},
})
}
} else {
for _, outNode := range outNodes {
description := fmt.Sprintf("第%d跳(%s)->出口(%s)", i+1, currentNode.NodeName, outNode.NodeName)
h.appendChainHopDiagnosis(&results, nodeCache, currentNode.NodeID, outNode, description, map[string]interface{}{
"fromChainType": 2,
"fromInx": currentNode.Inx,
"toChainType": 3,
}, ipPreference)
workItems = append(workItems, diagnosisWorkItem{
fromNodeID: currentNode.NodeID,
toNode: outNode,
hasChainHop: true,
ipPreference: ipPreference,
description: description,
metadata: map[string]interface{}{
"fromChainType": 2,
"fromInx": currentNode.Inx,
"toChainType": 3,
},
})
}
}
}
@@ -557,26 +719,33 @@ func (h *Handler) diagnoseTunnelRuntime(tunnelID int64) (map[string]interface{},
for _, outNode := range outNodes {
description := fmt.Sprintf("出口(%s)->外网", outNode.NodeName)
h.appendPathDiagnosis(&results, nodeCache, outNode.NodeID, "www.bing.com", 443, description, map[string]interface{}{
"fromChainType": 3,
workItems = append(workItems, diagnosisWorkItem{
fromNodeID: outNode.NodeID,
targetIP: "www.bing.com",
targetPort: 443,
description: description,
metadata: map[string]interface{}{
"fromChainType": 3,
},
})
}
default:
for _, inNode := range inNodes {
description := fmt.Sprintf("入口(%s)->外网", inNode.NodeName)
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.bing.com", 443, description, map[string]interface{}{
"fromChainType": 1,
workItems = append(workItems, diagnosisWorkItem{
fromNodeID: inNode.NodeID,
targetIP: "www.bing.com",
targetPort: 443,
description: description,
metadata: map[string]interface{}{
"fromChainType": 1,
},
})
}
}
payload := map[string]interface{}{
"tunnelName": tunnelName,
"tunnelType": map[bool]string{true: "端口转发", false: "隧道转发"}[tunnel.Type == 1],
"timestamp": time.Now().UnixMilli(),
"results": results,
}
return payload, nil
tunnelType := map[bool]string{true: "端口转发", false: "隧道转发"}[tunnel.Type == 1]
return tunnelName, tunnelType, workItems, nil
}
func splitChainNodeGroups(rows []chainNodeRecord) ([]chainNodeRecord, [][]chainNodeRecord, []chainNodeRecord) {
@@ -628,6 +797,157 @@ func resolveDiagnosisTargets(remoteAddr string) ([]diagnosisTarget, error) {
return targets, nil
}
func diagnosisContextMessage(ctx context.Context) string {
if ctx == nil {
return "诊断超时(2分钟)"
}
switch ctx.Err() {
case context.DeadlineExceeded:
return "诊断超时(2分钟)"
case context.Canceled:
return "诊断已取消"
default:
return "诊断超时(2分钟)"
}
}
func diagnosisExecOptionsFromContext(ctx context.Context) diagnosisExecOptions {
timeout := diagnosisCommandTimeout
if ctx != nil {
if deadline, ok := ctx.Deadline(); ok {
remaining := time.Until(deadline)
if remaining <= 0 {
remaining = 100 * time.Millisecond
}
if remaining < timeout {
timeout = remaining
}
}
}
if timeout <= 0 {
timeout = 100 * time.Millisecond
}
pingTimeoutMS := int(timeout / time.Millisecond)
if pingTimeoutMS <= 0 {
pingTimeoutMS = 100
}
return diagnosisExecOptions{
commandTimeout: timeout,
pingTimeoutMS: pingTimeoutMS,
timeoutMessage: diagnosisContextMessage(ctx),
}
}
func newDiagnosisTimeoutItem(workItem diagnosisWorkItem, message string) map[string]interface{} {
targetPort := workItem.targetPort
if targetPort <= 0 {
targetPort = workItem.toNode.Port
}
item := newDiagnosisResultItem(workItem.fromNodeID, workItem.targetIP, targetPort, workItem.description, workItem.metadata)
item["success"] = false
if strings.TrimSpace(message) == "" {
message = "诊断超时(2分钟)"
}
item["message"] = message
return item
}
func (h *Handler) executeDiagnosisWorkItem(workItem diagnosisWorkItem, options diagnosisExecOptions) map[string]interface{} {
single := make([]map[string]interface{}, 0, 1)
nodeCache := map[int64]*nodeRecord{}
if workItem.hasChainHop {
h.appendChainHopDiagnosis(&single, nodeCache, workItem.fromNodeID, workItem.toNode, workItem.description, workItem.metadata, workItem.ipPreference, options)
} else {
h.appendPathDiagnosis(&single, nodeCache, workItem.fromNodeID, workItem.targetIP, workItem.targetPort, workItem.description, workItem.metadata, options)
}
if len(single) == 0 {
return newDiagnosisTimeoutItem(workItem, "诊断任务未返回结果")
}
return single[0]
}
func (h *Handler) runDiagnosisWorkItems(ctx context.Context, workItems []diagnosisWorkItem, emitter diagnosisItemEmitter) []map[string]interface{} {
if ctx == nil {
ctx = context.Background()
}
results := make([]map[string]interface{}, len(workItems))
if len(workItems) == 0 {
return results
}
workerLimit := diagnosisMaxConcurrency
if workerLimit < 1 {
workerLimit = 1
}
if workerLimit > len(workItems) {
workerLimit = len(workItems)
}
type diagnosisWorkResult struct {
index int
item map[string]interface{}
}
jobs := make(chan int)
resultCh := make(chan diagnosisWorkResult, len(workItems))
var wg sync.WaitGroup
for i := 0; i < workerLimit; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for index := range jobs {
select {
case <-ctx.Done():
resultCh <- diagnosisWorkResult{index: index, item: newDiagnosisTimeoutItem(workItems[index], diagnosisContextMessage(ctx))}
continue
default:
}
options := diagnosisExecOptionsFromContext(ctx)
resultCh <- diagnosisWorkResult{index: index, item: h.executeDiagnosisWorkItem(workItems[index], options)}
}
}()
}
enqueueLoop:
for i := 0; i < len(workItems); i++ {
select {
case <-ctx.Done():
message := diagnosisContextMessage(ctx)
for j := i; j < len(workItems); j++ {
resultCh <- diagnosisWorkResult{index: j, item: newDiagnosisTimeoutItem(workItems[j], message)}
}
break enqueueLoop
case jobs <- i:
}
}
close(jobs)
wg.Wait()
close(resultCh)
progress := diagnosisProgress{Total: len(workItems)}
for result := range resultCh {
results[result.index] = result.item
progress.Completed++
if asBool(result.item["success"], false) {
progress.Success++
} else {
progress.Failed++
}
if emitter != nil {
emitter(result.index, result.item, progress)
}
}
for i := range results {
if results[i] == nil {
results[i] = newDiagnosisTimeoutItem(workItems[i], "诊断超时(2分钟)")
}
}
return results
}
func (h *Handler) cachedNode(nodeCache map[int64]*nodeRecord, nodeID int64) (*nodeRecord, error) {
if node, ok := nodeCache[nodeID]; ok {
return node, nil
@@ -669,7 +989,7 @@ func (h *Handler) appendFailedDiagnosis(results *[]map[string]interface{}, nodeC
*results = append(*results, item)
}
func (h *Handler) appendPathDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, targetIP string, targetPort int, description string, metadata map[string]interface{}) {
func (h *Handler) appendPathDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, targetIP string, targetPort int, description string, metadata map[string]interface{}, options diagnosisExecOptions) {
item := newDiagnosisResultItem(fromNodeID, targetIP, targetPort, description, metadata)
fromNode, err := h.cachedNode(nodeCache, fromNodeID)
@@ -686,9 +1006,9 @@ func (h *Handler) appendPathDiagnosis(results *[]map[string]interface{}, nodeCac
pingErr error
)
if fromNode.IsRemote == 1 {
pingData, pingErr = h.tcpPingViaRemoteNode(fromNode, targetIP, targetPort)
pingData, pingErr = h.tcpPingViaRemoteNode(fromNode, targetIP, targetPort, options)
} else {
pingData, pingErr = h.tcpPingViaNode(fromNodeID, targetIP, targetPort)
pingData, pingErr = h.tcpPingViaNode(fromNodeID, targetIP, targetPort, options)
}
if pingErr != nil {
item["success"] = false
@@ -719,7 +1039,7 @@ func (h *Handler) appendPathDiagnosis(results *[]map[string]interface{}, nodeCac
*results = append(*results, item)
}
func (h *Handler) appendChainHopDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, toNode chainNodeRecord, description string, metadata map[string]interface{}, ipPreference string) {
func (h *Handler) appendChainHopDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, toNode chainNodeRecord, description string, metadata map[string]interface{}, ipPreference string, options diagnosisExecOptions) {
fromNode, _ := h.cachedNode(nodeCache, fromNodeID)
targetNode, err := h.cachedNode(nodeCache, toNode.NodeID)
if err != nil {
@@ -731,7 +1051,7 @@ func (h *Handler) appendChainHopDiagnosis(results *[]map[string]interface{}, nod
h.appendFailedDiagnosis(results, nodeCache, fromNodeID, strings.Trim(strings.TrimSpace(targetNode.ServerIP), "[]"), toNode.Port, description, metadata, err.Error())
return
}
h.appendPathDiagnosis(results, nodeCache, fromNodeID, targetIP, targetPort, description, metadata)
h.appendPathDiagnosis(results, nodeCache, fromNodeID, targetIP, targetPort, description, metadata, options)
}
func resolveChainProbeTarget(fromNode, targetNode *nodeRecord, preferredPort int, ipPreference string) (string, int, error) {
@@ -784,13 +1104,19 @@ func (h *Handler) listChainNodesForTunnel(tunnelID int64) ([]chainNodeRecord, er
return h.repo.ListChainNodesForTunnel(tunnelID)
}
func (h *Handler) tcpPingViaNode(nodeID int64, ip string, port int) (map[string]interface{}, error) {
res, err := h.sendNodeCommand(nodeID, "TcpPing", map[string]interface{}{
func (h *Handler) tcpPingViaNode(nodeID int64, ip string, port int, options diagnosisExecOptions) (map[string]interface{}, error) {
if options.commandTimeout <= 0 {
options.commandTimeout = diagnosisCommandTimeout
}
if options.pingTimeoutMS <= 0 {
options.pingTimeoutMS = int(diagnosisCommandTimeout / time.Millisecond)
}
res, err := h.sendNodeCommandWithTimeout(nodeID, "TcpPing", map[string]interface{}{
"ip": ip,
"port": port,
"count": 4,
"timeout": 5000,
}, false, false)
"timeout": options.pingTimeoutMS,
}, options.commandTimeout, false, false)
if err != nil {
return nil, err
}
@@ -800,7 +1126,7 @@ func (h *Handler) tcpPingViaNode(nodeID int64, ip string, port int) (map[string]
return res.Data, nil
}
func (h *Handler) tcpPingViaRemoteNode(node *nodeRecord, ip string, port int) (map[string]interface{}, error) {
func (h *Handler) tcpPingViaRemoteNode(node *nodeRecord, ip string, port int, options diagnosisExecOptions) (map[string]interface{}, error) {
if node == nil {
return nil, errors.New("节点不存在")
}
@@ -809,13 +1135,19 @@ func (h *Handler) tcpPingViaRemoteNode(node *nodeRecord, ip string, port int) (m
if remoteURL == "" || remoteToken == "" {
return nil, errors.New("远程节点缺少共享配置")
}
if options.commandTimeout <= 0 {
options.commandTimeout = diagnosisCommandTimeout
}
if options.pingTimeoutMS <= 0 {
options.pingTimeoutMS = int(diagnosisCommandTimeout / time.Millisecond)
}
fc := client.NewFederationClient()
fc := client.NewFederationClientWithTimeout(options.commandTimeout)
return fc.Diagnose(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeDiagnoseRequest{
IP: strings.TrimSpace(ip),
Port: port,
Count: 4,
Timeout: 5000,
Timeout: options.pingTimeoutMS,
})
}
@@ -0,0 +1,208 @@
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"go-backend/internal/http/response"
)
type diagnosisStreamEvent struct {
Type string `json:"type"`
Data interface{} `json:"data,omitempty"`
TS int64 `json:"ts"`
}
func prepareDiagnosisStreamResponse(w http.ResponseWriter) (http.Flusher, error) {
flusher, ok := w.(http.Flusher)
if !ok {
return nil, errors.New("当前服务不支持流式响应")
}
w.Header().Set("Content-Type", "application/x-ndjson; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
return flusher, nil
}
func writeDiagnosisStreamEvent(encoder *json.Encoder, flusher http.Flusher, eventType string, data interface{}) error {
if encoder == nil || flusher == nil {
return errors.New("流式响应写入器未初始化")
}
event := diagnosisStreamEvent{Type: eventType, Data: data, TS: time.Now().UnixMilli()}
if err := encoder.Encode(event); err != nil {
return err
}
flusher.Flush()
return nil
}
func summarizeDiagnosisProgress(results []map[string]interface{}) diagnosisProgress {
progress := diagnosisProgress{Total: len(results)}
for _, item := range results {
progress.Completed++
if asBool(item["success"], false) {
progress.Success++
} else {
progress.Failed++
}
}
return progress
}
func shouldIgnoreDiagnosisStreamError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.Canceled) {
return true
}
msg := strings.ToLower(strings.TrimSpace(err.Error()))
if strings.Contains(msg, "broken pipe") || strings.Contains(msg, "connection reset by peer") {
return true
}
if strings.Contains(msg, "stream already closed") {
return true
}
return false
}
func (h *Handler) streamDiagnosisRuntime(ctx context.Context, cancel context.CancelFunc, w http.ResponseWriter, startPayload map[string]interface{}, workItems []diagnosisWorkItem) error {
flusher, err := prepareDiagnosisStreamResponse(w)
if err != nil {
return err
}
encoder := json.NewEncoder(w)
payload := map[string]interface{}{
"total": len(workItems),
"timestamp": time.Now().UnixMilli(),
}
for key, value := range startPayload {
payload[key] = value
}
if err := writeDiagnosisStreamEvent(encoder, flusher, "start", payload); err != nil {
return err
}
streamBroken := false
emitter := func(index int, item map[string]interface{}, progress diagnosisProgress) {
if streamBroken {
return
}
itemPayload := map[string]interface{}{
"index": index,
"result": item,
"progress": progress,
}
if err := writeDiagnosisStreamEvent(encoder, flusher, "item", itemPayload); err != nil {
streamBroken = true
if cancel != nil {
cancel()
}
}
}
results := h.runDiagnosisWorkItems(ctx, workItems, emitter)
if streamBroken {
return context.Canceled
}
progress := summarizeDiagnosisProgress(results)
donePayload := map[string]interface{}{
"progress": progress,
"timedOut": errors.Is(ctx.Err(), context.DeadlineExceeded),
}
return writeDiagnosisStreamEvent(encoder, flusher, "done", donePayload)
}
func (h *Handler) tunnelDiagnoseStream(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
id := asInt64FromBodyKey(r, w, "tunnelId")
if id <= 0 {
return
}
tunnelName, tunnelType, workItems, err := h.prepareTunnelDiagnosis(id)
if err != nil {
if strings.Contains(err.Error(), "不存在") || strings.Contains(err.Error(), "不完整") {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
ctx, cancel := context.WithTimeout(r.Context(), diagnosisRequestTimeout)
defer cancel()
startPayload := map[string]interface{}{
"tunnelName": tunnelName,
"tunnelType": tunnelType,
}
if err := h.streamDiagnosisRuntime(ctx, cancel, w, startPayload, workItems); err != nil {
if shouldIgnoreDiagnosisStreamError(err) {
return
}
if strings.Contains(err.Error(), "不支持流式响应") {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
return
}
}
func (h *Handler) forwardDiagnoseStream(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
id := asInt64FromBodyKey(r, w, "forwardId")
if id <= 0 {
return
}
forward, _, _, err := h.resolveForwardAccess(r, id)
if err != nil {
if errors.Is(err, errForwardNotFound) {
response.WriteJSON(w, response.ErrDefault("转发不存在"))
return
}
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
forwardName, workItems, err := h.prepareForwardDiagnosis(forward)
if err != nil {
if strings.Contains(err.Error(), "不存在") || strings.Contains(err.Error(), "不能为空") || strings.Contains(err.Error(), "错误") {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
ctx, cancel := context.WithTimeout(r.Context(), diagnosisRequestTimeout)
defer cancel()
startPayload := map[string]interface{}{
"forwardName": forwardName,
}
if err := h.streamDiagnosisRuntime(ctx, cancel, w, startPayload, workItems); err != nil {
if shouldIgnoreDiagnosisStreamError(err) {
return
}
if strings.Contains(err.Error(), "不支持流式响应") {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
return
}
}
@@ -1232,16 +1232,20 @@ func (h *Handler) federationRuntimeDiagnose(w http.ResponseWriter, r *http.Reque
if req.Count <= 0 {
req.Count = 4
}
if req.Timeout <= 0 {
req.Timeout = 5000
if req.Timeout <= 0 || req.Timeout > int(diagnosisCommandTimeout/time.Millisecond) {
req.Timeout = int(diagnosisCommandTimeout / time.Millisecond)
}
commandTimeout := time.Duration(req.Timeout) * time.Millisecond
if commandTimeout <= 0 || commandTimeout > diagnosisCommandTimeout {
commandTimeout = diagnosisCommandTimeout
}
res, err := h.sendNodeCommand(share.NodeID, "TcpPing", map[string]interface{}{
res, err := h.sendNodeCommandWithTimeout(share.NodeID, "TcpPing", map[string]interface{}{
"ip": req.IP,
"port": req.Port,
"count": req.Count,
"timeout": req.Timeout,
}, false, false)
}, commandTimeout, false, false)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
@@ -134,6 +134,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/tunnel/update", h.tunnelUpdate)
mux.HandleFunc("/api/v1/tunnel/delete", h.tunnelDelete)
mux.HandleFunc("/api/v1/tunnel/diagnose", h.tunnelDiagnose)
mux.HandleFunc("/api/v1/tunnel/diagnose/stream", h.tunnelDiagnoseStream)
mux.HandleFunc("/api/v1/tunnel/update-order", h.tunnelUpdateOrder)
mux.HandleFunc("/api/v1/tunnel/batch-delete", h.tunnelBatchDelete)
mux.HandleFunc("/api/v1/tunnel/batch-redeploy", h.tunnelBatchRedeploy)
@@ -149,6 +150,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/forward/pause", h.forwardPause)
mux.HandleFunc("/api/v1/forward/resume", h.forwardResume)
mux.HandleFunc("/api/v1/forward/diagnose", h.forwardDiagnose)
mux.HandleFunc("/api/v1/forward/diagnose/stream", h.forwardDiagnoseStream)
mux.HandleFunc("/api/v1/forward/update-order", h.forwardUpdateOrder)
mux.HandleFunc("/api/v1/forward/batch-delete", h.forwardBatchDelete)
mux.HandleFunc("/api/v1/forward/batch-pause", h.forwardBatchPause)
@@ -1,6 +1,7 @@
package handler
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
@@ -787,7 +788,9 @@ func (h *Handler) tunnelDiagnose(w http.ResponseWriter, r *http.Request) {
if id <= 0 {
return
}
result, err := h.diagnoseTunnelRuntime(id)
ctx, cancel := context.WithTimeout(r.Context(), diagnosisRequestTimeout)
defer cancel()
result, err := h.diagnoseTunnelRuntime(ctx, id)
if err != nil {
if strings.Contains(err.Error(), "不存在") || strings.Contains(err.Error(), "不完整") {
response.WriteJSON(w, response.ErrDefault(err.Error()))
@@ -1399,7 +1402,9 @@ func (h *Handler) forwardDiagnose(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
payload, err := h.diagnoseForwardRuntime(forward)
ctx, cancel := context.WithTimeout(r.Context(), diagnosisRequestTimeout)
defer cancel()
payload, err := h.diagnoseForwardRuntime(ctx, forward)
if err != nil {
if strings.Contains(err.Error(), "不存在") || strings.Contains(err.Error(), "不能为空") || strings.Contains(err.Error(), "错误") {
response.WriteJSON(w, response.ErrDefault(err.Error()))
+30
View File
@@ -55,11 +55,41 @@ http {
}
# API代理到后端
location = /api/v1/tunnel/diagnose/stream {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_pass http://backend:6365/api/v1/tunnel/diagnose/stream;
}
location = /api/v1/forward/diagnose/stream {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_pass http://backend:6365/api/v1/forward/diagnose/stream;
}
location ^~ /api/v1/ {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_pass http://backend:6365/api/v1/;
}
+3
View File
@@ -80,5 +80,8 @@
"vite": "npm:rolldown-vite@^7.3.1",
"vite-plugin-pwa": "^1.1.0",
"vite-tsconfig-paths": "^6.0.5"
},
"overrides": {
"serialize-javascript": "7.0.3"
}
}
+343
View File
@@ -0,0 +1,343 @@
import axios from "axios";
import type { TunnelDiagnosisApiItem } from "@/api/types";
import { clearSession, getToken } from "@/utils/session";
const DIAGNOSIS_STREAM_TIMEOUT_MS = 2 * 60 * 1000;
type RawObject = Record<string, unknown>;
interface DiagnosisStreamRawEvent {
type?: string;
data?: unknown;
ts?: number;
}
export interface DiagnosisStreamProgress {
total: number;
completed: number;
success: number;
failed: number;
timedOut?: boolean;
}
export interface DiagnosisStreamItemPayload {
index: number;
result: TunnelDiagnosisApiItem;
progress: DiagnosisStreamProgress;
}
export interface DiagnosisStreamRunResult {
fallback: boolean;
completed: boolean;
timedOut: boolean;
receivedItems: number;
}
interface DiagnosisStreamCallbacks {
onStart?: (payload: RawObject) => void;
onItem: (payload: DiagnosisStreamItemPayload) => void;
onDone?: (payload: DiagnosisStreamProgress) => void;
onError?: (message: string) => void;
}
interface RunDiagnosisStreamOptions extends DiagnosisStreamCallbacks {
path: string;
body: RawObject;
signal?: AbortSignal;
}
const normalizeProgress = (
payload: unknown,
fallback: DiagnosisStreamProgress,
): DiagnosisStreamProgress => {
if (!payload || typeof payload !== "object") {
return fallback;
}
const candidate = payload as RawObject;
const total = Number(candidate.total);
const completed = Number(candidate.completed);
const success = Number(candidate.success);
const failed = Number(candidate.failed);
return {
total: Number.isFinite(total) && total >= 0 ? total : fallback.total,
completed:
Number.isFinite(completed) && completed >= 0
? completed
: fallback.completed,
success:
Number.isFinite(success) && success >= 0 ? success : fallback.success,
failed: Number.isFinite(failed) && failed >= 0 ? failed : fallback.failed,
timedOut:
typeof candidate.timedOut === "boolean"
? candidate.timedOut
: fallback.timedOut,
};
};
const resolveApiPath = (path: string): string => {
const normalizedPath = path.replace(/^\//, "");
const baseURL = axios.defaults.baseURL || "/api/v1/";
const normalizedBase = baseURL.endsWith("/") ? baseURL : `${baseURL}/`;
return `${normalizedBase}${normalizedPath}`;
};
const isStreamSupported = (): boolean => {
return (
typeof window !== "undefined" &&
typeof fetch === "function" &&
typeof TextDecoder !== "undefined"
);
};
const handleTokenExpired = () => {
clearSession();
if (window.location.pathname !== "/") {
window.location.href = "/";
}
};
const combineAbortSignals = (signals: AbortSignal[]): AbortSignal => {
const controller = new AbortController();
const onAbort = () => {
if (!controller.signal.aborted) {
controller.abort();
}
};
signals.forEach((signal) => {
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener("abort", onAbort, { once: true });
});
return controller.signal;
};
const parseMessage = (err: unknown, fallback: string): string => {
if (err instanceof Error && err.message) {
return err.message;
}
return fallback;
};
const runDiagnosisStream = async ({
path,
body,
signal,
onStart,
onItem,
onDone,
onError,
}: RunDiagnosisStreamOptions): Promise<DiagnosisStreamRunResult> => {
if (!isStreamSupported()) {
return { fallback: true, completed: false, timedOut: false, receivedItems: 0 };
}
let receivedItems = 0;
let completed = false;
let timedOut = false;
let currentProgress: DiagnosisStreamProgress = {
total: 0,
completed: 0,
success: 0,
failed: 0,
};
const timeoutController = new AbortController();
const timeoutId = window.setTimeout(() => {
timedOut = true;
timeoutController.abort();
}, DIAGNOSIS_STREAM_TIMEOUT_MS);
const mergedSignal = signal
? combineAbortSignals([timeoutController.signal, signal])
: timeoutController.signal;
try {
const response = await fetch(resolveApiPath(path), {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/x-ndjson, application/json",
Authorization: getToken() || "",
},
body: JSON.stringify(body),
signal: mergedSignal,
});
if (response.status === 401) {
handleTokenExpired();
return { fallback: false, completed: false, timedOut: false, receivedItems };
}
if (response.status === 404) {
return { fallback: true, completed: false, timedOut: false, receivedItems };
}
if (!response.ok || !response.body) {
const fallbackMessage = `请求失败(${response.status})`;
let message = fallbackMessage;
try {
const data = (await response.json()) as RawObject;
if (typeof data.msg === "string" && data.msg.trim()) {
message = data.msg;
}
} catch {}
if (receivedItems === 0) {
return { fallback: true, completed: false, timedOut: false, receivedItems };
}
onError?.(message);
return { fallback: false, completed: false, timedOut: false, receivedItems };
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const processLine = (line: string) => {
if (!line) {
return;
}
let parsed: DiagnosisStreamRawEvent;
try {
parsed = JSON.parse(line) as DiagnosisStreamRawEvent;
} catch {
return;
}
const eventType = (parsed.type || "").toLowerCase();
if (eventType === "start") {
if (parsed.data && typeof parsed.data === "object") {
const startData = parsed.data as RawObject;
const startTotal = Number(startData.total);
if (Number.isFinite(startTotal) && startTotal >= 0) {
currentProgress = { ...currentProgress, total: startTotal };
}
onStart?.(startData);
}
return;
}
if (eventType === "item") {
if (!parsed.data || typeof parsed.data !== "object") {
return;
}
const itemData = parsed.data as RawObject;
const index = Number(itemData.index);
const result = itemData.result as TunnelDiagnosisApiItem | undefined;
if (!Number.isFinite(index) || !result || typeof result !== "object") {
return;
}
const progress = normalizeProgress(itemData.progress, currentProgress);
currentProgress = progress;
receivedItems += 1;
onItem({
index,
result,
progress,
});
return;
}
if (eventType === "done") {
completed = true;
const donePayload =
parsed.data && typeof parsed.data === "object"
? (parsed.data as RawObject)
: {};
const doneProgress = normalizeProgress(
donePayload.progress ?? donePayload,
currentProgress,
);
if (typeof donePayload.timedOut === "boolean") {
doneProgress.timedOut = donePayload.timedOut;
timedOut = donePayload.timedOut;
}
currentProgress = doneProgress;
onDone?.(doneProgress);
}
};
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
lines.forEach((line) => processLine(line.trim()));
}
const tail = buffer.trim();
if (tail) {
processLine(tail);
}
if (!completed && timedOut) {
const timeoutProgress = {
...currentProgress,
timedOut: true,
};
onDone?.(timeoutProgress);
}
return {
fallback: false,
completed,
timedOut,
receivedItems,
};
} catch (error) {
if (timedOut) {
const timeoutProgress = {
...currentProgress,
timedOut: true,
};
onDone?.(timeoutProgress);
return { fallback: false, completed: false, timedOut: true, receivedItems };
}
if (signal?.aborted) {
return { fallback: false, completed: false, timedOut: false, receivedItems };
}
if (receivedItems === 0) {
return { fallback: true, completed: false, timedOut: false, receivedItems };
}
onError?.(parseMessage(error, "流式诊断中断"));
return { fallback: false, completed: false, timedOut: false, receivedItems };
} finally {
clearTimeout(timeoutId);
}
};
export const diagnoseTunnelStream = (
tunnelId: number,
callbacks: DiagnosisStreamCallbacks,
signal?: AbortSignal,
) => {
return runDiagnosisStream({
path: "/tunnel/diagnose/stream",
body: { tunnelId },
signal,
...callbacks,
});
};
export const diagnoseForwardStream = (
forwardId: number,
callbacks: DiagnosisStreamCallbacks,
signal?: AbortSignal,
) => {
return runDiagnosisStream({
path: "/forward/diagnose/stream",
body: { forwardId },
signal,
...callbacks,
});
};
+10 -2
View File
@@ -118,7 +118,11 @@ export const updateTunnel = (data: TunnelMutationPayload) =>
export const deleteTunnel = (id: number) =>
Network.post("/tunnel/delete", { id });
export const diagnoseTunnel = (tunnelId: number) =>
Network.post<TunnelDiagnosisApiData>("/tunnel/diagnose", { tunnelId });
Network.post<TunnelDiagnosisApiData>(
"/tunnel/diagnose",
{ tunnelId },
{ timeout: 120 * 1000 },
);
export const updateTunnelOrder = (data: {
tunnels: Array<{ id: number; inx: number }>;
}) => Network.post("/tunnel/update-order", data);
@@ -159,7 +163,11 @@ export const resumeForwardService = (forwardId: number) =>
// 转发诊断操作
export const diagnoseForward = (forwardId: number) =>
Network.post<ForwardDiagnosisApiData>("/forward/diagnose", { forwardId });
Network.post<ForwardDiagnosisApiData>(
"/forward/diagnose",
{ forwardId },
{ timeout: 120 * 1000 },
);
// 转发排序操作
export const updateForwardOrder = (data: {
+624 -131
View File
@@ -1,6 +1,6 @@
import type { SpeedLimitApiItem } from "@/api/types";
import { useState, useEffect, useMemo } from "react";
import { useState, useEffect, useMemo, useRef } from "react";
import toast from "react-hot-toast";
import {
DndContext,
@@ -76,6 +76,7 @@ import {
getForwardDiagnosisQualityDisplay,
type ForwardDiagnosisResult,
} from "@/pages/forward/diagnosis";
import { diagnoseForwardStream } from "@/api/diagnosis-stream";
import {
executeForwardBatchChangeTunnel,
executeForwardBatchDelete,
@@ -130,6 +131,57 @@ interface ForwardForm {
speedId: number | null;
}
interface ForwardUserGroup {
userId: number;
userName: string;
tunnels: ForwardTunnelGroup[];
}
interface ForwardTunnelGroup {
tunnelKey: string;
tunnelName: string;
items: Forward[];
}
const UNKNOWN_FORWARD_USER_NAME = "未知用户";
const UNCATEGORIZED_FORWARD_TUNNEL_NAME = "未分类";
const normalizeForwardUserName = (userName?: string): string => {
const normalized = (userName || UNKNOWN_FORWARD_USER_NAME).trim();
return normalized || UNKNOWN_FORWARD_USER_NAME;
};
const compareForwardUserNameAsc = (a: string, b: string): number => {
return a.localeCompare(b, "en", {
sensitivity: "base",
numeric: true,
});
};
const normalizeForwardTunnelName = (tunnelName?: string): string => {
const normalized = (tunnelName || "").trim();
return normalized || UNCATEGORIZED_FORWARD_TUNNEL_NAME;
};
const buildForwardTunnelGroupKey = (tunnelName?: string): string => {
const normalized = normalizeForwardTunnelName(tunnelName);
if (normalized === UNCATEGORIZED_FORWARD_TUNNEL_NAME) {
return "__uncategorized__";
}
return normalized.toLocaleLowerCase();
};
const compareForwardTunnelNameAsc = (a: string, b: string): number => {
return a.localeCompare(b, "en", {
sensitivity: "base",
numeric: true,
});
};
export default function ForwardPage() {
const [loading, setLoading] = useState(true);
const [forwards, setForwards] = useState<Forward[]>([]);
@@ -177,6 +229,14 @@ export default function ForwardPage() {
useState<Forward | null>(null);
const [diagnosisResult, setDiagnosisResult] =
useState<ForwardDiagnosisResult | null>(null);
const [diagnosisProgress, setDiagnosisProgress] = useState({
total: 0,
completed: 0,
success: 0,
failed: 0,
timedOut: false,
});
const diagnosisAbortRef = useRef<AbortController | null>(null);
const [addressModalTitle, setAddressModalTitle] = useState("");
const [addressList, setAddressList] = useState<ForwardAddressItem[]>([]);
@@ -229,6 +289,13 @@ export default function ForwardPage() {
);
const [batchLoading, setBatchLoading] = useState(false);
useEffect(() => {
return () => {
diagnosisAbortRef.current?.abort();
diagnosisAbortRef.current = null;
};
}, []);
const parseShareIdFromTunnelName = (tunnelName: string): number | null => {
const normalized = (tunnelName || "").trim();
@@ -779,28 +846,148 @@ export default function ForwardPage() {
// 诊断转发
const handleDiagnose = async (forward: Forward) => {
diagnosisAbortRef.current?.abort();
const abortController = new AbortController();
diagnosisAbortRef.current = abortController;
setCurrentDiagnosisForward(forward);
setDiagnosisModalOpen(true);
setDiagnosisLoading(true);
setDiagnosisResult(null);
setDiagnosisProgress({
total: 0,
completed: 0,
success: 0,
failed: 0,
timedOut: false,
});
setDiagnosisResult({
forwardName: forward.name,
timestamp: Date.now(),
results: [],
});
try {
const response = await diagnoseForward(forward.id);
let streamErrorMessage = "";
const streamResult = await diagnoseForwardStream(
forward.id,
{
onStart: (payload) => {
const startForwardName =
typeof payload.forwardName === "string" &&
payload.forwardName.trim() !== ""
? payload.forwardName
: forward.name;
const startTotal = Number(payload.total);
setDiagnosisResult((prev) => ({
forwardName: startForwardName,
timestamp: Date.now(),
results: prev?.results || [],
}));
if (Number.isFinite(startTotal) && startTotal >= 0) {
setDiagnosisProgress((prev) => ({
...prev,
total: startTotal,
}));
}
},
onItem: ({ result, progress }) => {
setDiagnosisResult((prev) => {
const base: ForwardDiagnosisResult = prev || {
forwardName: forward.name,
timestamp: Date.now(),
results: [],
};
const nextResults = [...base.results];
const existingIndex = nextResults.findIndex(
(item) =>
item.description === result.description &&
item.nodeId === result.nodeId &&
item.targetIp === result.targetIp &&
item.targetPort === result.targetPort,
);
if (response.code === 0) {
setDiagnosisResult(response.data as ForwardDiagnosisResult);
} else {
toast.error(response.msg || "诊断失败");
setDiagnosisResult(
buildForwardDiagnosisFallbackResult({
forwardName: forward.name,
remoteAddr: forward.remoteAddr,
description: "诊断失败",
message: response.msg || "诊断过程中发生错误",
}),
);
if (existingIndex >= 0) {
nextResults[existingIndex] = result;
} else {
nextResults.push(result);
}
return {
...base,
timestamp: Date.now(),
results: nextResults,
};
});
setDiagnosisProgress({
total: progress.total,
completed: progress.completed,
success: progress.success,
failed: progress.failed,
timedOut: Boolean(progress.timedOut),
});
},
onDone: (progress) => {
setDiagnosisProgress({
total: progress.total,
completed: progress.completed,
success: progress.success,
failed: progress.failed,
timedOut: Boolean(progress.timedOut),
});
},
onError: (message) => {
streamErrorMessage = message;
},
},
abortController.signal,
);
if (streamResult.fallback) {
const response = await diagnoseForward(forward.id);
if (response.code === 0) {
const resultData = response.data as ForwardDiagnosisResult;
const successCount = resultData.results.filter((r) => r.success).length;
const failedCount = resultData.results.length - successCount;
setDiagnosisResult(resultData);
setDiagnosisProgress({
total: resultData.results.length,
completed: resultData.results.length,
success: successCount,
failed: failedCount,
timedOut: false,
});
} else {
toast.error(response.msg || "诊断失败");
setDiagnosisResult(
buildForwardDiagnosisFallbackResult({
forwardName: forward.name,
remoteAddr: forward.remoteAddr,
description: "诊断失败",
message: response.msg || "诊断过程中发生错误",
}),
);
setDiagnosisProgress({
total: 1,
completed: 1,
success: 0,
failed: 1,
timedOut: false,
});
}
return;
}
if (streamErrorMessage) {
toast.error(streamErrorMessage);
}
if (streamResult.timedOut) {
toast.error("诊断达到2分钟超时,已返回当前结果");
}
} catch {
if (abortController.signal.aborted) {
return;
}
toast.error("网络错误,请重试");
setDiagnosisResult(
buildForwardDiagnosisFallbackResult({
@@ -810,7 +997,17 @@ export default function ForwardPage() {
message: "无法连接到服务器",
}),
);
setDiagnosisProgress({
total: 1,
completed: 1,
success: 0,
failed: 1,
timedOut: false,
});
} finally {
if (diagnosisAbortRef.current === abortController) {
diagnosisAbortRef.current = null;
}
setDiagnosisLoading(false);
}
};
@@ -1145,6 +1342,20 @@ export default function ForwardPage() {
// 检查 ID 是否有效
if (isNaN(activeId) || isNaN(overId)) return;
const activeForward = forwards.find((forward) => forward.id === activeId);
const overForward = forwards.find((forward) => forward.id === overId);
const activeUserId = activeForward?.userId ?? 0;
const overUserId = overForward?.userId ?? 0;
const activeTunnelGroupKey = buildForwardTunnelGroupKey(
activeForward?.tunnelName,
);
const overTunnelGroupKey = buildForwardTunnelGroupKey(overForward?.tunnelName);
// 仅允许在同一用户+隧道分组内拖拽,避免混排
if (activeUserId !== overUserId || activeTunnelGroupKey !== overTunnelGroupKey) {
return;
}
const oldIndex = forwardOrder.indexOf(activeId);
const newIndex = forwardOrder.indexOf(overId);
@@ -1342,9 +1553,11 @@ export default function ForwardPage() {
);
const tokenUserId = JwtUtil.getUserIdFromToken();
const tokenRoleId = JwtUtil.getRoleIdFromToken();
const isAdmin = tokenRoleId === 0;
// 根据排序顺序获取转发列表
const sortedForwards = useMemo((): Forward[] => {
const orderedForwards = useMemo((): Forward[] => {
// 确保 forwards 数组存在且有效
if (!forwards || forwards.length === 0) {
return [];
@@ -1426,16 +1639,125 @@ export default function ForwardPage() {
}, [
forwards,
forwardOrder,
viewMode,
tokenUserId,
filterUserId,
filterTunnelId,
searchKeyword,
]);
const sortableForwardIds = useMemo(
() => sortedForwards.map((f) => f.id).filter((id) => id > 0),
[sortedForwards],
const groupedForwards = useMemo((): ForwardUserGroup[] => {
if (orderedForwards.length === 0) {
return [];
}
type MutableForwardUserGroup = {
userId: number;
userName: string;
tunnelMap: Map<string, ForwardTunnelGroup>;
};
const userGroupMap = new Map<number, MutableForwardUserGroup>();
orderedForwards.forEach((forward) => {
const userId = forward.userId ?? 0;
const userName = normalizeForwardUserName(forward.userName);
const tunnelName = normalizeForwardTunnelName(forward.tunnelName);
const tunnelKey = buildForwardTunnelGroupKey(forward.tunnelName);
let existingGroup = userGroupMap.get(userId);
if (!existingGroup) {
existingGroup = {
userId,
userName,
tunnelMap: new Map<string, ForwardTunnelGroup>(),
};
userGroupMap.set(userId, existingGroup);
} else if (
existingGroup.userName === UNKNOWN_FORWARD_USER_NAME &&
userName !== UNKNOWN_FORWARD_USER_NAME
) {
existingGroup.userName = userName;
}
const existingTunnelGroup = existingGroup.tunnelMap.get(tunnelKey);
if (!existingTunnelGroup) {
existingGroup.tunnelMap.set(tunnelKey, {
tunnelKey,
tunnelName,
items: [forward],
});
return;
}
existingTunnelGroup.items.push(forward);
if (
existingTunnelGroup.tunnelName === UNCATEGORIZED_FORWARD_TUNNEL_NAME &&
tunnelName !== UNCATEGORIZED_FORWARD_TUNNEL_NAME
) {
existingTunnelGroup.tunnelName = tunnelName;
}
});
const groups = Array.from(userGroupMap.values()).map((group) => {
const tunnels = Array.from(group.tunnelMap.values());
tunnels.sort((a, b) => {
const aIsUncategorized =
a.tunnelName === UNCATEGORIZED_FORWARD_TUNNEL_NAME;
const bIsUncategorized =
b.tunnelName === UNCATEGORIZED_FORWARD_TUNNEL_NAME;
if (aIsUncategorized !== bIsUncategorized) {
return aIsUncategorized ? 1 : -1;
}
const nameCompare = compareForwardTunnelNameAsc(a.tunnelName, b.tunnelName);
if (nameCompare !== 0) {
return nameCompare;
}
return compareForwardTunnelNameAsc(a.tunnelKey, b.tunnelKey);
});
return {
userId: group.userId,
userName: group.userName,
tunnels,
};
});
groups.sort((a, b) => {
if (isAdmin && tokenUserId !== null) {
const aIsSelf = a.userId === tokenUserId;
const bIsSelf = b.userId === tokenUserId;
if (aIsSelf !== bIsSelf) {
return aIsSelf ? -1 : 1;
}
}
const nameCompare = compareForwardUserNameAsc(a.userName, b.userName);
if (nameCompare !== 0) {
return nameCompare;
}
return a.userId - b.userId;
});
return groups;
}, [orderedForwards, isAdmin, tokenUserId]);
const sortedForwards = useMemo(
() =>
groupedForwards.flatMap((group) =>
group.tunnels.flatMap((tunnel) => tunnel.items),
),
[groupedForwards],
);
// 可拖拽的转发卡片组件
@@ -1474,15 +1796,46 @@ export default function ForwardPage() {
const userMap = new Map<number, { id: number; name: string }>();
forwards.forEach((f) => {
const uId = f.userId || 0;
const uId = f.userId ?? 0;
const userName = normalizeForwardUserName(f.userName);
const existingUser = userMap.get(uId);
if (!userMap.has(uId)) {
userMap.set(uId, { id: uId, name: f.userName || "未知用户" });
if (!existingUser) {
userMap.set(uId, { id: uId, name: userName });
return;
}
if (
existingUser.name === UNKNOWN_FORWARD_USER_NAME &&
userName !== UNKNOWN_FORWARD_USER_NAME
) {
existingUser.name = userName;
}
});
return Array.from(userMap.values());
}, [forwards]);
const users = Array.from(userMap.values());
users.sort((a, b) => {
if (isAdmin && tokenUserId !== null) {
const aIsSelf = a.id === tokenUserId;
const bIsSelf = b.id === tokenUserId;
if (aIsSelf !== bIsSelf) {
return aIsSelf ? -1 : 1;
}
}
const nameCompare = compareForwardUserNameAsc(a.name, b.name);
if (nameCompare !== 0) {
return nameCompare;
}
return a.id - b.id;
});
return users;
}, [forwards, isAdmin, tokenUserId]);
// 可拖拽的表格行组件
const SortableTableRow = ({
@@ -1552,25 +1905,9 @@ export default function ForwardPage() {
</svg>
</div>
</TableCell>
<TableCell className="whitespace-nowrap">
<span className="text-sm font-medium text-default-700">
{forward.userName || "未知用户"}
</span>
</TableCell>
<TableCell className="whitespace-nowrap font-semibold text-foreground">
{forward.name}
</TableCell>
<TableCell className="whitespace-nowrap">
<Chip
className="border-none bg-secondary/10 px-2"
color="secondary"
size="sm"
>
<span className="font-medium text-secondary-700">
{forward.tunnelName}
</span>
</Chip>
</TableCell>
<TableCell className="max-w-[220px]">
<button
className={`w-full truncate rounded-md bg-default-100/50 px-2.5 py-1.5 text-left font-mono text-xs font-medium text-default-700 transition-all ${
@@ -1724,7 +2061,7 @@ export default function ForwardPage() {
{forward.name}
</h3>
<p className="text-xs text-default-500 truncate">
{forward.tunnelName}
{normalizeForwardTunnelName(forward.tunnelName)}
</p>
</div>
<div className="flex items-center gap-1.5 ml-2">
@@ -2207,63 +2544,120 @@ export default function ForwardPage() {
{/* 根据显示模式渲染不同内容 */}
{viewMode === "grouped" ? (
sortedForwards.length > 0 ? (
<div className="overflow-hidden rounded-xl border border-divider bg-content1 shadow-md">
<DndContext
collisionDetection={closestCenter}
sensors={sensors}
onDragEnd={handleDragEnd}
>
<Table
aria-label="全部转发列表"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
}}
>
<TableHeader>
{selectMode && (
<TableColumn className="w-14">选择</TableColumn>
)}
<TableColumn className="w-10 pl-4" />
<TableColumn>用户</TableColumn>
<TableColumn>名称</TableColumn>
<TableColumn>隧道</TableColumn>
<TableColumn>入口</TableColumn>
<TableColumn>目标</TableColumn>
<TableColumn>策略</TableColumn>
<TableColumn>总流量</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn className="text-right">操作</TableColumn>
</TableHeader>
<TableBody emptyContent="暂无转发配置" items={sortedForwards}>
{(forward) => (
<SortableContext
key={forward.id}
items={sortableForwardIds}
strategy={verticalListSortingStrategy}
>
<SortableTableRow
formatFlow={formatFlow}
formatInAddress={formatInAddress}
formatRemoteAddress={formatRemoteAddress}
forward={forward}
getStrategyDisplay={getStrategyDisplay}
handleDelete={handleDelete}
handleDiagnose={handleDiagnose}
handleEdit={handleEdit}
handleServiceToggle={handleServiceToggle}
hasMultipleAddresses={hasMultipleAddresses}
selectMode={selectMode}
selectedIds={selectedIds}
showAddressModal={showAddressModal}
toggleSelect={toggleSelect}
/>
</SortableContext>
)}
</TableBody>
</Table>
</DndContext>
<div className="space-y-4">
{groupedForwards.map((group) => {
const isSelfGroup =
isAdmin && tokenUserId !== null && group.userId === tokenUserId;
const groupForwardCount = group.tunnels.reduce(
(total, tunnel) => total + tunnel.items.length,
0,
);
return (
<div
key={`grouped-table-${group.userId}-${group.userName}`}
className="overflow-hidden rounded-xl border border-divider bg-content1 shadow-md"
>
<div className="flex items-center justify-between border-b border-divider bg-default-100/40 px-4 py-3">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground">
{group.userName}
</span>
{isSelfGroup && (
<Chip color="primary" size="sm" variant="flat">
管理员本人
</Chip>
)}
</div>
<span className="text-xs text-default-600">
{groupForwardCount} 条转发
</span>
</div>
<div className="space-y-4 p-4">
{group.tunnels.map((tunnel) => {
const tunnelSortableForwardIds = tunnel.items
.map((item) => item.id)
.filter((id) => id > 0);
return (
<div
key={`grouped-table-${group.userId}-${tunnel.tunnelKey}`}
className="overflow-hidden rounded-lg border border-secondary/20 bg-secondary/5"
>
<div className="flex items-center justify-between border-b border-secondary/20 bg-secondary/10 px-4 py-2.5">
<span className="text-sm font-semibold text-secondary-700">
{tunnel.tunnelName}
</span>
<span className="text-xs text-secondary-700">
{tunnel.items.length} 条转发
</span>
</div>
<DndContext
collisionDetection={closestCenter}
sensors={sensors}
onDragEnd={handleDragEnd}
>
<Table
aria-label={`${group.userName}-${tunnel.tunnelName}转发列表`}
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
}}
>
<TableHeader>
{selectMode && (
<TableColumn className="w-14">选择</TableColumn>
)}
<TableColumn className="w-10 pl-4" />
<TableColumn>名称</TableColumn>
<TableColumn>入口</TableColumn>
<TableColumn>目标</TableColumn>
<TableColumn>策略</TableColumn>
<TableColumn>总流量</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn className="text-right">操作</TableColumn>
</TableHeader>
<TableBody
emptyContent="暂无转发配置"
items={tunnel.items}
>
{(forward) => (
<SortableContext
key={forward.id}
items={tunnelSortableForwardIds}
strategy={verticalListSortingStrategy}
>
<SortableTableRow
formatFlow={formatFlow}
formatInAddress={formatInAddress}
formatRemoteAddress={formatRemoteAddress}
forward={forward}
getStrategyDisplay={getStrategyDisplay}
handleDelete={handleDelete}
handleDiagnose={handleDiagnose}
handleEdit={handleEdit}
handleServiceToggle={handleServiceToggle}
hasMultipleAddresses={hasMultipleAddresses}
selectMode={selectMode}
selectedIds={selectedIds}
showAddressModal={showAddressModal}
toggleSelect={toggleSelect}
/>
</SortableContext>
)}
</TableBody>
</Table>
</DndContext>
</div>
);
})}
</div>
</div>
);
})}
</div>
) : (
/* 空状态 */
@@ -2279,26 +2673,87 @@ export default function ForwardPage() {
</Card>
)
) : /* 直接显示模式 */
forwards.length > 0 ? (
<DndContext
collisionDetection={closestCenter}
sensors={sensors}
onDragEnd={handleDragEnd}
onDragStart={() => {}} // 添加空的 onDragStart 处理器
>
<SortableContext
items={sortableForwardIds}
strategy={rectSortingStrategy}
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{sortedForwards.map((forward) =>
forward && forward.id ? (
<SortableForwardCard key={forward.id} forward={forward} />
) : null,
)}
</div>
</SortableContext>
</DndContext>
sortedForwards.length > 0 ? (
<div className="space-y-5">
{groupedForwards.map((group) => {
const isSelfGroup =
isAdmin && tokenUserId !== null && group.userId === tokenUserId;
const groupForwardCount = group.tunnels.reduce(
(total, tunnel) => total + tunnel.items.length,
0,
);
return (
<div
key={`direct-group-${group.userId}-${group.userName}`}
className="space-y-3"
>
<div className="flex items-center justify-between px-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground">
{group.userName}
</span>
{isSelfGroup && (
<Chip color="primary" size="sm" variant="flat">
管理员本人
</Chip>
)}
</div>
<span className="text-xs text-default-600">
{groupForwardCount} 条转发
</span>
</div>
<div className="space-y-4">
{group.tunnels.map((tunnel) => {
const tunnelSortableForwardIds = tunnel.items
.map((item) => item.id)
.filter((id) => id > 0);
return (
<div
key={`direct-group-${group.userId}-${tunnel.tunnelKey}`}
className="rounded-xl border border-secondary/20 bg-secondary/5 p-3 space-y-3"
>
<div className="flex items-center justify-between rounded-lg bg-secondary/10 px-3 py-2">
<span className="text-sm font-semibold text-secondary-700">
{tunnel.tunnelName}
</span>
<span className="text-xs text-secondary-700">
{tunnel.items.length} 条转发
</span>
</div>
<DndContext
collisionDetection={closestCenter}
sensors={sensors}
onDragEnd={handleDragEnd}
onDragStart={() => {}} // 添加空的 onDragStart 处理器
>
<SortableContext
items={tunnelSortableForwardIds}
strategy={rectSortingStrategy}
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{tunnel.items.map((forward) =>
forward && forward.id ? (
<SortableForwardCard
key={forward.id}
forward={forward}
/>
) : null,
)}
</div>
</SortableContext>
</DndContext>
</div>
);
})}
</div>
</div>
);
})}
</div>
) : (
/* 空状态 */
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
@@ -2909,7 +3364,14 @@ export default function ForwardPage() {
placement="center"
scrollBehavior="inside"
size="4xl"
onOpenChange={setDiagnosisModalOpen}
onOpenChange={(open) => {
setDiagnosisModalOpen(open);
if (!open) {
diagnosisAbortRef.current?.abort();
diagnosisAbortRef.current = null;
setDiagnosisLoading(false);
}
}}
>
<ModalContent>
{(onClose) => (
@@ -2933,7 +3395,8 @@ export default function ForwardPage() {
)}
</ModalHeader>
<ModalBody className="bg-content1">
{diagnosisLoading ? (
{diagnosisLoading &&
(!diagnosisResult || diagnosisResult.results.length === 0) ? (
<div className="flex items-center justify-center py-16">
<div className="flex items-center gap-3">
<Spinner size="sm" />
@@ -2942,11 +3405,39 @@ export default function ForwardPage() {
</div>
) : diagnosisResult ? (
<div className="space-y-4">
{diagnosisLoading && (
<div className="flex items-center justify-between rounded-lg border border-primary/20 bg-primary/5 px-3 py-2">
<div className="flex items-center gap-2 text-sm text-primary">
<Spinner size="sm" />
<span>
正在诊断 {diagnosisProgress.completed}/
{diagnosisProgress.total > 0
? diagnosisProgress.total
: "?"}
</span>
</div>
<Chip color="primary" size="sm" variant="flat">
流式更新中
</Chip>
</div>
)}
{diagnosisProgress.timedOut && (
<Alert
color="warning"
description="诊断已达到2分钟超时,以下为当前已完成结果。"
title="诊断超时"
variant="flat"
/>
)}
{/* 统计摘要 */}
<div className="grid grid-cols-3 gap-3">
<div className="text-center p-3 bg-default-100 dark:bg-gray-800 rounded-lg border border-divider">
<div className="text-2xl font-bold text-foreground">
{diagnosisResult.results.length}
{diagnosisProgress.total > 0
? diagnosisProgress.total
: diagnosisResult.results.length}
</div>
<div className="text-xs text-default-500 mt-1">
总测试数
@@ -2954,10 +3445,11 @@ export default function ForwardPage() {
</div>
<div className="text-center p-3 bg-success-50 dark:bg-success-900/20 rounded-lg border border-success-200 dark:border-success-700">
<div className="text-2xl font-bold text-success-600 dark:text-success-400">
{
diagnosisResult.results.filter((r) => r.success)
.length
}
{diagnosisProgress.completed > 0 ||
diagnosisProgress.total > 0
? diagnosisProgress.success
: diagnosisResult.results.filter((r) => r.success)
.length}
</div>
<div className="text-xs text-success-600 dark:text-success-400/80 mt-1">
成功
@@ -2965,10 +3457,11 @@ export default function ForwardPage() {
</div>
<div className="text-center p-3 bg-danger-50 dark:bg-danger-900/20 rounded-lg border border-danger-200 dark:border-danger-700">
<div className="text-2xl font-bold text-danger-600 dark:text-danger-400">
{
diagnosisResult.results.filter((r) => !r.success)
.length
}
{diagnosisProgress.completed > 0 ||
diagnosisProgress.total > 0
? diagnosisProgress.failed
: diagnosisResult.results.filter((r) => !r.success)
.length}
</div>
<div className="text-xs text-danger-600 dark:text-danger-400/80 mt-1">
失败
+220 -26
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from "react";
import { useState, useEffect, useMemo, useRef } from "react";
import toast from "react-hot-toast";
import {
DndContext,
@@ -53,6 +53,7 @@ import {
getDiagnosisQualityDisplay,
type DiagnosisResult,
} from "@/pages/tunnel/diagnosis";
import { diagnoseTunnelStream } from "@/api/diagnosis-stream";
import {
createTunnelFormDefaults,
getTunnelFlowDisplay,
@@ -133,6 +134,14 @@ export default function TunnelPage() {
useState<Tunnel | null>(null);
const [diagnosisResult, setDiagnosisResult] =
useState<DiagnosisResult | null>(null);
const [diagnosisProgress, setDiagnosisProgress] = useState({
total: 0,
completed: 0,
success: 0,
failed: 0,
timedOut: false,
});
const diagnosisAbortRef = useRef<AbortController | null>(null);
// 表单状态
const [form, setForm] = useState<TunnelForm>(createTunnelFormDefaults());
@@ -146,6 +155,13 @@ export default function TunnelPage() {
const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);
const [batchLoading, setBatchLoading] = useState(false);
useEffect(() => {
return () => {
diagnosisAbortRef.current?.abort();
diagnosisAbortRef.current = null;
};
}, []);
useEffect(() => {
loadData();
}, []);
@@ -434,28 +450,158 @@ export default function TunnelPage() {
// 诊断隧道
const handleDiagnose = async (tunnel: Tunnel) => {
diagnosisAbortRef.current?.abort();
const abortController = new AbortController();
diagnosisAbortRef.current = abortController;
setCurrentDiagnosisTunnel(tunnel);
setDiagnosisModalOpen(true);
setDiagnosisLoading(true);
setDiagnosisResult(null);
setDiagnosisProgress({
total: 0,
completed: 0,
success: 0,
failed: 0,
timedOut: false,
});
setDiagnosisResult({
tunnelName: tunnel.name,
tunnelType: tunnel.type === 1 ? "端口转发" : "隧道转发",
timestamp: Date.now(),
results: [],
});
try {
const response = await diagnoseTunnel(tunnel.id);
let streamErrorMessage = "";
const streamResult = await diagnoseTunnelStream(
tunnel.id,
{
onStart: (payload) => {
const startTunnelName =
typeof payload.tunnelName === "string" &&
payload.tunnelName.trim() !== ""
? payload.tunnelName
: tunnel.name;
const startTunnelType =
typeof payload.tunnelType === "string" &&
payload.tunnelType.trim() !== ""
? payload.tunnelType
: tunnel.type === 1
? "端口转发"
: "隧道转发";
const startTotal = Number(payload.total);
setDiagnosisResult((prev) => ({
tunnelName: startTunnelName,
tunnelType: startTunnelType,
timestamp: Date.now(),
results: prev?.results || [],
}));
if (Number.isFinite(startTotal) && startTotal >= 0) {
setDiagnosisProgress((prev) => ({
...prev,
total: startTotal,
}));
}
},
onItem: ({ result, progress }) => {
setDiagnosisResult((prev) => {
const base: DiagnosisResult = prev || {
tunnelName: tunnel.name,
tunnelType: tunnel.type === 1 ? "端口转发" : "隧道转发",
timestamp: Date.now(),
results: [],
};
const nextResults = [...base.results];
const existingIndex = nextResults.findIndex(
(item) =>
item.description === result.description &&
item.nodeId === result.nodeId &&
item.targetIp === result.targetIp &&
item.targetPort === result.targetPort,
);
if (response.code === 0) {
setDiagnosisResult(response.data as DiagnosisResult);
} else {
toast.error(response.msg || "诊断失败");
setDiagnosisResult(
buildDiagnosisFallbackResult({
tunnelName: tunnel.name,
tunnelType: tunnel.type,
description: "诊断失败",
message: response.msg || "诊断过程中发生错误",
}),
);
if (existingIndex >= 0) {
nextResults[existingIndex] = result;
} else {
nextResults.push(result);
}
return {
...base,
timestamp: Date.now(),
results: nextResults,
};
});
setDiagnosisProgress({
total: progress.total,
completed: progress.completed,
success: progress.success,
failed: progress.failed,
timedOut: Boolean(progress.timedOut),
});
},
onDone: (progress) => {
setDiagnosisProgress({
total: progress.total,
completed: progress.completed,
success: progress.success,
failed: progress.failed,
timedOut: Boolean(progress.timedOut),
});
},
onError: (message) => {
streamErrorMessage = message;
},
},
abortController.signal,
);
if (streamResult.fallback) {
const response = await diagnoseTunnel(tunnel.id);
if (response.code === 0) {
const resultData = response.data as DiagnosisResult;
const successCount = resultData.results.filter((r) => r.success).length;
const failedCount = resultData.results.length - successCount;
setDiagnosisResult(resultData);
setDiagnosisProgress({
total: resultData.results.length,
completed: resultData.results.length,
success: successCount,
failed: failedCount,
timedOut: false,
});
} else {
toast.error(response.msg || "诊断失败");
setDiagnosisResult(
buildDiagnosisFallbackResult({
tunnelName: tunnel.name,
tunnelType: tunnel.type,
description: "诊断失败",
message: response.msg || "诊断过程中发生错误",
}),
);
setDiagnosisProgress({
total: 1,
completed: 1,
success: 0,
failed: 1,
timedOut: false,
});
}
return;
}
if (streamErrorMessage) {
toast.error(streamErrorMessage);
}
if (streamResult.timedOut) {
toast.error("诊断达到2分钟超时,已返回当前结果");
}
} catch {
if (abortController.signal.aborted) {
return;
}
toast.error("网络错误,请重试");
setDiagnosisResult(
buildDiagnosisFallbackResult({
@@ -465,7 +611,17 @@ export default function TunnelPage() {
message: "无法连接到服务器",
}),
);
setDiagnosisProgress({
total: 1,
completed: 1,
success: 0,
failed: 1,
timedOut: false,
});
} finally {
if (diagnosisAbortRef.current === abortController) {
diagnosisAbortRef.current = null;
}
setDiagnosisLoading(false);
}
};
@@ -1914,7 +2070,14 @@ export default function TunnelPage() {
placement="center"
scrollBehavior="inside"
size="4xl"
onOpenChange={setDiagnosisModalOpen}
onOpenChange={(open) => {
setDiagnosisModalOpen(open);
if (!open) {
diagnosisAbortRef.current?.abort();
diagnosisAbortRef.current = null;
setDiagnosisLoading(false);
}
}}
>
<ModalContent>
{(onClose) => (
@@ -1943,7 +2106,8 @@ export default function TunnelPage() {
)}
</ModalHeader>
<ModalBody className="bg-content1">
{diagnosisLoading ? (
{diagnosisLoading &&
(!diagnosisResult || diagnosisResult.results.length === 0) ? (
<div className="flex items-center justify-center py-16">
<div className="flex items-center gap-3">
<Spinner size="sm" />
@@ -1952,11 +2116,39 @@ export default function TunnelPage() {
</div>
) : diagnosisResult ? (
<div className="space-y-4">
{diagnosisLoading && (
<div className="flex items-center justify-between rounded-lg border border-primary/20 bg-primary/5 px-3 py-2">
<div className="flex items-center gap-2 text-sm text-primary">
<Spinner size="sm" />
<span>
正在诊断 {diagnosisProgress.completed}/
{diagnosisProgress.total > 0
? diagnosisProgress.total
: "?"}
</span>
</div>
<Chip color="primary" size="sm" variant="flat">
流式更新中
</Chip>
</div>
)}
{diagnosisProgress.timedOut && (
<Alert
color="warning"
description="诊断已达到2分钟超时,以下为当前已完成结果。"
title="诊断超时"
variant="flat"
/>
)}
{/* 统计摘要 */}
<div className="grid grid-cols-3 gap-3">
<div className="text-center p-3 bg-default-100 dark:bg-gray-800 rounded-lg border border-divider">
<div className="text-2xl font-bold text-foreground">
{diagnosisResult.results.length}
{diagnosisProgress.total > 0
? diagnosisProgress.total
: diagnosisResult.results.length}
</div>
<div className="text-xs text-default-500 mt-1">
总测试数
@@ -1964,10 +2156,11 @@ export default function TunnelPage() {
</div>
<div className="text-center p-3 bg-success-50 dark:bg-success-900/20 rounded-lg border border-success-200 dark:border-success-700">
<div className="text-2xl font-bold text-success-600 dark:text-success-400">
{
diagnosisResult.results.filter((r) => r.success)
.length
}
{diagnosisProgress.completed > 0 ||
diagnosisProgress.total > 0
? diagnosisProgress.success
: diagnosisResult.results.filter((r) => r.success)
.length}
</div>
<div className="text-xs text-success-600 dark:text-success-400/80 mt-1">
成功
@@ -1975,10 +2168,11 @@ export default function TunnelPage() {
</div>
<div className="text-center p-3 bg-danger-50 dark:bg-danger-900/20 rounded-lg border border-danger-200 dark:border-danger-700">
<div className="text-2xl font-bold text-danger-600 dark:text-danger-400">
{
diagnosisResult.results.filter((r) => !r.success)
.length
}
{diagnosisProgress.completed > 0 ||
diagnosisProgress.total > 0
? diagnosisProgress.failed
: diagnosisResult.results.filter((r) => !r.success)
.length}
</div>
<div className="text-xs text-danger-600 dark:text-danger-400/80 mt-1">
失败