feat(batch): add failure details to batch operations with expandable result modal (#312)

## Summary
- Backend: Return per-item failure details (id, name, reason) for all
batch operations (delete/pause/resume/redeploy/change-tunnel) on
forwards and tunnels
- Frontend: Add `BatchActionResultModal` component to display failures
in an expandable list
- Add contract tests for batch action failure details
This commit is contained in:
sagit
2026-03-13 10:22:15 +08:00
committed by GitHub
11 changed files with 957 additions and 43 deletions
+116 -8
View File
@@ -1096,16 +1096,24 @@ func (h *Handler) tunnelBatchDelete(w http.ResponseWriter, r *http.Request) {
}
success := 0
fail := 0
failures := make([]batchFailureDetail, 0)
for _, id := range ids {
tunnelName, _ := h.repo.GetTunnelName(id)
if _, err := h.getTunnelRecord(id); err != nil {
fail++
failures = appendBatchFailure(failures, id, tunnelName, err)
continue
}
h.cleanupTunnelRuntime(id)
h.cleanupFederationRuntime(id)
if err := h.deleteTunnelByID(id); err != nil {
fail++
failures = appendBatchFailure(failures, id, tunnelName, err)
} else {
success++
}
}
response.WriteJSON(w, response.OK(map[string]interface{}{"successCount": success, "failCount": fail}))
response.WriteJSON(w, response.OK(batchOperationResult{SuccessCount: success, FailCount: fail, Failures: failures}))
}
func (h *Handler) reconstructTunnelState(tunnelID int64) (*tunnelCreateState, error) {
@@ -1241,6 +1249,60 @@ func (h *Handler) redeployTunnelAndForwards(tunnelID int64) error {
return nil
}
type batchFailureDetail struct {
ID int64 `json:"id"`
Name string `json:"name,omitempty"`
Reason string `json:"reason"`
}
type batchOperationResult struct {
SuccessCount int `json:"successCount"`
FailCount int `json:"failCount"`
Failures []batchFailureDetail `json:"failures,omitempty"`
}
func appendBatchFailure(failures []batchFailureDetail, id int64, name string, err error) []batchFailureDetail {
reason := normalizeBatchFailureReason(errString(err))
if reason == "" {
reason = "未知错误"
}
return append(failures, batchFailureDetail{
ID: id,
Name: strings.TrimSpace(name),
Reason: reason,
})
}
func appendBatchFailureReason(failures []batchFailureDetail, id int64, name, reason string) []batchFailureDetail {
normalized := normalizeBatchFailureReason(reason)
if normalized == "" {
normalized = "未知错误"
}
return append(failures, batchFailureDetail{
ID: id,
Name: strings.TrimSpace(name),
Reason: normalized,
})
}
func errString(err error) string {
if err == nil {
return ""
}
return err.Error()
}
func normalizeBatchFailureReason(reason string) string {
trimmed := strings.TrimSpace(reason)
if trimmed == "" {
return ""
}
if strings.EqualFold(trimmed, errForwardNotFound.Error()) {
return "转发不存在"
}
return trimmed
}
func (h *Handler) tunnelBatchRedeploy(w http.ResponseWriter, r *http.Request) {
ids := idsFromBody(r, w)
if ids == nil {
@@ -1248,14 +1310,26 @@ func (h *Handler) tunnelBatchRedeploy(w http.ResponseWriter, r *http.Request) {
}
success := 0
fail := 0
failures := make([]batchFailureDetail, 0)
for _, tunnelID := range ids {
tunnel, tunnelErr := h.getTunnelRecord(tunnelID)
tunnelName := ""
if tunnelErr == nil && tunnel != nil {
tunnelName, _ = h.repo.GetTunnelName(tunnelID)
}
if tunnelErr != nil {
fail++
failures = appendBatchFailure(failures, tunnelID, tunnelName, tunnelErr)
continue
}
if err := h.redeployTunnelAndForwards(tunnelID); err != nil {
fail++
failures = appendBatchFailure(failures, tunnelID, tunnelName, err)
continue
}
success++
}
response.WriteJSON(w, response.OK(map[string]interface{}{"successCount": success, "failCount": fail}))
response.WriteJSON(w, response.OK(batchOperationResult{SuccessCount: success, FailCount: fail, Failures: failures}))
}
func (h *Handler) userTunnelAssign(w http.ResponseWriter, r *http.Request) {
@@ -1806,23 +1880,27 @@ func (h *Handler) forwardBatchDelete(w http.ResponseWriter, r *http.Request) {
}
s := 0
f := 0
failures := make([]batchFailureDetail, 0)
for _, id := range ids {
forward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)
if accessErr != nil {
f++
failures = appendBatchFailure(failures, id, "", accessErr)
continue
}
if err := h.controlForwardServices(forward, "DeleteService", true); err != nil {
f++
failures = appendBatchFailure(failures, id, forward.Name, err)
continue
}
if err := h.deleteForwardByID(id); err != nil {
f++
failures = appendBatchFailure(failures, id, forward.Name, err)
} else {
s++
}
}
response.WriteJSON(w, response.OK(map[string]interface{}{"successCount": s, "failCount": f}))
response.WriteJSON(w, response.OK(batchOperationResult{SuccessCount: s, FailCount: f, Failures: failures}))
}
func (h *Handler) forwardBatchPause(w http.ResponseWriter, r *http.Request) {
@@ -1837,23 +1915,27 @@ func (h *Handler) forwardBatchPause(w http.ResponseWriter, r *http.Request) {
}
s := 0
f := 0
failures := make([]batchFailureDetail, 0)
for _, id := range ids {
forward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)
if accessErr != nil {
f++
failures = appendBatchFailure(failures, id, "", accessErr)
continue
}
if err := h.controlForwardServices(forward, "PauseService", false); err != nil {
f++
failures = appendBatchFailure(failures, id, forward.Name, err)
continue
}
if err := h.repo.UpdateForwardStatus(id, 0, time.Now().UnixMilli()); err != nil {
f++
failures = appendBatchFailure(failures, id, forward.Name, err)
} else {
s++
}
}
response.WriteJSON(w, response.OK(map[string]interface{}{"successCount": s, "failCount": f}))
response.WriteJSON(w, response.OK(batchOperationResult{SuccessCount: s, FailCount: f, Failures: failures}))
}
func (h *Handler) forwardBatchResume(w http.ResponseWriter, r *http.Request) {
@@ -1869,27 +1951,32 @@ func (h *Handler) forwardBatchResume(w http.ResponseWriter, r *http.Request) {
s := 0
f := 0
now := time.Now().UnixMilli()
failures := make([]batchFailureDetail, 0)
for _, id := range ids {
forward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)
if accessErr != nil {
f++
failures = appendBatchFailure(failures, id, "", accessErr)
continue
}
if err := h.ensureUserTunnelForwardAllowed(forward.UserID, forward.TunnelID, now); err != nil {
f++
failures = appendBatchFailure(failures, id, forward.Name, err)
continue
}
if err := h.controlForwardServices(forward, "ResumeService", false); err != nil {
f++
failures = appendBatchFailure(failures, id, forward.Name, err)
continue
}
if err := h.repo.UpdateForwardStatus(id, 1, now); err != nil {
f++
failures = appendBatchFailure(failures, id, forward.Name, err)
} else {
s++
}
}
response.WriteJSON(w, response.OK(map[string]interface{}{"successCount": s, "failCount": f}))
response.WriteJSON(w, response.OK(batchOperationResult{SuccessCount: s, FailCount: f, Failures: failures}))
}
func (h *Handler) forwardBatchRedeploy(w http.ResponseWriter, r *http.Request) {
@@ -1904,19 +1991,22 @@ func (h *Handler) forwardBatchRedeploy(w http.ResponseWriter, r *http.Request) {
}
s := 0
f := 0
failures := make([]batchFailureDetail, 0)
for _, id := range ids {
forward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)
if accessErr != nil {
f++
failures = appendBatchFailure(failures, id, "", accessErr)
continue
}
if err := h.syncForwardServices(forward, "UpdateService", true); err != nil {
f++
failures = appendBatchFailure(failures, id, forward.Name, err)
} else {
s++
}
}
response.WriteJSON(w, response.OK(map[string]interface{}{"successCount": s, "failCount": f}))
response.WriteJSON(w, response.OK(batchOperationResult{SuccessCount: s, FailCount: f, Failures: failures}))
}
func (h *Handler) forwardBatchChangeTunnel(w http.ResponseWriter, r *http.Request) {
@@ -1948,6 +2038,7 @@ func (h *Handler) forwardBatchChangeTunnel(w http.ResponseWriter, r *http.Reques
}
success := 0
fail := 0
failures := make([]batchFailureDetail, 0)
for _, id := range req.ForwardIDs {
if id <= 0 {
continue
@@ -1955,21 +2046,30 @@ func (h *Handler) forwardBatchChangeTunnel(w http.ResponseWriter, r *http.Reques
forward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)
if accessErr != nil {
fail++
failures = appendBatchFailure(failures, id, "", accessErr)
continue
}
if forward.TunnelID == req.TargetTunnelID {
fail++
failures = appendBatchFailureReason(failures, id, forward.Name, "规则已在目标隧道中")
continue
}
oldPorts, listPortsErr := h.listForwardPorts(id)
if listPortsErr != nil {
fail++
failures = appendBatchFailure(failures, id, forward.Name, listPortsErr)
continue
}
if len(oldPorts) == 0 {
fail++
failures = appendBatchFailureReason(failures, id, forward.Name, "转发入口端口不存在")
continue
}
oldNodeIDs := forwardPortNodeIDs(oldPorts)
port := h.repo.GetMinForwardPort(id)
if err := h.repo.UpdateForwardTunnel(id, req.TargetTunnelID, time.Now().UnixMilli()); err != nil {
fail++
failures = appendBatchFailure(failures, id, forward.Name, err)
continue
}
p := 0
@@ -1984,29 +2084,36 @@ func (h *Handler) forwardBatchChangeTunnel(w http.ResponseWriter, r *http.Reques
removedNodeIDs := diffInt64s(oldNodeIDs, newNodeIDs)
keptNodeIDs := diffInt64s(oldNodeIDs, removedNodeIDs)
portRangeOk := true
var portRangeErr error
for _, nid := range bctEntryNodes {
nd, ndErr := h.getNodeRecord(nid)
if ndErr != nil {
portRangeErr = ndErr
continue
}
if validateRemoteNodePort(nd, p) != nil {
if validateErr := validateRemoteNodePort(nd, p); validateErr != nil {
portRangeOk = false
portRangeErr = validateErr
break
}
}
if !portRangeOk {
fail++
failures = appendBatchFailure(failures, id, forward.Name, portRangeErr)
h.rollbackForwardMutation(forward, oldPorts)
continue
}
if err := h.replaceForwardPorts(id, req.TargetTunnelID, p, ""); err != nil {
h.rollbackForwardMutation(forward, oldPorts)
fail++
failures = appendBatchFailure(failures, id, forward.Name, err)
continue
}
updatedForward, fetchErr := h.getForwardRecord(id)
if fetchErr != nil {
h.rollbackForwardMutation(forward, oldPorts)
fail++
failures = appendBatchFailure(failures, id, forward.Name, fetchErr)
continue
}
if len(keptNodeIDs) > 0 {
@@ -2018,6 +2125,7 @@ func (h *Handler) forwardBatchChangeTunnel(w http.ResponseWriter, r *http.Reques
if err := h.syncForwardServices(updatedForward, "UpdateService", true); err != nil {
h.rollbackForwardMutation(forward, oldPorts)
fail++
failures = appendBatchFailure(failures, id, forward.Name, err)
continue
}
if len(removedNodeIDs) > 0 {
@@ -2027,7 +2135,7 @@ func (h *Handler) forwardBatchChangeTunnel(w http.ResponseWriter, r *http.Reques
}
success++
}
response.WriteJSON(w, response.OK(map[string]interface{}{"successCount": success, "failCount": fail}))
response.WriteJSON(w, response.OK(batchOperationResult{SuccessCount: success, FailCount: fail, Failures: failures}))
}
func (h *Handler) speedLimitCreate(w http.ResponseWriter, r *http.Request) {
@@ -0,0 +1,202 @@
package contract_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"go-backend/internal/auth"
"go-backend/internal/http/response"
"go-backend/internal/store/repo"
)
func TestForwardBatchDeleteReturnsFailureReasonsContract(t *testing.T) {
secret := "contract-jwt-secret"
router, _ := setupContractRouter(t, secret)
adminToken := mustAdminToken(t, secret)
out := postBatchRequest(t, router, adminToken, "/api/v1/forward/batch-delete", `{"ids":[999]}`)
result := mustBatchResult(t, out)
assertBatchFailureReasonContains(t, result, "转发不存在")
}
func TestForwardBatchPauseReturnsFailureReasonsContract(t *testing.T) {
secret := "contract-jwt-secret"
router, _ := setupContractRouter(t, secret)
adminToken := mustAdminToken(t, secret)
out := postBatchRequest(t, router, adminToken, "/api/v1/forward/batch-pause", `{"ids":[999]}`)
result := mustBatchResult(t, out)
assertBatchFailureReasonContains(t, result, "转发不存在")
}
func TestForwardBatchResumeReturnsFailureReasonsContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
adminToken := mustAdminToken(t, secret)
now := time.Now().UnixMilli()
forwardID := seedForwardForBatchAction(t, repo, batchForwardSeedOptions{
Now: now,
TunnelName: "resume-detail-tunnel",
ForwardName: "resume-detail-forward",
CreateUserTunnel: true,
UserTunnelStatus: 0,
})
out := postBatchRequest(t, router, adminToken, "/api/v1/forward/batch-resume", `{"ids":[`+jsonNumber(forwardID)+`]}`)
result := mustBatchResult(t, out)
assertBatchFailureNameAndReason(t, result, "resume-detail-forward", "该隧道已禁用")
}
func TestForwardBatchChangeTunnelReturnsFailureReasonsContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
adminToken := mustAdminToken(t, secret)
now := time.Now().UnixMilli()
forwardID := seedForwardForBatchAction(t, repo, batchForwardSeedOptions{
Now: now,
TunnelName: "change-detail-tunnel",
ForwardName: "change-detail-forward",
})
tunnelID := mustQueryInt64(t, repo, `SELECT tunnel_id FROM forward WHERE id = ?`, forwardID)
payload := `{"forwardIds":[` + jsonNumber(forwardID) + `],"targetTunnelId":` + jsonNumber(tunnelID) + `}`
out := postBatchRequest(t, router, adminToken, "/api/v1/forward/batch-change-tunnel", payload)
result := mustBatchResult(t, out)
assertBatchFailureNameAndReason(t, result, "change-detail-forward", "规则已在目标隧道中")
}
func TestTunnelBatchDeleteReturnsFailureReasonsContract(t *testing.T) {
secret := "contract-jwt-secret"
router, _ := setupContractRouter(t, secret)
adminToken := mustAdminToken(t, secret)
out := postBatchRequest(t, router, adminToken, "/api/v1/tunnel/batch-delete", `{"ids":[999]}`)
result := mustBatchResult(t, out)
assertBatchFailureReasonContains(t, result, "隧道不存在")
}
type batchForwardSeedOptions struct {
Now int64
TunnelName string
ForwardName string
CreateUserTunnel bool
UserTunnelStatus int
}
func mustAdminToken(t *testing.T, secret string) string {
t.Helper()
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
return token
}
func postBatchRequest(t *testing.T, router http.Handler, token, path, payload string) response.R {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(payload))
req.Header.Set("Authorization", token)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected API success envelope, got code=%d msg=%q", out.Code, out.Msg)
}
return out
}
func mustBatchResult(t *testing.T, out response.R) map[string]interface{} {
t.Helper()
result, ok := out.Data.(map[string]interface{})
if !ok {
t.Fatalf("expected map result, got %T", out.Data)
}
if int(result["failCount"].(float64)) != 1 {
t.Fatalf("expected failCount=1, got %v", result["failCount"])
}
return result
}
func assertBatchFailureReasonContains(t *testing.T, result map[string]interface{}, snippet string) {
t.Helper()
failures, ok := result["failures"].([]interface{})
if !ok || len(failures) != 1 {
t.Fatalf("expected exactly one failure detail, got %#v", result["failures"])
}
first, ok := failures[0].(map[string]interface{})
if !ok {
t.Fatalf("expected failure detail object, got %T", failures[0])
}
reason, _ := first["reason"].(string)
if !strings.Contains(reason, snippet) {
t.Fatalf("expected failure reason to contain %q, got %q", snippet, reason)
}
}
func assertBatchFailureNameAndReason(t *testing.T, result map[string]interface{}, expectedName, reasonSnippet string) {
t.Helper()
failures, ok := result["failures"].([]interface{})
if !ok || len(failures) != 1 {
t.Fatalf("expected exactly one failure detail, got %#v", result["failures"])
}
first, ok := failures[0].(map[string]interface{})
if !ok {
t.Fatalf("expected failure detail object, got %T", failures[0])
}
gotName, _ := first["name"].(string)
if strings.TrimSpace(gotName) != expectedName {
t.Fatalf("expected failure name %q, got %q", expectedName, gotName)
}
reason, _ := first["reason"].(string)
if !strings.Contains(reason, reasonSnippet) {
t.Fatalf("expected failure reason to contain %q, got %q", reasonSnippet, reason)
}
}
func seedForwardForBatchAction(t *testing.T, repo *repo.Repository, opts batchForwardSeedOptions) int64 {
t.Helper()
if err := repo.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(2, 'batch_action_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, opts.Now, opts.Now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, opts.TunnelName, 1.0, 1, "tls", 99999, opts.Now, opts.Now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, repo, opts.TunnelName)
if opts.CreateUserTunnel {
if err := repo.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(20, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, ?)
`, tunnelID, opts.UserTunnelStatus).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
}
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, 'batch_action_user', ?, ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
`, opts.ForwardName, tunnelID, opts.Now, opts.Now).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
return mustLastInsertID(t, repo, opts.ForwardName)
}
@@ -0,0 +1,161 @@
package contract_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"go-backend/internal/auth"
"go-backend/internal/http/response"
)
func TestForwardBatchRedeployReturnsFailureReasonsContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(2, 'batch_redeploy_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "batch-redeploy-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, repo, "batch-redeploy-tunnel")
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(?, ?, ?, ?, ?, ?, 0, 0, ?, ?, 1, 0)
`, 2, "batch_redeploy_user", "redeploy-forward", tunnelID, "1.1.1.1:443", "fifo", now, now).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID := mustLastInsertID(t, repo, "redeploy-forward")
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/batch-redeploy", bytes.NewBufferString(`{"ids":[`+jsonNumber(forwardID)+`]}`))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected API success envelope, got code=%d msg=%q", out.Code, out.Msg)
}
result, ok := out.Data.(map[string]interface{})
if !ok {
t.Fatalf("expected map result, got %T", out.Data)
}
if int(result["failCount"].(float64)) != 1 {
t.Fatalf("expected failCount=1, got %v", result["failCount"])
}
if int(result["successCount"].(float64)) != 0 {
t.Fatalf("expected successCount=0, got %v", result["successCount"])
}
failures, ok := result["failures"].([]interface{})
if !ok || len(failures) != 1 {
t.Fatalf("expected exactly one failure detail, got %#v", result["failures"])
}
first, ok := failures[0].(map[string]interface{})
if !ok {
t.Fatalf("expected failure detail object, got %T", failures[0])
}
if gotName := strings.TrimSpace(first["name"].(string)); gotName != "redeploy-forward" {
t.Fatalf("expected failure name redeploy-forward, got %q", gotName)
}
reason, _ := first["reason"].(string)
if !strings.Contains(reason, "转发入口端口不存在") {
t.Fatalf("expected forward failure reason to mention missing entry port, got %q", reason)
}
}
func TestTunnelBatchRedeployReturnsFailureReasonsContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "broken-redeploy-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, repo, "broken-redeploy-tunnel")
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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "entry-only-node", "entry-only-secret", "10.0.0.20", "10.0.0.20", "", "20000-20010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node: %v", err)
}
entryNodeID := mustLastInsertID(t, repo, "entry-only-node")
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 20001, 'round', 1, 'tls')
`, tunnelID, entryNodeID).Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/batch-redeploy", bytes.NewBufferString(`{"ids":[`+jsonNumber(tunnelID)+`]}`))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected API success envelope, got code=%d msg=%q", out.Code, out.Msg)
}
result, ok := out.Data.(map[string]interface{})
if !ok {
t.Fatalf("expected map result, got %T", out.Data)
}
if int(result["failCount"].(float64)) != 1 {
t.Fatalf("expected failCount=1, got %v", result["failCount"])
}
failures, ok := result["failures"].([]interface{})
if !ok || len(failures) != 1 {
t.Fatalf("expected exactly one failure detail, got %#v", result["failures"])
}
first, ok := failures[0].(map[string]interface{})
if !ok {
t.Fatalf("expected failure detail object, got %T", failures[0])
}
if gotName := strings.TrimSpace(first["name"].(string)); gotName != "broken-redeploy-tunnel" {
t.Fatalf("expected failure name broken-redeploy-tunnel, got %q", gotName)
}
reason, _ := first["reason"].(string)
if !strings.Contains(reason, "转发链目标不能为空") {
t.Fatalf("expected tunnel failure reason to mention missing target, got %q", reason)
}
}
@@ -0,0 +1,11 @@
# 规则/隧道下发失败原因可见性修复计划
- [x] 检查规则与隧道批量重新下发链路,确认失败原因在哪一层被丢失
- [x] 为后端批量下发接口补充失败明细返回
- [x] 为前端规则/隧道批量下发提示补充具体失败原因展示
- [x] 运行针对性验证并更新结论
## 验证结论
- 已通过 `go test ./tests/contract/... -run BatchRedeploy` 验证后端会返回批量下发失败明细。
- 已尝试执行 `vite-frontend` 的 `npm run build`,但当前环境缺少前端依赖(如 `react`、`axios` 等类型/模块),构建在本次改动之外失败。
@@ -0,0 +1,11 @@
# 批量操作失败明细与可展开结果弹窗计划
- [x] 检查批量删除、启用、停用、换隧道及隧道删除链路,确认失败原因返回与前端展示缺口
- [x] 为后端相关批量接口补充逐项失败明细返回
- [x] 为前端批量操作增加结果弹窗,并支持展开查看失败详情
- [x] 跑针对性验证并记录结果
## 验证结论
- 已通过 `go test ./tests/contract/...` 验证后端合同测试全部通过。
- 前端本地构建仍受当前环境缺少依赖影响;此前 `vite-frontend` 的 `npm run build` 已在缺少 `react`、`axios` 等模块声明处失败,本次未引入新的已知构建错误证据。
+105
View File
@@ -1,3 +1,5 @@
import type { BatchOperationFailure } from "@/api/types";
import axios from "axios";
interface ErrorPayload {
@@ -5,6 +7,20 @@ interface ErrorPayload {
message?: string;
}
interface BatchFailurePayload {
id?: number;
name?: string;
reason?: string;
msg?: string;
message?: string;
}
interface BatchResultPayload {
failures?: unknown[];
}
const MAX_BATCH_FAILURES_IN_TOAST = 3;
export const isUnauthorizedError = (error: unknown): boolean => {
return axios.isAxiosError(error) && error.response?.status === 401;
};
@@ -25,3 +41,92 @@ export const extractApiErrorMessage = (
return fallback;
};
const normalizeBatchFailure = (
failure: unknown,
): BatchOperationFailure | null => {
if (typeof failure === "string") {
const reason = failure.trim();
return reason ? { reason } : null;
}
const payload = (failure ?? {}) as BatchFailurePayload;
const id =
typeof payload.id === "number" && Number.isFinite(payload.id)
? payload.id
: undefined;
const name = typeof payload.name === "string" ? payload.name.trim() : "";
const reasonSource = [payload.reason, payload.msg, payload.message].find(
(item) => typeof item === "string" && item.trim() !== "",
);
const reason = typeof reasonSource === "string" ? reasonSource.trim() : "";
if (!name && !reason && id === undefined) {
return null;
}
return {
...(id !== undefined ? { id } : {}),
...(name ? { name } : {}),
...(reason ? { reason } : {}),
};
};
const normalizeBatchFailureReason = (failure: BatchOperationFailure): string => {
const name = typeof failure.name === "string" ? failure.name.trim() : "";
const reason = typeof failure.reason === "string" ? failure.reason.trim() : "";
if (name && reason) {
return `${name}: ${reason}`;
}
if (reason) {
if (typeof failure.id === "number" && Number.isFinite(failure.id)) {
return `ID ${failure.id}: ${reason}`;
}
return reason;
}
if (name) {
return name;
}
if (typeof failure.id === "number" && Number.isFinite(failure.id)) {
return `ID ${failure.id} 下发失败`;
}
return "";
};
export const extractBatchFailures = (
result: unknown,
): BatchOperationFailure[] => {
const payload = (result ?? {}) as BatchResultPayload;
return Array.isArray(payload.failures)
? payload.failures
.map((item) => normalizeBatchFailure(item))
.filter((item): item is BatchOperationFailure => item !== null)
: [];
};
export const buildBatchFailureMessage = (
result: unknown,
fallbackSummary: string,
): string => {
const failures = extractBatchFailures(result)
.map((item) => normalizeBatchFailureReason(item))
.filter((item) => item !== "");
if (failures.length === 0) {
return fallbackSummary;
}
const visibleFailures = failures.slice(0, MAX_BATCH_FAILURES_IN_TOAST);
const hiddenCount = failures.length - visibleFailures.length;
const hiddenSuffix = hiddenCount > 0 ? ` 等 ${failures.length} 项` : "";
return `${fallbackSummary}:${visibleFailures.join(";")}${hiddenSuffix}`;
};
+8
View File
@@ -210,6 +210,14 @@ export interface UserPackageInfoApiData {
export interface BatchOperationResult {
successCount: number;
failCount: number;
failures?: BatchOperationFailure[];
[key: string]: unknown;
}
export interface BatchOperationFailure {
id?: number;
name?: string;
reason?: string;
[key: string]: unknown;
}
@@ -0,0 +1,148 @@
import type { BatchOperationFailure } from "@/api/types";
import toast from "react-hot-toast";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Chip } from "@/shadcn-bridge/heroui/chip";
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from "@/shadcn-bridge/heroui/modal";
import { Alert } from "@/shadcn-bridge/heroui/alert";
interface BatchActionResultModalProps {
failures: BatchOperationFailure[];
isOpen: boolean;
onOpenChange: (open: boolean) => void;
summary: string;
title: string;
}
const getFailureTitle = (
failure: BatchOperationFailure,
index: number,
): string => {
const name = typeof failure.name === "string" ? failure.name.trim() : "";
if (name) {
return name;
}
if (typeof failure.id === "number" && Number.isFinite(failure.id)) {
return `ID ${failure.id}`;
}
return `失败项 ${index + 1}`;
};
const getFailureReason = (failure: BatchOperationFailure): string => {
const reason = typeof failure.reason === "string" ? failure.reason.trim() : "";
return reason || "未知错误";
};
const buildFailureCopyText = (
title: string,
summary: string,
failures: BatchOperationFailure[],
): string => {
return [
title,
summary,
"",
...failures.map(
(failure, index) =>
`${index + 1}. ${getFailureTitle(failure, index)}\n${getFailureReason(failure)}`,
),
].join("\n");
};
export function BatchActionResultModal({
failures,
isOpen,
onOpenChange,
summary,
title,
}: BatchActionResultModalProps) {
const handleCopy = async () => {
if (
typeof navigator === "undefined" ||
!navigator.clipboard ||
typeof navigator.clipboard.writeText !== "function"
) {
toast.error("当前环境不支持复制");
return;
}
try {
await navigator.clipboard.writeText(
buildFailureCopyText(title, summary, failures),
);
toast.success(`已复制 ${failures.length} 项失败原因`);
} catch {
toast.error("复制失败,请稍后重试");
}
};
return (
<Modal
isOpen={isOpen}
scrollBehavior="inside"
size="2xl"
onOpenChange={onOpenChange}
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>{title}</ModalHeader>
<ModalBody className="space-y-4">
<Alert
color="warning"
description={summary}
title={`共 ${failures.length} 项需要处理`}
variant="flat"
/>
<div className="space-y-3">
{failures.map((failure, index) => (
<details
key={`${failure.id ?? "unknown"}-${index}`}
className="group rounded-xl border border-divider bg-content2/40 px-4 py-3"
>
<summary className="flex cursor-pointer list-none items-center justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{getFailureTitle(failure, index)}
</p>
<p className="mt-1 text-xs text-default-500 group-open:hidden">
点击展开查看失败原因
</p>
</div>
<Chip color="danger" size="sm" variant="flat">
失败
</Chip>
</summary>
<div className="mt-3 rounded-lg bg-background/70 p-3 text-sm leading-6 text-foreground/90">
{getFailureReason(failure)}
</div>
</details>
))}
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={handleCopy}>
复制失败原因
</Button>
<Button color="primary" onPress={onClose}>
我知道了
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}
+72 -21
View File
@@ -1,4 +1,8 @@
import type { ForwardApiItem, SpeedLimitApiItem } from "@/api/types";
import type {
BatchOperationFailure,
ForwardApiItem,
SpeedLimitApiItem,
} from "@/api/types";
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
import toast from "react-hot-toast";
@@ -24,6 +28,7 @@ import { CSS } from "@dnd-kit/utilities";
import { SearchBar } from "@/components/search-bar";
import { AnimatedPage } from "@/components/animated-page";
import { BatchActionResultModal } from "@/components/batch-action-result-modal";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@/shadcn-bridge/heroui/input";
@@ -175,6 +180,20 @@ interface BatchProgressState {
percent: number;
}
interface BatchResultModalState {
failures: BatchOperationFailure[];
open: boolean;
summary: string;
title: string;
}
const EMPTY_BATCH_RESULT_MODAL_STATE: BatchResultModalState = {
failures: [],
open: false,
summary: "",
title: "",
};
type ForwardGroupOrderMap = Record<string, string[]>;
type ForwardGroupCollapsedMap = Record<string, boolean>;
@@ -672,6 +691,8 @@ export default function ForwardPage() {
const [batchTargetTunnelId, setBatchTargetTunnelId] = useState<number | null>(
null,
);
const [batchResultModal, setBatchResultModal] =
useState<BatchResultModalState>(EMPTY_BATCH_RESULT_MODAL_STATE);
const [batchLoading, setBatchLoading] = useState(false);
const [batchProgress, setBatchProgress] = useState<BatchProgressState>({
active: false,
@@ -2440,6 +2461,36 @@ export default function ForwardPage() {
setSelectedIds(new Set());
};
const presentBatchOutcome = useCallback(
(outcome: {
failureDetails?: BatchOperationFailure[];
resultSummary?: string;
resultTitle?: string;
toastMessage: string;
toastVariant: "success" | "error";
}) => {
const failureDetails = outcome.failureDetails || [];
if (failureDetails.length > 0) {
setBatchResultModal({
failures: failureDetails,
open: true,
summary: outcome.resultSummary || outcome.toastMessage,
title: outcome.resultTitle || "批量操作结果",
});
return;
}
if (outcome.toastVariant === "success") {
toast.success(outcome.toastMessage);
} else {
toast.error(outcome.toastMessage);
}
},
[],
);
const handleBatchDelete = async () => {
if (selectedIds.size === 0) return;
setBatchLoading(true);
@@ -2451,11 +2502,7 @@ export default function ForwardPage() {
try {
const outcome = await executeForwardBatchDelete(Array.from(selectedIds));
if (outcome.toastVariant === "success") {
toast.success(outcome.toastMessage);
} else {
toast.error(outcome.toastMessage);
}
presentBatchOutcome(outcome);
if (outcome.shouldRefresh) {
setBatchProgress({
@@ -2490,11 +2537,7 @@ export default function ForwardPage() {
enable,
);
if (outcome.toastVariant === "success") {
toast.success(outcome.toastMessage);
} else {
toast.error(outcome.toastMessage);
}
presentBatchOutcome(outcome);
if (outcome.shouldRefresh) {
setBatchProgress({
@@ -2525,11 +2568,7 @@ export default function ForwardPage() {
Array.from(selectedIds),
);
if (outcome.toastVariant === "success") {
toast.success(outcome.toastMessage);
} else {
toast.error(outcome.toastMessage);
}
presentBatchOutcome(outcome);
if (outcome.shouldRefresh) {
setBatchProgress({
@@ -2561,11 +2600,7 @@ export default function ForwardPage() {
batchTargetTunnelId,
);
if (outcome.toastVariant === "success") {
toast.success(outcome.toastMessage);
} else {
toast.error(outcome.toastMessage);
}
presentBatchOutcome(outcome);
if (outcome.shouldRefresh) {
setBatchProgress({
@@ -5848,6 +5883,22 @@ export default function ForwardPage() {
</ModalContent>
</Modal>
<BatchActionResultModal
failures={batchResultModal.failures}
isOpen={batchResultModal.open}
summary={batchResultModal.summary}
title={batchResultModal.title}
onOpenChange={(open) => {
if (open) {
setBatchResultModal((prev) => ({ ...prev, open: true }));
return;
}
setBatchResultModal(EMPTY_BATCH_RESULT_MODAL_STATE);
}}
/>
{/* 筛选模态框 */}
<Modal
isOpen={isFilterModalOpen}
@@ -1,4 +1,7 @@
import type { BatchOperationResult } from "@/api/types";
import type {
BatchOperationFailure,
BatchOperationResult,
} from "@/api/types";
import {
batchChangeTunnel,
@@ -7,12 +10,19 @@ import {
batchRedeployForwards,
batchResumeForwards,
} from "@/api";
import { extractApiErrorMessage } from "@/api/error-message";
import {
buildBatchFailureMessage,
extractBatchFailures,
extractApiErrorMessage,
} from "@/api/error-message";
export interface ForwardBatchActionOutcome {
toastVariant: "success" | "error";
toastMessage: string;
shouldRefresh: boolean;
resultTitle?: string;
resultSummary?: string;
failureDetails?: BatchOperationFailure[];
progressPercent?: number;
progressLabel?: string;
closeDeleteModal?: boolean;
@@ -26,23 +36,37 @@ const normalizeBatchResult = (value: unknown): BatchOperationResult => {
return {
successCount: Number(raw.successCount ?? 0),
failCount: Number(raw.failCount ?? 0),
failures: extractBatchFailures(raw),
};
};
const buildBatchToast = (
result: BatchOperationResult,
successText: string,
): Pick<ForwardBatchActionOutcome, "toastVariant" | "toastMessage"> => {
resultTitle: string,
): Pick<
ForwardBatchActionOutcome,
"toastVariant" | "toastMessage" | "resultTitle" | "resultSummary" | "failureDetails"
> => {
if (result.failCount === 0) {
return {
toastVariant: "success",
toastMessage: successText,
resultTitle,
resultSummary: successText,
failureDetails: [],
};
}
return {
toastVariant: "error",
toastMessage: `成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
toastMessage: buildBatchFailureMessage(
result,
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
),
resultTitle,
resultSummary: `成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
failureDetails: result.failures || [],
};
};
@@ -63,7 +87,11 @@ export const executeForwardBatchDelete = async (
const summary = normalizeBatchResult(response.data);
return {
...buildBatchToast(summary, `成功删除 ${summary.successCount} 项`),
...buildBatchToast(
summary,
`成功删除 ${summary.successCount} 项`,
"批量删除结果",
),
shouldRefresh: true,
progressPercent: 100,
progressLabel: `删除完成:成功 ${summary.successCount} 项`,
@@ -105,6 +133,7 @@ export const executeForwardBatchToggleService = async (
enable
? `成功启用 ${summary.successCount} 项`
: `成功停用 ${summary.successCount} 项`,
enable ? "批量启用结果" : "批量停用结果",
),
shouldRefresh: true,
progressPercent: 100,
@@ -136,7 +165,11 @@ export const executeForwardBatchRedeploy = async (
const summary = normalizeBatchResult(response.data);
return {
...buildBatchToast(summary, `成功重新下发 ${summary.successCount} 项`),
...buildBatchToast(
summary,
`成功重新下发 ${summary.successCount} 项`,
"批量下发结果",
),
shouldRefresh: true,
progressPercent: 100,
progressLabel: `重新下发完成:成功 ${summary.successCount} 项`,
@@ -171,7 +204,11 @@ export const executeForwardBatchChangeTunnel = async (
const summary = normalizeBatchResult(response.data);
return {
...buildBatchToast(summary, `成功换隧道 ${summary.successCount} 项`),
...buildBatchToast(
summary,
`成功换隧道 ${summary.successCount} 项`,
"批量换隧道结果",
),
shouldRefresh: true,
progressPercent: 100,
progressLabel: `批量换隧道完成:成功 ${summary.successCount} 项`,
+79 -7
View File
@@ -1,3 +1,5 @@
import type { BatchOperationFailure } from "@/api/types";
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
import toast from "react-hot-toast";
import {
@@ -20,6 +22,7 @@ import { CSS } from "@dnd-kit/utilities";
import { SearchBar } from "@/components/search-bar";
import { AnimatedPage } from "@/components/animated-page";
import { BatchActionResultModal } from "@/components/batch-action-result-modal";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Input, Textarea } from "@/shadcn-bridge/heroui/input";
@@ -63,7 +66,11 @@ import {
} from "@/pages/tunnel/form";
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
import { loadStoredOrder, saveOrder } from "@/utils/order-storage";
import { extractApiErrorMessage } from "@/api/error-message";
import {
buildBatchFailureMessage,
extractBatchFailures,
extractApiErrorMessage,
} from "@/api/error-message";
interface ChainTunnel {
nodeId: number;
@@ -122,6 +129,20 @@ interface BatchProgressState {
percent: number;
}
interface BatchResultModalState {
failures: BatchOperationFailure[];
open: boolean;
summary: string;
title: string;
}
const EMPTY_BATCH_RESULT_MODAL_STATE: BatchResultModalState = {
failures: [],
open: false,
summary: "",
title: "",
};
const TUNNEL_ORDER_KEY = "tunnel-order";
const mapTunnelApiItems = (items: any[]): Tunnel[] => {
@@ -226,6 +247,8 @@ export default function TunnelPage() {
const [selectMode, setSelectMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);
const [batchResultModal, setBatchResultModal] =
useState<BatchResultModalState>(EMPTY_BATCH_RESULT_MODAL_STATE);
const [batchLoading, setBatchLoading] = useState(false);
const [batchProgress, setBatchProgress] = useState<BatchProgressState>({
active: false,
@@ -842,6 +865,18 @@ export default function TunnelPage() {
setSelectedIds(new Set());
};
const openBatchResultModal = useCallback(
(title: string, summary: string, failures: BatchOperationFailure[]) => {
setBatchResultModal({
failures,
open: true,
summary,
title,
});
},
[],
);
const handleBatchDelete = async () => {
if (selectedIds.size === 0) return;
setBatchLoading(true);
@@ -874,9 +909,17 @@ export default function TunnelPage() {
return next;
});
} else {
toast.error(
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
);
const failures = extractBatchFailures(result);
if (failures.length > 0) {
openBatchResultModal(
"批量删除结果",
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
failures,
);
} else {
toast.error(`成功 ${result.successCount} 项,失败 ${result.failCount} 项`);
}
setBatchProgress({
active: true,
label: `部分完成:成功 ${result.successCount} 项,正在刷新列表...`,
@@ -915,9 +958,22 @@ export default function TunnelPage() {
if (result.failCount === 0) {
toast.success(`成功重新下发 ${result.successCount} 项`);
} else {
toast.error(
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
);
const failures = extractBatchFailures(result);
if (failures.length > 0) {
openBatchResultModal(
"批量下发结果",
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
failures,
);
} else {
toast.error(
buildBatchFailureMessage(
result,
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
),
);
}
}
setSelectedIds(new Set());
setSelectMode(false);
@@ -3049,6 +3105,22 @@ export default function TunnelPage() {
)}
</ModalContent>
</Modal>
<BatchActionResultModal
failures={batchResultModal.failures}
isOpen={batchResultModal.open}
summary={batchResultModal.summary}
title={batchResultModal.title}
onOpenChange={(open) => {
if (open) {
setBatchResultModal((prev) => ({ ...prev, open: true }));
return;
}
setBatchResultModal(EMPTY_BATCH_RESULT_MODAL_STATE);
}}
/>
</AnimatedPage>
);
}