mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
feat: add per-rule traffic reset (#526)
Reset only the selected forwarding rule's displayed upload/download usage without affecting user totals, tunnel quotas, historical statistics, nftables baselines, or running services. Closes #523
This commit is contained in:
@@ -0,0 +1,650 @@
|
|||||||
|
# Forward Flow Reset Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add a permission-checked action that resets only one forward rule's displayed upload and download counters.
|
||||||
|
|
||||||
|
**Architecture:** A dedicated repository method updates only the selected `forward` row. A dedicated authenticated handler reuses `resolveForwardAccess`, and the React page calls the endpoint from all three rule views through one confirmation modal.
|
||||||
|
|
||||||
|
**Tech Stack:** Go `net/http`, GORM, SQLite/PostgreSQL-compatible models, React, TypeScript, shadcn bridge components, Tailwind CSS v4.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Only `forward.in_flow`, `forward.out_flow`, and `forward.updated_time` may change during reset.
|
||||||
|
- Do not modify `user`, `user_tunnel`, quota, historical statistics, nftables counter state, or running services.
|
||||||
|
- Administrators may reset any rule; non-admin users may reset only their own rules through existing `resolveForwardAccess` behavior.
|
||||||
|
- All API responses must keep the `{code, msg, data, ts}` envelope.
|
||||||
|
- Frontend imports must use `src/shadcn-bridge/heroui/*`; do not add `@heroui/*` or `@nextui-org/*` dependencies.
|
||||||
|
- Do not add frontend test infrastructure.
|
||||||
|
- Do not edit generated protobuf files, `install.sh`, or `panel_install.sh`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add the repository flow-reset primitive
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `go-backend/internal/store/repo/repository_forward_flow_reset_test.go`
|
||||||
|
- Modify: `go-backend/internal/store/repo/repository_mutations.go`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `model.Forward`, the repository's GORM database handle, and an explicit Unix-millisecond timestamp.
|
||||||
|
- Produces: `func (r *Repository) ResetForwardFlow(forwardID int64, now int64) error`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing repository tests**
|
||||||
|
|
||||||
|
Create `go-backend/internal/store/repo/repository_forward_flow_reset_test.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResetForwardFlowOnlyUpdatesSelectedForward(t *testing.T) {
|
||||||
|
r, err := Open(filepath.Join(t.TempDir(), "forward-flow-reset.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open repo: %v", err)
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
const originalUpdated int64 = 1000
|
||||||
|
if err := r.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, 'owner', 'pwd', 1, 0, 100, 700, 900, 0, 10, 1000, 1000, 1)
|
||||||
|
`).Error; err != nil {
|
||||||
|
t.Fatalf("insert user: %v", err)
|
||||||
|
}
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||||
|
VALUES(1, 'tunnel', 1, 1, 'tls', 1, 1000, 1000, 1, NULL, 0)
|
||||||
|
`).Error; err != nil {
|
||||||
|
t.Fatalf("insert tunnel: %v", err)
|
||||||
|
}
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO user_tunnel(id, user_id, tunnel_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||||
|
VALUES(10, 2, 1, 10, 100, 500, 600, 0, 0, 1)
|
||||||
|
`).Error; err != nil {
|
||||||
|
t.Fatalf("insert user tunnel: %v", err)
|
||||||
|
}
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||||
|
VALUES
|
||||||
|
(20, 2, 'owner', 'target', 1, '127.0.0.1:80', 'fifo', 111, 222, 1000, ?, 1, 0),
|
||||||
|
(21, 2, 'owner', 'other', 1, '127.0.0.1:81', 'fifo', 333, 444, 1000, ?, 1, 1)
|
||||||
|
`, originalUpdated, originalUpdated).Error; err != nil {
|
||||||
|
t.Fatalf("insert forwards: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetAt int64 = 2000
|
||||||
|
if err := r.ResetForwardFlow(20, resetAt); err != nil {
|
||||||
|
t.Fatalf("ResetForwardFlow: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertForwardFlowResetValue(t, r, "SELECT in_flow FROM forward WHERE id = 20", 0)
|
||||||
|
assertForwardFlowResetValue(t, r, "SELECT out_flow FROM forward WHERE id = 20", 0)
|
||||||
|
assertForwardFlowResetValue(t, r, "SELECT updated_time FROM forward WHERE id = 20", resetAt)
|
||||||
|
assertForwardFlowResetValue(t, r, "SELECT in_flow FROM forward WHERE id = 21", 333)
|
||||||
|
assertForwardFlowResetValue(t, r, "SELECT out_flow FROM forward WHERE id = 21", 444)
|
||||||
|
assertForwardFlowResetValue(t, r, "SELECT in_flow FROM user WHERE id = 2", 700)
|
||||||
|
assertForwardFlowResetValue(t, r, "SELECT out_flow FROM user WHERE id = 2", 900)
|
||||||
|
assertForwardFlowResetValue(t, r, "SELECT in_flow FROM user_tunnel WHERE id = 10", 500)
|
||||||
|
assertForwardFlowResetValue(t, r, "SELECT out_flow FROM user_tunnel WHERE id = 10", 600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResetForwardFlowRejectsUninitializedRepository(t *testing.T) {
|
||||||
|
var r *Repository
|
||||||
|
if err := r.ResetForwardFlow(20, 2000); err == nil {
|
||||||
|
t.Fatal("expected uninitialized repository error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertForwardFlowResetValue(t *testing.T, r *Repository, query string, want int64) {
|
||||||
|
t.Helper()
|
||||||
|
var got int64
|
||||||
|
if err := r.DB().Raw(query).Scan(&got).Error; err != nil {
|
||||||
|
t.Fatalf("query %q: %v", query, err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("query %q returned %d, want %d", query, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the repository tests and verify the missing method failure**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd go-backend && go test ./internal/store/repo -run TestResetForwardFlow -count=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: compilation fails because `ResetForwardFlow` is undefined.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the minimal repository method**
|
||||||
|
|
||||||
|
Add to the flow-reset section of `go-backend/internal/store/repo/repository_mutations.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (r *Repository) ResetForwardFlow(forwardID int64, now int64) error {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return errors.New("repository not initialized")
|
||||||
|
}
|
||||||
|
return r.db.Model(&model.Forward{}).
|
||||||
|
Where("id = ?", forwardID).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"in_flow": 0,
|
||||||
|
"out_flow": 0,
|
||||||
|
"updated_time": now,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The file already imports `errors` and `model`; do not add a new dependency.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Format and run the focused repository tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd go-backend && gofmt -w internal/store/repo/repository_forward_flow_reset_test.go internal/store/repo/repository_mutations.go
|
||||||
|
go test ./internal/store/repo -run TestResetForwardFlow -count=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both reset tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit the repository change**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add go-backend/internal/store/repo/repository_mutations.go go-backend/internal/store/repo/repository_forward_flow_reset_test.go
|
||||||
|
git commit -m "feat: add forward flow reset repository method"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Add the authenticated reset endpoint
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `go-backend/internal/http/handler/forward_reset_flow_test.go`
|
||||||
|
- Modify: `go-backend/internal/http/handler/handler.go`
|
||||||
|
- Modify: `go-backend/internal/http/handler/mutations.go`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `POST` JSON `{ "id": number }`, `resolveForwardAccess`, and `Repository.ResetForwardFlow` from Task 1.
|
||||||
|
- Produces: `POST /api/v1/forward/reset-flow` and `func (h *Handler) forwardResetFlow(http.ResponseWriter, *http.Request)`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing handler tests**
|
||||||
|
|
||||||
|
Create `go-backend/internal/http/handler/forward_reset_flow_test.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go-backend/internal/auth"
|
||||||
|
"go-backend/internal/http/middleware"
|
||||||
|
"go-backend/internal/store/repo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestForwardResetFlowPermissionsAndIsolation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
actorID int64
|
||||||
|
actorRole int
|
||||||
|
forwardID int64
|
||||||
|
wantCode int
|
||||||
|
wantInFlow int64
|
||||||
|
wantOutFlow int64
|
||||||
|
}{
|
||||||
|
{name: "admin resets another user's rule", actorID: 1, actorRole: 0, forwardID: 20, wantCode: 0, wantInFlow: 0, wantOutFlow: 0},
|
||||||
|
{name: "owner resets own rule", actorID: 2, actorRole: 1, forwardID: 20, wantCode: 0, wantInFlow: 0, wantOutFlow: 0},
|
||||||
|
{name: "user cannot reset another user's rule", actorID: 3, actorRole: 1, forwardID: 20, wantCode: -1, wantInFlow: 111, wantOutFlow: 222},
|
||||||
|
{name: "missing rule is rejected", actorID: 1, actorRole: 0, forwardID: 999, wantCode: -1, wantInFlow: 111, wantOutFlow: 222},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
h, r := setupForwardResetFlowHandler(t)
|
||||||
|
req := newForwardResetFlowRequest(t, http.MethodPost, tt.forwardID, tt.actorID, tt.actorRole)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.forwardResetFlow(res, req)
|
||||||
|
|
||||||
|
if got := decodeForwardResetFlowCode(t, res); got != tt.wantCode {
|
||||||
|
t.Fatalf("code = %d, want %d; body=%s", got, tt.wantCode, res.Body.String())
|
||||||
|
}
|
||||||
|
assertForwardResetFlowDBValue(t, r, "SELECT in_flow FROM forward WHERE id = 20", tt.wantInFlow)
|
||||||
|
assertForwardResetFlowDBValue(t, r, "SELECT out_flow FROM forward WHERE id = 20", tt.wantOutFlow)
|
||||||
|
assertForwardResetFlowDBValue(t, r, "SELECT in_flow FROM user WHERE id = 2", 700)
|
||||||
|
assertForwardResetFlowDBValue(t, r, "SELECT out_flow FROM user_tunnel WHERE id = 10", 600)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForwardResetFlowRejectsInvalidRequests(t *testing.T) {
|
||||||
|
h, _ := setupForwardResetFlowHandler(t)
|
||||||
|
|
||||||
|
t.Run("non post", func(t *testing.T) {
|
||||||
|
req := newForwardResetFlowRequest(t, http.MethodGet, 20, 1, 0)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
h.forwardResetFlow(res, req)
|
||||||
|
if code := decodeForwardResetFlowCode(t, res); code != -1 {
|
||||||
|
t.Fatalf("code = %d, want -1", code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid id", func(t *testing.T) {
|
||||||
|
req := newForwardResetFlowRequest(t, http.MethodPost, 0, 1, 0)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
h.forwardResetFlow(res, req)
|
||||||
|
if code := decodeForwardResetFlowCode(t, res); code != -1 {
|
||||||
|
t.Fatalf("code = %d, want -1", code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupForwardResetFlowHandler(t *testing.T) (*Handler, *repo.Repository) {
|
||||||
|
t.Helper()
|
||||||
|
r, err := repo.Open(filepath.Join(t.TempDir(), "forward-reset-handler.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open repo: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = r.Close() })
|
||||||
|
|
||||||
|
statements := []string{
|
||||||
|
`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(1, 'admin', 'pwd', 0, 0, 100, 0, 0, 0, 10, 1000, 1000, 1)`,
|
||||||
|
`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, 'owner', 'pwd', 1, 0, 100, 700, 900, 0, 10, 1000, 1000, 1)`,
|
||||||
|
`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(3, 'other', 'pwd', 1, 0, 100, 0, 0, 0, 10, 1000, 1000, 1)`,
|
||||||
|
`INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(1, 'tunnel', 1, 1, 'tls', 1, 1000, 1000, 1, NULL, 0)`,
|
||||||
|
`INSERT INTO user_tunnel(id, user_id, tunnel_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(10, 2, 1, 10, 100, 500, 600, 0, 0, 1)`,
|
||||||
|
`INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx) VALUES(20, 2, 'owner', 'target', 1, '127.0.0.1:80', 'fifo', 111, 222, 1000, 1000, 1, 0)`,
|
||||||
|
}
|
||||||
|
for _, statement := range statements {
|
||||||
|
if err := r.DB().Exec(statement).Error; err != nil {
|
||||||
|
t.Fatalf("seed database: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return New(r, "test-secret"), r
|
||||||
|
}
|
||||||
|
|
||||||
|
func newForwardResetFlowRequest(t *testing.T, method string, forwardID, actorID int64, roleID int) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
body, err := json.Marshal(map[string]int64{"id": forwardID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal request: %v", err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(method, "/api/v1/forward/reset-flow", bytes.NewReader(body))
|
||||||
|
claims := auth.Claims{Sub: strconv.FormatInt(actorID, 10), RoleID: roleID}
|
||||||
|
return req.WithContext(context.WithValue(req.Context(), middleware.ClaimsContextKey, claims))
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeForwardResetFlowCode(t *testing.T, res *httptest.ResponseRecorder) int {
|
||||||
|
t.Helper()
|
||||||
|
var payload struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("decode response: %v; body=%s", err, res.Body.String())
|
||||||
|
}
|
||||||
|
return payload.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertForwardResetFlowDBValue(t *testing.T, r *repo.Repository, query string, want int64) {
|
||||||
|
t.Helper()
|
||||||
|
var got int64
|
||||||
|
if err := r.DB().Raw(query).Scan(&got).Error; err != nil {
|
||||||
|
t.Fatalf("query %q: %v", query, err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("query %q returned %d, want %d", query, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If the project's default error code differs from `-1`, replace the test expectation with the actual `response.ErrDefault` code after inspecting one existing handler response; do not weaken the success and database assertions.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the handler tests and verify the missing handler failure**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd go-backend && go test ./internal/http/handler -run TestForwardResetFlow -count=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: compilation fails because `forwardResetFlow` is undefined.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Register and implement the endpoint**
|
||||||
|
|
||||||
|
Add this route beside the other forward routes in `go-backend/internal/http/handler/handler.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
mux.HandleFunc("/api/v1/forward/reset-flow", h.forwardResetFlow)
|
||||||
|
```
|
||||||
|
|
||||||
|
Add this handler beside `forwardPause` and `forwardResume` in `go-backend/internal/http/handler/mutations.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (h *Handler) forwardResetFlow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := idFromBody(r, w)
|
||||||
|
if id <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, _, _, err := h.resolveForwardAccess(r, id); err != nil {
|
||||||
|
if errors.Is(err, errForwardNotFound) {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("转发不存在"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.repo.ResetForwardFlow(id, time.Now().UnixMilli()); err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.WriteJSON(w, response.OKEmpty())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This deliberately does not call runtime service controls or nftables reconciliation.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Format and run the focused handler tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd go-backend && gofmt -w internal/http/handler/forward_reset_flow_test.go internal/http/handler/handler.go internal/http/handler/mutations.go
|
||||||
|
go test ./internal/http/handler -run TestForwardResetFlow -count=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all reset endpoint tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run all backend tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd go-backend && go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all backend packages and contract tests pass, excluding environment-gated PostgreSQL tests when `FLVX_POSTGRES_TEST_DSN` is unset.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit the endpoint change**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add go-backend/internal/http/handler/handler.go go-backend/internal/http/handler/mutations.go go-backend/internal/http/handler/forward_reset_flow_test.go
|
||||||
|
git commit -m "feat: add forward flow reset endpoint"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Add the rule-page reset action and confirmation modal
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `vite-frontend/src/api/index.ts`
|
||||||
|
- Modify: `vite-frontend/src/pages/forward.tsx`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `POST /forward/reset-flow`, the page's `Forward` shape, `refreshForwardList`, toast notifications, and existing modal/button bridge components.
|
||||||
|
- Produces: `resetForwardFlow(id: number)`, a shared reset handler, disabled zero-usage actions in all rule views, and one confirmation modal.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the frontend API wrapper**
|
||||||
|
|
||||||
|
Add beside the forward control operations in `vite-frontend/src/api/index.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const resetForwardFlow = (forwardId: number) =>
|
||||||
|
Network.post("/forward/reset-flow", { id: forwardId });
|
||||||
|
```
|
||||||
|
|
||||||
|
Import `resetForwardFlow` from `@/api` in `vite-frontend/src/pages/forward.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add page state and shared reset handlers**
|
||||||
|
|
||||||
|
Add state beside the existing delete modal state:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const [resetFlowModalOpen, setResetFlowModalOpen] = useState(false);
|
||||||
|
const [resetFlowLoading, setResetFlowLoading] = useState(false);
|
||||||
|
const [forwardToResetFlow, setForwardToResetFlow] = useState<Forward | null>(null);
|
||||||
|
```
|
||||||
|
|
||||||
|
Add these handlers beside `handleDelete` and `confirmDelete`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const handleResetFlow = (forward: Forward) => {
|
||||||
|
if ((forward.inFlow || 0) + (forward.outFlow || 0) <= 0) return;
|
||||||
|
setForwardToResetFlow(forward);
|
||||||
|
setResetFlowModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmResetFlow = async () => {
|
||||||
|
if (!forwardToResetFlow) return;
|
||||||
|
|
||||||
|
setResetFlowLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await resetForwardFlow(forwardToResetFlow.id);
|
||||||
|
|
||||||
|
if (res.code !== 0) {
|
||||||
|
toast.error(res.msg || "流量清零失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("规则流量已清零");
|
||||||
|
setResetFlowModalOpen(false);
|
||||||
|
setForwardToResetFlow(null);
|
||||||
|
await refreshForwardList(false);
|
||||||
|
} catch {
|
||||||
|
toast.error("流量清零失败");
|
||||||
|
} finally {
|
||||||
|
setResetFlowLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add one reusable reset icon button to both table row components**
|
||||||
|
|
||||||
|
Pass `handleResetFlow` into `SortableTableRow` and `SortableCompactTableRow` at every render site. Add it to each component's destructured props.
|
||||||
|
|
||||||
|
Insert this button between diagnosis and delete in each table action cell:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
className="bg-secondary/10 text-secondary hover:bg-secondary/20"
|
||||||
|
isDisabled={(forward.inFlow || 0) + (forward.outFlow || 0) <= 0}
|
||||||
|
size="sm"
|
||||||
|
title="流量清零"
|
||||||
|
onPress={() => handleResetFlow(forward)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M4 4v6h6M20 20v-6h-6M20 9a8 8 0 00-13.657-3.657L4 8m16 8-2.343 2.657A8 8 0 014 15"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add the reset action to the card view**
|
||||||
|
|
||||||
|
Insert a fourth action button between diagnosis and delete in `renderForwardCard`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button
|
||||||
|
className="flex-1 min-h-8"
|
||||||
|
color="secondary"
|
||||||
|
isDisabled={(forward.inFlow || 0) + (forward.outFlow || 0) <= 0}
|
||||||
|
size="sm"
|
||||||
|
startContent={
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="w-3 h-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M4 4v6h6M20 20v-6h-6M20 9a8 8 0 00-13.657-3.657L4 8m16 8-2.343 2.657A8 8 0 014 15"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => handleResetFlow(forward)}
|
||||||
|
>
|
||||||
|
清零
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Change the card action container from `flex gap-1.5 mt-3` to `grid grid-cols-2 gap-1.5 mt-3` so all four actions remain readable at the smallest supported card width.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add the confirmation modal**
|
||||||
|
|
||||||
|
Add beside the delete confirmation modal:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Modal
|
||||||
|
backdrop="blur"
|
||||||
|
classNames={{
|
||||||
|
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
|
||||||
|
}}
|
||||||
|
isOpen={resetFlowModalOpen}
|
||||||
|
placement="center"
|
||||||
|
scrollBehavior="inside"
|
||||||
|
size="lg"
|
||||||
|
onOpenChange={setResetFlowModalOpen}
|
||||||
|
>
|
||||||
|
<ModalContent>
|
||||||
|
{(onClose) => (
|
||||||
|
<>
|
||||||
|
<ModalHeader className="flex flex-col gap-1">
|
||||||
|
<h2 className="text-lg font-bold text-secondary">确认流量清零</h2>
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<p className="text-default-600">
|
||||||
|
确定要清零规则{" "}
|
||||||
|
<span className="font-semibold text-foreground">
|
||||||
|
"{forwardToResetFlow?.name}"
|
||||||
|
</span>{" "}
|
||||||
|
当前显示的上传和下载流量吗?
|
||||||
|
</p>
|
||||||
|
<p className="text-small text-default-500 mt-2">
|
||||||
|
此操作不可撤销,但不会影响用户总流量、用户隧道配额和历史统计。
|
||||||
|
</p>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button isDisabled={resetFlowLoading} variant="light" onPress={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="secondary"
|
||||||
|
isLoading={resetFlowLoading}
|
||||||
|
onPress={confirmResetFlow}
|
||||||
|
>
|
||||||
|
确认清零
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
```
|
||||||
|
|
||||||
|
Add this wrapper beside the other reset handlers and pass it to the modal as `onOpenChange={handleResetFlowModalOpenChange}`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const handleResetFlowModalOpenChange = (isOpen: boolean) => {
|
||||||
|
if (resetFlowLoading) return;
|
||||||
|
setResetFlowModalOpen(isOpen);
|
||||||
|
if (!isOpen) {
|
||||||
|
setForwardToResetFlow(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Format and verify the frontend**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd vite-frontend && pnpm exec prettier --write src/api/index.ts src/pages/forward.tsx
|
||||||
|
pnpm run build
|
||||||
|
pnpm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: TypeScript/Vite build succeeds and ESLint finishes without errors.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit the frontend change**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add vite-frontend/src/api/index.ts vite-frontend/src/pages/forward.tsx
|
||||||
|
git commit -m "feat: add forward flow reset action"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Perform integrated verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Verify only; no planned source changes.
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: the repository method, API endpoint, and rule-page action from Tasks 1-3.
|
||||||
|
- Produces: evidence that the complete feature builds and all affected tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run the complete backend suite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd go-backend && go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all available backend tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the complete frontend checks**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd vite-frontend && pnpm run build && pnpm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both commands exit successfully.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Check formatting and working-tree scope**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff --check
|
||||||
|
git status --short
|
||||||
|
git log -4 --oneline
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no whitespace errors; the working tree is clean; the three feature commits are visible after the design and implementation-plan commits.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Manually verify the feature when a local panel is available**
|
||||||
|
|
||||||
|
1. Open the Rules page as an administrator and reset a rule with non-zero upload/download traffic.
|
||||||
|
2. Confirm the modal states that user totals, tunnel quota, and history are unaffected.
|
||||||
|
3. Confirm the rule immediately shows zero after success.
|
||||||
|
4. Confirm the user page's total traffic and user-tunnel traffic values did not change.
|
||||||
|
5. Generate new traffic and confirm the rule starts accumulating from zero.
|
||||||
|
6. Log in as a normal user and confirm the user can reset an owned rule but cannot access another user's rule through a direct API request.
|
||||||
|
|
||||||
|
Expected: all six checks match the design specification.
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# 规则流量清零设计
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
Issue #523 希望“规则”页面中每条隧道规则显示的流量使用量支持手动清零。
|
||||||
|
|
||||||
|
当前规则流量保存在 `forward.in_flow` 和 `forward.out_flow`。流量上报时,同一份增量还会累计到用户总流量、用户隧道流量和相关配额统计中。因此,本功能必须将“规则展示计数器清零”与“用户或隧道配额重置”严格区分。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
为单条规则提供手动流量清零能力:
|
||||||
|
|
||||||
|
- 将所选规则的上传流量和下载流量清零。
|
||||||
|
- 管理员可以清零任意规则。
|
||||||
|
- 普通用户只能清零自己的规则。
|
||||||
|
- 清零后,新产生的流量继续从零正常累计。
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
本功能不会:
|
||||||
|
|
||||||
|
- 修改用户总流量 `user.in_flow` 或 `user.out_flow`。
|
||||||
|
- 修改用户隧道流量 `user_tunnel.in_flow` 或 `user_tunnel.out_flow`。
|
||||||
|
- 修改每日或每月配额用量。
|
||||||
|
- 修改历史流量统计。
|
||||||
|
- 重置 nftables 节点计数器或其增量计算基线。
|
||||||
|
- 重启、暂停、恢复或重新部署规则服务。
|
||||||
|
- 增加批量流量清零功能。
|
||||||
|
|
||||||
|
## 后端设计
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
新增接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/v1/forward/reset-flow
|
||||||
|
```
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 123
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功响应沿用统一 envelope:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "success",
|
||||||
|
"data": null,
|
||||||
|
"ts": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
具体 `msg`、`data` 和 `ts` 值继续由现有 response helper 生成。
|
||||||
|
|
||||||
|
### 参数与权限校验
|
||||||
|
|
||||||
|
Handler 执行以下步骤:
|
||||||
|
|
||||||
|
1. 只接受 `POST` 请求。
|
||||||
|
2. 从 JSON 请求体读取正整数规则 ID。
|
||||||
|
3. 调用现有 `resolveForwardAccess`:
|
||||||
|
- 管理员角色可以访问任意存在的规则。
|
||||||
|
- 普通用户仅能访问 `forward.user_id` 等于当前用户 ID 的规则。
|
||||||
|
- 对普通用户访问他人规则的情况,沿用现有逻辑返回“转发不存在”,避免暴露规则存在性。
|
||||||
|
4. 调用 Repository 完成清零。
|
||||||
|
5. 返回统一成功响应。
|
||||||
|
|
||||||
|
### Repository
|
||||||
|
|
||||||
|
新增方法:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (r *Repository) ResetForwardFlow(forwardID int64, now int64) error
|
||||||
|
```
|
||||||
|
|
||||||
|
该方法只更新指定 `forward` 记录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
in_flow = 0
|
||||||
|
out_flow = 0
|
||||||
|
updated_time = now
|
||||||
|
```
|
||||||
|
|
||||||
|
Repository 不直接操作 Handler 的身份信息,也不更新任何其他表。
|
||||||
|
|
||||||
|
### 并发与后续流量
|
||||||
|
|
||||||
|
清零使用单条 SQL `UPDATE`。agent 流量上报和 nftables 流量采集仍使用原有增量累加逻辑。清零不会重置采集基线,因此下一次采集只会把清零之后新计算出的增量加回规则计数,不会把清零前的累计值整体恢复。
|
||||||
|
|
||||||
|
若清零 SQL 与流量增量 SQL 同时执行,数据库按实际语句执行顺序决定最终值;每条更新本身保持原子性。本功能不引入暂停采集或跨节点同步流程。
|
||||||
|
|
||||||
|
## 前端设计
|
||||||
|
|
||||||
|
### API 封装
|
||||||
|
|
||||||
|
在 `vite-frontend/src/api/index.ts` 新增:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const resetForwardFlow = (id: number) =>
|
||||||
|
Network.post("/forward/reset-flow", { id });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 入口
|
||||||
|
|
||||||
|
在规则页面所有单条规则操作入口中增加“流量清零”操作:
|
||||||
|
|
||||||
|
- 分组表格视图。
|
||||||
|
- 精简表格视图。
|
||||||
|
- 卡片视图。
|
||||||
|
|
||||||
|
按钮使用独立的清零/刷新语义图标和提示文本,不复用删除按钮样式。
|
||||||
|
|
||||||
|
当规则的 `inFlow + outFlow` 等于零时,按钮禁用,避免重复请求。
|
||||||
|
|
||||||
|
### 确认交互
|
||||||
|
|
||||||
|
点击按钮后打开确认弹窗,显示规则名称,并明确说明:
|
||||||
|
|
||||||
|
- 仅清零当前规则显示的上传和下载流量。
|
||||||
|
- 不影响用户总流量、用户隧道配额和历史统计。
|
||||||
|
- 操作不可撤销。
|
||||||
|
|
||||||
|
确认期间显示 loading 状态并阻止重复提交。
|
||||||
|
|
||||||
|
### 成功与失败
|
||||||
|
|
||||||
|
- 成功:关闭弹窗,显示成功 toast,并刷新规则列表。
|
||||||
|
- 失败:保留弹窗,显示后端错误信息或通用失败 toast。
|
||||||
|
- 刷新后,该规则上传和下载均显示为零;后续流量继续正常累计。
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
- 非 POST 请求:返回现有通用请求失败响应。
|
||||||
|
- 请求体无法解析、ID 缺失或 ID 非正数:返回“请求参数错误”。
|
||||||
|
- 规则不存在或普通用户访问他人规则:返回“转发不存在”。
|
||||||
|
- Repository 更新失败:返回包含 Repository 错误信息的统一错误响应。
|
||||||
|
- 前端网络错误:显示“流量清零失败”。
|
||||||
|
|
||||||
|
## 测试策略
|
||||||
|
|
||||||
|
### Repository 测试
|
||||||
|
|
||||||
|
验证:
|
||||||
|
|
||||||
|
- 指定规则的 `in_flow`、`out_flow` 被清零。
|
||||||
|
- 指定规则的 `updated_time` 被更新。
|
||||||
|
- 其他规则的流量不变。
|
||||||
|
- 用户总流量不变。
|
||||||
|
- 用户隧道流量不变。
|
||||||
|
- Repository 未初始化时返回错误。
|
||||||
|
|
||||||
|
### Handler 测试
|
||||||
|
|
||||||
|
验证:
|
||||||
|
|
||||||
|
- 管理员能够清零任意存在的规则。
|
||||||
|
- 普通用户能够清零自己的规则。
|
||||||
|
- 普通用户不能清零他人的规则。
|
||||||
|
- 不存在的规则返回错误。
|
||||||
|
- 无效 ID 返回参数错误。
|
||||||
|
- 非 POST 请求返回请求失败。
|
||||||
|
- 成功请求不修改用户和用户隧道流量。
|
||||||
|
|
||||||
|
### 前端验证
|
||||||
|
|
||||||
|
项目没有配置前端测试框架,因此不新增前端单元测试。使用以下命令验证:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
(cd vite-frontend && pnpm run build)
|
||||||
|
(cd vite-frontend && pnpm run lint)
|
||||||
|
```
|
||||||
|
|
||||||
|
后端使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
(cd go-backend && go test ./...)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文件范围
|
||||||
|
|
||||||
|
预计修改:
|
||||||
|
|
||||||
|
- `go-backend/internal/http/handler/handler.go`
|
||||||
|
- `go-backend/internal/http/handler/mutations.go`
|
||||||
|
- `go-backend/internal/http/handler/*_test.go`
|
||||||
|
- `go-backend/internal/store/repo/repository_mutations.go`
|
||||||
|
- `go-backend/internal/store/repo/*_test.go`
|
||||||
|
- `vite-frontend/src/api/index.ts`
|
||||||
|
- `vite-frontend/src/pages/forward.tsx`
|
||||||
|
|
||||||
|
不需要数据库迁移或新增依赖。
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go-backend/internal/auth"
|
||||||
|
"go-backend/internal/http/middleware"
|
||||||
|
"go-backend/internal/store/repo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestForwardResetFlowPermissionsAndIsolation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
actorID int64
|
||||||
|
actorRole int
|
||||||
|
forwardID int64
|
||||||
|
wantCode int
|
||||||
|
wantInFlow int64
|
||||||
|
wantOutFlow int64
|
||||||
|
}{
|
||||||
|
{name: "admin resets another user's rule", actorID: 1, actorRole: 0, forwardID: 20, wantCode: 0, wantInFlow: 0, wantOutFlow: 0},
|
||||||
|
{name: "owner resets own rule", actorID: 2, actorRole: 1, forwardID: 20, wantCode: 0, wantInFlow: 0, wantOutFlow: 0},
|
||||||
|
{name: "user cannot reset another user's rule", actorID: 3, actorRole: 1, forwardID: 20, wantCode: -1, wantInFlow: 111, wantOutFlow: 222},
|
||||||
|
{name: "missing rule is rejected", actorID: 1, actorRole: 0, forwardID: 999, wantCode: -1, wantInFlow: 111, wantOutFlow: 222},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
h, r := setupForwardResetFlowHandler(t)
|
||||||
|
req := newForwardResetFlowRequest(t, http.MethodPost, tt.forwardID, tt.actorID, tt.actorRole)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.forwardResetFlow(res, req)
|
||||||
|
|
||||||
|
if got := decodeForwardResetFlowCode(t, res); got != tt.wantCode {
|
||||||
|
t.Fatalf("code = %d, want %d; body=%s", got, tt.wantCode, res.Body.String())
|
||||||
|
}
|
||||||
|
assertForwardResetFlowDBValue(t, r, "SELECT in_flow FROM forward WHERE id = 20", tt.wantInFlow)
|
||||||
|
assertForwardResetFlowDBValue(t, r, "SELECT out_flow FROM forward WHERE id = 20", tt.wantOutFlow)
|
||||||
|
assertForwardResetFlowDBValue(t, r, "SELECT in_flow FROM user WHERE id = 2", 700)
|
||||||
|
assertForwardResetFlowDBValue(t, r, "SELECT out_flow FROM user_tunnel WHERE id = 10", 600)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForwardResetFlowRejectsInvalidRequests(t *testing.T) {
|
||||||
|
h, _ := setupForwardResetFlowHandler(t)
|
||||||
|
|
||||||
|
t.Run("non post", func(t *testing.T) {
|
||||||
|
req := newForwardResetFlowRequest(t, http.MethodGet, 20, 1, 0)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
h.forwardResetFlow(res, req)
|
||||||
|
if code := decodeForwardResetFlowCode(t, res); code != -1 {
|
||||||
|
t.Fatalf("code = %d, want -1", code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid id", func(t *testing.T) {
|
||||||
|
req := newForwardResetFlowRequest(t, http.MethodPost, 0, 1, 0)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
h.forwardResetFlow(res, req)
|
||||||
|
if code := decodeForwardResetFlowCode(t, res); code != -1 {
|
||||||
|
t.Fatalf("code = %d, want -1", code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupForwardResetFlowHandler(t *testing.T) (*Handler, *repo.Repository) {
|
||||||
|
t.Helper()
|
||||||
|
r, err := repo.Open(filepath.Join(t.TempDir(), "forward-reset-handler.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open repo: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = r.Close() })
|
||||||
|
|
||||||
|
statements := []string{
|
||||||
|
`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, 'owner', 'pwd', 1, 0, 100, 700, 900, 0, 10, 1000, 1000, 1)`,
|
||||||
|
`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(3, 'other', 'pwd', 1, 0, 100, 0, 0, 0, 10, 1000, 1000, 1)`,
|
||||||
|
`INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(1, 'tunnel', 1, 1, 'tls', 1, 1000, 1000, 1, NULL, 0)`,
|
||||||
|
`INSERT INTO user_tunnel(id, user_id, tunnel_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(10, 2, 1, 10, 100, 500, 600, 0, 0, 1)`,
|
||||||
|
`INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx) VALUES(20, 2, 'owner', 'target', 1, '127.0.0.1:80', 'fifo', 111, 222, 1000, 1000, 1, 0)`,
|
||||||
|
}
|
||||||
|
for _, statement := range statements {
|
||||||
|
if err := r.DB().Exec(statement).Error; err != nil {
|
||||||
|
t.Fatalf("seed database: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return New(r, "test-secret"), r
|
||||||
|
}
|
||||||
|
|
||||||
|
func newForwardResetFlowRequest(t *testing.T, method string, forwardID, actorID int64, roleID int) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
body, err := json.Marshal(map[string]int64{"id": forwardID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal request: %v", err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(method, "/api/v1/forward/reset-flow", bytes.NewReader(body))
|
||||||
|
claims := auth.Claims{Sub: strconv.FormatInt(actorID, 10), RoleID: roleID}
|
||||||
|
return req.WithContext(context.WithValue(req.Context(), middleware.ClaimsContextKey, claims))
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeForwardResetFlowCode(t *testing.T, res *httptest.ResponseRecorder) int {
|
||||||
|
t.Helper()
|
||||||
|
var payload struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("decode response: %v; body=%s", err, res.Body.String())
|
||||||
|
}
|
||||||
|
return payload.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertForwardResetFlowDBValue(t *testing.T, r *repo.Repository, query string, want int64) {
|
||||||
|
t.Helper()
|
||||||
|
var got int64
|
||||||
|
if err := r.DB().Raw(query).Scan(&got).Error; err != nil {
|
||||||
|
t.Fatalf("query %q: %v", query, err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("query %q returned %d, want %d", query, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -221,6 +221,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/forward/force-delete", h.forwardForceDelete)
|
mux.HandleFunc("/api/v1/forward/force-delete", h.forwardForceDelete)
|
||||||
mux.HandleFunc("/api/v1/forward/pause", h.forwardPause)
|
mux.HandleFunc("/api/v1/forward/pause", h.forwardPause)
|
||||||
mux.HandleFunc("/api/v1/forward/resume", h.forwardResume)
|
mux.HandleFunc("/api/v1/forward/resume", h.forwardResume)
|
||||||
|
mux.HandleFunc("/api/v1/forward/reset-flow", h.forwardResetFlow)
|
||||||
mux.HandleFunc("/api/v1/forward/diagnose", h.forwardDiagnose)
|
mux.HandleFunc("/api/v1/forward/diagnose", h.forwardDiagnose)
|
||||||
mux.HandleFunc("/api/v1/forward/diagnose/stream", h.forwardDiagnoseStream)
|
mux.HandleFunc("/api/v1/forward/diagnose/stream", h.forwardDiagnoseStream)
|
||||||
mux.HandleFunc("/api/v1/forward/update-order", h.forwardUpdateOrder)
|
mux.HandleFunc("/api/v1/forward/update-order", h.forwardUpdateOrder)
|
||||||
|
|||||||
@@ -2502,6 +2502,26 @@ func (h *Handler) forwardPause(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.WriteJSON(w, response.OKEmpty())
|
response.WriteJSON(w, response.OKEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) forwardResetFlow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := idFromBody(r, w)
|
||||||
|
if id <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, _, _, err := h.resolveForwardAccess(r, id); err != nil {
|
||||||
|
if errors.Is(err, errForwardNotFound) {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("转发不存在"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.repo.ResetForwardFlow(id, time.Now().UnixMilli()); err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.WriteJSON(w, response.OKEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) forwardResume(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) forwardResume(w http.ResponseWriter, r *http.Request) {
|
||||||
id := idFromBody(r, w)
|
id := idFromBody(r, w)
|
||||||
if id <= 0 {
|
if id <= 0 {
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResetForwardFlowOnlyUpdatesSelectedForward(t *testing.T) {
|
||||||
|
r, err := Open(filepath.Join(t.TempDir(), "forward-flow-reset.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open repo: %v", err)
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
const originalUpdated int64 = 1000
|
||||||
|
if err := r.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, 'owner', 'pwd', 1, 0, 100, 700, 900, 0, 10, 1000, 1000, 1)
|
||||||
|
`).Error; err != nil {
|
||||||
|
t.Fatalf("insert user: %v", err)
|
||||||
|
}
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||||
|
VALUES(1, 'tunnel', 1, 1, 'tls', 1, 1000, 1000, 1, NULL, 0)
|
||||||
|
`).Error; err != nil {
|
||||||
|
t.Fatalf("insert tunnel: %v", err)
|
||||||
|
}
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO user_tunnel(id, user_id, tunnel_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||||
|
VALUES(10, 2, 1, 10, 100, 500, 600, 0, 0, 1)
|
||||||
|
`).Error; err != nil {
|
||||||
|
t.Fatalf("insert user tunnel: %v", err)
|
||||||
|
}
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||||
|
VALUES
|
||||||
|
(20, 2, 'owner', 'target', 1, '127.0.0.1:80', 'fifo', 111, 222, 1000, ?, 1, 0),
|
||||||
|
(21, 2, 'owner', 'other', 1, '127.0.0.1:81', 'fifo', 333, 444, 1000, ?, 1, 1)
|
||||||
|
`, originalUpdated, originalUpdated).Error; err != nil {
|
||||||
|
t.Fatalf("insert forwards: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetAt int64 = 2000
|
||||||
|
if err := r.ResetForwardFlow(20, resetAt); err != nil {
|
||||||
|
t.Fatalf("ResetForwardFlow: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertForwardFlowResetValue(t, r, "SELECT in_flow FROM forward WHERE id = 20", 0)
|
||||||
|
assertForwardFlowResetValue(t, r, "SELECT out_flow FROM forward WHERE id = 20", 0)
|
||||||
|
assertForwardFlowResetValue(t, r, "SELECT updated_time FROM forward WHERE id = 20", resetAt)
|
||||||
|
assertForwardFlowResetValue(t, r, "SELECT in_flow FROM forward WHERE id = 21", 333)
|
||||||
|
assertForwardFlowResetValue(t, r, "SELECT out_flow FROM forward WHERE id = 21", 444)
|
||||||
|
assertForwardFlowResetValue(t, r, "SELECT in_flow FROM user WHERE id = 2", 700)
|
||||||
|
assertForwardFlowResetValue(t, r, "SELECT out_flow FROM user WHERE id = 2", 900)
|
||||||
|
assertForwardFlowResetValue(t, r, "SELECT in_flow FROM user_tunnel WHERE id = 10", 500)
|
||||||
|
assertForwardFlowResetValue(t, r, "SELECT out_flow FROM user_tunnel WHERE id = 10", 600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResetForwardFlowRejectsUninitializedRepository(t *testing.T) {
|
||||||
|
var r *Repository
|
||||||
|
if err := r.ResetForwardFlow(20, 2000); err == nil {
|
||||||
|
t.Fatal("expected uninitialized repository error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertForwardFlowResetValue(t *testing.T, r *Repository, query string, want int64) {
|
||||||
|
t.Helper()
|
||||||
|
var got int64
|
||||||
|
if err := r.DB().Raw(query).Scan(&got).Error; err != nil {
|
||||||
|
t.Fatalf("query %q: %v", query, err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("query %q returned %d, want %d", query, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -197,6 +197,19 @@ func (r *Repository) ResetUserFlowByUserTunnel(userTunnelID int64) {
|
|||||||
Updates(map[string]interface{}{"in_flow": 0, "out_flow": 0}).Error
|
Updates(map[string]interface{}{"in_flow": 0, "out_flow": 0}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) ResetForwardFlow(forwardID int64, now int64) error {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return errors.New("repository not initialized")
|
||||||
|
}
|
||||||
|
return r.db.Model(&model.Forward{}).
|
||||||
|
Where("id = ?", forwardID).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"in_flow": 0,
|
||||||
|
"out_flow": 0,
|
||||||
|
"updated_time": now,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) GetUsernameByID(userID int64) string {
|
func (r *Repository) GetUsernameByID(userID int64) string {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -217,6 +217,8 @@ export const pauseForwardService = (forwardId: number) =>
|
|||||||
Network.post("/forward/pause", { id: forwardId });
|
Network.post("/forward/pause", { id: forwardId });
|
||||||
export const resumeForwardService = (forwardId: number) =>
|
export const resumeForwardService = (forwardId: number) =>
|
||||||
Network.post("/forward/resume", { id: forwardId });
|
Network.post("/forward/resume", { id: forwardId });
|
||||||
|
export const resetForwardFlow = (forwardId: number) =>
|
||||||
|
Network.post("/forward/reset-flow", { id: forwardId });
|
||||||
|
|
||||||
// 转发诊断操作
|
// 转发诊断操作
|
||||||
export const diagnoseForward = (forwardId: number) =>
|
export const diagnoseForward = (forwardId: number) =>
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ import {
|
|||||||
getNodeList,
|
getNodeList,
|
||||||
pauseForwardService,
|
pauseForwardService,
|
||||||
resumeForwardService,
|
resumeForwardService,
|
||||||
|
resetForwardFlow,
|
||||||
diagnoseForward,
|
diagnoseForward,
|
||||||
updateForwardOrder,
|
updateForwardOrder,
|
||||||
getConfigByName,
|
getConfigByName,
|
||||||
@@ -230,7 +231,7 @@ const FORWARD_GROUPED_TABLE_COLUMN_CLASS = {
|
|||||||
strategy: "w-[100px]",
|
strategy: "w-[100px]",
|
||||||
totalFlow: "w-[120px]",
|
totalFlow: "w-[120px]",
|
||||||
status: "w-[100px]",
|
status: "w-[100px]",
|
||||||
actions: "w-[144px] text-right",
|
actions: "w-[176px] text-right",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const normalizeForwardUserName = (userName?: string): string => {
|
const normalizeForwardUserName = (userName?: string): string => {
|
||||||
@@ -764,6 +765,7 @@ const SortableTableRow = ({
|
|||||||
handleEdit,
|
handleEdit,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
handleDiagnose,
|
handleDiagnose,
|
||||||
|
handleResetFlow,
|
||||||
showAddressModal,
|
showAddressModal,
|
||||||
formatFlow,
|
formatFlow,
|
||||||
}: any) => {
|
}: any) => {
|
||||||
@@ -919,6 +921,29 @@ const SortableTableRow = ({
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
className="bg-secondary/10 text-secondary hover:bg-secondary/20"
|
||||||
|
isDisabled={(forward.inFlow || 0) + (forward.outFlow || 0) <= 0}
|
||||||
|
size="sm"
|
||||||
|
title="流量清零"
|
||||||
|
onPress={() => handleResetFlow(forward)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M4 4v6h6M20 20v-6h-6M20 9a8 8 0 00-13.657-3.657L4 8m16 8-2.343 2.657A8 8 0 014 15"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
isIconOnly
|
isIconOnly
|
||||||
className="bg-danger/10 text-danger hover:bg-danger/20"
|
className="bg-danger/10 text-danger hover:bg-danger/20"
|
||||||
@@ -958,6 +983,7 @@ const SortableCompactTableRow = ({
|
|||||||
handleEdit,
|
handleEdit,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
handleDiagnose,
|
handleDiagnose,
|
||||||
|
handleResetFlow,
|
||||||
showAddressModal,
|
showAddressModal,
|
||||||
hasMultipleAddresses,
|
hasMultipleAddresses,
|
||||||
formatFlow,
|
formatFlow,
|
||||||
@@ -1144,6 +1170,29 @@ const SortableCompactTableRow = ({
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
className="bg-secondary/10 text-secondary hover:bg-secondary/20"
|
||||||
|
isDisabled={(forward.inFlow || 0) + (forward.outFlow || 0) <= 0}
|
||||||
|
size="sm"
|
||||||
|
title="流量清零"
|
||||||
|
onPress={() => handleResetFlow(forward)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M4 4v6h6M20 20v-6h-6M20 9a8 8 0 00-13.657-3.657L4 8m16 8-2.343 2.657A8 8 0 014 15"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
isIconOnly
|
isIconOnly
|
||||||
className="bg-danger/10 text-danger hover:bg-danger/20"
|
className="bg-danger/10 text-danger hover:bg-danger/20"
|
||||||
@@ -1289,13 +1338,18 @@ export default function ForwardPage() {
|
|||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
// isFilterModalOpen removed
|
// isFilterModalOpen removed
|
||||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||||
|
const [resetFlowModalOpen, setResetFlowModalOpen] = useState(false);
|
||||||
const [addressModalOpen, setAddressModalOpen] = useState(false);
|
const [addressModalOpen, setAddressModalOpen] = useState(false);
|
||||||
const [diagnosisModalOpen, setDiagnosisModalOpen] = useState(false);
|
const [diagnosisModalOpen, setDiagnosisModalOpen] = useState(false);
|
||||||
const [isEdit, setIsEdit] = useState(false);
|
const [isEdit, setIsEdit] = useState(false);
|
||||||
const [submitLoading, setSubmitLoading] = useState(false);
|
const [submitLoading, setSubmitLoading] = useState(false);
|
||||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||||
|
const [resetFlowLoading, setResetFlowLoading] = useState(false);
|
||||||
const [diagnosisLoading, setDiagnosisLoading] = useState(false);
|
const [diagnosisLoading, setDiagnosisLoading] = useState(false);
|
||||||
const [forwardToDelete, setForwardToDelete] = useState<Forward | null>(null);
|
const [forwardToDelete, setForwardToDelete] = useState<Forward | null>(null);
|
||||||
|
const [forwardToResetFlow, setForwardToResetFlow] = useState<Forward | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [currentDiagnosisForward, setCurrentDiagnosisForward] =
|
const [currentDiagnosisForward, setCurrentDiagnosisForward] =
|
||||||
useState<Forward | null>(null);
|
useState<Forward | null>(null);
|
||||||
const [diagnosisResult, setDiagnosisResult] =
|
const [diagnosisResult, setDiagnosisResult] =
|
||||||
@@ -2171,7 +2225,8 @@ export default function ForwardPage() {
|
|||||||
maxConn: forward.maxConn ?? 0,
|
maxConn: forward.maxConn ?? 0,
|
||||||
proxyProtocol: forward.proxyProtocol ?? 0,
|
proxyProtocol: forward.proxyProtocol ?? 0,
|
||||||
proxyProtocolReceive: forward.proxyProtocolReceive ?? 0,
|
proxyProtocolReceive: forward.proxyProtocolReceive ?? 0,
|
||||||
proxyProtocolSend: forward.proxyProtocolSend ?? forward.proxyProtocol ?? 0,
|
proxyProtocolSend:
|
||||||
|
forward.proxyProtocolSend ?? forward.proxyProtocol ?? 0,
|
||||||
});
|
});
|
||||||
setErrors({});
|
setErrors({});
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
@@ -2183,6 +2238,46 @@ export default function ForwardPage() {
|
|||||||
setDeleteModalOpen(true);
|
setDeleteModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResetFlow = (forward: Forward) => {
|
||||||
|
if ((forward.inFlow || 0) + (forward.outFlow || 0) <= 0) return;
|
||||||
|
|
||||||
|
setForwardToResetFlow(forward);
|
||||||
|
setResetFlowModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetFlowModalOpenChange = (isOpen: boolean) => {
|
||||||
|
if (resetFlowLoading) return;
|
||||||
|
|
||||||
|
setResetFlowModalOpen(isOpen);
|
||||||
|
if (!isOpen) {
|
||||||
|
setForwardToResetFlow(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmResetFlow = async () => {
|
||||||
|
if (!forwardToResetFlow) return;
|
||||||
|
|
||||||
|
setResetFlowLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await resetForwardFlow(forwardToResetFlow.id);
|
||||||
|
|
||||||
|
if (res.code !== 0) {
|
||||||
|
toast.error(res.msg || "流量清零失败");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("规则流量已清零");
|
||||||
|
setResetFlowModalOpen(false);
|
||||||
|
setForwardToResetFlow(null);
|
||||||
|
await refreshForwardList(false);
|
||||||
|
} catch {
|
||||||
|
toast.error("流量清零失败");
|
||||||
|
} finally {
|
||||||
|
setResetFlowLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 确认删除规则
|
// 确认删除规则
|
||||||
const confirmDelete = async () => {
|
const confirmDelete = async () => {
|
||||||
if (!forwardToDelete) return;
|
if (!forwardToDelete) return;
|
||||||
@@ -3957,7 +4052,7 @@ export default function ForwardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-1.5 mt-3">
|
<div className="grid grid-cols-2 gap-1.5 mt-3">
|
||||||
<Button
|
<Button
|
||||||
className="flex-1 min-h-8"
|
className="flex-1 min-h-8"
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -4000,6 +4095,32 @@ export default function ForwardPage() {
|
|||||||
>
|
>
|
||||||
诊断
|
诊断
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="flex-1 min-h-8"
|
||||||
|
color="secondary"
|
||||||
|
isDisabled={(forward.inFlow || 0) + (forward.outFlow || 0) <= 0}
|
||||||
|
size="sm"
|
||||||
|
startContent={
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="w-3 h-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M4 4v6h6M20 20v-6h-6M20 9a8 8 0 00-13.657-3.657L4 8m16 8-2.343 2.657A8 8 0 014 15"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => handleResetFlow(forward)}
|
||||||
|
>
|
||||||
|
清零
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="flex-1 min-h-8"
|
className="flex-1 min-h-8"
|
||||||
color="danger"
|
color="danger"
|
||||||
@@ -4302,7 +4423,7 @@ export default function ForwardPage() {
|
|||||||
<TableColumn className="w-[80px]">策略</TableColumn>
|
<TableColumn className="w-[80px]">策略</TableColumn>
|
||||||
<TableColumn className="w-[100px]">用量</TableColumn>
|
<TableColumn className="w-[100px]">用量</TableColumn>
|
||||||
<TableColumn className="w-[80px]">状态</TableColumn>
|
<TableColumn className="w-[80px]">状态</TableColumn>
|
||||||
<TableColumn align="left" className="w-[120px] pl-4">
|
<TableColumn align="left" className="w-[160px] pl-4">
|
||||||
操作
|
操作
|
||||||
</TableColumn>
|
</TableColumn>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -4321,6 +4442,7 @@ export default function ForwardPage() {
|
|||||||
handleDelete={handleDelete}
|
handleDelete={handleDelete}
|
||||||
handleDiagnose={handleDiagnose}
|
handleDiagnose={handleDiagnose}
|
||||||
handleEdit={handleEdit}
|
handleEdit={handleEdit}
|
||||||
|
handleResetFlow={handleResetFlow}
|
||||||
handleServiceToggle={handleServiceToggle}
|
handleServiceToggle={handleServiceToggle}
|
||||||
hasMultipleAddresses={hasMultipleAddresses}
|
hasMultipleAddresses={hasMultipleAddresses}
|
||||||
selectMode={selectMode}
|
selectMode={selectMode}
|
||||||
@@ -4616,6 +4738,7 @@ export default function ForwardPage() {
|
|||||||
handleDelete={handleDelete}
|
handleDelete={handleDelete}
|
||||||
handleDiagnose={handleDiagnose}
|
handleDiagnose={handleDiagnose}
|
||||||
handleEdit={handleEdit}
|
handleEdit={handleEdit}
|
||||||
|
handleResetFlow={handleResetFlow}
|
||||||
handleServiceToggle={
|
handleServiceToggle={
|
||||||
handleServiceToggle
|
handleServiceToggle
|
||||||
}
|
}
|
||||||
@@ -5172,6 +5295,59 @@ export default function ForwardPage() {
|
|||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* 规则流量清零确认模态框 */}
|
||||||
|
<Modal
|
||||||
|
backdrop="blur"
|
||||||
|
classNames={{
|
||||||
|
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
|
||||||
|
}}
|
||||||
|
isOpen={resetFlowModalOpen}
|
||||||
|
placement="center"
|
||||||
|
scrollBehavior="inside"
|
||||||
|
size="lg"
|
||||||
|
onOpenChange={handleResetFlowModalOpenChange}
|
||||||
|
>
|
||||||
|
<ModalContent>
|
||||||
|
{(onClose) => (
|
||||||
|
<>
|
||||||
|
<ModalHeader className="flex flex-col gap-1">
|
||||||
|
<h2 className="text-lg font-bold text-secondary">
|
||||||
|
确认流量清零
|
||||||
|
</h2>
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<p className="text-default-600">
|
||||||
|
确定要清零规则{" "}
|
||||||
|
<span className="font-semibold text-foreground">
|
||||||
|
"{forwardToResetFlow?.name}"
|
||||||
|
</span>{" "}
|
||||||
|
当前显示的上传和下载流量吗?
|
||||||
|
</p>
|
||||||
|
<p className="text-small text-default-500 mt-2">
|
||||||
|
此操作不可撤销,但不会影响用户总流量、用户隧道配额和历史统计。
|
||||||
|
</p>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button
|
||||||
|
isDisabled={resetFlowLoading}
|
||||||
|
variant="light"
|
||||||
|
onPress={onClose}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="secondary"
|
||||||
|
isLoading={resetFlowLoading}
|
||||||
|
onPress={confirmResetFlow}
|
||||||
|
>
|
||||||
|
确认清零
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* 地址列表弹窗 */}
|
{/* 地址列表弹窗 */}
|
||||||
<Modal
|
<Modal
|
||||||
classNames={{
|
classNames={{
|
||||||
|
|||||||
Reference in New Issue
Block a user