mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
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:
@@ -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` 等模块声明处失败,本次未引入新的已知构建错误证据。
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} 项`,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user