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:
sagit
2026-03-18 10:05:03 +08:00
committed by GitHub
parent 2df061a19f
commit 9b98194a0a
7 changed files with 1800 additions and 42 deletions
@@ -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
}
+183
View File
@@ -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`
+28
View File
@@ -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",
+44
View File
@@ -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;
+651 -42
View File
@@ -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>&quot;{tunnelToDelete?.name}&quot;</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>
</>