From d1e3c5953741e97a9195d52cf757b1c2c2382930 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Fri, 13 Mar 2026 10:20:36 +0800 Subject: [PATCH] feat(batch): add failure details to batch operations with expandable result modal - Backend: return per-item failure details (id, name, reason) for all batch operations - Frontend: add BatchActionResultModal component to display failures - Support delete/pause/resume/redeploy/change-tunnel for forwards and tunnels --- go-backend/internal/http/handler/mutations.go | 124 ++++++++++- ...ch_action_failure_details_contract_test.go | 202 ++++++++++++++++++ ...ch_redeploy_error_details_contract_test.go | 161 ++++++++++++++ ...4-rule-tunnel-dispatch-error-visibility.md | 11 + ...ction-failure-details-and-expandable-ui.md | 11 + vite-frontend/src/api/error-message.ts | 105 +++++++++ vite-frontend/src/api/types.ts | 8 + .../components/batch-action-result-modal.tsx | 148 +++++++++++++ vite-frontend/src/pages/forward.tsx | 93 ++++++-- .../src/pages/forward/batch-actions.ts | 51 ++++- vite-frontend/src/pages/tunnel.tsx | 86 +++++++- 11 files changed, 957 insertions(+), 43 deletions(-) create mode 100644 go-backend/tests/contract/batch_action_failure_details_contract_test.go create mode 100644 go-backend/tests/contract/batch_redeploy_error_details_contract_test.go create mode 100644 plans/034-rule-tunnel-dispatch-error-visibility.md create mode 100644 plans/035-batch-action-failure-details-and-expandable-ui.md create mode 100644 vite-frontend/src/components/batch-action-result-modal.tsx diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index 614a37c..0c7e273 100644 --- a/go-backend/internal/http/handler/mutations.go +++ b/go-backend/internal/http/handler/mutations.go @@ -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) { diff --git a/go-backend/tests/contract/batch_action_failure_details_contract_test.go b/go-backend/tests/contract/batch_action_failure_details_contract_test.go new file mode 100644 index 0000000..42cf9e0 --- /dev/null +++ b/go-backend/tests/contract/batch_action_failure_details_contract_test.go @@ -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) +} diff --git a/go-backend/tests/contract/batch_redeploy_error_details_contract_test.go b/go-backend/tests/contract/batch_redeploy_error_details_contract_test.go new file mode 100644 index 0000000..5a317fc --- /dev/null +++ b/go-backend/tests/contract/batch_redeploy_error_details_contract_test.go @@ -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) + } +} diff --git a/plans/034-rule-tunnel-dispatch-error-visibility.md b/plans/034-rule-tunnel-dispatch-error-visibility.md new file mode 100644 index 0000000..22b1b81 --- /dev/null +++ b/plans/034-rule-tunnel-dispatch-error-visibility.md @@ -0,0 +1,11 @@ +# 规则/隧道下发失败原因可见性修复计划 + +- [x] 检查规则与隧道批量重新下发链路,确认失败原因在哪一层被丢失 +- [x] 为后端批量下发接口补充失败明细返回 +- [x] 为前端规则/隧道批量下发提示补充具体失败原因展示 +- [x] 运行针对性验证并更新结论 + +## 验证结论 + +- 已通过 `go test ./tests/contract/... -run BatchRedeploy` 验证后端会返回批量下发失败明细。 +- 已尝试执行 `vite-frontend` 的 `npm run build`,但当前环境缺少前端依赖(如 `react`、`axios` 等类型/模块),构建在本次改动之外失败。 diff --git a/plans/035-batch-action-failure-details-and-expandable-ui.md b/plans/035-batch-action-failure-details-and-expandable-ui.md new file mode 100644 index 0000000..ee5fbfc --- /dev/null +++ b/plans/035-batch-action-failure-details-and-expandable-ui.md @@ -0,0 +1,11 @@ +# 批量操作失败明细与可展开结果弹窗计划 + +- [x] 检查批量删除、启用、停用、换隧道及隧道删除链路,确认失败原因返回与前端展示缺口 +- [x] 为后端相关批量接口补充逐项失败明细返回 +- [x] 为前端批量操作增加结果弹窗,并支持展开查看失败详情 +- [x] 跑针对性验证并记录结果 + +## 验证结论 + +- 已通过 `go test ./tests/contract/...` 验证后端合同测试全部通过。 +- 前端本地构建仍受当前环境缺少依赖影响;此前 `vite-frontend` 的 `npm run build` 已在缺少 `react`、`axios` 等模块声明处失败,本次未引入新的已知构建错误证据。 diff --git a/vite-frontend/src/api/error-message.ts b/vite-frontend/src/api/error-message.ts index 11d861f..3f36920 100644 --- a/vite-frontend/src/api/error-message.ts +++ b/vite-frontend/src/api/error-message.ts @@ -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}`; +}; diff --git a/vite-frontend/src/api/types.ts b/vite-frontend/src/api/types.ts index f8d1043..a9e1945 100644 --- a/vite-frontend/src/api/types.ts +++ b/vite-frontend/src/api/types.ts @@ -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; } diff --git a/vite-frontend/src/components/batch-action-result-modal.tsx b/vite-frontend/src/components/batch-action-result-modal.tsx new file mode 100644 index 0000000..f580844 --- /dev/null +++ b/vite-frontend/src/components/batch-action-result-modal.tsx @@ -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 ( + + + {(onClose) => ( + <> + {title} + + +
+ {failures.map((failure, index) => ( +
+ +
+

+ {getFailureTitle(failure, index)} +

+

+ 点击展开查看失败原因 +

+
+ + 失败 + +
+
+ {getFailureReason(failure)} +
+
+ ))} +
+
+ + + + + + )} +
+
+ ); +} diff --git a/vite-frontend/src/pages/forward.tsx b/vite-frontend/src/pages/forward.tsx index 72fab20..8129ed0 100644 --- a/vite-frontend/src/pages/forward.tsx +++ b/vite-frontend/src/pages/forward.tsx @@ -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; type ForwardGroupCollapsedMap = Record; @@ -672,6 +691,8 @@ export default function ForwardPage() { const [batchTargetTunnelId, setBatchTargetTunnelId] = useState( null, ); + const [batchResultModal, setBatchResultModal] = + useState(EMPTY_BATCH_RESULT_MODAL_STATE); const [batchLoading, setBatchLoading] = useState(false); const [batchProgress, setBatchProgress] = useState({ 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() { + { + if (open) { + setBatchResultModal((prev) => ({ ...prev, open: true })); + + return; + } + + setBatchResultModal(EMPTY_BATCH_RESULT_MODAL_STATE); + }} + /> + {/* 筛选模态框 */} { return { successCount: Number(raw.successCount ?? 0), failCount: Number(raw.failCount ?? 0), + failures: extractBatchFailures(raw), }; }; const buildBatchToast = ( result: BatchOperationResult, successText: string, -): Pick => { + 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} 项`, diff --git a/vite-frontend/src/pages/tunnel.tsx b/vite-frontend/src/pages/tunnel.tsx index 75c5b21..e4bb546 100644 --- a/vite-frontend/src/pages/tunnel.tsx +++ b/vite-frontend/src/pages/tunnel.tsx @@ -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>(new Set()); const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false); + const [batchResultModal, setBatchResultModal] = + useState(EMPTY_BATCH_RESULT_MODAL_STATE); const [batchLoading, setBatchLoading] = useState(false); const [batchProgress, setBatchProgress] = useState({ 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() { )} + + { + if (open) { + setBatchResultModal((prev) => ({ ...prev, open: true })); + + return; + } + + setBatchResultModal(EMPTY_BATCH_RESULT_MODAL_STATE); + }} + /> ); }