mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
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:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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/;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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
@@ -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">
|
||||
失败
|
||||
|
||||
@@ -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">
|
||||
失败
|
||||
|
||||
Reference in New Issue
Block a user