mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
feat(tunnel): add delete rule resolution settings (#335)
- Add backend API for tunnel delete rule resolution (allow, deny, confirm) - Add contract tests for delete resolution endpoint - Add frontend API types and endpoints for delete resolution - Add tunnel delete resolution settings UI with resolution mode selector - Support per-tunnel and global delete resolution configuration
This commit is contained in:
@@ -135,6 +135,10 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/tunnel/get", h.tunnelGet)
|
||||
mux.HandleFunc("/api/v1/tunnel/update", h.tunnelUpdate)
|
||||
mux.HandleFunc("/api/v1/tunnel/delete", h.tunnelDelete)
|
||||
mux.HandleFunc("/api/v1/tunnel/delete-preview", h.tunnelDeletePreview)
|
||||
mux.HandleFunc("/api/v1/tunnel/delete-with-forwards", h.tunnelDeleteWithForwards)
|
||||
mux.HandleFunc("/api/v1/tunnel/batch-delete-preview", h.tunnelBatchDeletePreview)
|
||||
mux.HandleFunc("/api/v1/tunnel/batch-delete-with-forwards", h.tunnelBatchDeleteWithForwards)
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,655 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
const tunnelDeletePreviewSampleLimit = 5
|
||||
|
||||
const (
|
||||
tunnelDeleteActionReplace = "replace"
|
||||
tunnelDeleteActionDeleteForwards = "delete_forwards"
|
||||
)
|
||||
|
||||
var (
|
||||
errInvalidTunnelDeleteTarget = errors.New("invalid tunnel delete target")
|
||||
)
|
||||
|
||||
type tunnelDeleteForwardPreviewItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
UserID int64 `json:"userId"`
|
||||
UserName string `json:"userName"`
|
||||
InPort int `json:"inPort"`
|
||||
}
|
||||
|
||||
type tunnelDeletePreviewData struct {
|
||||
TunnelID int64 `json:"tunnelId"`
|
||||
TunnelName string `json:"tunnelName"`
|
||||
ForwardCount int `json:"forwardCount"`
|
||||
SampleForwards []tunnelDeleteForwardPreviewItem `json:"sampleForwards"`
|
||||
}
|
||||
|
||||
type tunnelBatchDeletePreviewData struct {
|
||||
TunnelCount int `json:"tunnelCount"`
|
||||
TotalForwardCount int `json:"totalForwardCount"`
|
||||
Items []tunnelDeletePreviewData `json:"items"`
|
||||
}
|
||||
|
||||
type tunnelDeleteWithForwardsRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Action string `json:"action"`
|
||||
TargetTunnelID int64 `json:"targetTunnelId"`
|
||||
}
|
||||
|
||||
type tunnelBatchDeleteWithForwardsRequest struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
Action string `json:"action"`
|
||||
TargetTunnelID int64 `json:"targetTunnelId"`
|
||||
}
|
||||
|
||||
type tunnelDeleteWithForwardsResult struct {
|
||||
ForwardCount int `json:"forwardCount"`
|
||||
MigratedCount int `json:"migratedCount"`
|
||||
DeletedForwardCount int `json:"deletedForwardCount"`
|
||||
PortAdjustedCount int `json:"portAdjustedCount"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
type tunnelBatchDeleteWithForwardsResult struct {
|
||||
SuccessCount int `json:"successCount"`
|
||||
FailCount int `json:"failCount"`
|
||||
Failures []batchFailureDetail `json:"failures,omitempty"`
|
||||
DeletedForwardCount int `json:"deletedForwardCount"`
|
||||
MigratedCount int `json:"migratedCount"`
|
||||
PortAdjustedCount int `json:"portAdjustedCount"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
type tunnelForwardMigrationPlan struct {
|
||||
forward *forwardRecord
|
||||
oldPorts []forwardPortRecord
|
||||
targetTunnelID int64
|
||||
targetPort int
|
||||
keptNodeIDs []int64
|
||||
removedNodeIDs []int64
|
||||
portAdjusted bool
|
||||
}
|
||||
|
||||
func (h *Handler) tunnelDeletePreview(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
id := idFromBody(r, w)
|
||||
if id <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
preview, err := h.buildTunnelDeletePreview(id)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "不存在") {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(preview))
|
||||
}
|
||||
|
||||
func (h *Handler) tunnelBatchDeletePreview(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
}
|
||||
if err := decodeJSON(r.Body, &req); err != nil || len(req.IDs) == 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
preview, err := h.buildTunnelBatchDeletePreview(req.IDs)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "不存在") {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(preview))
|
||||
}
|
||||
|
||||
func (h *Handler) tunnelDeleteWithForwards(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var req tunnelDeleteWithForwardsRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil || req.ID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
action, err := normalizeTunnelDeleteAction(req.Action)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if action == tunnelDeleteActionReplace {
|
||||
if _, _, authErr := userRoleFromRequest(r); authErr != nil {
|
||||
response.WriteJSON(w, response.Err(401, "无效的token或token已过期"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
result, failures, err := h.processTunnelDeleteWithForwards(req.ID, action, req.TargetTunnelID)
|
||||
if err != nil {
|
||||
if err == errInvalidTunnelDeleteTarget {
|
||||
response.WriteJSON(w, response.ErrDefault("目标隧道不能为空"))
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "目标隧道不能与当前隧道相同") || 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
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
response.WriteJSON(w, response.R{
|
||||
Code: -2,
|
||||
Msg: "部分规则迁移失败",
|
||||
TS: time.Now().UnixMilli(),
|
||||
Data: batchOperationResult{SuccessCount: 0, FailCount: len(failures), Failures: failures},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(result))
|
||||
}
|
||||
|
||||
func (h *Handler) tunnelBatchDeleteWithForwards(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var req tunnelBatchDeleteWithForwardsRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil || len(req.IDs) == 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
action, err := normalizeTunnelDeleteAction(req.Action)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if action == tunnelDeleteActionReplace {
|
||||
if _, _, authErr := userRoleFromRequest(r); authErr != nil {
|
||||
response.WriteJSON(w, response.Err(401, "无效的token或token已过期"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
normalizedIDs := normalizeTunnelIDs(req.IDs)
|
||||
if len(normalizedIDs) == 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
if action == tunnelDeleteActionReplace {
|
||||
if req.TargetTunnelID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("目标隧道不能为空"))
|
||||
return
|
||||
}
|
||||
for _, id := range normalizedIDs {
|
||||
if id == req.TargetTunnelID {
|
||||
response.WriteJSON(w, response.ErrDefault("目标隧道不能包含在删除列表中"))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := tunnelBatchDeleteWithForwardsResult{}
|
||||
for _, tunnelID := range normalizedIDs {
|
||||
tunnelName, _ := h.repo.GetTunnelName(tunnelID)
|
||||
singleResult, failures, processErr := h.processTunnelDeleteWithForwards(tunnelID, action, req.TargetTunnelID)
|
||||
if processErr != nil {
|
||||
result.FailCount++
|
||||
result.Failures = appendBatchFailure(result.Failures, tunnelID, tunnelName, processErr)
|
||||
continue
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
result.FailCount++
|
||||
result.Failures = appendBatchFailureReason(
|
||||
result.Failures,
|
||||
tunnelID,
|
||||
tunnelName,
|
||||
summarizeTunnelDeleteRuleFailures(failures),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
result.SuccessCount++
|
||||
result.DeletedForwardCount += singleResult.DeletedForwardCount
|
||||
result.MigratedCount += singleResult.MigratedCount
|
||||
result.PortAdjustedCount += singleResult.PortAdjustedCount
|
||||
if len(singleResult.Warnings) > 0 {
|
||||
result.Warnings = append(result.Warnings, singleResult.Warnings...)
|
||||
}
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(result))
|
||||
}
|
||||
|
||||
func (h *Handler) buildTunnelDeletePreview(tunnelID int64) (*tunnelDeletePreviewData, error) {
|
||||
if _, err := h.getTunnelRecord(tunnelID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tunnelName, err := h.repo.GetTunnelName(tunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
forwards, err := h.listForwardsByTunnel(tunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
samples := make([]tunnelDeleteForwardPreviewItem, 0, minInt(len(forwards), tunnelDeletePreviewSampleLimit))
|
||||
for i, forward := range forwards {
|
||||
if i >= tunnelDeletePreviewSampleLimit {
|
||||
break
|
||||
}
|
||||
ports, portsErr := h.listForwardPorts(forward.ID)
|
||||
if portsErr != nil {
|
||||
return nil, portsErr
|
||||
}
|
||||
inPort := 0
|
||||
if len(ports) > 0 {
|
||||
inPort = ports[0].Port
|
||||
}
|
||||
samples = append(samples, tunnelDeleteForwardPreviewItem{
|
||||
ID: forward.ID,
|
||||
Name: forward.Name,
|
||||
UserID: forward.UserID,
|
||||
UserName: forward.UserName,
|
||||
InPort: inPort,
|
||||
})
|
||||
}
|
||||
|
||||
return &tunnelDeletePreviewData{
|
||||
TunnelID: tunnelID,
|
||||
TunnelName: tunnelName,
|
||||
ForwardCount: len(forwards),
|
||||
SampleForwards: samples,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) buildTunnelBatchDeletePreview(ids []int64) (*tunnelBatchDeletePreviewData, error) {
|
||||
normalizedIDs := normalizeTunnelIDs(ids)
|
||||
items := make([]tunnelDeletePreviewData, 0, len(normalizedIDs))
|
||||
totalForwardCount := 0
|
||||
for _, id := range normalizedIDs {
|
||||
preview, err := h.buildTunnelDeletePreview(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, *preview)
|
||||
totalForwardCount += preview.ForwardCount
|
||||
}
|
||||
return &tunnelBatchDeletePreviewData{
|
||||
TunnelCount: len(items),
|
||||
TotalForwardCount: totalForwardCount,
|
||||
Items: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeTunnelDeleteAction(action string) (string, error) {
|
||||
normalized := strings.TrimSpace(action)
|
||||
if normalized == "" {
|
||||
return tunnelDeleteActionDeleteForwards, nil
|
||||
}
|
||||
if normalized != tunnelDeleteActionReplace && normalized != tunnelDeleteActionDeleteForwards {
|
||||
return "", errors.New("invalid tunnel delete action")
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeTunnelIDs(ids []int64) []int64 {
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
out := make([]int64, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func summarizeTunnelDeleteRuleFailures(failures []batchFailureDetail) string {
|
||||
if len(failures) == 0 {
|
||||
return "未知错误"
|
||||
}
|
||||
parts := make([]string, 0, minInt(len(failures), 3))
|
||||
for i, failure := range failures {
|
||||
if i >= 3 {
|
||||
break
|
||||
}
|
||||
name := strings.TrimSpace(failure.Name)
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("规则 #%d", failure.ID)
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s: %s", name, strings.TrimSpace(failure.Reason)))
|
||||
}
|
||||
if len(failures) > 3 {
|
||||
parts = append(parts, fmt.Sprintf("另有 %d 条规则失败", len(failures)-3))
|
||||
}
|
||||
return strings.Join(parts, ";")
|
||||
}
|
||||
|
||||
func (h *Handler) processTunnelDeleteWithForwards(tunnelID int64, action string, targetTunnelID int64) (tunnelDeleteWithForwardsResult, []batchFailureDetail, error) {
|
||||
preview, err := h.buildTunnelDeletePreview(tunnelID)
|
||||
if err != nil {
|
||||
return tunnelDeleteWithForwardsResult{}, nil, err
|
||||
}
|
||||
|
||||
result := tunnelDeleteWithForwardsResult{ForwardCount: preview.ForwardCount}
|
||||
if preview.ForwardCount == 0 {
|
||||
if err := h.deleteTunnelAndCleanup(tunnelID); err != nil {
|
||||
return tunnelDeleteWithForwardsResult{}, nil, err
|
||||
}
|
||||
return result, nil, nil
|
||||
}
|
||||
|
||||
if action == tunnelDeleteActionDeleteForwards {
|
||||
result.DeletedForwardCount = preview.ForwardCount
|
||||
if err := h.deleteTunnelAndCleanup(tunnelID); err != nil {
|
||||
return tunnelDeleteWithForwardsResult{}, nil, err
|
||||
}
|
||||
return result, nil, nil
|
||||
}
|
||||
|
||||
if targetTunnelID <= 0 {
|
||||
return tunnelDeleteWithForwardsResult{}, nil, errInvalidTunnelDeleteTarget
|
||||
}
|
||||
if targetTunnelID == tunnelID {
|
||||
return tunnelDeleteWithForwardsResult{}, nil, errors.New("目标隧道不能与当前隧道相同")
|
||||
}
|
||||
return h.processTunnelDeleteReplaceAction(tunnelID, targetTunnelID, result)
|
||||
}
|
||||
|
||||
func (h *Handler) processTunnelDeleteReplaceAction(tunnelID, targetTunnelID int64, result tunnelDeleteWithForwardsResult) (tunnelDeleteWithForwardsResult, []batchFailureDetail, error) {
|
||||
targetTunnel, err := h.getTunnelRecord(targetTunnelID)
|
||||
if err != nil {
|
||||
return tunnelDeleteWithForwardsResult{}, nil, errors.New("目标隧道不存在")
|
||||
}
|
||||
if targetTunnel.Status != 1 {
|
||||
return tunnelDeleteWithForwardsResult{}, nil, errors.New("目标隧道已禁用")
|
||||
}
|
||||
|
||||
plans, failures, err := h.planTunnelDeleteForwardMigrations(tunnelID, targetTunnelID)
|
||||
if err != nil {
|
||||
return tunnelDeleteWithForwardsResult{}, nil, err
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return tunnelDeleteWithForwardsResult{}, failures, nil
|
||||
}
|
||||
|
||||
portAdjustedCount := 0
|
||||
warnings, execErr, execFailure := h.executeTunnelDeleteForwardMigrations(plans)
|
||||
for _, plan := range plans {
|
||||
if plan.portAdjusted {
|
||||
portAdjustedCount++
|
||||
}
|
||||
}
|
||||
if execErr != nil {
|
||||
failures = append(failures, execFailure)
|
||||
return tunnelDeleteWithForwardsResult{}, failures, nil
|
||||
}
|
||||
|
||||
if err := h.deleteTunnelAndCleanup(tunnelID); err != nil {
|
||||
h.rollbackTunnelForwardMigrationPlans(plans)
|
||||
_ = h.redeployTunnelAndForwards(tunnelID)
|
||||
return tunnelDeleteWithForwardsResult{}, nil, err
|
||||
}
|
||||
|
||||
result.MigratedCount = len(plans)
|
||||
result.PortAdjustedCount = portAdjustedCount
|
||||
if len(warnings) > 0 {
|
||||
result.Warnings = warnings
|
||||
}
|
||||
return result, nil, nil
|
||||
}
|
||||
|
||||
func (h *Handler) planTunnelDeleteForwardMigrations(sourceTunnelID, targetTunnelID int64) ([]tunnelForwardMigrationPlan, []batchFailureDetail, error) {
|
||||
forwards, err := h.listForwardsByTunnel(sourceTunnelID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
entryNodes, err := h.tunnelEntryNodeIDs(targetTunnelID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(entryNodes) == 0 {
|
||||
return nil, nil, errors.New("目标隧道缺少入口节点")
|
||||
}
|
||||
|
||||
plans := make([]tunnelForwardMigrationPlan, 0, len(forwards))
|
||||
failures := make([]batchFailureDetail, 0)
|
||||
reservedPorts := make(map[int64]map[int]bool)
|
||||
|
||||
for _, forward := range forwards {
|
||||
plan, planErr := h.planSingleTunnelDeleteForwardMigration(&forward, targetTunnelID, entryNodes, reservedPorts)
|
||||
if planErr != nil {
|
||||
failures = appendBatchFailure(failures, forward.ID, forward.Name, planErr)
|
||||
continue
|
||||
}
|
||||
plans = append(plans, plan)
|
||||
}
|
||||
|
||||
return plans, failures, nil
|
||||
}
|
||||
|
||||
func (h *Handler) planSingleTunnelDeleteForwardMigration(forward *forwardRecord, targetTunnelID int64, targetEntryNodes []int64, reservedPorts map[int64]map[int]bool) (tunnelForwardMigrationPlan, error) {
|
||||
if forward == nil {
|
||||
return tunnelForwardMigrationPlan{}, errors.New("转发不存在")
|
||||
}
|
||||
|
||||
oldPorts, err := h.listForwardPorts(forward.ID)
|
||||
if err != nil {
|
||||
return tunnelForwardMigrationPlan{}, err
|
||||
}
|
||||
if len(oldPorts) == 0 {
|
||||
return tunnelForwardMigrationPlan{}, errors.New("转发入口端口不存在")
|
||||
}
|
||||
|
||||
minPort := h.repo.GetMinForwardPort(forward.ID)
|
||||
targetPort := 0
|
||||
if minPort.Valid {
|
||||
targetPort = int(minPort.Int64)
|
||||
}
|
||||
if targetPort <= 0 {
|
||||
targetPort = h.pickTunnelPort(targetTunnelID)
|
||||
}
|
||||
if targetPort <= 0 {
|
||||
targetPort = 10000
|
||||
}
|
||||
|
||||
hasCustomInIP := false
|
||||
for _, oldPort := range oldPorts {
|
||||
if strings.TrimSpace(oldPort.InIP) != "" {
|
||||
hasCustomInIP = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasCustomInIP && len(targetEntryNodes) > 1 {
|
||||
return tunnelForwardMigrationPlan{}, errors.New("多入口隧道的转发不支持保留自定义监听IP,请先手动调整该规则")
|
||||
}
|
||||
|
||||
for _, nodeID := range targetEntryNodes {
|
||||
node, nodeErr := h.getNodeRecord(nodeID)
|
||||
if nodeErr != nil {
|
||||
return tunnelForwardMigrationPlan{}, nodeErr
|
||||
}
|
||||
if err := validateRemoteNodePort(node, targetPort); err != nil {
|
||||
return tunnelForwardMigrationPlan{}, err
|
||||
}
|
||||
if err := validateLocalNodePort(node, targetPort); err != nil {
|
||||
return tunnelForwardMigrationPlan{}, err
|
||||
}
|
||||
if err := h.validateForwardPortAvailability(node, targetPort, forward.ID); err != nil {
|
||||
return tunnelForwardMigrationPlan{}, err
|
||||
}
|
||||
if reservedOnNode, ok := reservedPorts[nodeID]; ok && reservedOnNode[targetPort] {
|
||||
return tunnelForwardMigrationPlan{}, fmt.Errorf("目标隧道入口节点端口 %d 已被本次迁移中的其他规则占用", targetPort)
|
||||
}
|
||||
}
|
||||
|
||||
for _, nodeID := range targetEntryNodes {
|
||||
reservedOnNode := reservedPorts[nodeID]
|
||||
if reservedOnNode == nil {
|
||||
reservedOnNode = make(map[int]bool)
|
||||
reservedPorts[nodeID] = reservedOnNode
|
||||
}
|
||||
reservedOnNode[targetPort] = true
|
||||
}
|
||||
|
||||
oldNodeIDs := forwardPortNodeIDs(oldPorts)
|
||||
newNodeIDs := uniqueInt64s(targetEntryNodes)
|
||||
removedNodeIDs := diffInt64s(oldNodeIDs, newNodeIDs)
|
||||
keptNodeIDs := diffInt64s(oldNodeIDs, removedNodeIDs)
|
||||
|
||||
previousPort := 0
|
||||
if len(oldPorts) > 0 {
|
||||
previousPort = oldPorts[0].Port
|
||||
}
|
||||
|
||||
return tunnelForwardMigrationPlan{
|
||||
forward: forward,
|
||||
oldPorts: oldPorts,
|
||||
targetTunnelID: targetTunnelID,
|
||||
targetPort: targetPort,
|
||||
keptNodeIDs: keptNodeIDs,
|
||||
removedNodeIDs: removedNodeIDs,
|
||||
portAdjusted: previousPort > 0 && previousPort != targetPort,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) executeTunnelDeleteForwardMigrations(plans []tunnelForwardMigrationPlan) ([]string, error, batchFailureDetail) {
|
||||
warnings := make([]string, 0)
|
||||
completed := make([]tunnelForwardMigrationPlan, 0, len(plans))
|
||||
|
||||
for _, plan := range plans {
|
||||
migrationWarnings, err := h.applyTunnelDeleteForwardMigration(plan)
|
||||
if err != nil {
|
||||
h.rollbackTunnelForwardMigrationPlans(completed)
|
||||
return warnings, err, batchFailureDetail{ID: plan.forward.ID, Name: plan.forward.Name, Reason: normalizeBatchFailureReason(errString(err))}
|
||||
}
|
||||
warnings = append(warnings, migrationWarnings...)
|
||||
completed = append(completed, plan)
|
||||
}
|
||||
|
||||
return warnings, nil, batchFailureDetail{}
|
||||
}
|
||||
|
||||
func (h *Handler) applyTunnelDeleteForwardMigration(plan tunnelForwardMigrationPlan) ([]string, error) {
|
||||
if plan.forward == nil {
|
||||
return nil, errors.New("转发不存在")
|
||||
}
|
||||
|
||||
if err := h.repo.UpdateForwardTunnel(plan.forward.ID, plan.targetTunnelID, time.Now().UnixMilli()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := h.replaceForwardPorts(plan.forward.ID, plan.targetTunnelID, plan.targetPort, ""); err != nil {
|
||||
h.rollbackForwardMutation(plan.forward, plan.oldPorts)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updatedForward, err := h.getForwardRecord(plan.forward.ID)
|
||||
if err != nil {
|
||||
h.rollbackForwardMutation(plan.forward, plan.oldPorts)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
warnings := make([]string, 0)
|
||||
if len(plan.keptNodeIDs) > 0 {
|
||||
for _, nodeID := range plan.keptNodeIDs {
|
||||
if delErr := h.deleteForwardServicesOnNodeBatch(plan.forward, nodeID); delErr != nil {
|
||||
nodeLabel := fmt.Sprintf("%d", nodeID)
|
||||
if n, nErr := h.getNodeRecord(nodeID); nErr == nil && n != nil && strings.TrimSpace(n.Name) != "" {
|
||||
nodeLabel = strings.TrimSpace(n.Name)
|
||||
}
|
||||
warnings = append(warnings, fmt.Sprintf("节点 %s 清理旧转发监听失败: %v", nodeLabel, delErr))
|
||||
}
|
||||
}
|
||||
time.Sleep(tunnelServiceBindRetryDelay)
|
||||
}
|
||||
|
||||
syncWarnings, err := h.syncForwardServicesWithWarnings(updatedForward, "UpdateService", true)
|
||||
if err != nil {
|
||||
h.rollbackForwardMutation(plan.forward, plan.oldPorts)
|
||||
return nil, err
|
||||
}
|
||||
warnings = append(warnings, syncWarnings...)
|
||||
|
||||
if len(plan.removedNodeIDs) > 0 {
|
||||
for _, nodeID := range plan.removedNodeIDs {
|
||||
if delErr := h.deleteForwardServicesOnNodeBatch(plan.forward, nodeID); delErr != nil {
|
||||
nodeLabel := fmt.Sprintf("%d", nodeID)
|
||||
if n, nErr := h.getNodeRecord(nodeID); nErr == nil && n != nil && strings.TrimSpace(n.Name) != "" {
|
||||
nodeLabel = strings.TrimSpace(n.Name)
|
||||
}
|
||||
warnings = append(warnings, fmt.Sprintf("节点 %s 清理旧隧道残留服务失败: %v", nodeLabel, delErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return warnings, nil
|
||||
}
|
||||
|
||||
func (h *Handler) rollbackTunnelForwardMigrationPlans(plans []tunnelForwardMigrationPlan) {
|
||||
for i := len(plans) - 1; i >= 0; i-- {
|
||||
plan := plans[i]
|
||||
h.rollbackForwardMutation(plan.forward, plan.oldPorts)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) deleteTunnelAndCleanup(tunnelID int64) error {
|
||||
h.cleanupTunnelRuntime(tunnelID)
|
||||
h.cleanupFederationRuntime(tunnelID)
|
||||
if err := h.deleteTunnelByID(tunnelID); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
storeRepo "go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestTunnelDeletePreviewIncludesDependentRulesContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
adminToken := mustAdminToken(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
sourceTunnelID, sourceNodeID := seedTunnelDeleteTunnelWithNode(t, repo, now, "preview-source-tunnel", "preview-source-node", "21000-21010")
|
||||
seedTunnelDeleteForward(t, repo, now, sourceTunnelID, sourceNodeID, "preview-forward", 21001)
|
||||
|
||||
out := requestContractEnvelope(t, router, adminToken, "/api/v1/tunnel/delete-preview", map[string]interface{}{"id": sourceTunnelID})
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected success, got code=%d msg=%q", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
data, ok := out.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected preview data object, got %T", out.Data)
|
||||
}
|
||||
if contractValueAsInt64(data["tunnelId"]) != sourceTunnelID {
|
||||
t.Fatalf("unexpected tunnelId: %#v", data["tunnelId"])
|
||||
}
|
||||
if contractValueAsInt64(data["forwardCount"]) != 1 {
|
||||
t.Fatalf("expected forwardCount=1, got %#v", data["forwardCount"])
|
||||
}
|
||||
|
||||
samples, ok := data["sampleForwards"].([]interface{})
|
||||
if !ok || len(samples) != 1 {
|
||||
t.Fatalf("expected one sample forward, got %#v", data["sampleForwards"])
|
||||
}
|
||||
first, ok := samples[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected sample object, got %T", samples[0])
|
||||
}
|
||||
if first["name"] != "preview-forward" {
|
||||
t.Fatalf("unexpected sample name: %#v", first["name"])
|
||||
}
|
||||
if contractValueAsInt64(first["inPort"]) != 21001 {
|
||||
t.Fatalf("unexpected sample inPort: %#v", first["inPort"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelDeleteWithForwardsDeleteActionRemovesTunnelAndRulesContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
adminToken := mustAdminToken(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
sourceTunnelID, sourceNodeID := seedTunnelDeleteTunnelWithNode(t, repo, now, "delete-source-tunnel", "delete-source-node", "22000-22010")
|
||||
forwardID := seedTunnelDeleteForward(t, repo, now, sourceTunnelID, sourceNodeID, "delete-forward", 22001)
|
||||
|
||||
out := requestContractEnvelope(t, router, adminToken, "/api/v1/tunnel/delete-with-forwards", map[string]interface{}{
|
||||
"id": sourceTunnelID,
|
||||
"action": "delete_forwards",
|
||||
})
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected success, got code=%d msg=%q", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM tunnel WHERE id = ?`, sourceTunnelID); count != 0 {
|
||||
t.Fatalf("expected tunnel deleted, got count=%d", count)
|
||||
}
|
||||
if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM forward WHERE id = ?`, forwardID); count != 0 {
|
||||
t.Fatalf("expected forward deleted, got count=%d", count)
|
||||
}
|
||||
if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM forward_port WHERE forward_id = ?`, forwardID); count != 0 {
|
||||
t.Fatalf("expected forward ports deleted, got count=%d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelDeleteWithForwardsReplaceReturnsFailureDetailsContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
adminToken := mustAdminToken(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
sourceTunnelID, sourceNodeID := seedTunnelDeleteTunnelWithNode(t, repo, now, "replace-source-tunnel", "replace-source-node", "23000-23010")
|
||||
forwardID := seedTunnelDeleteForward(t, repo, now, sourceTunnelID, sourceNodeID, "replace-forward", 23001)
|
||||
targetTunnelID, targetNodeID := seedTunnelDeleteTunnelWithNode(t, repo, now, "replace-target-tunnel", "replace-target-node", "23000-23010")
|
||||
seedTunnelDeleteForward(t, repo, now, targetTunnelID, targetNodeID, "occupied-forward", 23001)
|
||||
|
||||
out := requestContractEnvelope(t, router, adminToken, "/api/v1/tunnel/delete-with-forwards", map[string]interface{}{
|
||||
"id": sourceTunnelID,
|
||||
"action": "replace",
|
||||
"targetTunnelId": targetTunnelID,
|
||||
})
|
||||
if out.Code != -2 {
|
||||
t.Fatalf("expected failure code -2, got code=%d msg=%q", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
result := mustTunnelDeleteFailureResult(t, out)
|
||||
if contractValueAsInt64(result["failCount"]) != 1 {
|
||||
t.Fatalf("expected failCount=1, got %#v", result["failCount"])
|
||||
}
|
||||
assertBatchFailureNameAndReason(t, result, "replace-forward", "节点 replace-target-node 端口 23001 已被其他转发占用")
|
||||
|
||||
if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM tunnel WHERE id = ?`, sourceTunnelID); count != 1 {
|
||||
t.Fatalf("expected source tunnel kept, got count=%d", count)
|
||||
}
|
||||
if tunnelAfter := mustQueryInt64(t, repo, `SELECT tunnel_id FROM forward WHERE id = ?`, forwardID); tunnelAfter != sourceTunnelID {
|
||||
t.Fatalf("expected forward tunnel unchanged, got %d", tunnelAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelBatchDeletePreviewIncludesTotalsContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
adminToken := mustAdminToken(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
tunnelA, nodeA := seedTunnelDeleteTunnelWithNode(t, repo, now, "batch-preview-a", "batch-preview-node-a", "24000-24010")
|
||||
tunnelB, _ := seedTunnelDeleteTunnelWithNode(t, repo, now, "batch-preview-b", "batch-preview-node-b", "24100-24110")
|
||||
seedTunnelDeleteForward(t, repo, now, tunnelA, nodeA, "batch-preview-forward", 24001)
|
||||
|
||||
out := requestContractEnvelope(t, router, adminToken, "/api/v1/tunnel/batch-delete-preview", map[string]interface{}{
|
||||
"ids": []int64{tunnelA, tunnelB},
|
||||
})
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected success, got code=%d msg=%q", out.Code, out.Msg)
|
||||
}
|
||||
data, ok := out.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected preview object, got %T", out.Data)
|
||||
}
|
||||
if contractValueAsInt64(data["tunnelCount"]) != 2 {
|
||||
t.Fatalf("expected tunnelCount=2, got %#v", data["tunnelCount"])
|
||||
}
|
||||
if contractValueAsInt64(data["totalForwardCount"]) != 1 {
|
||||
t.Fatalf("expected totalForwardCount=1, got %#v", data["totalForwardCount"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelBatchDeleteWithForwardsReturnsTunnelLevelFailuresContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
adminToken := mustAdminToken(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
sourceTunnelA, _ := seedTunnelDeleteTunnelWithNode(t, repo, now, "batch-replace-source-a", "batch-replace-source-node-a", "25000-25010")
|
||||
sourceTunnelB, sourceNodeB := seedTunnelDeleteTunnelWithNode(t, repo, now, "batch-replace-source-b", "batch-replace-source-node-b", "25100-25110")
|
||||
targetTunnelID, targetNodeID := seedTunnelDeleteTunnelWithNode(t, repo, now, "batch-replace-target", "batch-replace-target-node", "25000-25010")
|
||||
|
||||
seedTunnelDeleteForward(t, repo, now, sourceTunnelB, sourceNodeB, "batch-replace-forward-b", 25002)
|
||||
seedTunnelDeleteForward(t, repo, now, targetTunnelID, targetNodeID, "batch-replace-occupied", 25002)
|
||||
|
||||
out := requestContractEnvelope(t, router, adminToken, "/api/v1/tunnel/batch-delete-with-forwards", map[string]interface{}{
|
||||
"ids": []int64{sourceTunnelA, sourceTunnelB},
|
||||
"action": "replace",
|
||||
"targetTunnelId": targetTunnelID,
|
||||
})
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected success envelope, got code=%d msg=%q", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
result := mustTunnelDeleteFailureResult(t, out)
|
||||
if contractValueAsInt64(result["successCount"]) != 1 {
|
||||
t.Fatalf("expected successCount=1, got %#v", result["successCount"])
|
||||
}
|
||||
if contractValueAsInt64(result["failCount"]) != 1 {
|
||||
t.Fatalf("expected failCount=1, got %#v", result["failCount"])
|
||||
}
|
||||
assertBatchFailureNameAndReason(t, result, "batch-replace-source-b", "batch-replace-forward-b: 节点 batch-replace-target-node 端口 25002 已被其他转发占用")
|
||||
|
||||
if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM tunnel WHERE id = ?`, sourceTunnelA); count != 0 {
|
||||
t.Fatalf("expected source tunnel A deleted, got count=%d", count)
|
||||
}
|
||||
if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM tunnel WHERE id = ?`, sourceTunnelB); count != 1 {
|
||||
t.Fatalf("expected source tunnel B kept, got count=%d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func seedTunnelDeleteTunnelWithNode(t *testing.T, repo *storeRepo.Repository, now int64, tunnelName, nodeName, portRange string) (int64, int64) {
|
||||
t.Helper()
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, status, created_time, updated_time, in_ip, inx, ip_preference)
|
||||
VALUES(?, 1.0, 1, 'tls', 1, 1, ?, ?, NULL, 0, '')
|
||||
`, tunnelName, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel %s: %v", tunnelName, err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, repo, tunnelName)
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, '10.0.0.1', '10.0.0.1', '', ?, '', 'v1', 1, 1, 1, ?, ?, 1, '[::]', '[::]', 0)
|
||||
`, nodeName, nodeName+"-secret", portRange, now, now).Error; err != nil {
|
||||
t.Fatalf("insert node %s: %v", nodeName, err)
|
||||
}
|
||||
nodeID := mustLastInsertID(t, repo, nodeName)
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 0, 'round', 1, 'tls')
|
||||
`, tunnelID, nodeID).Error; err != nil {
|
||||
t.Fatalf("insert chain_tunnel for %s: %v", tunnelName, err)
|
||||
}
|
||||
|
||||
return tunnelID, nodeID
|
||||
}
|
||||
|
||||
func seedTunnelDeleteForward(t *testing.T, repo *storeRepo.Repository, now int64, tunnelID, nodeID int64, forwardName string, port int) int64 {
|
||||
t.Helper()
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(2, 'contract-user', ?, ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, forwardName, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert forward %s: %v", forwardName, err)
|
||||
}
|
||||
forwardID := mustLastInsertID(t, repo, forwardName)
|
||||
|
||||
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, port).Error; err != nil {
|
||||
t.Fatalf("insert forward_port for %s: %v", forwardName, err)
|
||||
}
|
||||
|
||||
return forwardID
|
||||
}
|
||||
|
||||
func mustTunnelDeleteFailureResult(t *testing.T, out response.R) map[string]interface{} {
|
||||
t.Helper()
|
||||
result, ok := out.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected result object, got %T", out.Data)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
# 044 - 隧道删除时的规则依赖处理设计
|
||||
|
||||
## Goal
|
||||
- 删除隧道前识别关联规则,避免默认级联误删。
|
||||
- 在隧道页提供两种处理方式:迁移规则到其他隧道,或连同规则一起删除。
|
||||
- 第一阶段只覆盖单条隧道删除,先把核心交互和执行链路做稳。
|
||||
|
||||
## Checklist
|
||||
- [x] 复核当前 `tunnel.tsx` 删除交互与 `DeleteTunnelCascade` 行为。
|
||||
- [x] 梳理可复用的规则换隧道能力(`forward/batch-change-tunnel`)。
|
||||
- [x] 产出前端交互、接口与执行链路设计。
|
||||
- [x] 明确第一阶段范围、异常处理与回滚要求。
|
||||
|
||||
## Implementation Checklist
|
||||
- [x] 新增后端删除预检与带规则处理的删除接口。
|
||||
- [x] 在后端补齐规则迁移/失败明细/回滚链路。
|
||||
- [x] 接入前端隧道删除预检、替换/删除规则弹窗与结果展示。
|
||||
- [x] 运行定向测试并记录结果。
|
||||
|
||||
## Current State
|
||||
- `vite-frontend/src/pages/tunnel.tsx` 的单条删除当前直接调用 `/tunnel/delete`,没有前置依赖检查。
|
||||
- `go-backend/internal/store/repo/repository_mutations.go` 里的 `DeleteTunnelCascade` 会直接删除该隧道下的 `forward`、`forward_port`、`user_tunnel`、`chain_tunnel` 等关联数据。
|
||||
- `vite-frontend/src/pages/forward.tsx` 已经有“批量换隧道”交互和 `/forward/batch-change-tunnel`,但如果前端直接串联“先换规则、再删隧道”,中间任一步失败都会留下半完成状态,不适合作为删除流程的最终方案。
|
||||
|
||||
## Proposed UX
|
||||
- 点击隧道删除按钮后,前端先请求“删除预检”接口,不直接进入最终确认。
|
||||
- 若关联规则数为 `0`,继续沿用当前简单确认弹框。
|
||||
- 若关联规则数大于 `0`,改为展示“处理关联规则”弹框:
|
||||
- 顶部 `Alert` 提示:`隧道 "A" 正被 12 条规则使用`。
|
||||
- 展示前 5 条规则摘要:规则名、所属用户、入口端口;剩余规则用“还有 N 条未展开”提示即可。
|
||||
- 提供 `RadioGroup` 两个动作:
|
||||
- `替换到其他隧道`(推荐,默认)
|
||||
- `删除这些规则`
|
||||
- 选择“替换到其他隧道”时显示 `Select`:
|
||||
- 数据源直接复用当前页已经加载的 `tunnels`。
|
||||
- 仅显示 `status === 1` 且 `id !== 当前隧道` 的选项。
|
||||
- 若没有可选隧道,则禁用该选项并自动回退到“删除这些规则”。
|
||||
- 确认按钮文案动态变化:
|
||||
- `替换规则并删除隧道`
|
||||
- `删除规则并删除隧道`
|
||||
- 提交时展示 loading 状态;成功后关闭弹框、刷新隧道列表并 toast 成功。
|
||||
- 如果后端返回规则级失败明细,前端复用现有 `BatchActionResultModal` 展示失败项,而不是只给一个通用 toast。
|
||||
|
||||
## API Design
|
||||
### 1. 删除预检
|
||||
`POST /api/v1/tunnel/delete-preview`
|
||||
|
||||
Request:
|
||||
```json
|
||||
{ "id": 12 }
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": {
|
||||
"tunnelId": 12,
|
||||
"tunnelName": "HK-Entry",
|
||||
"forwardCount": 12,
|
||||
"sampleForwards": [
|
||||
{
|
||||
"id": 101,
|
||||
"name": "web-1",
|
||||
"userId": 9,
|
||||
"userName": "alice",
|
||||
"inPort": 443
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `sampleForwards` 只需要返回前 5 条,前端用 `forwardCount` 决定是否显示“更多”提示。
|
||||
- 预检只描述现状,不负责最终授权或一致性保证;真正提交删除时,后端必须再校验一次。
|
||||
|
||||
### 2. 带规则处理的删除
|
||||
`POST /api/v1/tunnel/delete-with-forwards`
|
||||
|
||||
Request:
|
||||
```json
|
||||
{
|
||||
"id": 12,
|
||||
"action": "replace",
|
||||
"targetTunnelId": 18
|
||||
}
|
||||
```
|
||||
|
||||
或:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 12,
|
||||
"action": "delete_forwards"
|
||||
}
|
||||
```
|
||||
|
||||
Success response:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": {
|
||||
"forwardCount": 12,
|
||||
"migratedCount": 12,
|
||||
"deletedForwardCount": 0,
|
||||
"portAdjustedCount": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Failure response:
|
||||
```json
|
||||
{
|
||||
"code": -2,
|
||||
"msg": "部分规则迁移失败",
|
||||
"data": {
|
||||
"successCount": 9,
|
||||
"failCount": 3,
|
||||
"failures": [
|
||||
{
|
||||
"id": 101,
|
||||
"name": "web-1",
|
||||
"reason": "目标隧道入口节点端口 443 已占用"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 保持现有 `/tunnel/delete` 不动,兼容旧调用和当前批量删除逻辑。
|
||||
- `vite-frontend/src/pages/tunnel.tsx` 的单条删除新流程全部走新接口。
|
||||
|
||||
## Backend Execution Strategy
|
||||
- `delete-preview`
|
||||
- 校验 tunnel 是否存在。
|
||||
- 查询 `forward.tunnel_id = 当前隧道` 的数量和前 5 条摘要。
|
||||
- `delete-with-forwards`
|
||||
- 再次查询依赖规则,避免 preview 与提交之间状态变化导致误判。
|
||||
- `action = delete_forwards`
|
||||
- 直接复用当前级联删除逻辑。
|
||||
- `action = replace`
|
||||
- 校验 `targetTunnelId`:存在、启用、且不等于当前隧道。
|
||||
- 先对全部关联规则做一次前置校验:目标隧道入口节点、端口范围、端口冲突、监听 IP 约束。
|
||||
- 全部校验通过后,再逐条迁移规则并同步运行时。
|
||||
- 任一规则迁移失败时,返回失败明细并中止删除;对已迁移规则做回滚,保证用户感知为“要么都成功,要么都不删”。
|
||||
- 规则处理完成后,再删除隧道本身及非 `forward` 关联数据。
|
||||
|
||||
## Frontend Implementation Notes
|
||||
- `vite-frontend/src/pages/tunnel.tsx`
|
||||
- 新增删除预检 loading、依赖摘要、处理动作等 state。
|
||||
- 复用现有 `Modal`,根据 preview 结果切换普通确认视图和依赖处理视图。
|
||||
- 复用 `Select`、`RadioGroup`、`Alert`。
|
||||
- 成功后继续沿用当前 `setTunnels`、`setTunnelOrder`、`setSelectedIds` 清理逻辑。
|
||||
- `vite-frontend/src/api/index.ts`
|
||||
- 新增 `previewTunnelDelete`。
|
||||
- 新增 `deleteTunnelWithForwards`。
|
||||
- 删除失败且返回批量明细时,复用 `BatchActionResultModal`,避免具体失败规则原因被 toast 吞掉。
|
||||
|
||||
## Scope Decision
|
||||
- 第一阶段只改“隧道页单条删除”。
|
||||
- `批量删除隧道` 先保持现有“级联删除规则”行为和提示文案不变。
|
||||
- 如果第一阶段确认体验和后端回滚链路都稳定,再补第二阶段:批量删除时按每条隧道分别预检和处理。
|
||||
|
||||
## Edge Cases
|
||||
- 没有可替换隧道:只允许“删除这些规则”。
|
||||
- 目标隧道在提交前被禁用或删除:后端返回明确错误,前端保留当前弹框和用户选择。
|
||||
- preview 时没有规则、提交时新建了规则:以后端最终校验为准,返回需要重新处理的提示。
|
||||
- 迁移时若原入口端口在目标隧道不可用,沿用现有“换隧道”语义:优先保留原端口,不可用时自动分配可用端口,并通过 `portAdjustedCount` 给前端一个非阻塞提示。
|
||||
- 第一阶段不自动补发目标隧道的 `user_tunnel` 授权,先与现有“批量换隧道”语义保持一致;如果后续需要让用户也获得目标隧道的新增规则权限,再单独评估自动补授权。
|
||||
|
||||
## Implementation Notes
|
||||
- 后端新增 `/api/v1/tunnel/delete-preview` 和 `/api/v1/tunnel/delete-with-forwards`,单条隧道删除改为先预检再执行。
|
||||
- 后端新增 `/api/v1/tunnel/batch-delete-preview` 和 `/api/v1/tunnel/batch-delete-with-forwards`,批量删除支持统一预检并选择“批量替换规则”或“批量删除规则”。
|
||||
- 规则替换删除在后端先做端口/节点占用预检,再执行逐条迁移;执行阶段若任一规则失败,会回滚已迁移规则,并把失败明细返回给前端弹窗展示。
|
||||
- 前端 `tunnel.tsx` 删除弹窗已支持三种状态:预检中、普通删除确认、有依赖规则时的“替换/删除规则”决策视图。
|
||||
- 批量删除弹窗现在会汇总每条选中隧道的依赖规则数量,并在批量替换失败时按“隧道级”返回失败原因,避免整批操作信息丢失。
|
||||
|
||||
## Verification
|
||||
- `cd go-backend && go test ./internal/http/handler/...`
|
||||
- `cd go-backend && go test ./tests/contract/... -run 'TestTunnelDeletePreviewIncludesDependentRulesContract|TestTunnelDeleteWithForwardsDeleteActionRemovesTunnelAndRulesContract|TestTunnelDeleteWithForwardsReplaceReturnsFailureDetailsContract'`
|
||||
- `cd vite-frontend && npm run build`
|
||||
@@ -6,6 +6,10 @@ import type {
|
||||
NodeReleaseApiItem,
|
||||
NodeApiItem,
|
||||
SpeedLimitApiItem,
|
||||
TunnelBatchDeletePreviewApiData,
|
||||
TunnelBatchDeleteWithForwardsApiData,
|
||||
TunnelDeletePreviewApiData,
|
||||
TunnelDeleteWithForwardsApiData,
|
||||
TunnelDiagnosisApiData,
|
||||
TunnelGroupApiItem,
|
||||
UserApiItem,
|
||||
@@ -122,6 +126,30 @@ export const updateTunnel = (data: TunnelMutationPayload) =>
|
||||
Network.post("/tunnel/update", data);
|
||||
export const deleteTunnel = (id: number) =>
|
||||
Network.post("/tunnel/delete", { id });
|
||||
export const previewTunnelDelete = (id: number) =>
|
||||
Network.post<TunnelDeletePreviewApiData>("/tunnel/delete-preview", { id });
|
||||
export const deleteTunnelWithForwards = (data: {
|
||||
id: number;
|
||||
action: "replace" | "delete_forwards";
|
||||
targetTunnelId?: number;
|
||||
}) =>
|
||||
Network.post<TunnelDeleteWithForwardsApiData | BatchOperationResult>(
|
||||
"/tunnel/delete-with-forwards",
|
||||
data,
|
||||
);
|
||||
export const previewBatchTunnelDelete = (ids: number[]) =>
|
||||
Network.post<TunnelBatchDeletePreviewApiData>("/tunnel/batch-delete-preview", {
|
||||
ids,
|
||||
});
|
||||
export const batchDeleteTunnelsWithForwards = (data: {
|
||||
ids: number[];
|
||||
action: "replace" | "delete_forwards";
|
||||
targetTunnelId?: number;
|
||||
}) =>
|
||||
Network.post<TunnelBatchDeleteWithForwardsApiData>(
|
||||
"/tunnel/batch-delete-with-forwards",
|
||||
data,
|
||||
);
|
||||
export const diagnoseTunnel = (tunnelId: number) =>
|
||||
Network.post<TunnelDiagnosisApiData>(
|
||||
"/tunnel/diagnose",
|
||||
|
||||
@@ -222,6 +222,50 @@ export interface BatchOperationFailure {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TunnelDeletePreviewForwardApiItem {
|
||||
id: number;
|
||||
name: string;
|
||||
userId: number;
|
||||
userName: string;
|
||||
inPort: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TunnelDeletePreviewApiData {
|
||||
tunnelId: number;
|
||||
tunnelName: string;
|
||||
forwardCount: number;
|
||||
sampleForwards: TunnelDeletePreviewForwardApiItem[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TunnelBatchDeletePreviewApiData {
|
||||
tunnelCount: number;
|
||||
totalForwardCount: number;
|
||||
items: TunnelDeletePreviewApiData[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TunnelDeleteWithForwardsApiData {
|
||||
forwardCount: number;
|
||||
migratedCount: number;
|
||||
deletedForwardCount: number;
|
||||
portAdjustedCount: number;
|
||||
warnings?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TunnelBatchDeleteWithForwardsApiData {
|
||||
successCount: number;
|
||||
failCount: number;
|
||||
failures?: BatchOperationFailure[];
|
||||
deletedForwardCount: number;
|
||||
migratedCount: number;
|
||||
portAdjustedCount: number;
|
||||
warnings?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface UserMutationPayload {
|
||||
id?: number;
|
||||
user?: string;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { BatchOperationFailure } from "@/api/types";
|
||||
import type {
|
||||
BatchOperationFailure,
|
||||
TunnelBatchDeletePreviewApiData,
|
||||
TunnelDeletePreviewApiData,
|
||||
} from "@/api/types";
|
||||
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -40,16 +44,19 @@ import { Divider } from "@/shadcn-bridge/heroui/divider";
|
||||
import { Alert } from "@/shadcn-bridge/heroui/alert";
|
||||
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
|
||||
import { Progress } from "@/shadcn-bridge/heroui/progress";
|
||||
import { Radio, RadioGroup } from "@/shadcn-bridge/heroui/radio";
|
||||
import {
|
||||
createTunnel,
|
||||
batchDeleteTunnelsWithForwards,
|
||||
getTunnelList,
|
||||
updateTunnel,
|
||||
deleteTunnel,
|
||||
deleteTunnelWithForwards,
|
||||
getNodeList,
|
||||
diagnoseTunnel,
|
||||
updateTunnelOrder,
|
||||
batchDeleteTunnels,
|
||||
batchRedeployTunnels,
|
||||
previewBatchTunnelDelete,
|
||||
previewTunnelDelete,
|
||||
} from "@/api";
|
||||
import { PageLoadingState } from "@/components/page-state";
|
||||
import {
|
||||
@@ -136,6 +143,8 @@ interface BatchResultModalState {
|
||||
title: string;
|
||||
}
|
||||
|
||||
type TunnelDeleteAction = "replace" | "delete_forwards";
|
||||
|
||||
const EMPTY_BATCH_RESULT_MODAL_STATE: BatchResultModalState = {
|
||||
failures: [],
|
||||
open: false,
|
||||
@@ -143,6 +152,8 @@ const EMPTY_BATCH_RESULT_MODAL_STATE: BatchResultModalState = {
|
||||
title: "",
|
||||
};
|
||||
|
||||
const DEFAULT_TUNNEL_DELETE_ACTION: TunnelDeleteAction = "replace";
|
||||
|
||||
const TUNNEL_ORDER_KEY = "tunnel-order";
|
||||
|
||||
const mapTunnelApiItems = (items: any[]): Tunnel[] => {
|
||||
@@ -178,8 +189,16 @@ export default function TunnelPage() {
|
||||
const [isEdit, setIsEdit] = useState(false);
|
||||
const [submitLoading, setSubmitLoading] = useState(false);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [deletePreviewLoading, setDeletePreviewLoading] = useState(false);
|
||||
const [diagnosisLoading, setDiagnosisLoading] = useState(false);
|
||||
const [tunnelToDelete, setTunnelToDelete] = useState<Tunnel | null>(null);
|
||||
const [tunnelDeletePreview, setTunnelDeletePreview] =
|
||||
useState<TunnelDeletePreviewApiData | null>(null);
|
||||
const [deleteAction, setDeleteAction] =
|
||||
useState<TunnelDeleteAction>(DEFAULT_TUNNEL_DELETE_ACTION);
|
||||
const [deleteTargetTunnelId, setDeleteTargetTunnelId] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [currentDiagnosisTunnel, setCurrentDiagnosisTunnel] =
|
||||
useState<Tunnel | null>(null);
|
||||
const [diagnosisResult, setDiagnosisResult] =
|
||||
@@ -247,6 +266,15 @@ export default function TunnelPage() {
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);
|
||||
const [batchDeletePreviewLoading, setBatchDeletePreviewLoading] =
|
||||
useState(false);
|
||||
const [batchDeletePreview, setBatchDeletePreview] =
|
||||
useState<TunnelBatchDeletePreviewApiData | null>(null);
|
||||
const [batchDeleteAction, setBatchDeleteAction] =
|
||||
useState<TunnelDeleteAction>(DEFAULT_TUNNEL_DELETE_ACTION);
|
||||
const [batchDeleteTargetTunnelId, setBatchDeleteTargetTunnelId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
const [batchProgress, setBatchProgress] = useState<BatchProgressState>({
|
||||
active: false,
|
||||
@@ -339,6 +367,42 @@ export default function TunnelPage() {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const resetDeleteState = useCallback(() => {
|
||||
setDeleteLoading(false);
|
||||
setDeletePreviewLoading(false);
|
||||
setTunnelToDelete(null);
|
||||
setTunnelDeletePreview(null);
|
||||
setDeleteAction(DEFAULT_TUNNEL_DELETE_ACTION);
|
||||
setDeleteTargetTunnelId(null);
|
||||
}, []);
|
||||
|
||||
const handleDeleteModalOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
setDeleteModalOpen(open);
|
||||
if (!open) {
|
||||
resetDeleteState();
|
||||
}
|
||||
},
|
||||
[resetDeleteState],
|
||||
);
|
||||
|
||||
const resetBatchDeleteState = useCallback(() => {
|
||||
setBatchDeletePreviewLoading(false);
|
||||
setBatchDeletePreview(null);
|
||||
setBatchDeleteAction(DEFAULT_TUNNEL_DELETE_ACTION);
|
||||
setBatchDeleteTargetTunnelId(null);
|
||||
}, []);
|
||||
|
||||
const handleBatchDeleteModalOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
setBatchDeleteModalOpen(open);
|
||||
if (!open) {
|
||||
resetBatchDeleteState();
|
||||
}
|
||||
},
|
||||
[resetBatchDeleteState],
|
||||
);
|
||||
|
||||
// 表单验证
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors = validateTunnelForm(form, nodes);
|
||||
@@ -384,22 +448,78 @@ export default function TunnelPage() {
|
||||
};
|
||||
|
||||
// 删除隧道
|
||||
const handleDelete = (tunnel: Tunnel) => {
|
||||
const handleDelete = async (tunnel: Tunnel) => {
|
||||
setTunnelToDelete(tunnel);
|
||||
setDeleteModalOpen(true);
|
||||
|
||||
setDeletePreviewLoading(true);
|
||||
setTunnelDeletePreview(null);
|
||||
setDeleteAction(DEFAULT_TUNNEL_DELETE_ACTION);
|
||||
setDeleteTargetTunnelId(null);
|
||||
|
||||
try {
|
||||
const response = await previewTunnelDelete(tunnel.id);
|
||||
|
||||
if (response.code !== 0 || !response.data) {
|
||||
toast.error(response.msg || "获取删除依赖失败");
|
||||
setDeleteModalOpen(false);
|
||||
resetDeleteState();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setTunnelDeletePreview(response.data);
|
||||
} catch (error) {
|
||||
toast.error(extractApiErrorMessage(error, "获取删除依赖失败"));
|
||||
setDeleteModalOpen(false);
|
||||
resetDeleteState();
|
||||
} finally {
|
||||
setDeletePreviewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!tunnelToDelete) return;
|
||||
|
||||
const forwardCount = tunnelDeletePreview?.forwardCount ?? 0;
|
||||
const action: TunnelDeleteAction =
|
||||
forwardCount > 0 ? deleteAction : "delete_forwards";
|
||||
|
||||
if (
|
||||
action === "replace" &&
|
||||
forwardCount > 0 &&
|
||||
(!deleteTargetTunnelId ||
|
||||
!deleteReplacementTunnels.some(
|
||||
(tunnel) => tunnel.id === deleteTargetTunnelId,
|
||||
))
|
||||
) {
|
||||
toast.error("请选择替换规则的目标隧道");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
const response = await deleteTunnel(tunnelToDelete.id);
|
||||
const response = await deleteTunnelWithForwards({
|
||||
id: tunnelToDelete.id,
|
||||
action,
|
||||
targetTunnelId:
|
||||
action === "replace" ? deleteTargetTunnelId ?? undefined : undefined,
|
||||
});
|
||||
|
||||
if (response.code === 0) {
|
||||
toast.success("删除成功");
|
||||
const deleteResult = (response.data || null) as {
|
||||
warnings?: string[];
|
||||
} | null;
|
||||
|
||||
if ((deleteResult?.warnings?.length ?? 0) > 0) {
|
||||
toast.success(
|
||||
`删除成功,另有 ${deleteResult?.warnings?.length ?? 0} 条节点清理提示`,
|
||||
);
|
||||
} else {
|
||||
toast.success("删除成功");
|
||||
}
|
||||
setDeleteModalOpen(false);
|
||||
setTunnelToDelete(null);
|
||||
setTunnels((prev) =>
|
||||
prev.filter((tunnel) => tunnel.id !== tunnelToDelete.id),
|
||||
);
|
||||
@@ -417,11 +537,32 @@ export default function TunnelPage() {
|
||||
|
||||
return next;
|
||||
});
|
||||
resetDeleteState();
|
||||
} else if (
|
||||
response.data &&
|
||||
typeof response.data === "object" &&
|
||||
Number((response.data as { failCount?: number }).failCount ?? 0) > 0
|
||||
) {
|
||||
const result = response.data as {
|
||||
failCount?: number;
|
||||
successCount?: number;
|
||||
};
|
||||
const failures = extractBatchFailures(response.data);
|
||||
|
||||
if (failures.length > 0) {
|
||||
setBatchResultModal({
|
||||
failures,
|
||||
open: true,
|
||||
summary: `成功 ${Number(result.successCount ?? 0)} 项,失败 ${Number(result.failCount ?? failures.length)} 项`,
|
||||
title: "规则处理失败",
|
||||
});
|
||||
}
|
||||
toast.error(response.msg || "删除失败");
|
||||
} else {
|
||||
toast.error(response.msg || "删除失败");
|
||||
}
|
||||
} catch {
|
||||
toast.error("删除失败");
|
||||
} catch (error) {
|
||||
toast.error(extractApiErrorMessage(error, "删除失败"));
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
@@ -877,8 +1018,48 @@ export default function TunnelPage() {
|
||||
[],
|
||||
);
|
||||
|
||||
const handleOpenBatchDeleteModal = async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
|
||||
setBatchDeleteModalOpen(true);
|
||||
setBatchDeletePreviewLoading(true);
|
||||
setBatchDeletePreview(null);
|
||||
setBatchDeleteAction(DEFAULT_TUNNEL_DELETE_ACTION);
|
||||
setBatchDeleteTargetTunnelId(null);
|
||||
|
||||
try {
|
||||
const response = await previewBatchTunnelDelete(selectedTunnelIdList);
|
||||
|
||||
if (response.code !== 0 || !response.data) {
|
||||
toast.error(response.msg || "获取批量删除依赖失败");
|
||||
setBatchDeleteModalOpen(false);
|
||||
resetBatchDeleteState();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setBatchDeletePreview(response.data);
|
||||
} catch (error) {
|
||||
toast.error(extractApiErrorMessage(error, "获取批量删除依赖失败"));
|
||||
setBatchDeleteModalOpen(false);
|
||||
resetBatchDeleteState();
|
||||
} finally {
|
||||
setBatchDeletePreviewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
if (
|
||||
batchDeleteHasForwardDependencies &&
|
||||
batchDeleteAction === "replace" &&
|
||||
(!batchDeleteTargetTunnelId || batchDeleteReplaceUnavailable)
|
||||
) {
|
||||
toast.error("请选择替换规则的目标隧道");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setBatchLoading(true);
|
||||
setBatchProgress({
|
||||
active: true,
|
||||
@@ -886,28 +1067,40 @@ export default function TunnelPage() {
|
||||
percent: 30,
|
||||
});
|
||||
try {
|
||||
const res = await batchDeleteTunnels(Array.from(selectedIds));
|
||||
const res = await batchDeleteTunnelsWithForwards({
|
||||
ids: selectedTunnelIdList,
|
||||
action: batchDeleteHasForwardDependencies
|
||||
? batchDeleteAction
|
||||
: "delete_forwards",
|
||||
targetTunnelId:
|
||||
batchDeleteHasForwardDependencies && batchDeleteAction === "replace"
|
||||
? batchDeleteTargetTunnelId ?? undefined
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (res.code === 0) {
|
||||
const result = res.data;
|
||||
const result = (res.data || {
|
||||
successCount: 0,
|
||||
failCount: 0,
|
||||
warnings: [],
|
||||
}) as {
|
||||
successCount: number;
|
||||
failCount: number;
|
||||
warnings?: string[];
|
||||
};
|
||||
const warningCount = result?.warnings?.length ?? 0;
|
||||
|
||||
if (result.failCount === 0) {
|
||||
toast.success(`成功删除 ${result.successCount} 项`);
|
||||
toast.success(
|
||||
warningCount > 0
|
||||
? `成功删除 ${result.successCount} 项,另有 ${warningCount} 条节点清理提示`
|
||||
: `成功删除 ${result.successCount} 项`,
|
||||
);
|
||||
setBatchProgress({
|
||||
active: true,
|
||||
label: `删除完成:成功 ${result.successCount} 项`,
|
||||
percent: 100,
|
||||
});
|
||||
setTunnels((prev) =>
|
||||
prev.filter((tunnel) => !selectedIds.has(tunnel.id)),
|
||||
);
|
||||
setTunnelOrder((prev) => {
|
||||
const next = prev.filter((id) => !selectedIds.has(id));
|
||||
|
||||
saveOrder(TUNNEL_ORDER_KEY, next);
|
||||
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
const failures = extractBatchFailures(result);
|
||||
|
||||
@@ -927,11 +1120,12 @@ export default function TunnelPage() {
|
||||
label: `部分完成:成功 ${result.successCount} 项,正在刷新列表...`,
|
||||
percent: 75,
|
||||
});
|
||||
await refreshTunnelList(false);
|
||||
}
|
||||
await refreshTunnelList(false);
|
||||
setSelectedIds(new Set());
|
||||
setSelectMode(false);
|
||||
setBatchDeleteModalOpen(false);
|
||||
resetBatchDeleteState();
|
||||
} else {
|
||||
toast.error(res.msg || "删除失败");
|
||||
}
|
||||
@@ -1069,6 +1263,150 @@ export default function TunnelPage() {
|
||||
[sortedTunnels],
|
||||
);
|
||||
|
||||
const deleteReplacementTunnels = useMemo(() => {
|
||||
if (!tunnelToDelete) {
|
||||
return [] as Tunnel[];
|
||||
}
|
||||
|
||||
return tunnels
|
||||
.filter((tunnel) => tunnel.id !== tunnelToDelete.id && tunnel.status === 1)
|
||||
.sort((a, b) => {
|
||||
const aInx = a.inx ?? 0;
|
||||
const bInx = b.inx ?? 0;
|
||||
|
||||
return aInx - bInx;
|
||||
});
|
||||
}, [tunnelToDelete, tunnels]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!deleteModalOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((tunnelDeletePreview?.forwardCount ?? 0) <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (deleteReplacementTunnels.length === 0) {
|
||||
setDeleteAction("delete_forwards");
|
||||
setDeleteTargetTunnelId(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (deleteAction !== "replace") {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteTargetTunnelId((prev) => {
|
||||
if (prev && deleteReplacementTunnels.some((tunnel) => tunnel.id === prev)) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return deleteReplacementTunnels[0]?.id ?? null;
|
||||
});
|
||||
}, [
|
||||
deleteAction,
|
||||
deleteModalOpen,
|
||||
deleteReplacementTunnels,
|
||||
tunnelDeletePreview?.forwardCount,
|
||||
]);
|
||||
|
||||
const deletePreviewForwardCount = tunnelDeletePreview?.forwardCount ?? 0;
|
||||
const deleteHasForwardDependencies = deletePreviewForwardCount > 0;
|
||||
const deleteReplaceUnavailable =
|
||||
deleteHasForwardDependencies && deleteReplacementTunnels.length === 0;
|
||||
const deleteConfirmLabel = deleteHasForwardDependencies
|
||||
? deleteAction === "replace"
|
||||
? "迁移规则后删除该隧道"
|
||||
: "删除规则并删除该隧道"
|
||||
: "删除该隧道";
|
||||
|
||||
const selectedTunnelIdList = useMemo(
|
||||
() => Array.from(selectedIds),
|
||||
[selectedIds],
|
||||
);
|
||||
const batchDeleteReplacementTunnels = useMemo(() => {
|
||||
if (selectedIds.size === 0) {
|
||||
return [] as Tunnel[];
|
||||
}
|
||||
|
||||
return tunnels
|
||||
.filter((tunnel) => !selectedIds.has(tunnel.id) && tunnel.status === 1)
|
||||
.sort((a, b) => {
|
||||
const aInx = a.inx ?? 0;
|
||||
const bInx = b.inx ?? 0;
|
||||
|
||||
return aInx - bInx;
|
||||
});
|
||||
}, [selectedIds, tunnels]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!batchDeleteModalOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((batchDeletePreview?.totalForwardCount ?? 0) <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (batchDeleteReplacementTunnels.length === 0) {
|
||||
setBatchDeleteAction("delete_forwards");
|
||||
setBatchDeleteTargetTunnelId(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (batchDeleteAction !== "replace") {
|
||||
return;
|
||||
}
|
||||
|
||||
setBatchDeleteTargetTunnelId((prev) => {
|
||||
if (
|
||||
prev &&
|
||||
batchDeleteReplacementTunnels.some((tunnel) => tunnel.id === prev)
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return batchDeleteReplacementTunnels[0]?.id ?? null;
|
||||
});
|
||||
}, [
|
||||
batchDeleteAction,
|
||||
batchDeleteModalOpen,
|
||||
batchDeletePreview?.totalForwardCount,
|
||||
batchDeleteReplacementTunnels,
|
||||
]);
|
||||
|
||||
const batchDeleteTotalForwardCount = batchDeletePreview?.totalForwardCount ?? 0;
|
||||
const batchDeleteHasForwardDependencies = batchDeleteTotalForwardCount > 0;
|
||||
const batchDeleteDependentTunnelCount =
|
||||
batchDeletePreview?.items?.filter((item) => item.forwardCount > 0).length ?? 0;
|
||||
const batchDeleteDirectDeleteTunnelCount = Math.max(
|
||||
selectedTunnelIdList.length - batchDeleteDependentTunnelCount,
|
||||
0,
|
||||
);
|
||||
const batchDeletePreviewItems = useMemo(() => {
|
||||
return [...(batchDeletePreview?.items ?? [])].sort((a, b) => {
|
||||
if ((a.forwardCount > 0) === (b.forwardCount > 0)) {
|
||||
return a.tunnelName.localeCompare(b.tunnelName, "zh-CN");
|
||||
}
|
||||
|
||||
return a.forwardCount > 0 ? -1 : 1;
|
||||
});
|
||||
}, [batchDeletePreview?.items]);
|
||||
const batchDeleteDependentItems = useMemo(
|
||||
() => batchDeletePreviewItems.filter((item) => item.forwardCount > 0),
|
||||
[batchDeletePreviewItems],
|
||||
);
|
||||
const batchDeleteReplaceUnavailable =
|
||||
batchDeleteHasForwardDependencies && batchDeleteReplacementTunnels.length === 0;
|
||||
const batchDeleteConfirmLabel = batchDeleteHasForwardDependencies
|
||||
? batchDeleteAction === "replace"
|
||||
? `迁移规则后删除这 ${selectedTunnelIdList.length} 条隧道`
|
||||
: `删除规则并删除 ${selectedTunnelIdList.length} 条隧道`
|
||||
: `删除这 ${selectedTunnelIdList.length} 条隧道`;
|
||||
|
||||
const SortableItem = ({
|
||||
id,
|
||||
children,
|
||||
@@ -1151,7 +1489,7 @@ export default function TunnelPage() {
|
||||
isDisabled={selectedIds.size === 0}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => setBatchDeleteModalOpen(true)}
|
||||
onPress={handleOpenBatchDeleteModal}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
@@ -2459,22 +2797,143 @@ export default function TunnelPage() {
|
||||
placement="center"
|
||||
scrollBehavior="outside"
|
||||
size="2xl"
|
||||
onOpenChange={setDeleteModalOpen}
|
||||
onOpenChange={handleDeleteModalOpenChange}
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
<h2 className="text-xl font-bold">确认删除</h2>
|
||||
<h2 className="text-lg font-bold sm:text-xl">删除隧道</h2>
|
||||
<p className="text-xs font-normal leading-5 text-default-500 sm:text-sm">
|
||||
{tunnelDeletePreview?.tunnelName || tunnelToDelete?.name
|
||||
? `即将删除“${tunnelDeletePreview?.tunnelName || tunnelToDelete?.name}”,删除前会先检查是否有关联规则。`
|
||||
: "删除前会先检查是否有关联规则。"}
|
||||
</p>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<p>
|
||||
确定要删除隧道{" "}
|
||||
<strong>"{tunnelToDelete?.name}"</strong> 吗?
|
||||
</p>
|
||||
<p className="text-small text-default-500">
|
||||
此操作不可恢复,请谨慎操作。
|
||||
</p>
|
||||
<ModalBody className="space-y-3 sm:space-y-4">
|
||||
{deletePreviewLoading ? (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-divider bg-content2/40 px-3 py-5 text-sm text-default-600 sm:px-4 sm:py-6">
|
||||
<Spinner size="sm" />
|
||||
正在检查是否有规则正在使用该隧道...
|
||||
</div>
|
||||
) : deleteHasForwardDependencies ? (
|
||||
<>
|
||||
<Alert
|
||||
color="warning"
|
||||
description={`隧道 \"${tunnelDeletePreview?.tunnelName || tunnelToDelete?.name || ""}\" 当前被 ${deletePreviewForwardCount} 条规则使用。删除前需要先处理这些规则。`}
|
||||
title="发现关联规则"
|
||||
variant="flat"
|
||||
/>
|
||||
|
||||
{(tunnelDeletePreview?.sampleForwards?.length ?? 0) > 0 ? (
|
||||
<div className="space-y-3 rounded-xl border border-divider bg-content2/40 p-3 sm:p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
关联规则预览
|
||||
</h3>
|
||||
<span className="text-xs text-default-500">
|
||||
前 {tunnelDeletePreview?.sampleForwards?.length ?? 0} 条
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{tunnelDeletePreview?.sampleForwards?.map((forward) => (
|
||||
<div
|
||||
key={forward.id}
|
||||
className="rounded-lg border border-divider/70 bg-background/80 px-2.5 py-2 sm:px-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="truncate text-sm font-medium text-foreground">
|
||||
{forward.name}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-xs text-default-500">
|
||||
:{forward.inPort || 0}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-default-500">
|
||||
用户:{forward.userName || `#${forward.userId}`}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{deletePreviewForwardCount >
|
||||
(tunnelDeletePreview?.sampleForwards?.length ?? 0) ? (
|
||||
<p className="text-xs text-default-500">
|
||||
还有 {deletePreviewForwardCount - (tunnelDeletePreview?.sampleForwards?.length ?? 0)} 条规则未展开显示。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<RadioGroup
|
||||
label="处理方式"
|
||||
value={deleteAction}
|
||||
onValueChange={(value) => {
|
||||
const nextAction = value as TunnelDeleteAction;
|
||||
|
||||
setDeleteAction(nextAction);
|
||||
if (nextAction !== "replace") {
|
||||
setDeleteTargetTunnelId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteTargetTunnelId(
|
||||
deleteReplacementTunnels[0]?.id ?? null,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Radio value="replace">
|
||||
保留规则,迁移到其他隧道{deleteReplaceUnavailable ? "(当前无可用目标)" : "(推荐)"}
|
||||
</Radio>
|
||||
<Radio value="delete_forwards">直接删除这些关联规则</Radio>
|
||||
</RadioGroup>
|
||||
|
||||
{deleteReplaceUnavailable ? (
|
||||
<Alert
|
||||
color="warning"
|
||||
description="当前没有其他启用中的隧道可用于承接这些规则,只能删除关联规则后再删除该隧道。"
|
||||
variant="flat"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{deleteAction === "replace" && !deleteReplaceUnavailable ? (
|
||||
<div className="space-y-2">
|
||||
<Select
|
||||
label="目标隧道"
|
||||
placeholder="请选择目标隧道"
|
||||
selectedKeys={
|
||||
deleteTargetTunnelId
|
||||
? [String(deleteTargetTunnelId)]
|
||||
: []
|
||||
}
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selected = Array.from(keys)[0];
|
||||
|
||||
setDeleteTargetTunnelId(
|
||||
selected ? Number(selected) : null,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{deleteReplacementTunnels.map((tunnel) => (
|
||||
<SelectItem key={String(tunnel.id)}>
|
||||
{tunnel.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
<p className="text-xs text-default-500">
|
||||
关联规则会迁移到这里,当前要删除的隧道不会出现在可选项里。
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<Alert
|
||||
color="warning"
|
||||
description={`当前未发现关联规则。确认后将直接删除“${tunnelToDelete?.name || "该隧道"}”,此操作不可撤销。`}
|
||||
title="可以直接删除"
|
||||
variant="flat"
|
||||
/>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
@@ -2482,10 +2941,16 @@ export default function TunnelPage() {
|
||||
</Button>
|
||||
<Button
|
||||
color="danger"
|
||||
isDisabled={
|
||||
deletePreviewLoading ||
|
||||
(deleteHasForwardDependencies &&
|
||||
deleteAction === "replace" &&
|
||||
(!deleteTargetTunnelId || deleteReplaceUnavailable))
|
||||
}
|
||||
isLoading={deleteLoading}
|
||||
onPress={confirmDelete}
|
||||
>
|
||||
{deleteLoading ? "删除中..." : "确认删除"}
|
||||
{deleteLoading ? "删除中..." : deleteConfirmLabel}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
@@ -3085,17 +3550,154 @@ export default function TunnelPage() {
|
||||
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
|
||||
}}
|
||||
isOpen={batchDeleteModalOpen}
|
||||
onOpenChange={setBatchDeleteModalOpen}
|
||||
onOpenChange={handleBatchDeleteModalOpenChange}
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader>确认删除</ModalHeader>
|
||||
<ModalBody>
|
||||
<p>
|
||||
确定要删除选中的 {selectedIds.size}{" "}
|
||||
项隧道吗?此操作不可撤销,相关规则也将被删除。
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
<h2 className="text-lg font-bold sm:text-xl">批量删除隧道</h2>
|
||||
<p className="text-xs font-normal leading-5 text-default-500 sm:text-sm">
|
||||
即将删除这 {selectedTunnelIdList.length} 条隧道,删除前会先检查是否有关联规则。
|
||||
</p>
|
||||
</ModalHeader>
|
||||
<ModalBody className="space-y-3 sm:space-y-4">
|
||||
{batchDeletePreviewLoading ? (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-divider bg-content2/40 px-3 py-5 text-sm text-default-600 sm:px-4 sm:py-6">
|
||||
<Spinner size="sm" />
|
||||
正在检查选中隧道是否有关联规则...
|
||||
</div>
|
||||
) : batchDeleteHasForwardDependencies ? (
|
||||
<>
|
||||
<Alert
|
||||
color="warning"
|
||||
description={`已选 ${selectedTunnelIdList.length} 条隧道,其中 ${batchDeleteDependentTunnelCount} 条仍被规则使用,共 ${batchDeleteTotalForwardCount} 条规则待处理。${batchDeleteDirectDeleteTunnelCount > 0 ? `其余 ${batchDeleteDirectDeleteTunnelCount} 条会直接删除。` : ""}`}
|
||||
title="发现关联规则"
|
||||
variant="flat"
|
||||
/>
|
||||
|
||||
<div className="max-h-64 space-y-3 overflow-y-auto rounded-xl border border-divider bg-content2/40 p-3 sm:max-h-72 sm:p-4">
|
||||
{batchDeleteDependentItems.map((item) => (
|
||||
<div
|
||||
key={item.tunnelId}
|
||||
className="rounded-lg border border-divider/70 bg-background/80 p-2.5 sm:p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{item.tunnelName}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-default-500">
|
||||
{item.forwardCount} 条规则依赖
|
||||
</p>
|
||||
</div>
|
||||
<Chip color="warning" size="sm" variant="flat">
|
||||
有关联
|
||||
</Chip>
|
||||
</div>
|
||||
|
||||
{item.sampleForwards.length > 0 ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{item.sampleForwards.map((forward) => (
|
||||
<div
|
||||
key={forward.id}
|
||||
className="rounded-md bg-content1/70 px-2.5 py-2 sm:px-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="truncate text-xs font-medium text-foreground">
|
||||
{forward.name}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-[11px] text-default-500">
|
||||
:{forward.inPort || 0}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-default-500">
|
||||
用户:{forward.userName || `#${forward.userId}`}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{item.forwardCount > item.sampleForwards.length ? (
|
||||
<p className="text-[11px] text-default-500">
|
||||
还有 {item.forwardCount - item.sampleForwards.length} 条规则未展开显示。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<RadioGroup
|
||||
label="处理方式"
|
||||
value={batchDeleteAction}
|
||||
onValueChange={(value) => {
|
||||
const nextAction = value as TunnelDeleteAction;
|
||||
|
||||
setBatchDeleteAction(nextAction);
|
||||
if (nextAction !== "replace") {
|
||||
setBatchDeleteTargetTunnelId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setBatchDeleteTargetTunnelId(
|
||||
batchDeleteReplacementTunnels[0]?.id ?? null,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Radio value="replace">
|
||||
保留规则,统一迁移到其他隧道{batchDeleteReplaceUnavailable ? "(当前无可用目标)" : "(推荐)"}
|
||||
</Radio>
|
||||
<Radio value="delete_forwards">直接删除这些关联规则</Radio>
|
||||
</RadioGroup>
|
||||
|
||||
{batchDeleteReplaceUnavailable ? (
|
||||
<Alert
|
||||
color="warning"
|
||||
description="当前没有可承接这些规则的启用隧道,只能删除关联规则后再删除所选隧道。"
|
||||
variant="flat"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{batchDeleteAction === "replace" &&
|
||||
!batchDeleteReplaceUnavailable ? (
|
||||
<div className="space-y-2">
|
||||
<Select
|
||||
label="目标隧道"
|
||||
placeholder="请选择目标隧道"
|
||||
selectedKeys={
|
||||
batchDeleteTargetTunnelId
|
||||
? [String(batchDeleteTargetTunnelId)]
|
||||
: []
|
||||
}
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selected = Array.from(keys)[0];
|
||||
|
||||
setBatchDeleteTargetTunnelId(
|
||||
selected ? Number(selected) : null,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{batchDeleteReplacementTunnels.map((tunnel) => (
|
||||
<SelectItem key={String(tunnel.id)}>
|
||||
{tunnel.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
<p className="text-xs text-default-500">
|
||||
所有关联规则都会迁移到这里,删除列表中的隧道不会出现在可选项里。
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<Alert
|
||||
color="warning"
|
||||
description={`已选 ${selectedTunnelIdList.length} 条隧道,当前未发现关联规则。确认后将直接删除这些隧道,此操作不可撤销。`}
|
||||
title="可以直接删除"
|
||||
variant="flat"
|
||||
/>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
@@ -3103,10 +3705,17 @@ export default function TunnelPage() {
|
||||
</Button>
|
||||
<Button
|
||||
color="danger"
|
||||
isDisabled={
|
||||
batchDeletePreviewLoading ||
|
||||
(batchDeleteHasForwardDependencies &&
|
||||
batchDeleteAction === "replace" &&
|
||||
(!batchDeleteTargetTunnelId ||
|
||||
batchDeleteReplaceUnavailable))
|
||||
}
|
||||
isLoading={batchLoading}
|
||||
onPress={handleBatchDelete}
|
||||
>
|
||||
确认删除
|
||||
{batchLoading ? "删除中..." : batchDeleteConfirmLabel}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user