mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-07 18:26:37 +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/get", h.tunnelGet)
|
||||||
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/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", h.tunnelDiagnose)
|
||||||
mux.HandleFunc("/api/v1/tunnel/diagnose/stream", h.tunnelDiagnoseStream)
|
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)
|
||||||
|
|||||||
@@ -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,
|
NodeReleaseApiItem,
|
||||||
NodeApiItem,
|
NodeApiItem,
|
||||||
SpeedLimitApiItem,
|
SpeedLimitApiItem,
|
||||||
|
TunnelBatchDeletePreviewApiData,
|
||||||
|
TunnelBatchDeleteWithForwardsApiData,
|
||||||
|
TunnelDeletePreviewApiData,
|
||||||
|
TunnelDeleteWithForwardsApiData,
|
||||||
TunnelDiagnosisApiData,
|
TunnelDiagnosisApiData,
|
||||||
TunnelGroupApiItem,
|
TunnelGroupApiItem,
|
||||||
UserApiItem,
|
UserApiItem,
|
||||||
@@ -122,6 +126,30 @@ export const updateTunnel = (data: TunnelMutationPayload) =>
|
|||||||
Network.post("/tunnel/update", data);
|
Network.post("/tunnel/update", data);
|
||||||
export const deleteTunnel = (id: number) =>
|
export const deleteTunnel = (id: number) =>
|
||||||
Network.post("/tunnel/delete", { id });
|
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) =>
|
export const diagnoseTunnel = (tunnelId: number) =>
|
||||||
Network.post<TunnelDiagnosisApiData>(
|
Network.post<TunnelDiagnosisApiData>(
|
||||||
"/tunnel/diagnose",
|
"/tunnel/diagnose",
|
||||||
|
|||||||
@@ -222,6 +222,50 @@ export interface BatchOperationFailure {
|
|||||||
[key: string]: unknown;
|
[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 {
|
export interface UserMutationPayload {
|
||||||
id?: number;
|
id?: number;
|
||||||
user?: string;
|
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 { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
||||||
import toast from "react-hot-toast";
|
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 { Alert } from "@/shadcn-bridge/heroui/alert";
|
||||||
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
|
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
|
||||||
import { Progress } from "@/shadcn-bridge/heroui/progress";
|
import { Progress } from "@/shadcn-bridge/heroui/progress";
|
||||||
|
import { Radio, RadioGroup } from "@/shadcn-bridge/heroui/radio";
|
||||||
import {
|
import {
|
||||||
createTunnel,
|
createTunnel,
|
||||||
|
batchDeleteTunnelsWithForwards,
|
||||||
getTunnelList,
|
getTunnelList,
|
||||||
updateTunnel,
|
updateTunnel,
|
||||||
deleteTunnel,
|
deleteTunnelWithForwards,
|
||||||
getNodeList,
|
getNodeList,
|
||||||
diagnoseTunnel,
|
diagnoseTunnel,
|
||||||
updateTunnelOrder,
|
updateTunnelOrder,
|
||||||
batchDeleteTunnels,
|
|
||||||
batchRedeployTunnels,
|
batchRedeployTunnels,
|
||||||
|
previewBatchTunnelDelete,
|
||||||
|
previewTunnelDelete,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
import { PageLoadingState } from "@/components/page-state";
|
import { PageLoadingState } from "@/components/page-state";
|
||||||
import {
|
import {
|
||||||
@@ -136,6 +143,8 @@ interface BatchResultModalState {
|
|||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TunnelDeleteAction = "replace" | "delete_forwards";
|
||||||
|
|
||||||
const EMPTY_BATCH_RESULT_MODAL_STATE: BatchResultModalState = {
|
const EMPTY_BATCH_RESULT_MODAL_STATE: BatchResultModalState = {
|
||||||
failures: [],
|
failures: [],
|
||||||
open: false,
|
open: false,
|
||||||
@@ -143,6 +152,8 @@ const EMPTY_BATCH_RESULT_MODAL_STATE: BatchResultModalState = {
|
|||||||
title: "",
|
title: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEFAULT_TUNNEL_DELETE_ACTION: TunnelDeleteAction = "replace";
|
||||||
|
|
||||||
const TUNNEL_ORDER_KEY = "tunnel-order";
|
const TUNNEL_ORDER_KEY = "tunnel-order";
|
||||||
|
|
||||||
const mapTunnelApiItems = (items: any[]): Tunnel[] => {
|
const mapTunnelApiItems = (items: any[]): Tunnel[] => {
|
||||||
@@ -178,8 +189,16 @@ export default function TunnelPage() {
|
|||||||
const [isEdit, setIsEdit] = useState(false);
|
const [isEdit, setIsEdit] = useState(false);
|
||||||
const [submitLoading, setSubmitLoading] = useState(false);
|
const [submitLoading, setSubmitLoading] = useState(false);
|
||||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||||
|
const [deletePreviewLoading, setDeletePreviewLoading] = useState(false);
|
||||||
const [diagnosisLoading, setDiagnosisLoading] = useState(false);
|
const [diagnosisLoading, setDiagnosisLoading] = useState(false);
|
||||||
const [tunnelToDelete, setTunnelToDelete] = useState<Tunnel | null>(null);
|
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] =
|
const [currentDiagnosisTunnel, setCurrentDiagnosisTunnel] =
|
||||||
useState<Tunnel | null>(null);
|
useState<Tunnel | null>(null);
|
||||||
const [diagnosisResult, setDiagnosisResult] =
|
const [diagnosisResult, setDiagnosisResult] =
|
||||||
@@ -247,6 +266,15 @@ export default function TunnelPage() {
|
|||||||
const [selectMode, setSelectMode] = useState(false);
|
const [selectMode, setSelectMode] = useState(false);
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||||
const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);
|
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 [batchLoading, setBatchLoading] = useState(false);
|
||||||
const [batchProgress, setBatchProgress] = useState<BatchProgressState>({
|
const [batchProgress, setBatchProgress] = useState<BatchProgressState>({
|
||||||
active: false,
|
active: false,
|
||||||
@@ -339,6 +367,42 @@ export default function TunnelPage() {
|
|||||||
loadData();
|
loadData();
|
||||||
}, [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 validateForm = (): boolean => {
|
||||||
const newErrors = validateTunnelForm(form, nodes);
|
const newErrors = validateTunnelForm(form, nodes);
|
||||||
@@ -384,22 +448,78 @@ export default function TunnelPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 删除隧道
|
// 删除隧道
|
||||||
const handleDelete = (tunnel: Tunnel) => {
|
const handleDelete = async (tunnel: Tunnel) => {
|
||||||
setTunnelToDelete(tunnel);
|
setTunnelToDelete(tunnel);
|
||||||
setDeleteModalOpen(true);
|
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 () => {
|
const confirmDelete = async () => {
|
||||||
if (!tunnelToDelete) return;
|
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);
|
setDeleteLoading(true);
|
||||||
try {
|
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) {
|
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);
|
setDeleteModalOpen(false);
|
||||||
setTunnelToDelete(null);
|
|
||||||
setTunnels((prev) =>
|
setTunnels((prev) =>
|
||||||
prev.filter((tunnel) => tunnel.id !== tunnelToDelete.id),
|
prev.filter((tunnel) => tunnel.id !== tunnelToDelete.id),
|
||||||
);
|
);
|
||||||
@@ -417,11 +537,32 @@ export default function TunnelPage() {
|
|||||||
|
|
||||||
return next;
|
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 {
|
} else {
|
||||||
toast.error(response.msg || "删除失败");
|
toast.error(response.msg || "删除失败");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
toast.error("删除失败");
|
toast.error(extractApiErrorMessage(error, "删除失败"));
|
||||||
} finally {
|
} finally {
|
||||||
setDeleteLoading(false);
|
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 () => {
|
const handleBatchDelete = async () => {
|
||||||
if (selectedIds.size === 0) return;
|
if (selectedIds.size === 0) return;
|
||||||
|
if (
|
||||||
|
batchDeleteHasForwardDependencies &&
|
||||||
|
batchDeleteAction === "replace" &&
|
||||||
|
(!batchDeleteTargetTunnelId || batchDeleteReplaceUnavailable)
|
||||||
|
) {
|
||||||
|
toast.error("请选择替换规则的目标隧道");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setBatchLoading(true);
|
setBatchLoading(true);
|
||||||
setBatchProgress({
|
setBatchProgress({
|
||||||
active: true,
|
active: true,
|
||||||
@@ -886,28 +1067,40 @@ export default function TunnelPage() {
|
|||||||
percent: 30,
|
percent: 30,
|
||||||
});
|
});
|
||||||
try {
|
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) {
|
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) {
|
if (result.failCount === 0) {
|
||||||
toast.success(`成功删除 ${result.successCount} 项`);
|
toast.success(
|
||||||
|
warningCount > 0
|
||||||
|
? `成功删除 ${result.successCount} 项,另有 ${warningCount} 条节点清理提示`
|
||||||
|
: `成功删除 ${result.successCount} 项`,
|
||||||
|
);
|
||||||
setBatchProgress({
|
setBatchProgress({
|
||||||
active: true,
|
active: true,
|
||||||
label: `删除完成:成功 ${result.successCount} 项`,
|
label: `删除完成:成功 ${result.successCount} 项`,
|
||||||
percent: 100,
|
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 {
|
} else {
|
||||||
const failures = extractBatchFailures(result);
|
const failures = extractBatchFailures(result);
|
||||||
|
|
||||||
@@ -927,11 +1120,12 @@ export default function TunnelPage() {
|
|||||||
label: `部分完成:成功 ${result.successCount} 项,正在刷新列表...`,
|
label: `部分完成:成功 ${result.successCount} 项,正在刷新列表...`,
|
||||||
percent: 75,
|
percent: 75,
|
||||||
});
|
});
|
||||||
await refreshTunnelList(false);
|
|
||||||
}
|
}
|
||||||
|
await refreshTunnelList(false);
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
setSelectMode(false);
|
setSelectMode(false);
|
||||||
setBatchDeleteModalOpen(false);
|
setBatchDeleteModalOpen(false);
|
||||||
|
resetBatchDeleteState();
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.msg || "删除失败");
|
toast.error(res.msg || "删除失败");
|
||||||
}
|
}
|
||||||
@@ -1069,6 +1263,150 @@ export default function TunnelPage() {
|
|||||||
[sortedTunnels],
|
[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 = ({
|
const SortableItem = ({
|
||||||
id,
|
id,
|
||||||
children,
|
children,
|
||||||
@@ -1151,7 +1489,7 @@ export default function TunnelPage() {
|
|||||||
isDisabled={selectedIds.size === 0}
|
isDisabled={selectedIds.size === 0}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
onPress={() => setBatchDeleteModalOpen(true)}
|
onPress={handleOpenBatchDeleteModal}
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
</Button>
|
</Button>
|
||||||
@@ -2459,22 +2797,143 @@ export default function TunnelPage() {
|
|||||||
placement="center"
|
placement="center"
|
||||||
scrollBehavior="outside"
|
scrollBehavior="outside"
|
||||||
size="2xl"
|
size="2xl"
|
||||||
onOpenChange={setDeleteModalOpen}
|
onOpenChange={handleDeleteModalOpenChange}
|
||||||
>
|
>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
{(onClose) => (
|
{(onClose) => (
|
||||||
<>
|
<>
|
||||||
<ModalHeader className="flex flex-col gap-1">
|
<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>
|
</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody className="space-y-3 sm:space-y-4">
|
||||||
<p>
|
{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">
|
||||||
<strong>"{tunnelToDelete?.name}"</strong> 吗?
|
<Spinner size="sm" />
|
||||||
</p>
|
正在检查是否有规则正在使用该隧道...
|
||||||
<p className="text-small text-default-500">
|
</div>
|
||||||
此操作不可恢复,请谨慎操作。
|
) : deleteHasForwardDependencies ? (
|
||||||
</p>
|
<>
|
||||||
|
<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>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button variant="light" onPress={onClose}>
|
<Button variant="light" onPress={onClose}>
|
||||||
@@ -2482,10 +2941,16 @@ export default function TunnelPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="danger"
|
color="danger"
|
||||||
|
isDisabled={
|
||||||
|
deletePreviewLoading ||
|
||||||
|
(deleteHasForwardDependencies &&
|
||||||
|
deleteAction === "replace" &&
|
||||||
|
(!deleteTargetTunnelId || deleteReplaceUnavailable))
|
||||||
|
}
|
||||||
isLoading={deleteLoading}
|
isLoading={deleteLoading}
|
||||||
onPress={confirmDelete}
|
onPress={confirmDelete}
|
||||||
>
|
>
|
||||||
{deleteLoading ? "删除中..." : "确认删除"}
|
{deleteLoading ? "删除中..." : deleteConfirmLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</>
|
</>
|
||||||
@@ -3085,17 +3550,154 @@ export default function TunnelPage() {
|
|||||||
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
|
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
|
||||||
}}
|
}}
|
||||||
isOpen={batchDeleteModalOpen}
|
isOpen={batchDeleteModalOpen}
|
||||||
onOpenChange={setBatchDeleteModalOpen}
|
onOpenChange={handleBatchDeleteModalOpenChange}
|
||||||
>
|
>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
{(onClose) => (
|
{(onClose) => (
|
||||||
<>
|
<>
|
||||||
<ModalHeader>确认删除</ModalHeader>
|
<ModalHeader className="flex flex-col gap-1">
|
||||||
<ModalBody>
|
<h2 className="text-lg font-bold sm:text-xl">批量删除隧道</h2>
|
||||||
<p>
|
<p className="text-xs font-normal leading-5 text-default-500 sm:text-sm">
|
||||||
确定要删除选中的 {selectedIds.size}{" "}
|
即将删除这 {selectedTunnelIdList.length} 条隧道,删除前会先检查是否有关联规则。
|
||||||
项隧道吗?此操作不可撤销,相关规则也将被删除。
|
|
||||||
</p>
|
</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>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button variant="light" onPress={onClose}>
|
<Button variant="light" onPress={onClose}>
|
||||||
@@ -3103,10 +3705,17 @@ export default function TunnelPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="danger"
|
color="danger"
|
||||||
|
isDisabled={
|
||||||
|
batchDeletePreviewLoading ||
|
||||||
|
(batchDeleteHasForwardDependencies &&
|
||||||
|
batchDeleteAction === "replace" &&
|
||||||
|
(!batchDeleteTargetTunnelId ||
|
||||||
|
batchDeleteReplaceUnavailable))
|
||||||
|
}
|
||||||
isLoading={batchLoading}
|
isLoading={batchLoading}
|
||||||
onPress={handleBatchDelete}
|
onPress={handleBatchDelete}
|
||||||
>
|
>
|
||||||
确认删除
|
{batchLoading ? "删除中..." : batchDeleteConfirmLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user