mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-03 17:06:36 +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,
|
Handler: router,
|
||||||
ReadTimeout: 30 * time.Second,
|
ReadTimeout: 30 * time.Second,
|
||||||
ReadHeaderTimeout: 5 * time.Second,
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
WriteTimeout: 30 * time.Second,
|
WriteTimeout: 2 * time.Minute,
|
||||||
IdleTimeout: 60 * time.Second,
|
IdleTimeout: 60 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
@@ -8,6 +9,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go-backend/internal/http/client"
|
"go-backend/internal/http/client"
|
||||||
@@ -30,6 +32,40 @@ type diagnosisTarget struct {
|
|||||||
Port int
|
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) {
|
func (h *Handler) resolveForwardAccess(r *http.Request, forwardID int64) (*forwardRecord, int64, int, error) {
|
||||||
userID, roleID, err := userRoleFromRequest(r)
|
userID, roleID, err := userRoleFromRequest(r)
|
||||||
if err != nil {
|
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) {
|
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 (
|
var (
|
||||||
result ws.CommandResult
|
result ws.CommandResult
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = defaultNodeCommandTimeout
|
||||||
|
}
|
||||||
|
|
||||||
node, nodeErr := h.getNodeRecord(nodeID)
|
node, nodeErr := h.getNodeRecord(nodeID)
|
||||||
if nodeErr == nil && node != nil && node.IsRemote == 1 {
|
if nodeErr == nil && node != nil && node.IsRemote == 1 {
|
||||||
result, err = h.sendRemoteNodeCommand(node, commandType, data)
|
result, err = h.sendRemoteNodeCommandWithTimeout(node, commandType, data, timeout)
|
||||||
} else {
|
} else {
|
||||||
result, err = h.wsServer.SendCommand(nodeID, commandType, data, 12*time.Second)
|
result, err = h.wsServer.SendCommand(nodeID, commandType, data, timeout)
|
||||||
}
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return result, 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) {
|
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 {
|
if node == nil {
|
||||||
return ws.CommandResult{}, errors.New("节点不存在")
|
return ws.CommandResult{}, errors.New("节点不存在")
|
||||||
}
|
}
|
||||||
@@ -334,6 +381,9 @@ func (h *Handler) sendRemoteNodeCommand(node *nodeRecord, commandType string, da
|
|||||||
}
|
}
|
||||||
|
|
||||||
fc := client.NewFederationClient()
|
fc := client.NewFederationClient()
|
||||||
|
if timeout > 0 {
|
||||||
|
fc = client.NewFederationClientWithTimeout(timeout)
|
||||||
|
}
|
||||||
res, err := fc.Command(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeNodeCommandRequest{
|
res, err := fc.Command(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeNodeCommandRequest{
|
||||||
CommandType: commandType,
|
CommandType: commandType,
|
||||||
Data: data,
|
Data: data,
|
||||||
@@ -361,41 +411,65 @@ func (h *Handler) sendRemoteNodeCommand(node *nodeRecord, commandType string, da
|
|||||||
return result, nil
|
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 {
|
if forward == nil {
|
||||||
return nil, errForwardNotFound
|
return "", nil, errForwardNotFound
|
||||||
}
|
}
|
||||||
targets, err := resolveDiagnosisTargets(forward.RemoteAddr)
|
targets, err := resolveDiagnosisTargets(forward.RemoteAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return "", nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
tunnel, err := h.getTunnelRecord(forward.TunnelID)
|
tunnel, err := h.getTunnelRecord(forward.TunnelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return "", nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
chainRows, err := h.listChainNodesForTunnel(forward.TunnelID)
|
chainRows, err := h.listChainNodesForTunnel(forward.TunnelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return "", nil, err
|
||||||
}
|
}
|
||||||
if len(chainRows) == 0 {
|
if len(chainRows) == 0 {
|
||||||
return nil, errors.New("隧道配置不完整")
|
return "", nil, errors.New("隧道配置不完整")
|
||||||
}
|
}
|
||||||
|
|
||||||
ipPreference := h.repo.GetTunnelIPPreference(forward.TunnelID)
|
ipPreference := h.repo.GetTunnelIPPreference(forward.TunnelID)
|
||||||
|
|
||||||
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
|
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
|
||||||
results := make([]map[string]interface{}, 0, len(chainRows)*2+len(targets))
|
workItems := make([]diagnosisWorkItem, 0, len(chainRows)*2+len(targets))
|
||||||
nodeCache := map[int64]*nodeRecord{}
|
|
||||||
|
|
||||||
switch tunnel.Type {
|
switch tunnel.Type {
|
||||||
case 1:
|
case 1:
|
||||||
for _, inNode := range inNodes {
|
for _, inNode := range inNodes {
|
||||||
for _, target := range targets {
|
for _, target := range targets {
|
||||||
description := fmt.Sprintf("入口(%s)->目标(%s)", inNode.NodeName, target.Address)
|
description := fmt.Sprintf("入口(%s)->目标(%s)", inNode.NodeName, target.Address)
|
||||||
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, target.IP, target.Port, description, map[string]interface{}{
|
workItems = append(workItems, diagnosisWorkItem{
|
||||||
"fromChainType": 1,
|
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 {
|
if len(chainHops) > 0 {
|
||||||
for _, firstNode := range chainHops[0] {
|
for _, firstNode := range chainHops[0] {
|
||||||
description := fmt.Sprintf("入口(%s)->第1跳(%s)", inNode.NodeName, firstNode.NodeName)
|
description := fmt.Sprintf("入口(%s)->第1跳(%s)", inNode.NodeName, firstNode.NodeName)
|
||||||
h.appendChainHopDiagnosis(&results, nodeCache, inNode.NodeID, firstNode, description, map[string]interface{}{
|
workItems = append(workItems, diagnosisWorkItem{
|
||||||
"fromChainType": 1,
|
fromNodeID: inNode.NodeID,
|
||||||
"toChainType": 2,
|
toNode: firstNode,
|
||||||
"toInx": firstNode.Inx,
|
hasChainHop: true,
|
||||||
}, ipPreference)
|
ipPreference: ipPreference,
|
||||||
|
description: description,
|
||||||
|
metadata: map[string]interface{}{
|
||||||
|
"fromChainType": 1,
|
||||||
|
"toChainType": 2,
|
||||||
|
"toInx": firstNode.Inx,
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for _, outNode := range outNodes {
|
for _, outNode := range outNodes {
|
||||||
description := fmt.Sprintf("入口(%s)->出口(%s)", inNode.NodeName, outNode.NodeName)
|
description := fmt.Sprintf("入口(%s)->出口(%s)", inNode.NodeName, outNode.NodeName)
|
||||||
h.appendChainHopDiagnosis(&results, nodeCache, inNode.NodeID, outNode, description, map[string]interface{}{
|
workItems = append(workItems, diagnosisWorkItem{
|
||||||
"fromChainType": 1,
|
fromNodeID: inNode.NodeID,
|
||||||
"toChainType": 3,
|
toNode: outNode,
|
||||||
}, ipPreference)
|
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) {
|
if i+1 < len(chainHops) {
|
||||||
for _, nextNode := range chainHops[i+1] {
|
for _, nextNode := range chainHops[i+1] {
|
||||||
description := fmt.Sprintf("第%d跳(%s)->第%d跳(%s)", i+1, currentNode.NodeName, i+2, nextNode.NodeName)
|
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{}{
|
workItems = append(workItems, diagnosisWorkItem{
|
||||||
"fromChainType": 2,
|
fromNodeID: currentNode.NodeID,
|
||||||
"fromInx": currentNode.Inx,
|
toNode: nextNode,
|
||||||
"toChainType": 2,
|
hasChainHop: true,
|
||||||
"toInx": nextNode.Inx,
|
ipPreference: ipPreference,
|
||||||
}, ipPreference)
|
description: description,
|
||||||
|
metadata: map[string]interface{}{
|
||||||
|
"fromChainType": 2,
|
||||||
|
"fromInx": currentNode.Inx,
|
||||||
|
"toChainType": 2,
|
||||||
|
"toInx": nextNode.Inx,
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for _, outNode := range outNodes {
|
for _, outNode := range outNodes {
|
||||||
description := fmt.Sprintf("第%d跳(%s)->出口(%s)", i+1, currentNode.NodeName, outNode.NodeName)
|
description := fmt.Sprintf("第%d跳(%s)->出口(%s)", i+1, currentNode.NodeName, outNode.NodeName)
|
||||||
h.appendChainHopDiagnosis(&results, nodeCache, currentNode.NodeID, outNode, description, map[string]interface{}{
|
workItems = append(workItems, diagnosisWorkItem{
|
||||||
"fromChainType": 2,
|
fromNodeID: currentNode.NodeID,
|
||||||
"fromInx": currentNode.Inx,
|
toNode: outNode,
|
||||||
"toChainType": 3,
|
hasChainHop: true,
|
||||||
}, ipPreference)
|
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 _, outNode := range outNodes {
|
||||||
for _, target := range targets {
|
for _, target := range targets {
|
||||||
description := fmt.Sprintf("出口(%s)->目标(%s)", outNode.NodeName, target.Address)
|
description := fmt.Sprintf("出口(%s)->目标(%s)", outNode.NodeName, target.Address)
|
||||||
h.appendPathDiagnosis(&results, nodeCache, outNode.NodeID, target.IP, target.Port, description, map[string]interface{}{
|
workItems = append(workItems, diagnosisWorkItem{
|
||||||
"fromChainType": 3,
|
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 _, inNode := range inNodes {
|
||||||
for _, target := range targets {
|
for _, target := range targets {
|
||||||
description := fmt.Sprintf("入口(%s)->目标(%s)", inNode.NodeName, target.Address)
|
description := fmt.Sprintf("入口(%s)->目标(%s)", inNode.NodeName, target.Address)
|
||||||
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, target.IP, target.Port, description, map[string]interface{}{
|
workItems = append(workItems, diagnosisWorkItem{
|
||||||
"fromChainType": 1,
|
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{}{
|
payload := map[string]interface{}{
|
||||||
"forwardName": forward.Name,
|
"tunnelName": tunnelName,
|
||||||
"timestamp": time.Now().UnixMilli(),
|
"tunnelType": tunnelType,
|
||||||
"results": results,
|
"timestamp": time.Now().UnixMilli(),
|
||||||
|
"results": results,
|
||||||
}
|
}
|
||||||
return payload, nil
|
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)
|
tunnel, err := h.getTunnelRecord(tunnelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return "", "", nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
tunnelName, err := h.repo.GetTunnelName(tunnelID)
|
tunnelName, err := h.repo.GetTunnelName(tunnelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return "", "", nil, err
|
||||||
}
|
}
|
||||||
if tunnelName == "" {
|
if tunnelName == "" {
|
||||||
return nil, errors.New("隧道不存在")
|
return "", "", nil, errors.New("隧道不存在")
|
||||||
}
|
}
|
||||||
|
|
||||||
chainRows, err := h.listChainNodesForTunnel(tunnelID)
|
chainRows, err := h.listChainNodesForTunnel(tunnelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return "", "", nil, err
|
||||||
}
|
}
|
||||||
if len(chainRows) == 0 {
|
if len(chainRows) == 0 {
|
||||||
return nil, errors.New("隧道配置不完整")
|
return "", "", nil, errors.New("隧道配置不完整")
|
||||||
}
|
}
|
||||||
|
|
||||||
ipPreference := h.repo.GetTunnelIPPreference(tunnelID)
|
ipPreference := h.repo.GetTunnelIPPreference(tunnelID)
|
||||||
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
|
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
|
||||||
results := make([]map[string]interface{}, 0, len(chainRows)*2)
|
workItems := make([]diagnosisWorkItem, 0, len(chainRows)*2)
|
||||||
nodeCache := map[int64]*nodeRecord{}
|
|
||||||
|
|
||||||
switch tunnel.Type {
|
switch tunnel.Type {
|
||||||
case 1:
|
case 1:
|
||||||
for _, inNode := range inNodes {
|
for _, inNode := range inNodes {
|
||||||
description := fmt.Sprintf("入口(%s)->外网", inNode.NodeName)
|
description := fmt.Sprintf("入口(%s)->外网", inNode.NodeName)
|
||||||
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.bing.com", 443, description, map[string]interface{}{
|
workItems = append(workItems, diagnosisWorkItem{
|
||||||
"fromChainType": 1,
|
fromNodeID: inNode.NodeID,
|
||||||
|
targetIP: "www.bing.com",
|
||||||
|
targetPort: 443,
|
||||||
|
description: description,
|
||||||
|
metadata: map[string]interface{}{
|
||||||
|
"fromChainType": 1,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
case 2:
|
case 2:
|
||||||
@@ -513,19 +647,33 @@ func (h *Handler) diagnoseTunnelRuntime(tunnelID int64) (map[string]interface{},
|
|||||||
if len(chainHops) > 0 {
|
if len(chainHops) > 0 {
|
||||||
for _, firstNode := range chainHops[0] {
|
for _, firstNode := range chainHops[0] {
|
||||||
description := fmt.Sprintf("入口(%s)->第1跳(%s)", inNode.NodeName, firstNode.NodeName)
|
description := fmt.Sprintf("入口(%s)->第1跳(%s)", inNode.NodeName, firstNode.NodeName)
|
||||||
h.appendChainHopDiagnosis(&results, nodeCache, inNode.NodeID, firstNode, description, map[string]interface{}{
|
workItems = append(workItems, diagnosisWorkItem{
|
||||||
"fromChainType": 1,
|
fromNodeID: inNode.NodeID,
|
||||||
"toChainType": 2,
|
toNode: firstNode,
|
||||||
"toInx": firstNode.Inx,
|
hasChainHop: true,
|
||||||
}, ipPreference)
|
ipPreference: ipPreference,
|
||||||
|
description: description,
|
||||||
|
metadata: map[string]interface{}{
|
||||||
|
"fromChainType": 1,
|
||||||
|
"toChainType": 2,
|
||||||
|
"toInx": firstNode.Inx,
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for _, outNode := range outNodes {
|
for _, outNode := range outNodes {
|
||||||
description := fmt.Sprintf("入口(%s)->出口(%s)", inNode.NodeName, outNode.NodeName)
|
description := fmt.Sprintf("入口(%s)->出口(%s)", inNode.NodeName, outNode.NodeName)
|
||||||
h.appendChainHopDiagnosis(&results, nodeCache, inNode.NodeID, outNode, description, map[string]interface{}{
|
workItems = append(workItems, diagnosisWorkItem{
|
||||||
"fromChainType": 1,
|
fromNodeID: inNode.NodeID,
|
||||||
"toChainType": 3,
|
toNode: outNode,
|
||||||
}, ipPreference)
|
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) {
|
if i+1 < len(chainHops) {
|
||||||
for _, nextNode := range chainHops[i+1] {
|
for _, nextNode := range chainHops[i+1] {
|
||||||
description := fmt.Sprintf("第%d跳(%s)->第%d跳(%s)", i+1, currentNode.NodeName, i+2, nextNode.NodeName)
|
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{}{
|
workItems = append(workItems, diagnosisWorkItem{
|
||||||
"fromChainType": 2,
|
fromNodeID: currentNode.NodeID,
|
||||||
"fromInx": currentNode.Inx,
|
toNode: nextNode,
|
||||||
"toChainType": 2,
|
hasChainHop: true,
|
||||||
"toInx": nextNode.Inx,
|
ipPreference: ipPreference,
|
||||||
}, ipPreference)
|
description: description,
|
||||||
|
metadata: map[string]interface{}{
|
||||||
|
"fromChainType": 2,
|
||||||
|
"fromInx": currentNode.Inx,
|
||||||
|
"toChainType": 2,
|
||||||
|
"toInx": nextNode.Inx,
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for _, outNode := range outNodes {
|
for _, outNode := range outNodes {
|
||||||
description := fmt.Sprintf("第%d跳(%s)->出口(%s)", i+1, currentNode.NodeName, outNode.NodeName)
|
description := fmt.Sprintf("第%d跳(%s)->出口(%s)", i+1, currentNode.NodeName, outNode.NodeName)
|
||||||
h.appendChainHopDiagnosis(&results, nodeCache, currentNode.NodeID, outNode, description, map[string]interface{}{
|
workItems = append(workItems, diagnosisWorkItem{
|
||||||
"fromChainType": 2,
|
fromNodeID: currentNode.NodeID,
|
||||||
"fromInx": currentNode.Inx,
|
toNode: outNode,
|
||||||
"toChainType": 3,
|
hasChainHop: true,
|
||||||
}, ipPreference)
|
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 {
|
for _, outNode := range outNodes {
|
||||||
description := fmt.Sprintf("出口(%s)->外网", outNode.NodeName)
|
description := fmt.Sprintf("出口(%s)->外网", outNode.NodeName)
|
||||||
h.appendPathDiagnosis(&results, nodeCache, outNode.NodeID, "www.bing.com", 443, description, map[string]interface{}{
|
workItems = append(workItems, diagnosisWorkItem{
|
||||||
"fromChainType": 3,
|
fromNodeID: outNode.NodeID,
|
||||||
|
targetIP: "www.bing.com",
|
||||||
|
targetPort: 443,
|
||||||
|
description: description,
|
||||||
|
metadata: map[string]interface{}{
|
||||||
|
"fromChainType": 3,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
for _, inNode := range inNodes {
|
for _, inNode := range inNodes {
|
||||||
description := fmt.Sprintf("入口(%s)->外网", inNode.NodeName)
|
description := fmt.Sprintf("入口(%s)->外网", inNode.NodeName)
|
||||||
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.bing.com", 443, description, map[string]interface{}{
|
workItems = append(workItems, diagnosisWorkItem{
|
||||||
"fromChainType": 1,
|
fromNodeID: inNode.NodeID,
|
||||||
|
targetIP: "www.bing.com",
|
||||||
|
targetPort: 443,
|
||||||
|
description: description,
|
||||||
|
metadata: map[string]interface{}{
|
||||||
|
"fromChainType": 1,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
tunnelType := map[bool]string{true: "端口转发", false: "隧道转发"}[tunnel.Type == 1]
|
||||||
"tunnelName": tunnelName,
|
return tunnelName, tunnelType, workItems, nil
|
||||||
"tunnelType": map[bool]string{true: "端口转发", false: "隧道转发"}[tunnel.Type == 1],
|
|
||||||
"timestamp": time.Now().UnixMilli(),
|
|
||||||
"results": results,
|
|
||||||
}
|
|
||||||
return payload, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func splitChainNodeGroups(rows []chainNodeRecord) ([]chainNodeRecord, [][]chainNodeRecord, []chainNodeRecord) {
|
func splitChainNodeGroups(rows []chainNodeRecord) ([]chainNodeRecord, [][]chainNodeRecord, []chainNodeRecord) {
|
||||||
@@ -628,6 +797,157 @@ func resolveDiagnosisTargets(remoteAddr string) ([]diagnosisTarget, error) {
|
|||||||
return targets, nil
|
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) {
|
func (h *Handler) cachedNode(nodeCache map[int64]*nodeRecord, nodeID int64) (*nodeRecord, error) {
|
||||||
if node, ok := nodeCache[nodeID]; ok {
|
if node, ok := nodeCache[nodeID]; ok {
|
||||||
return node, nil
|
return node, nil
|
||||||
@@ -669,7 +989,7 @@ func (h *Handler) appendFailedDiagnosis(results *[]map[string]interface{}, nodeC
|
|||||||
*results = append(*results, item)
|
*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)
|
item := newDiagnosisResultItem(fromNodeID, targetIP, targetPort, description, metadata)
|
||||||
|
|
||||||
fromNode, err := h.cachedNode(nodeCache, fromNodeID)
|
fromNode, err := h.cachedNode(nodeCache, fromNodeID)
|
||||||
@@ -686,9 +1006,9 @@ func (h *Handler) appendPathDiagnosis(results *[]map[string]interface{}, nodeCac
|
|||||||
pingErr error
|
pingErr error
|
||||||
)
|
)
|
||||||
if fromNode.IsRemote == 1 {
|
if fromNode.IsRemote == 1 {
|
||||||
pingData, pingErr = h.tcpPingViaRemoteNode(fromNode, targetIP, targetPort)
|
pingData, pingErr = h.tcpPingViaRemoteNode(fromNode, targetIP, targetPort, options)
|
||||||
} else {
|
} else {
|
||||||
pingData, pingErr = h.tcpPingViaNode(fromNodeID, targetIP, targetPort)
|
pingData, pingErr = h.tcpPingViaNode(fromNodeID, targetIP, targetPort, options)
|
||||||
}
|
}
|
||||||
if pingErr != nil {
|
if pingErr != nil {
|
||||||
item["success"] = false
|
item["success"] = false
|
||||||
@@ -719,7 +1039,7 @@ func (h *Handler) appendPathDiagnosis(results *[]map[string]interface{}, nodeCac
|
|||||||
*results = append(*results, item)
|
*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)
|
fromNode, _ := h.cachedNode(nodeCache, fromNodeID)
|
||||||
targetNode, err := h.cachedNode(nodeCache, toNode.NodeID)
|
targetNode, err := h.cachedNode(nodeCache, toNode.NodeID)
|
||||||
if err != nil {
|
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())
|
h.appendFailedDiagnosis(results, nodeCache, fromNodeID, strings.Trim(strings.TrimSpace(targetNode.ServerIP), "[]"), toNode.Port, description, metadata, err.Error())
|
||||||
return
|
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) {
|
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)
|
return h.repo.ListChainNodesForTunnel(tunnelID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) tcpPingViaNode(nodeID int64, ip string, port int) (map[string]interface{}, error) {
|
func (h *Handler) tcpPingViaNode(nodeID int64, ip string, port int, options diagnosisExecOptions) (map[string]interface{}, error) {
|
||||||
res, err := h.sendNodeCommand(nodeID, "TcpPing", map[string]interface{}{
|
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,
|
"ip": ip,
|
||||||
"port": port,
|
"port": port,
|
||||||
"count": 4,
|
"count": 4,
|
||||||
"timeout": 5000,
|
"timeout": options.pingTimeoutMS,
|
||||||
}, false, false)
|
}, options.commandTimeout, false, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -800,7 +1126,7 @@ func (h *Handler) tcpPingViaNode(nodeID int64, ip string, port int) (map[string]
|
|||||||
return res.Data, nil
|
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 {
|
if node == nil {
|
||||||
return nil, errors.New("节点不存在")
|
return nil, errors.New("节点不存在")
|
||||||
}
|
}
|
||||||
@@ -809,13 +1135,19 @@ func (h *Handler) tcpPingViaRemoteNode(node *nodeRecord, ip string, port int) (m
|
|||||||
if remoteURL == "" || remoteToken == "" {
|
if remoteURL == "" || remoteToken == "" {
|
||||||
return nil, errors.New("远程节点缺少共享配置")
|
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{
|
return fc.Diagnose(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeDiagnoseRequest{
|
||||||
IP: strings.TrimSpace(ip),
|
IP: strings.TrimSpace(ip),
|
||||||
Port: port,
|
Port: port,
|
||||||
Count: 4,
|
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 {
|
if req.Count <= 0 {
|
||||||
req.Count = 4
|
req.Count = 4
|
||||||
}
|
}
|
||||||
if req.Timeout <= 0 {
|
if req.Timeout <= 0 || req.Timeout > int(diagnosisCommandTimeout/time.Millisecond) {
|
||||||
req.Timeout = 5000
|
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,
|
"ip": req.IP,
|
||||||
"port": req.Port,
|
"port": req.Port,
|
||||||
"count": req.Count,
|
"count": req.Count,
|
||||||
"timeout": req.Timeout,
|
"timeout": req.Timeout,
|
||||||
}, false, false)
|
}, commandTimeout, false, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||||
return
|
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/update", h.tunnelUpdate)
|
||||||
mux.HandleFunc("/api/v1/tunnel/delete", h.tunnelDelete)
|
mux.HandleFunc("/api/v1/tunnel/delete", h.tunnelDelete)
|
||||||
mux.HandleFunc("/api/v1/tunnel/diagnose", h.tunnelDiagnose)
|
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/update-order", h.tunnelUpdateOrder)
|
||||||
mux.HandleFunc("/api/v1/tunnel/batch-delete", h.tunnelBatchDelete)
|
mux.HandleFunc("/api/v1/tunnel/batch-delete", h.tunnelBatchDelete)
|
||||||
mux.HandleFunc("/api/v1/tunnel/batch-redeploy", h.tunnelBatchRedeploy)
|
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/pause", h.forwardPause)
|
||||||
mux.HandleFunc("/api/v1/forward/resume", h.forwardResume)
|
mux.HandleFunc("/api/v1/forward/resume", h.forwardResume)
|
||||||
mux.HandleFunc("/api/v1/forward/diagnose", h.forwardDiagnose)
|
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/update-order", h.forwardUpdateOrder)
|
||||||
mux.HandleFunc("/api/v1/forward/batch-delete", h.forwardBatchDelete)
|
mux.HandleFunc("/api/v1/forward/batch-delete", h.forwardBatchDelete)
|
||||||
mux.HandleFunc("/api/v1/forward/batch-pause", h.forwardBatchPause)
|
mux.HandleFunc("/api/v1/forward/batch-pause", h.forwardBatchPause)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -787,7 +788,9 @@ func (h *Handler) tunnelDiagnose(w http.ResponseWriter, r *http.Request) {
|
|||||||
if id <= 0 {
|
if id <= 0 {
|
||||||
return
|
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 err != nil {
|
||||||
if strings.Contains(err.Error(), "不存在") || strings.Contains(err.Error(), "不完整") {
|
if strings.Contains(err.Error(), "不存在") || strings.Contains(err.Error(), "不完整") {
|
||||||
response.WriteJSON(w, response.ErrDefault(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()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
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 err != nil {
|
||||||
if strings.Contains(err.Error(), "不存在") || strings.Contains(err.Error(), "不能为空") || strings.Contains(err.Error(), "错误") {
|
if strings.Contains(err.Error(), "不存在") || strings.Contains(err.Error(), "不能为空") || strings.Contains(err.Error(), "错误") {
|
||||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||||
|
|||||||
@@ -55,11 +55,41 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# API代理到后端
|
# 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/ {
|
location ^~ /api/v1/ {
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
proxy_send_timeout 120s;
|
||||||
proxy_pass http://backend:6365/api/v1/;
|
proxy_pass http://backend:6365/api/v1/;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,5 +80,8 @@
|
|||||||
"vite": "npm:rolldown-vite@^7.3.1",
|
"vite": "npm:rolldown-vite@^7.3.1",
|
||||||
"vite-plugin-pwa": "^1.1.0",
|
"vite-plugin-pwa": "^1.1.0",
|
||||||
"vite-tsconfig-paths": "^6.0.5"
|
"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) =>
|
export const deleteTunnel = (id: number) =>
|
||||||
Network.post("/tunnel/delete", { id });
|
Network.post("/tunnel/delete", { id });
|
||||||
export const diagnoseTunnel = (tunnelId: number) =>
|
export const diagnoseTunnel = (tunnelId: number) =>
|
||||||
Network.post<TunnelDiagnosisApiData>("/tunnel/diagnose", { tunnelId });
|
Network.post<TunnelDiagnosisApiData>(
|
||||||
|
"/tunnel/diagnose",
|
||||||
|
{ tunnelId },
|
||||||
|
{ timeout: 120 * 1000 },
|
||||||
|
);
|
||||||
export const updateTunnelOrder = (data: {
|
export const updateTunnelOrder = (data: {
|
||||||
tunnels: Array<{ id: number; inx: number }>;
|
tunnels: Array<{ id: number; inx: number }>;
|
||||||
}) => Network.post("/tunnel/update-order", data);
|
}) => Network.post("/tunnel/update-order", data);
|
||||||
@@ -159,7 +163,11 @@ export const resumeForwardService = (forwardId: number) =>
|
|||||||
|
|
||||||
// 转发诊断操作
|
// 转发诊断操作
|
||||||
export const diagnoseForward = (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: {
|
export const updateForwardOrder = (data: {
|
||||||
|
|||||||
+624
-131
@@ -1,6 +1,6 @@
|
|||||||
import type { SpeedLimitApiItem } from "@/api/types";
|
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 toast from "react-hot-toast";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
@@ -76,6 +76,7 @@ import {
|
|||||||
getForwardDiagnosisQualityDisplay,
|
getForwardDiagnosisQualityDisplay,
|
||||||
type ForwardDiagnosisResult,
|
type ForwardDiagnosisResult,
|
||||||
} from "@/pages/forward/diagnosis";
|
} from "@/pages/forward/diagnosis";
|
||||||
|
import { diagnoseForwardStream } from "@/api/diagnosis-stream";
|
||||||
import {
|
import {
|
||||||
executeForwardBatchChangeTunnel,
|
executeForwardBatchChangeTunnel,
|
||||||
executeForwardBatchDelete,
|
executeForwardBatchDelete,
|
||||||
@@ -130,6 +131,57 @@ interface ForwardForm {
|
|||||||
speedId: number | null;
|
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() {
|
export default function ForwardPage() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [forwards, setForwards] = useState<Forward[]>([]);
|
const [forwards, setForwards] = useState<Forward[]>([]);
|
||||||
@@ -177,6 +229,14 @@ export default function ForwardPage() {
|
|||||||
useState<Forward | null>(null);
|
useState<Forward | null>(null);
|
||||||
const [diagnosisResult, setDiagnosisResult] =
|
const [diagnosisResult, setDiagnosisResult] =
|
||||||
useState<ForwardDiagnosisResult | null>(null);
|
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 [addressModalTitle, setAddressModalTitle] = useState("");
|
||||||
const [addressList, setAddressList] = useState<ForwardAddressItem[]>([]);
|
const [addressList, setAddressList] = useState<ForwardAddressItem[]>([]);
|
||||||
|
|
||||||
@@ -229,6 +289,13 @@ export default function ForwardPage() {
|
|||||||
);
|
);
|
||||||
const [batchLoading, setBatchLoading] = useState(false);
|
const [batchLoading, setBatchLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
diagnosisAbortRef.current?.abort();
|
||||||
|
diagnosisAbortRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const parseShareIdFromTunnelName = (tunnelName: string): number | null => {
|
const parseShareIdFromTunnelName = (tunnelName: string): number | null => {
|
||||||
const normalized = (tunnelName || "").trim();
|
const normalized = (tunnelName || "").trim();
|
||||||
|
|
||||||
@@ -779,28 +846,148 @@ export default function ForwardPage() {
|
|||||||
|
|
||||||
// 诊断转发
|
// 诊断转发
|
||||||
const handleDiagnose = async (forward: Forward) => {
|
const handleDiagnose = async (forward: Forward) => {
|
||||||
|
diagnosisAbortRef.current?.abort();
|
||||||
|
const abortController = new AbortController();
|
||||||
|
diagnosisAbortRef.current = abortController;
|
||||||
|
|
||||||
setCurrentDiagnosisForward(forward);
|
setCurrentDiagnosisForward(forward);
|
||||||
setDiagnosisModalOpen(true);
|
setDiagnosisModalOpen(true);
|
||||||
setDiagnosisLoading(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 {
|
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) {
|
if (existingIndex >= 0) {
|
||||||
setDiagnosisResult(response.data as ForwardDiagnosisResult);
|
nextResults[existingIndex] = result;
|
||||||
} else {
|
} else {
|
||||||
toast.error(response.msg || "诊断失败");
|
nextResults.push(result);
|
||||||
setDiagnosisResult(
|
}
|
||||||
buildForwardDiagnosisFallbackResult({
|
return {
|
||||||
forwardName: forward.name,
|
...base,
|
||||||
remoteAddr: forward.remoteAddr,
|
timestamp: Date.now(),
|
||||||
description: "诊断失败",
|
results: nextResults,
|
||||||
message: response.msg || "诊断过程中发生错误",
|
};
|
||||||
}),
|
});
|
||||||
);
|
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 {
|
} catch {
|
||||||
|
if (abortController.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
toast.error("网络错误,请重试");
|
toast.error("网络错误,请重试");
|
||||||
setDiagnosisResult(
|
setDiagnosisResult(
|
||||||
buildForwardDiagnosisFallbackResult({
|
buildForwardDiagnosisFallbackResult({
|
||||||
@@ -810,7 +997,17 @@ export default function ForwardPage() {
|
|||||||
message: "无法连接到服务器",
|
message: "无法连接到服务器",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
setDiagnosisProgress({
|
||||||
|
total: 1,
|
||||||
|
completed: 1,
|
||||||
|
success: 0,
|
||||||
|
failed: 1,
|
||||||
|
timedOut: false,
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
if (diagnosisAbortRef.current === abortController) {
|
||||||
|
diagnosisAbortRef.current = null;
|
||||||
|
}
|
||||||
setDiagnosisLoading(false);
|
setDiagnosisLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1145,6 +1342,20 @@ export default function ForwardPage() {
|
|||||||
// 检查 ID 是否有效
|
// 检查 ID 是否有效
|
||||||
if (isNaN(activeId) || isNaN(overId)) return;
|
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 oldIndex = forwardOrder.indexOf(activeId);
|
||||||
const newIndex = forwardOrder.indexOf(overId);
|
const newIndex = forwardOrder.indexOf(overId);
|
||||||
|
|
||||||
@@ -1342,9 +1553,11 @@ export default function ForwardPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const tokenUserId = JwtUtil.getUserIdFromToken();
|
const tokenUserId = JwtUtil.getUserIdFromToken();
|
||||||
|
const tokenRoleId = JwtUtil.getRoleIdFromToken();
|
||||||
|
const isAdmin = tokenRoleId === 0;
|
||||||
|
|
||||||
// 根据排序顺序获取转发列表
|
// 根据排序顺序获取转发列表
|
||||||
const sortedForwards = useMemo((): Forward[] => {
|
const orderedForwards = useMemo((): Forward[] => {
|
||||||
// 确保 forwards 数组存在且有效
|
// 确保 forwards 数组存在且有效
|
||||||
if (!forwards || forwards.length === 0) {
|
if (!forwards || forwards.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
@@ -1426,16 +1639,125 @@ export default function ForwardPage() {
|
|||||||
}, [
|
}, [
|
||||||
forwards,
|
forwards,
|
||||||
forwardOrder,
|
forwardOrder,
|
||||||
viewMode,
|
|
||||||
tokenUserId,
|
|
||||||
filterUserId,
|
filterUserId,
|
||||||
filterTunnelId,
|
filterTunnelId,
|
||||||
searchKeyword,
|
searchKeyword,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const sortableForwardIds = useMemo(
|
const groupedForwards = useMemo((): ForwardUserGroup[] => {
|
||||||
() => sortedForwards.map((f) => f.id).filter((id) => id > 0),
|
if (orderedForwards.length === 0) {
|
||||||
[sortedForwards],
|
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 }>();
|
const userMap = new Map<number, { id: number; name: string }>();
|
||||||
|
|
||||||
forwards.forEach((f) => {
|
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)) {
|
if (!existingUser) {
|
||||||
userMap.set(uId, { id: uId, name: f.userName || "未知用户" });
|
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());
|
const users = Array.from(userMap.values());
|
||||||
}, [forwards]);
|
|
||||||
|
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 = ({
|
const SortableTableRow = ({
|
||||||
@@ -1552,25 +1905,9 @@ export default function ForwardPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</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">
|
<TableCell className="whitespace-nowrap font-semibold text-foreground">
|
||||||
{forward.name}
|
{forward.name}
|
||||||
</TableCell>
|
</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]">
|
<TableCell className="max-w-[220px]">
|
||||||
<button
|
<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 ${
|
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}
|
{forward.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-default-500 truncate">
|
<p className="text-xs text-default-500 truncate">
|
||||||
{forward.tunnelName}
|
{normalizeForwardTunnelName(forward.tunnelName)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 ml-2">
|
<div className="flex items-center gap-1.5 ml-2">
|
||||||
@@ -2207,63 +2544,120 @@ export default function ForwardPage() {
|
|||||||
{/* 根据显示模式渲染不同内容 */}
|
{/* 根据显示模式渲染不同内容 */}
|
||||||
{viewMode === "grouped" ? (
|
{viewMode === "grouped" ? (
|
||||||
sortedForwards.length > 0 ? (
|
sortedForwards.length > 0 ? (
|
||||||
<div className="overflow-hidden rounded-xl border border-divider bg-content1 shadow-md">
|
<div className="space-y-4">
|
||||||
<DndContext
|
{groupedForwards.map((group) => {
|
||||||
collisionDetection={closestCenter}
|
const isSelfGroup =
|
||||||
sensors={sensors}
|
isAdmin && tokenUserId !== null && group.userId === tokenUserId;
|
||||||
onDragEnd={handleDragEnd}
|
const groupForwardCount = group.tunnels.reduce(
|
||||||
>
|
(total, tunnel) => total + tunnel.items.length,
|
||||||
<Table
|
0,
|
||||||
aria-label="全部转发列表"
|
);
|
||||||
classNames={{
|
|
||||||
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
|
return (
|
||||||
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
<div
|
||||||
tr: "hover:bg-default-50/50 transition-colors",
|
key={`grouped-table-${group.userId}-${group.userName}`}
|
||||||
}}
|
className="overflow-hidden rounded-xl border border-divider bg-content1 shadow-md"
|
||||||
>
|
>
|
||||||
<TableHeader>
|
<div className="flex items-center justify-between border-b border-divider bg-default-100/40 px-4 py-3">
|
||||||
{selectMode && (
|
<div className="flex items-center gap-2">
|
||||||
<TableColumn className="w-14">选择</TableColumn>
|
<span className="text-sm font-semibold text-foreground">
|
||||||
)}
|
{group.userName}
|
||||||
<TableColumn className="w-10 pl-4" />
|
</span>
|
||||||
<TableColumn>用户</TableColumn>
|
{isSelfGroup && (
|
||||||
<TableColumn>名称</TableColumn>
|
<Chip color="primary" size="sm" variant="flat">
|
||||||
<TableColumn>隧道</TableColumn>
|
管理员本人
|
||||||
<TableColumn>入口</TableColumn>
|
</Chip>
|
||||||
<TableColumn>目标</TableColumn>
|
)}
|
||||||
<TableColumn>策略</TableColumn>
|
</div>
|
||||||
<TableColumn>总流量</TableColumn>
|
<span className="text-xs text-default-600">
|
||||||
<TableColumn>状态</TableColumn>
|
{groupForwardCount} 条转发
|
||||||
<TableColumn className="text-right">操作</TableColumn>
|
</span>
|
||||||
</TableHeader>
|
</div>
|
||||||
<TableBody emptyContent="暂无转发配置" items={sortedForwards}>
|
|
||||||
{(forward) => (
|
<div className="space-y-4 p-4">
|
||||||
<SortableContext
|
{group.tunnels.map((tunnel) => {
|
||||||
key={forward.id}
|
const tunnelSortableForwardIds = tunnel.items
|
||||||
items={sortableForwardIds}
|
.map((item) => item.id)
|
||||||
strategy={verticalListSortingStrategy}
|
.filter((id) => id > 0);
|
||||||
>
|
|
||||||
<SortableTableRow
|
return (
|
||||||
formatFlow={formatFlow}
|
<div
|
||||||
formatInAddress={formatInAddress}
|
key={`grouped-table-${group.userId}-${tunnel.tunnelKey}`}
|
||||||
formatRemoteAddress={formatRemoteAddress}
|
className="overflow-hidden rounded-lg border border-secondary/20 bg-secondary/5"
|
||||||
forward={forward}
|
>
|
||||||
getStrategyDisplay={getStrategyDisplay}
|
<div className="flex items-center justify-between border-b border-secondary/20 bg-secondary/10 px-4 py-2.5">
|
||||||
handleDelete={handleDelete}
|
<span className="text-sm font-semibold text-secondary-700">
|
||||||
handleDiagnose={handleDiagnose}
|
{tunnel.tunnelName}
|
||||||
handleEdit={handleEdit}
|
</span>
|
||||||
handleServiceToggle={handleServiceToggle}
|
<span className="text-xs text-secondary-700">
|
||||||
hasMultipleAddresses={hasMultipleAddresses}
|
{tunnel.items.length} 条转发
|
||||||
selectMode={selectMode}
|
</span>
|
||||||
selectedIds={selectedIds}
|
</div>
|
||||||
showAddressModal={showAddressModal}
|
|
||||||
toggleSelect={toggleSelect}
|
<DndContext
|
||||||
/>
|
collisionDetection={closestCenter}
|
||||||
</SortableContext>
|
sensors={sensors}
|
||||||
)}
|
onDragEnd={handleDragEnd}
|
||||||
</TableBody>
|
>
|
||||||
</Table>
|
<Table
|
||||||
</DndContext>
|
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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* 空状态 */
|
/* 空状态 */
|
||||||
@@ -2279,26 +2673,87 @@ export default function ForwardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
) : /* 直接显示模式 */
|
) : /* 直接显示模式 */
|
||||||
forwards.length > 0 ? (
|
sortedForwards.length > 0 ? (
|
||||||
<DndContext
|
<div className="space-y-5">
|
||||||
collisionDetection={closestCenter}
|
{groupedForwards.map((group) => {
|
||||||
sensors={sensors}
|
const isSelfGroup =
|
||||||
onDragEnd={handleDragEnd}
|
isAdmin && tokenUserId !== null && group.userId === tokenUserId;
|
||||||
onDragStart={() => {}} // 添加空的 onDragStart 处理器
|
const groupForwardCount = group.tunnels.reduce(
|
||||||
>
|
(total, tunnel) => total + tunnel.items.length,
|
||||||
<SortableContext
|
0,
|
||||||
items={sortableForwardIds}
|
);
|
||||||
strategy={rectSortingStrategy}
|
|
||||||
>
|
return (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
<div
|
||||||
{sortedForwards.map((forward) =>
|
key={`direct-group-${group.userId}-${group.userName}`}
|
||||||
forward && forward.id ? (
|
className="space-y-3"
|
||||||
<SortableForwardCard key={forward.id} forward={forward} />
|
>
|
||||||
) : null,
|
<div className="flex items-center justify-between px-1">
|
||||||
)}
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<span className="text-sm font-semibold text-foreground">
|
||||||
</SortableContext>
|
{group.userName}
|
||||||
</DndContext>
|
</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">
|
<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"
|
placement="center"
|
||||||
scrollBehavior="inside"
|
scrollBehavior="inside"
|
||||||
size="4xl"
|
size="4xl"
|
||||||
onOpenChange={setDiagnosisModalOpen}
|
onOpenChange={(open) => {
|
||||||
|
setDiagnosisModalOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
diagnosisAbortRef.current?.abort();
|
||||||
|
diagnosisAbortRef.current = null;
|
||||||
|
setDiagnosisLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
{(onClose) => (
|
{(onClose) => (
|
||||||
@@ -2933,7 +3395,8 @@ export default function ForwardPage() {
|
|||||||
)}
|
)}
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<ModalBody className="bg-content1">
|
<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 justify-center py-16">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Spinner size="sm" />
|
<Spinner size="sm" />
|
||||||
@@ -2942,11 +3405,39 @@ export default function ForwardPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : diagnosisResult ? (
|
) : diagnosisResult ? (
|
||||||
<div className="space-y-4">
|
<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="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-center p-3 bg-default-100 dark:bg-gray-800 rounded-lg border border-divider">
|
||||||
<div className="text-2xl font-bold text-foreground">
|
<div className="text-2xl font-bold text-foreground">
|
||||||
{diagnosisResult.results.length}
|
{diagnosisProgress.total > 0
|
||||||
|
? diagnosisProgress.total
|
||||||
|
: diagnosisResult.results.length}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-default-500 mt-1">
|
<div className="text-xs text-default-500 mt-1">
|
||||||
总测试数
|
总测试数
|
||||||
@@ -2954,10 +3445,11 @@ export default function ForwardPage() {
|
|||||||
</div>
|
</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-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">
|
<div className="text-2xl font-bold text-success-600 dark:text-success-400">
|
||||||
{
|
{diagnosisProgress.completed > 0 ||
|
||||||
diagnosisResult.results.filter((r) => r.success)
|
diagnosisProgress.total > 0
|
||||||
.length
|
? diagnosisProgress.success
|
||||||
}
|
: diagnosisResult.results.filter((r) => r.success)
|
||||||
|
.length}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-success-600 dark:text-success-400/80 mt-1">
|
<div className="text-xs text-success-600 dark:text-success-400/80 mt-1">
|
||||||
成功
|
成功
|
||||||
@@ -2965,10 +3457,11 @@ export default function ForwardPage() {
|
|||||||
</div>
|
</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-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">
|
<div className="text-2xl font-bold text-danger-600 dark:text-danger-400">
|
||||||
{
|
{diagnosisProgress.completed > 0 ||
|
||||||
diagnosisResult.results.filter((r) => !r.success)
|
diagnosisProgress.total > 0
|
||||||
.length
|
? diagnosisProgress.failed
|
||||||
}
|
: diagnosisResult.results.filter((r) => !r.success)
|
||||||
|
.length}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-danger-600 dark:text-danger-400/80 mt-1">
|
<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 toast from "react-hot-toast";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
getDiagnosisQualityDisplay,
|
getDiagnosisQualityDisplay,
|
||||||
type DiagnosisResult,
|
type DiagnosisResult,
|
||||||
} from "@/pages/tunnel/diagnosis";
|
} from "@/pages/tunnel/diagnosis";
|
||||||
|
import { diagnoseTunnelStream } from "@/api/diagnosis-stream";
|
||||||
import {
|
import {
|
||||||
createTunnelFormDefaults,
|
createTunnelFormDefaults,
|
||||||
getTunnelFlowDisplay,
|
getTunnelFlowDisplay,
|
||||||
@@ -133,6 +134,14 @@ export default function TunnelPage() {
|
|||||||
useState<Tunnel | null>(null);
|
useState<Tunnel | null>(null);
|
||||||
const [diagnosisResult, setDiagnosisResult] =
|
const [diagnosisResult, setDiagnosisResult] =
|
||||||
useState<DiagnosisResult | null>(null);
|
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());
|
const [form, setForm] = useState<TunnelForm>(createTunnelFormDefaults());
|
||||||
@@ -146,6 +155,13 @@ export default function TunnelPage() {
|
|||||||
const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);
|
const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);
|
||||||
const [batchLoading, setBatchLoading] = useState(false);
|
const [batchLoading, setBatchLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
diagnosisAbortRef.current?.abort();
|
||||||
|
diagnosisAbortRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -434,28 +450,158 @@ export default function TunnelPage() {
|
|||||||
|
|
||||||
// 诊断隧道
|
// 诊断隧道
|
||||||
const handleDiagnose = async (tunnel: Tunnel) => {
|
const handleDiagnose = async (tunnel: Tunnel) => {
|
||||||
|
diagnosisAbortRef.current?.abort();
|
||||||
|
const abortController = new AbortController();
|
||||||
|
diagnosisAbortRef.current = abortController;
|
||||||
|
|
||||||
setCurrentDiagnosisTunnel(tunnel);
|
setCurrentDiagnosisTunnel(tunnel);
|
||||||
setDiagnosisModalOpen(true);
|
setDiagnosisModalOpen(true);
|
||||||
setDiagnosisLoading(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 {
|
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) {
|
if (existingIndex >= 0) {
|
||||||
setDiagnosisResult(response.data as DiagnosisResult);
|
nextResults[existingIndex] = result;
|
||||||
} else {
|
} else {
|
||||||
toast.error(response.msg || "诊断失败");
|
nextResults.push(result);
|
||||||
setDiagnosisResult(
|
}
|
||||||
buildDiagnosisFallbackResult({
|
return {
|
||||||
tunnelName: tunnel.name,
|
...base,
|
||||||
tunnelType: tunnel.type,
|
timestamp: Date.now(),
|
||||||
description: "诊断失败",
|
results: nextResults,
|
||||||
message: response.msg || "诊断过程中发生错误",
|
};
|
||||||
}),
|
});
|
||||||
);
|
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 {
|
} catch {
|
||||||
|
if (abortController.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
toast.error("网络错误,请重试");
|
toast.error("网络错误,请重试");
|
||||||
setDiagnosisResult(
|
setDiagnosisResult(
|
||||||
buildDiagnosisFallbackResult({
|
buildDiagnosisFallbackResult({
|
||||||
@@ -465,7 +611,17 @@ export default function TunnelPage() {
|
|||||||
message: "无法连接到服务器",
|
message: "无法连接到服务器",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
setDiagnosisProgress({
|
||||||
|
total: 1,
|
||||||
|
completed: 1,
|
||||||
|
success: 0,
|
||||||
|
failed: 1,
|
||||||
|
timedOut: false,
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
if (diagnosisAbortRef.current === abortController) {
|
||||||
|
diagnosisAbortRef.current = null;
|
||||||
|
}
|
||||||
setDiagnosisLoading(false);
|
setDiagnosisLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1914,7 +2070,14 @@ export default function TunnelPage() {
|
|||||||
placement="center"
|
placement="center"
|
||||||
scrollBehavior="inside"
|
scrollBehavior="inside"
|
||||||
size="4xl"
|
size="4xl"
|
||||||
onOpenChange={setDiagnosisModalOpen}
|
onOpenChange={(open) => {
|
||||||
|
setDiagnosisModalOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
diagnosisAbortRef.current?.abort();
|
||||||
|
diagnosisAbortRef.current = null;
|
||||||
|
setDiagnosisLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
{(onClose) => (
|
{(onClose) => (
|
||||||
@@ -1943,7 +2106,8 @@ export default function TunnelPage() {
|
|||||||
)}
|
)}
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<ModalBody className="bg-content1">
|
<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 justify-center py-16">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Spinner size="sm" />
|
<Spinner size="sm" />
|
||||||
@@ -1952,11 +2116,39 @@ export default function TunnelPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : diagnosisResult ? (
|
) : diagnosisResult ? (
|
||||||
<div className="space-y-4">
|
<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="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-center p-3 bg-default-100 dark:bg-gray-800 rounded-lg border border-divider">
|
||||||
<div className="text-2xl font-bold text-foreground">
|
<div className="text-2xl font-bold text-foreground">
|
||||||
{diagnosisResult.results.length}
|
{diagnosisProgress.total > 0
|
||||||
|
? diagnosisProgress.total
|
||||||
|
: diagnosisResult.results.length}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-default-500 mt-1">
|
<div className="text-xs text-default-500 mt-1">
|
||||||
总测试数
|
总测试数
|
||||||
@@ -1964,10 +2156,11 @@ export default function TunnelPage() {
|
|||||||
</div>
|
</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-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">
|
<div className="text-2xl font-bold text-success-600 dark:text-success-400">
|
||||||
{
|
{diagnosisProgress.completed > 0 ||
|
||||||
diagnosisResult.results.filter((r) => r.success)
|
diagnosisProgress.total > 0
|
||||||
.length
|
? diagnosisProgress.success
|
||||||
}
|
: diagnosisResult.results.filter((r) => r.success)
|
||||||
|
.length}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-success-600 dark:text-success-400/80 mt-1">
|
<div className="text-xs text-success-600 dark:text-success-400/80 mt-1">
|
||||||
成功
|
成功
|
||||||
@@ -1975,10 +2168,11 @@ export default function TunnelPage() {
|
|||||||
</div>
|
</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-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">
|
<div className="text-2xl font-bold text-danger-600 dark:text-danger-400">
|
||||||
{
|
{diagnosisProgress.completed > 0 ||
|
||||||
diagnosisResult.results.filter((r) => !r.success)
|
diagnosisProgress.total > 0
|
||||||
.length
|
? diagnosisProgress.failed
|
||||||
}
|
: diagnosisResult.results.filter((r) => !r.success)
|
||||||
|
.length}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-danger-600 dark:text-danger-400/80 mt-1">
|
<div className="text-xs text-danger-600 dark:text-danger-400/80 mt-1">
|
||||||
失败
|
失败
|
||||||
|
|||||||
Reference in New Issue
Block a user