Compare commits

...

20 Commits

Author SHA1 Message Date
sagit 508a37a84c feat(nodes): add node metadata and expiry reminders (#293)
## Summary

- 为节点新增备注、标签、到期时间字段,并贯通后端存储、导入导出与前端 API
- 节点管理页支持编辑、搜索、筛选并高亮即将到期或已过期节点
- Dashboard 为管理员新增节点到期提醒卡片,集中展示 7 天内到期和已过期节点

## Changes

### Backend
- `node/create` 和 `node/update` 支持 `remark`、`tags`、`expiryTime`
- 节点列表接口返回新增字段
- 节点导入导出保留备注、标签与到期时间

### Frontend
- 节点页面新增备注、标签、到期时间表单项
- 节点列表支持按备注/标签搜索,并支持到期状态筛选
- 节点卡片展示备注、标签、到期时间以及过期提醒样式
- Dashboard 管理员视图新增节点到期提醒模块

## Plans

- `plans/021-node-remarks-tags-expiry.md`
- `plans/022-node-expiry-highlights-dashboard-reminders.md`

## Issue

Closes #246
2026-03-08 20:02:36 +08:00
sagitchu d60655045a feat(forward): display tunnel traffic ratio on forward page 2026-03-08 20:00:26 +08:00
sagitchu 31ef861504 feat(nodes): add node metadata and expiry reminders (#246)
Entire-Checkpoint: d8b429492cbe
2026-03-08 19:44:46 +08:00
sagitchu f1bdb2e2ef feat(frontend): implement AJAX no-refresh UX improvements (#276)
- Add dashboard auto-refresh with 5s polling and visibility-aware pause
- Harden node realtime reconnection with exponential backoff and polling fallback
- Implement local state patching for forward/tunnel/user mutations
- Add batch operation progress feedback UI
- Add shared list-state helpers for replace/remove operations
- Preserve derived UI state during server payload merges

Closes #276

Entire-Checkpoint: 7d6188355d7f
2026-03-08 19:15:02 +08:00
sagit bd30b61018 fix(backend): migrate PostgreSQL traffic columns from int4 to bigint (#290)
## Summary

- Widens legacy PostgreSQL traffic/quota columns from `integer` to
`bigint` to prevent int4 overflow
- Fixes federation share creation failure when traffic limits exceed 2GB
(e.g., `536870912000` bytes = 500GB)
- Bumps schema version from 4 to 5 with auto-migration on backend
startup

## Affected Tables

- `user`: `flow`, `in_flow`, `out_flow`
- `forward`: `in_flow`, `out_flow`
- `statistics_flow`: `flow`, `total_flow`
- `tunnel`: `flow`
- `user_tunnel`: `flow`, `in_flow`, `out_flow`
- `peer_share`: `max_bandwidth`, `current_flow`

## Test Plan

- ✅ Unit tests added for migration execution and error handling
- ✅ Contract tests passed
- ✅ Repository tests passed

Commands:
```bash
cd go-backend && go test ./internal/store/repo/...
cd go-backend && go test ./tests/contract/...
```
2026-03-08 11:50:14 +08:00
sagitchu e0dd70a054 fix(backend): migrate PostgreSQL traffic columns from int4 to bigint
Widens legacy PostgreSQL traffic/quota columns to BIGINT to prevent
int4 overflow when storing large values like 536870912000 (500GB).

Affected tables:
- user (flow, in_flow, out_flow)
- forward (in_flow, out_flow)
- statistics_flow (flow, total_flow)
- tunnel (flow)
- user_tunnel (flow, in_flow, out_flow)
- peer_share (max_bandwidth, current_flow)

Schema version bumped from 4 to 5 with auto-migration on startup.
2026-03-08 11:48:36 +08:00
sagit 4966a8aad1 fix(backend): sync user tunnel status and relax forward speedId permission check (#288)
## Summary

- Return actual `user_tunnel.status` in admin permission list instead of
hardcoded enabled state (1)
- Allow non-admin users to update forwards when keeping the same
`speedId` selection
- Add contract tests for user tunnel status mapping and forward
permission edge case

## Test Plan

- [x] Contract tests pass: `cd go-backend && go test
./tests/contract/...`
- [x] User tunnel permission list returns correct status values
(enabled/disabled)
- [x] Non-admin users can update forward details when keeping existing
speedId
2026-03-08 00:57:57 +08:00
sagitchu 3e11549370 fix(backend): sync user tunnel status and relax forward speedId permission check
- Return actual user_tunnel.status in admin permission list instead of hardcoded 1
- Allow non-admin users to update forwards when keeping the same speedId selection
- Add contract tests for user tunnel status mapping and forward permission edge case

Entire-Checkpoint: deb90fb942ee
2026-03-08 00:56:13 +08:00
sagit addf83a249 fix(ui): improve date input parsing and add missing back navigation (#284)
## Summary
- Improve DatePicker text parsing to accept more input formats
(including `YYYYMMDD`) without requiring explicit separators
- Add missing H5 back-navigation for panel-sharing by using simple
layout route
- Add a back button on config page with history fallback (`navigate(-1)`
then `/profile`)

## Why
These are usability/accessibility improvements that are generic and not
project-brand specific:
- Mobile keyboards may not easily input `-` in date fields
- Certain pages in H5 lacked a consistent return path
- Config page had no explicit in-page back action

## Scope
- `vite-frontend/src/shadcn-bridge/heroui/date-picker.tsx`
- `vite-frontend/src/App.tsx`
- `vite-frontend/src/pages/config.tsx`

## Notes
No branding/identity/ownership/visual-theme customizations included in
this PR.
2026-03-07 18:07:07 +08:00
sagitchu c3e35fd416 fix(ui): tighten date parsing and back navigation
Entire-Checkpoint: fea62b38d8c7
2026-03-07 18:02:00 +08:00
sagit 775dfe19f1 Merge branch 'main' into fix/upstream-friendly-ui-3pack 2026-03-07 17:36:29 +08:00
sagit db3b2f651b fix: improve bind-conflict detection and forward cleanup reliability (#287)
## Summary
- Normalize whitespace in bind-conflict error messages to handle
collapsed variants (e.g., "address alreadyin use")
- Update forward cleanup to delete all service name variants (_tcp,
_udp, base) instead of stopping after first success
- Add comprehensive test coverage for edge cases with missing-space
error variants

## Test plan
- ✅ Unit tests: `cd go-backend && go test ./internal/http/handler/...`
- ✅ Contract tests: `cd go-backend && go test ./tests/contract/... -run
'TestForwardUpdateRecoversFromAddressInUseContract|TestTunnelUpdateRecoversFromAddressInUseContract'`
2026-03-07 17:21:00 +08:00
sagitchu 669323f926 fix: improve bind-conflict detection and forward cleanup reliability
- Normalize whitespace in error messages to handle collapsed variants (e.g., 'address alreadyin use')
- Delete all forward service name variants (_tcp, _udp, base) during cleanup instead of stopping after first success
- Add comprehensive test coverage for edge cases
2026-03-07 17:19:28 +08:00
𝓐𝓵𝓽𝓲𝓸𝓷 7202b69e4e Merge branch 'main' into fix/upstream-friendly-ui-3pack 2026-03-07 17:00:31 +08:00
sagit 31977a62e6 fix: add retry mechanism for tunnel service bind conflicts (#286)
## Summary
- Add retry logic for tunnel service creation/update when encountering
"address already in use" bind errors
- Automatically cleanup stale service and retry once before failing
- Add comprehensive unit and contract tests for bind conflict scenarios

## Changes
- `mutations.go`: Add `retryTunnelServiceAddWithCleanup` helper and
`addTunnelServiceOnNode` wrapper
- `control_plane_test.go`: Unit tests for retry behavior on address
conflicts
- `dual_stack_test.go`: Test fallback to node listen address
- `forward_contract_test.go`: Contract test for forward update with bind
retry
- `limiter_sync_failure_contract_test.go`: Contract test for tunnel
update with bind retry
- `plans/016-tunnel-runtime-bind-conflict-retry.md`: Implementation plan
document

## Test Plan
- Unit tests verify retry logic executes correctly
- Contract tests validate end-to-end behavior with mock nodes
- All tests pass with race detector enabled
2026-03-07 16:23:13 +08:00
sagitchu 87479c2ac1 fix: add retry mechanism for tunnel service bind conflicts
When tunnel services encounter 'address already in use' errors during
creation/update, automatically cleanup and retry once instead of failing
immediately. This handles race conditions during rapid tunnel reconfiguration.

Entire-Checkpoint: 39e6fb9de836
2026-03-07 16:21:44 +08:00
Su-cyber-art ffda0fb71a fix(ui): improve date input parsing and add missing back navigation 2026-03-07 14:19:23 +08:00
sagit 9c0e7341c3 feat: add helpful hints for form fields in tunnel and node management (#279)
Entire-Checkpoint: 20cc01a9700d
2026-03-06 08:21:19 +00:00
sagit 1db5452be9 fix: add forward port occupancy validation and runtime residual cleanup (#278)
* fix: tolerate service not found during forward deletion

- Refactor deleteForwardServicesOnNode to handle not-found errors gracefully
- Extract deleteForwardServiceCandidates helper for reuse
- Add tests for not-found tolerance scenarios
- Ensures compatibility with legacy node versions

Entire-Checkpoint: a3bacf836c57

* fix: add forward port occupancy validation and runtime residual cleanup

- Add forward port occupancy validation on create/update paths
- Extend self-occupy recovery to clean residual candidate service names
- Add regression tests for address-in-use recovery with legacy runtime residue

Fixes port conflict issues when upgrading from 2.1.6 to later versions

Entire-Checkpoint: fb0a2aee4cb5
2026-03-06 10:57:07 +08:00
sagit c10f894afd fix: tolerate service not found during forward deletion (#277)
- Refactor deleteForwardServicesOnNode to handle not-found errors gracefully
- Extract deleteForwardServiceCandidates helper for reuse
- Add tests for not-found tolerance scenarios
- Ensures compatibility with legacy node versions

Entire-Checkpoint: a3bacf836c57
2026-03-06 09:03:28 +08:00
42 changed files with 2864 additions and 269 deletions
+1
View File
@@ -53,6 +53,7 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
| `websocket_reporter` | Func | `go-gost/x/socket/websocket_reporter.go` | Panel Telemetry |
## CONVENTIONS
- **Skills & MCP**: Always prefer using available skills (via `skill` tool) and MCP tools when applicable. Check for relevant skills before implementing from scratch.
- **Auth**: `Authorization` header carries the raw JWT token (no `Bearer` prefix) between `vite-frontend/` and `go-backend/`.
- **Module Fork**: `go-gost/` uses `replace github.com/go-gost/x => ./x` and `go-gost/x/` is also its own Go module.
- **Encryption**: Agent-to-panel communication uses AES encryption with node `secret` as PSK.
@@ -359,7 +359,12 @@ func (h *Handler) rebindForwardServiceOnSelfOccupiedPort(forward *forwardRecord,
return fmt.Errorf("端口 %d 已被其他转发占用", port)
}
if err := h.deleteForwardServicesOnNode(forward, node.ID); err != nil {
bases, err := h.forwardServiceBaseCandidates(forward)
if err != nil {
return err
}
if err := h.deleteForwardServiceBasesOnNode(node.ID, bases); err != nil {
return err
}
@@ -377,41 +382,45 @@ func (h *Handler) deleteForwardServicesOnNode(forward *forwardRecord, nodeID int
if h == nil || forward == nil {
return errors.New("invalid forward delete context")
}
userTunnelID, _, _, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
bases, err := h.forwardServiceBaseCandidates(forward)
if err != nil {
return err
}
return h.deleteForwardServiceBasesOnNode(nodeID, bases)
}
func (h *Handler) forwardServiceBaseCandidates(forward *forwardRecord) ([]string, error) {
if h == nil || forward == nil {
return nil, errors.New("invalid forward service base context")
}
userTunnelID, _, _, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
if err != nil {
return nil, err
}
userTunnelIDs, err := h.listUserTunnelIDs(forward.UserID, forward.TunnelID)
if err != nil {
return err
return nil, err
}
allUserTunnelIDs, err := h.listUserTunnelIDsByUser(forward.UserID)
if err != nil {
return err
return nil, err
}
candidateTunnelIDs := make([]int64, 0, len(userTunnelIDs)+len(allUserTunnelIDs))
candidateTunnelIDs = append(candidateTunnelIDs, userTunnelIDs...)
candidateTunnelIDs = append(candidateTunnelIDs, allUserTunnelIDs...)
bases := buildForwardServiceBaseCandidates(forward.ID, forward.UserID, userTunnelID, candidateTunnelIDs)
return buildForwardServiceBaseCandidates(forward.ID, forward.UserID, userTunnelID, candidateTunnelIDs), nil
var lastErr error
for _, base := range bases {
names := buildForwardControlServiceNames(base, "DeleteService")
}
func (h *Handler) deleteForwardServiceBasesOnNode(nodeID int64, bases []string) error {
return deleteForwardServiceCandidates(bases, func(name string) error {
payload := map[string]interface{}{
"services": names,
"services": []string{name},
}
_, cmdErr := h.sendNodeCommand(nodeID, "DeleteService", payload, false, true)
if cmdErr == nil {
return nil
}
lastErr = cmdErr
}
if lastErr != nil {
return lastErr
}
return nil
_, err := h.sendNodeCommand(nodeID, "DeleteService", payload, false, false)
return err
})
}
func (h *Handler) controlForwardServices(forward *forwardRecord, commandType string, tolerateNotFound bool) error {
@@ -517,6 +526,22 @@ func controlForwardServiceCommand(bases []string, commandType string, send func(
return false, lastNotFoundErr, nil
}
func deleteForwardServiceCandidates(bases []string, send func(name string) error) error {
for _, base := range bases {
for _, name := range append([]string{base + "_tcp", base + "_udp", base}, []string{}...) {
err := send(name)
if err == nil {
continue
}
if isNotFoundError(err) {
continue
}
return err
}
}
return nil
}
func shouldSelfHealForwardServiceControl(commandType string) bool {
cmd := strings.ToLower(strings.TrimSpace(commandType))
return cmd == "pauseservice" || cmd == "resumeservice"
@@ -1454,10 +1479,11 @@ func isAlreadyExistsMessage(message string) bool {
if msg == "" {
return false
}
if strings.Contains(msg, "address already in use") {
if isAddressAlreadyInUseMessage(msg) {
return false
}
return strings.Contains(msg, "already exists") || strings.Contains(msg, "已存在")
compact := compactErrorMessage(msg)
return strings.Contains(msg, "already exists") || strings.Contains(msg, "已存在") || strings.Contains(compact, "alreadyexists")
}
func isBindAddressInUseError(err error) bool {
@@ -1482,7 +1508,10 @@ func isAddressAlreadyInUseMessage(msg string) bool {
if msg == "" {
return false
}
return strings.Contains(msg, "address already in use")
if strings.Contains(msg, "address already in use") {
return true
}
return strings.Contains(compactErrorMessage(msg), "addressalreadyinuse")
}
func isCannotAssignRequestedAddressError(err error) bool {
@@ -1493,7 +1522,18 @@ func isCannotAssignRequestedAddressError(err error) bool {
if msg == "" {
return false
}
return strings.Contains(msg, "cannot assign requested address")
if strings.Contains(msg, "cannot assign requested address") {
return true
}
return strings.Contains(compactErrorMessage(msg), "cannotassignrequestedaddress")
}
func compactErrorMessage(msg string) string {
msg = strings.TrimSpace(msg)
if msg == "" {
return ""
}
return strings.Join(strings.Fields(strings.ToLower(msg)), "")
}
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, bindIP string, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {
@@ -4,6 +4,8 @@ import (
"errors"
"reflect"
"testing"
"go-backend/internal/store/repo"
)
func TestBuildForwardControlServiceNamesPauseResume(t *testing.T) {
@@ -122,6 +124,112 @@ func TestControlForwardServiceCommandReturnsLastNotFoundWhenAllMissing(t *testin
}
}
func TestDeleteForwardServiceCandidatesSkipsNotFoundUntilLegacyMatch(t *testing.T) {
bases := []string{"12_34_56", "12_34_0"}
called := make([]string, 0)
err := deleteForwardServiceCandidates(bases, func(name string) error {
called = append(called, name)
if name == "12_34_0" {
return nil
}
return errors.New("service " + name + " not found")
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
wantCalls := []string{"12_34_56_tcp", "12_34_56_udp", "12_34_56", "12_34_0_tcp", "12_34_0_udp", "12_34_0"}
if !reflect.DeepEqual(called, wantCalls) {
t.Fatalf("expected calls %v, got %v", wantCalls, called)
}
}
func TestDeleteForwardServiceCandidatesTreatsAllMissingAsSuccess(t *testing.T) {
bases := []string{"12_34_56", "12_34_0"}
err := deleteForwardServiceCandidates(bases, func(name string) error {
return errors.New("service " + name + " not found")
})
if err != nil {
t.Fatalf("all-missing delete should be tolerated, got %v", err)
}
}
func TestForwardServiceBaseCandidatesIncludesResolvedAndLegacyZero(t *testing.T) {
bases := buildForwardServiceBaseCandidates(46, 9, 123, []int64{123, 77, 0})
want := []string{"46_9_123", "46_9_77", "46_9_0"}
if !reflect.DeepEqual(bases, want) {
t.Fatalf("expected %v, got %v", want, bases)
}
}
func TestDeleteForwardServiceBasesOnNodeRetriesLegacyZeroResidue(t *testing.T) {
bases := []string{"46_9_123", "46_9_0"}
called := make([]string, 0)
err := deleteForwardServiceCandidates(bases, func(name string) error {
called = append(called, name)
if name == "46_9_0_tcp" || name == "46_9_0_udp" {
return nil
}
return errors.New("service " + name + " not found")
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"46_9_123_tcp", "46_9_123_udp", "46_9_123", "46_9_0_tcp", "46_9_0_udp", "46_9_0"}
if !reflect.DeepEqual(called, want) {
t.Fatalf("expected calls %v, got %v", want, called)
}
}
func TestDeleteForwardServiceCandidatesDeletesAllMatchingVariants(t *testing.T) {
bases := []string{"57_7_7", "57_7_0"}
called := make([]string, 0)
err := deleteForwardServiceCandidates(bases, func(name string) error {
called = append(called, name)
switch name {
case "57_7_7_tcp", "57_7_7_udp", "57_7_0_tcp", "57_7_0_udp":
return nil
default:
return errors.New("service " + name + " not found")
}
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"57_7_7_tcp", "57_7_7_udp", "57_7_7", "57_7_0_tcp", "57_7_0_udp", "57_7_0"}
if !reflect.DeepEqual(called, want) {
t.Fatalf("expected calls %v, got %v", want, called)
}
}
func TestValidateForwardPortAvailabilityRejectsOtherForwardOccupancy(t *testing.T) {
h := &Handler{repo: nil}
node := &nodeRecord{ID: 9, Name: "test-node"}
_ = h
_ = node
rawRepo, err := repo.Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
h = &Handler{repo: rawRepo}
if err := rawRepo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(1, 9, 2000)`).Error; err != nil {
t.Fatalf("insert forward port: %v", err)
}
err = h.validateForwardPortAvailability(&nodeRecord{ID: 9, Name: "test-node"}, 2000, 2)
if err == nil {
t.Fatalf("expected occupancy error")
}
if err.Error() != "节点 test-node 端口 2000 已被其他转发占用" {
t.Fatalf("unexpected error: %v", err)
}
err = h.validateForwardPortAvailability(&nodeRecord{ID: 9, Name: "test-node"}, 2000, 1)
if err != nil {
t.Fatalf("same forward should be allowed, got %v", err)
}
}
func TestControlForwardServiceCommandReturnsHardError(t *testing.T) {
bases := []string{"12_34_56"}
handled, lastNotFoundErr, err := controlForwardServiceCommand(bases, "PauseService", func(name string) error {
@@ -148,9 +256,15 @@ func TestIsAlreadyExistsMessage(t *testing.T) {
if !isAlreadyExistsMessage("服务已存在") {
t.Fatalf("expected Chinese already exists message to be tolerated")
}
if !isAlreadyExistsMessage("service demo alreadyexists") {
t.Fatalf("missing-space alreadyexists should be tolerated")
}
if isAlreadyExistsMessage("listen tcp [::]:10001: bind: address already in use") {
t.Fatalf("address already in use must not be treated as already exists")
}
if isAlreadyExistsMessage("create service 57_7_7_tcp failed: listen tcp4 0.0.0.0:46222: bind: address alreadyin use") {
t.Fatalf("alreadyin-use variant must not be treated as already exists")
}
}
func TestIsBindAddressInUseError(t *testing.T) {
@@ -172,6 +286,9 @@ func TestIsAddressAlreadyInUseError(t *testing.T) {
if !isAddressAlreadyInUseError(errors.New("listen tcp [::]:10001: bind: address already in use")) {
t.Fatalf("address already in use should be detected")
}
if !isAddressAlreadyInUseError(errors.New("create service 57_7_7_tcp failed: listen tcp4 0.0.0.0:46222: bind: address alreadyin use")) {
t.Fatalf("missing-space alreadyin-use variant should be detected")
}
if isAddressAlreadyInUseError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannot assign requested address")) {
t.Fatalf("cannot assign requested address should not be treated as address-in-use")
}
@@ -181,11 +298,83 @@ func TestIsCannotAssignRequestedAddressError(t *testing.T) {
if !isCannotAssignRequestedAddressError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannot assign requested address")) {
t.Fatalf("cannot assign requested address should be detected")
}
if !isCannotAssignRequestedAddressError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannotassignrequestedaddress")) {
t.Fatalf("missing-space cannotassignrequestedaddress variant should be detected")
}
if isCannotAssignRequestedAddressError(errors.New("listen tcp [::]:10001: bind: address already in use")) {
t.Fatalf("address already in use should not be treated as cannot-assign")
}
}
func TestRetryTunnelServiceAddWithCleanupRetriesOnAddressInUse(t *testing.T) {
addCalls := 0
cleanupCalls := 0
err := retryTunnelServiceAddWithCleanup(
func() error {
addCalls++
if addCalls == 1 {
return errors.New("listen tcp 10.0.0.1:32000: bind: address already in use")
}
return nil
},
func() error {
cleanupCalls++
return nil
},
0,
)
if err != nil {
t.Fatalf("expected retry to succeed, got %v", err)
}
if addCalls != 2 {
t.Fatalf("expected 2 add attempts, got %d", addCalls)
}
if cleanupCalls != 1 {
t.Fatalf("expected 1 cleanup attempt, got %d", cleanupCalls)
}
}
func TestRetryTunnelServiceAddWithCleanupSkipsCleanupOnNonBindError(t *testing.T) {
addCalls := 0
cleanupCalls := 0
err := retryTunnelServiceAddWithCleanup(
func() error {
addCalls++
return errors.New("network timeout")
},
func() error {
cleanupCalls++
return nil
},
0,
)
if err == nil {
t.Fatalf("expected hard error")
}
if addCalls != 1 {
t.Fatalf("expected 1 add attempt, got %d", addCalls)
}
if cleanupCalls != 0 {
t.Fatalf("expected 0 cleanup attempts, got %d", cleanupCalls)
}
}
func TestRetryTunnelServiceAddWithCleanupReturnsCleanupError(t *testing.T) {
cleanupErr := errors.New("delete failed")
err := retryTunnelServiceAddWithCleanup(
func() error {
return errors.New("listen tcp 10.0.0.1:32000: bind: address already in use")
},
func() error {
return cleanupErr
},
0,
)
if !errors.Is(err, cleanupErr) {
t.Fatalf("expected cleanup error %v, got %v", cleanupErr, err)
}
}
func TestBuildForwardServiceConfigs_UsesBindIPForListen(t *testing.T) {
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"}
@@ -43,6 +43,19 @@ func TestBuildTunnelChainServiceConfig_UsesConnectIPForListen(t *testing.T) {
}
}
func TestBuildTunnelChainServiceConfig_FallsBackToNodeListenAddr(t *testing.T) {
node := &nodeRecord{TCPListenAddr: "10.8.0.5"}
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21002}
services := buildTunnelChainServiceConfig(99, chain, node)
if len(services) != 1 {
t.Fatalf("expected 1 service, got %d", len(services))
}
addr, _ := services[0]["addr"].(string)
if addr != "10.8.0.5:21002" {
t.Fatalf("expected node listen addr 10.8.0.5:21002, got %q", addr)
}
}
func TestBuildTunnelChainServiceConfig_DefaultListenAddrWhenConnectIPEmpty(t *testing.T) {
node := &nodeRecord{TCPListenAddr: "[::]"}
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21001}
+1 -1
View File
@@ -571,7 +571,7 @@ func (h *Handler) userTunnelList(w http.ResponseWriter, r *http.Request) {
"userId": t.UserID,
"tunnelId": t.TunnelID,
"tunnelName": t.TunnelName,
"status": 1,
"status": t.Status,
"flow": t.Flow,
"num": t.Num,
"expTime": t.ExpTime,
+91 -8
View File
@@ -25,6 +25,8 @@ import (
"gorm.io/gorm"
)
const tunnelServiceBindRetryDelay = 150 * time.Millisecond
func (h *Handler) userCreate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
@@ -259,6 +261,9 @@ func (h *Handler) nodeCreate(w http.ResponseWriter, r *http.Request) {
defaultString(asString(req["port"]), "1000-65535"),
nullableText(asString(req["interfaceName"])),
nullableText(""),
nullableText(strings.TrimSpace(asString(req["remark"]))),
nullableText(strings.TrimSpace(asString(req["tags"]))),
nullableUnixMilli(asInt64(req["expiryTime"], 0)),
asInt(req["http"], 0),
asInt(req["tls"], 0),
asInt(req["socks"], 0),
@@ -324,6 +329,9 @@ func (h *Handler) nodeUpdate(w http.ResponseWriter, r *http.Request) {
defaultString(asString(req["port"]), "1000-65535"),
nullableText(asString(req["interfaceName"])),
nullableText(asString(req["extraIPs"])),
nullableText(strings.TrimSpace(asString(req["remark"]))),
nullableText(strings.TrimSpace(asString(req["tags"]))),
nullableUnixMilli(asInt64(req["expiryTime"], 0)),
newHTTP,
newTLS,
newSocks,
@@ -1191,6 +1199,10 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if err := h.validateForwardPortAvailability(node, port, 0); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
}
now := time.Now().UnixMilli()
inx := h.repo.NextIndex("forward")
@@ -1273,13 +1285,13 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
if strategy == "" {
strategy = forward.Strategy
}
if actorRole != 0 {
if speedIDVal, ok := req["speedId"]; ok && speedIDVal != nil {
response.WriteJSON(w, response.Err(-1, "普通用户无法修改限速规则"))
return
}
rawSpeedID, hasSpeedID := req["speedId"]
requestedSpeedID := asAnyToInt64Ptr(rawSpeedID)
if actorRole != 0 && hasSpeedID && requestedSpeedID != nil && !sameSpeedLimitSelection(forward.SpeedID, requestedSpeedID) {
response.WriteJSON(w, response.Err(-1, "普通用户无法修改限速规则"))
return
}
speedID := asAnyToInt64Ptr(req["speedId"])
speedID := requestedSpeedID
speedID, err = h.normalizeSpeedLimitReference(speedID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
@@ -1326,6 +1338,10 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if err := h.validateForwardPortAvailability(node, port, id); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
}
now := time.Now().UnixMilli()
if err := h.repo.UpdateForward(id, name, tunnelID, remoteAddr, strategy, now, newSpeedID); err != nil {
@@ -2576,7 +2592,7 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
createdChains = append(createdChains, chainNode.NodeID)
serviceData := buildTunnelChainServiceConfig(state.TunnelID, chainNode, state.Nodes[chainNode.NodeID])
if _, err := h.sendNodeCommand(chainNode.NodeID, "AddService", serviceData, true, false); err != nil {
if err := h.addTunnelServiceOnNode(chainNode.NodeID, state.TunnelID, serviceData); err != nil {
return createdChains, createdServices, fmt.Errorf("转发链节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)
}
createdServices = append(createdServices, chainNode.NodeID)
@@ -2588,7 +2604,7 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
continue
}
serviceData := buildTunnelChainServiceConfig(state.TunnelID, outNode, state.Nodes[outNode.NodeID])
if _, err := h.sendNodeCommand(outNode.NodeID, "AddService", serviceData, true, false); err != nil {
if err := h.addTunnelServiceOnNode(outNode.NodeID, state.TunnelID, serviceData); err != nil {
return createdChains, createdServices, fmt.Errorf("出口节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[outNode.NodeID]), err)
}
createdServices = append(createdServices, outNode.NodeID)
@@ -2597,6 +2613,44 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
return createdChains, createdServices, nil
}
func retryTunnelServiceAddWithCleanup(add func() error, cleanup func() error, wait time.Duration) error {
if add == nil {
return errors.New("invalid tunnel service add callback")
}
err := add()
if err == nil || !isAddressAlreadyInUseError(err) {
return err
}
if cleanup == nil {
return err
}
if cleanupErr := cleanup(); cleanupErr != nil {
return cleanupErr
}
if wait > 0 {
time.Sleep(wait)
}
return add()
}
func (h *Handler) addTunnelServiceOnNode(nodeID, tunnelID int64, serviceData []map[string]interface{}) error {
if h == nil {
return errors.New("invalid tunnel service context")
}
serviceName := fmt.Sprintf("%d_tls", tunnelID)
return retryTunnelServiceAddWithCleanup(
func() error {
_, err := h.sendNodeCommand(nodeID, "AddService", serviceData, true, false)
return err
},
func() error {
_, err := h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{serviceName}}, false, true)
return err
},
tunnelServiceBindRetryDelay,
)
}
func (h *Handler) rollbackTunnelRuntime(chainNodeIDs, serviceNodeIDs []int64, tunnelID int64) {
if h == nil || tunnelID <= 0 {
return
@@ -3332,6 +3386,14 @@ func (h *Handler) normalizeSpeedLimitReference(speedID *int64) (*int64, error) {
return speedID, nil
}
func sameSpeedLimitSelection(current sql.NullInt64, requested *int64) bool {
if requested == nil {
return !current.Valid
}
return current.Valid && current.Int64 == *requested
}
func asAnySlice(v interface{}) []interface{} {
if v == nil {
return nil
@@ -3478,6 +3540,13 @@ func nullableText(s string) interface{} {
return s
}
func nullableUnixMilli(v int64) interface{} {
if v <= 0 {
return nil
}
return v
}
func nullableInt(v *int64) interface{} {
if v == nil {
return nil
@@ -3532,6 +3601,20 @@ func validateLocalNodePort(node *nodeRecord, port int) error {
return nil
}
func (h *Handler) validateForwardPortAvailability(node *nodeRecord, port int, currentForwardID int64) error {
if h == nil || h.repo == nil || node == nil || port <= 0 {
return nil
}
occupied, err := h.repo.HasOtherForwardOnNodePort(node.ID, port, currentForwardID)
if err != nil {
return err
}
if occupied {
return fmt.Errorf("节点 %s 端口 %d 已被其他转发占用", node.Name, port)
}
return nil
}
func parsePortRangeMinMax(input string) (int, int) {
input = strings.TrimSpace(input)
if input == "" {
+7
View File
@@ -60,6 +60,9 @@ func (ForwardPort) TableName() string { return "forward_port" }
type Node struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"type:varchar(100);not null"`
Remark sql.NullString `gorm:"column:remark;type:text"`
Tags sql.NullString `gorm:"column:tags;type:text"`
ExpiryTime sql.NullInt64 `gorm:"column:expiry_time"`
Secret string `gorm:"type:varchar(100);not null"`
ServerIP string `gorm:"column:server_ip;type:varchar(100);not null"`
ServerIPV4 sql.NullString `gorm:"column:server_ip_v4;type:varchar(100)"`
@@ -336,6 +339,9 @@ type UserBackup struct {
type NodeBackup struct {
ID int64 `json:"id"`
Name string `json:"name"`
Remark string `json:"remark,omitempty"`
Tags string `json:"tags,omitempty"`
ExpiryTime int64 `json:"expiryTime,omitempty"`
Secret string `json:"secret"`
ServerIP string `json:"serverIp"`
ServerIPv4 string `json:"serverIpV4,omitempty"`
@@ -573,6 +579,7 @@ type UserTunnelDetail struct {
UserID int64
TunnelID int64
TunnelName string
Status int
TunnelFlow int
Flow int64
InFlow int64
+120 -20
View File
@@ -447,7 +447,7 @@ func (r *Repository) GetUserPackageTunnels(userID int64) ([]model.UserTunnelDeta
}
var items []model.UserTunnelDetail
err := r.db.Model(&model.UserTunnel{}).
Select("user_tunnel.id, user_tunnel.user_id, user_tunnel.tunnel_id, tunnel.name AS tunnel_name, tunnel.flow AS tunnel_flow, user_tunnel.flow, user_tunnel.in_flow, user_tunnel.out_flow, user_tunnel.num, user_tunnel.flow_reset_time, user_tunnel.exp_time, user_tunnel.speed_id, speed_limit.name AS speed_limit, speed_limit.speed").
Select("user_tunnel.id, user_tunnel.user_id, user_tunnel.tunnel_id, tunnel.name AS tunnel_name, user_tunnel.status, tunnel.flow AS tunnel_flow, user_tunnel.flow, user_tunnel.in_flow, user_tunnel.out_flow, user_tunnel.num, user_tunnel.flow_reset_time, user_tunnel.exp_time, user_tunnel.speed_id, speed_limit.name AS speed_limit, speed_limit.speed").
Joins("LEFT JOIN tunnel ON tunnel.id = user_tunnel.tunnel_id").
Joins("LEFT JOIN speed_limit ON speed_limit.id = user_tunnel.speed_id").
Where("user_tunnel.user_id = ?", userID).
@@ -634,7 +634,10 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) {
for _, n := range nodes {
items = append(items, map[string]interface{}{
"id": n.ID, "inx": n.Inx, "name": n.Name,
"ip": n.ServerIP, "serverIp": n.ServerIP,
"remark": nullableString(n.Remark),
"tags": nullableString(n.Tags),
"expiryTime": nullableInt64(n.ExpiryTime),
"ip": n.ServerIP, "serverIp": n.ServerIP,
"serverIpV4": nullableString(n.ServerIPV4),
"serverIpV6": nullableString(n.ServerIPV6),
"extraIPs": nullableString(n.ExtraIPs),
@@ -700,25 +703,26 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) {
}
type fwdRow struct {
ID int64
UserID int64
UserName string
Name string
TunnelID int64
TunnelName string
RemoteAddr string
Strategy string
InFlow int64
OutFlow int64
CreatedTime int64
Status int
Inx int
SpeedID sql.NullInt64
ID int64
UserID int64
UserName string
Name string
TunnelID int64
TunnelName string
TrafficRatio float64
RemoteAddr string
Strategy string
InFlow int64
OutFlow int64
CreatedTime int64
Status int
Inx int
SpeedID sql.NullInt64
}
var rows []fwdRow
err := r.db.Model(&model.Forward{}).
Select("forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id").
Select("forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, COALESCE(tunnel.traffic_ratio, 1.0) AS traffic_ratio, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id").
Joins("LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id").
Order("forward.inx ASC, forward.id ASC").
Find(&rows).Error
@@ -735,7 +739,8 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) {
item := map[string]interface{}{
"id": row.ID, "userId": row.UserID, "userName": row.UserName,
"name": row.Name, "tunnelId": row.TunnelID, "tunnelName": row.TunnelName,
"inIp": nullableForwardIngress(inIP), "inPort": nullableInt64(inPort),
"tunnelTrafficRatio": row.TrafficRatio,
"inIp": nullableForwardIngress(inIP), "inPort": nullableInt64(inPort),
"remoteAddr": row.RemoteAddr, "strategy": row.Strategy,
"inFlow": row.InFlow, "outFlow": row.OutFlow,
"createdTime": row.CreatedTime, "status": row.Status, "inx": int64(row.Inx),
@@ -1814,11 +1819,15 @@ func (r *Repository) exportNodes() ([]model.NodeBackup, error) {
for _, n := range nodes {
b := model.NodeBackup{
ID: n.ID, Name: n.Name, Secret: n.Secret, ServerIP: n.ServerIP,
Remark: n.Remark.String, Tags: n.Tags.String,
Port: n.Port, HTTP: n.HTTP, TLS: n.TLS, Socks: n.Socks,
CreatedTime: n.CreatedTime, Status: n.Status,
TCPListenAddr: n.TCPListenAddr, UDPListenAddr: n.UDPListenAddr,
Inx: n.Inx, IsRemote: n.IsRemote,
}
if n.ExpiryTime.Valid {
b.ExpiryTime = n.ExpiryTime.Int64
}
if n.UpdatedTime.Valid {
b.UpdatedTime = n.UpdatedTime.Int64
}
@@ -2168,6 +2177,9 @@ func importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error)
item := model.Node{
ID: n.ID,
Name: n.Name,
Remark: sql.NullString{String: n.Remark, Valid: n.Remark != ""},
Tags: sql.NullString{String: n.Tags, Valid: n.Tags != ""},
ExpiryTime: sql.NullInt64{Int64: n.ExpiryTime, Valid: n.ExpiryTime > 0},
Secret: n.Secret,
ServerIP: n.ServerIP,
ServerIPV4: sql.NullString{String: n.ServerIPv4, Valid: true},
@@ -2192,7 +2204,7 @@ func importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error)
err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.AssignmentColumns([]string{
"name", "secret", "server_ip", "server_ip_v4", "server_ip_v6", "port", "interface_name", "version",
"name", "remark", "tags", "expiry_time", "secret", "server_ip", "server_ip_v4", "server_ip_v6", "port", "interface_name", "version",
"http", "tls", "socks", "updated_time", "status", "tcp_listen_addr", "udp_listen_addr",
"inx", "is_remote", "remote_url", "remote_token", "remote_config",
}),
@@ -2612,11 +2624,12 @@ func (r *Repository) GetUserTunnelByID(id int64) (*model.UserTunnel, error) {
// ─── Migration ───────────────────────────────────────────────────────
const currentSchemaVersion = 4
const currentSchemaVersion = 5
var ensurePostgresIDDefaultsFn = ensurePostgresIDDefaults
var migrateViteConfigValueColumnTypeFn = migrateViteConfigValueColumnType
var migrateSpeedLimitTunnelBindingFn = migrateSpeedLimitTunnelBinding
var migratePostgresTrafficInt64ColumnsFn = migratePostgresTrafficInt64Columns
func getSchemaVersion(db *gorm.DB) int {
var v model.SchemaVersion
@@ -2680,6 +2693,12 @@ func migrateSchema(db *gorm.DB) error {
}
}
if ver < 5 {
if err := migratePostgresTrafficInt64ColumnsFn(db); err != nil {
return err
}
}
setSchemaVersion(db, currentSchemaVersion)
return nil
}
@@ -2744,6 +2763,87 @@ func migrateSpeedLimitTunnelBinding(db *gorm.DB) error {
return nil
}
func migratePostgresTrafficInt64Columns(db *gorm.DB) error {
if db == nil {
return errors.New("nil db")
}
if db.Dialector.Name() != "postgres" {
return nil
}
type trafficColumn struct {
TableName string
ColumnName string
}
columns := []trafficColumn{
{TableName: "user", ColumnName: "flow"},
{TableName: "user", ColumnName: "in_flow"},
{TableName: "user", ColumnName: "out_flow"},
{TableName: "forward", ColumnName: "in_flow"},
{TableName: "forward", ColumnName: "out_flow"},
{TableName: "statistics_flow", ColumnName: "flow"},
{TableName: "statistics_flow", ColumnName: "total_flow"},
{TableName: "tunnel", ColumnName: "flow"},
{TableName: "user_tunnel", ColumnName: "flow"},
{TableName: "user_tunnel", ColumnName: "in_flow"},
{TableName: "user_tunnel", ColumnName: "out_flow"},
{TableName: "peer_share", ColumnName: "max_bandwidth"},
{TableName: "peer_share", ColumnName: "current_flow"},
}
for _, column := range columns {
if err := alterPostgresColumnToBigIntIfNeeded(db, column.TableName, column.ColumnName); err != nil {
return err
}
}
return nil
}
func alterPostgresColumnToBigIntIfNeeded(db *gorm.DB, tableName, columnName string) error {
if db == nil {
return errors.New("nil db")
}
if tableName == "" || columnName == "" {
return errors.New("empty table or column name")
}
type columnRow struct {
DataType string `gorm:"column:data_type"`
}
var row columnRow
if err := db.Raw(
`SELECT data_type FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = ?
AND column_name = ?`,
tableName, columnName,
).Scan(&row).Error; err != nil {
return fmt.Errorf("inspect %s.%s type: %w", tableName, columnName, err)
}
if row.DataType == "" || strings.EqualFold(row.DataType, "bigint") {
return nil
}
if !strings.EqualFold(row.DataType, "integer") {
return nil
}
if err := db.Exec(fmt.Sprintf(
"ALTER TABLE %s ALTER COLUMN %s TYPE BIGINT",
quoteSQLIdentifier(tableName),
quoteSQLIdentifier(columnName),
)).Error; err != nil {
return fmt.Errorf("alter %s.%s to bigint: %w", tableName, columnName, err)
}
return nil
}
func ensurePostgresIDDefaults(db *gorm.DB) error {
if db.Dialector.Name() != "postgres" {
return nil
@@ -3,6 +3,7 @@ package repo
import (
"database/sql"
"errors"
"strings"
"testing"
gsqlite "github.com/glebarez/sqlite"
@@ -250,3 +251,115 @@ func TestMigrateSchemaClearsSpeedLimitTunnelBinding(t *testing.T) {
t.Fatalf("expected schema version %d, got %d", currentSchemaVersion, schemaVersion)
}
}
func TestMigrateSchemaRunsTrafficInt64MigrationForLegacySchema(t *testing.T) {
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() {
sqlDB, _ := db.DB()
if sqlDB != nil {
_ = sqlDB.Close()
}
})
if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {
t.Fatalf("create schema_version: %v", err)
}
if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, 4).Error; err != nil {
t.Fatalf("seed schema_version: %v", err)
}
originalIDRepair := ensurePostgresIDDefaultsFn
ensurePostgresIDDefaultsFn = func(db *gorm.DB) error {
return nil
}
t.Cleanup(func() {
ensurePostgresIDDefaultsFn = originalIDRepair
})
called := 0
originalMigrate := migratePostgresTrafficInt64ColumnsFn
migratePostgresTrafficInt64ColumnsFn = func(db *gorm.DB) error {
called++
return nil
}
t.Cleanup(func() {
migratePostgresTrafficInt64ColumnsFn = originalMigrate
})
if err := migrateSchema(db); err != nil {
t.Fatalf("migrateSchema: %v", err)
}
if called != 1 {
t.Fatalf("expected traffic bigint migration to run once, got %d", called)
}
var schemaVersion int
if err := db.Raw(`SELECT version FROM schema_version LIMIT 1`).Row().Scan(&schemaVersion); err != nil {
t.Fatalf("query schema_version: %v", err)
}
if schemaVersion != currentSchemaVersion {
t.Fatalf("expected schema version %d, got %d", currentSchemaVersion, schemaVersion)
}
}
func TestMigrateSchemaReturnsTrafficInt64MigrationError(t *testing.T) {
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() {
sqlDB, _ := db.DB()
if sqlDB != nil {
_ = sqlDB.Close()
}
})
if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {
t.Fatalf("create schema_version: %v", err)
}
if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, 4).Error; err != nil {
t.Fatalf("seed schema_version: %v", err)
}
originalIDRepair := ensurePostgresIDDefaultsFn
ensurePostgresIDDefaultsFn = func(db *gorm.DB) error {
return nil
}
t.Cleanup(func() {
ensurePostgresIDDefaultsFn = originalIDRepair
})
wantErr := errors.New("traffic bigint migration failed")
originalMigrate := migratePostgresTrafficInt64ColumnsFn
migratePostgresTrafficInt64ColumnsFn = func(db *gorm.DB) error {
return wantErr
}
t.Cleanup(func() {
migratePostgresTrafficInt64ColumnsFn = originalMigrate
})
err = migrateSchema(db)
if !errors.Is(err, wantErr) {
t.Fatalf("expected error %v, got %v", wantErr, err)
}
}
func TestAlterPostgresColumnToBigIntIfNeededValidatesNames(t *testing.T) {
if err := alterPostgresColumnToBigIntIfNeeded(nil, "peer_share", "max_bandwidth"); err == nil || !strings.Contains(err.Error(), "nil db") {
t.Fatalf("expected nil db error, got %v", err)
}
if err := alterPostgresColumnToBigIntIfNeeded(&gorm.DB{}, "", "max_bandwidth"); err == nil || !strings.Contains(err.Error(), "empty table or column name") {
t.Fatalf("expected empty name error, got %v", err)
}
if err := alterPostgresColumnToBigIntIfNeeded(&gorm.DB{}, "peer_share", ""); err == nil || !strings.Contains(err.Error(), "empty table or column name") {
t.Fatalf("expected empty name error, got %v", err)
}
}
@@ -196,12 +196,15 @@ func (r *Repository) GetUserDefaultsForTunnel(userID int64) (flow int64, num int
return user.Flow, user.Num, user.ExpTime, user.FlowResetTime, nil
}
func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version interface{}, httpFlag, tlsFlag, socksFlag int, now int64, status int, tcpAddr, udpAddr string, inx, isRemote int, remoteURL, remoteToken, remoteConfig, extraIPs interface{}) error {
func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version, remark, tags, expiryTime interface{}, httpFlag, tlsFlag, socksFlag int, now int64, status int, tcpAddr, udpAddr string, inx, isRemote int, remoteURL, remoteToken, remoteConfig, extraIPs interface{}) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
node := model.Node{
Name: name,
Remark: nullStringFromInterface(remark),
Tags: nullStringFromInterface(tags),
ExpiryTime: nullInt64FromInterface(expiryTime),
Secret: secret,
ServerIP: serverIP,
ServerIPV4: nullStringFromInterface(serverIPV4),
@@ -239,7 +242,7 @@ func (r *Repository) GetNodeStatusFields(nodeID int64) (status, httpFlag, tlsFla
return node.Status, node.HTTP, node.TLS, node.Socks, nil
}
func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName, extraIPs interface{}, httpFlag, tlsFlag, socksFlag int, tcpAddr, udpAddr string, now int64) error {
func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName, extraIPs, remark, tags, expiryTime interface{}, httpFlag, tlsFlag, socksFlag int, tcpAddr, udpAddr string, now int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
@@ -247,6 +250,9 @@ func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, ser
Where("id = ?", id).
Updates(map[string]interface{}{
"name": name,
"remark": nullStringFromInterface(remark),
"tags": nullStringFromInterface(tags),
"expiry_time": nullInt64FromInterface(expiryTime),
"server_ip": serverIP,
"server_ip_v4": nullStringFromInterface(serverIPV4),
"server_ip_v6": nullStringFromInterface(serverIPV6),
@@ -7,6 +7,8 @@ import (
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"time"
@@ -29,7 +31,7 @@ func TestForwardOwnershipAndScopeContracts(t *testing.T) {
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "contract-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
`, "contract-tunnel", 2.5, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, repo, "contract-tunnel")
@@ -116,6 +118,13 @@ func TestForwardOwnershipAndScopeContracts(t *testing.T) {
if got := int64(idFloat); got != userForwardID {
t.Fatalf("expected forward id %d, got %d", userForwardID, got)
}
ratioFloat, ok := item["tunnelTrafficRatio"].(float64)
if !ok {
t.Fatalf("expected tunnelTrafficRatio to be float64, got %T", item["tunnelTrafficRatio"])
}
if ratioFloat != 2.5 {
t.Fatalf("expected tunnelTrafficRatio 2.5, got %v", ratioFloat)
}
})
t.Run("forward diagnose returns structured payload", func(t *testing.T) {
@@ -914,6 +923,164 @@ func TestForwardCreateThenPauseResumeContract(t *testing.T) {
}
}
func TestForwardUpdateRecoversFromAddressInUseContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
server := httptest.NewServer(router)
defer server.Close()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
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(202, 'forward_bind_retry_user', 'pwd', 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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "forward-bind-retry-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, repo, "forward-bind-retry-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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "forward-bind-retry-node", "forward-bind-retry-secret", "10.42.0.1", "10.42.0.1", "", "44000-44010", "", "v1", 1, 1, 1, now, now, 1, "10.42.0.9", "[::]", 0).Error; err != nil {
t.Fatalf("insert node: %v", err)
}
nodeID := mustLastInsertID(t, repo, "forward-bind-retry-node")
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 44001, 'round', 1, 'tls')
`, tunnelID, nodeID).Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
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(41, 202, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
`, tunnelID).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
createPayload := map[string]interface{}{
"name": "forward-bind-retry-target",
"tunnelId": tunnelID,
"remoteAddr": "1.1.1.1:443",
"strategy": "fifo",
}
createBody, err := json.Marshal(createPayload)
if err != nil {
t.Fatalf("marshal create payload: %v", err)
}
var mu sync.Mutex
counts := map[string]int{}
var addServiceAddrs []string
triggerConflict := false
stopNode := startMockNodeSessionWithCommandRecorder(t, server.URL, "forward-bind-retry-secret", func(cmdType string, data json.RawMessage) (bool, string) {
key := strings.ToLower(strings.TrimSpace(cmdType))
mu.Lock()
counts[key]++
attempt := counts[key]
if strings.EqualFold(strings.TrimSpace(cmdType), "AddService") || strings.EqualFold(strings.TrimSpace(cmdType), "UpdateService") {
var services []map[string]interface{}
if err := json.Unmarshal(data, &services); err == nil {
for _, svc := range services {
if addr, _ := svc["addr"].(string); strings.TrimSpace(addr) != "" {
addServiceAddrs = append(addServiceAddrs, addr)
}
}
}
}
shouldFail := false
if triggerConflict {
if strings.EqualFold(strings.TrimSpace(cmdType), "UpdateService") && attempt == 1 {
shouldFail = true
}
if strings.EqualFold(strings.TrimSpace(cmdType), "AddService") && attempt == 1 {
shouldFail = true
}
}
mu.Unlock()
if shouldFail {
return true, "create service 57_7_7_tcp failed: listen tcp4 0.0.0.0:46222: bind: address alreadyin use"
}
return false, ""
})
defer stopNode()
waitNodeStatus(t, repo, nodeID, 1)
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
createReq.Header.Set("Authorization", adminToken)
createReq.Header.Set("Content-Type", "application/json")
createRes := httptest.NewRecorder()
router.ServeHTTP(createRes, createReq)
assertCode(t, createRes, 0)
mu.Lock()
counts = map[string]int{}
addServiceAddrs = nil
triggerConflict = true
mu.Unlock()
forwardID := mustLastInsertID(t, repo, "forward-bind-retry-target")
updatePayload := map[string]interface{}{
"id": forwardID,
"name": "forward-bind-retry-target-updated",
"tunnelId": tunnelID,
"remoteAddr": "9.9.9.9:8443",
"strategy": "fifo",
}
updateBody, err := json.Marshal(updatePayload)
if err != nil {
t.Fatalf("marshal update payload: %v", err)
}
updateReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
updateReq.Header.Set("Authorization", adminToken)
updateReq.Header.Set("Content-Type", "application/json")
updateRes := httptest.NewRecorder()
router.ServeHTTP(updateRes, updateReq)
assertCode(t, updateRes, 0)
mu.Lock()
defer mu.Unlock()
boundPort := mustQueryInt(t, repo, `SELECT port FROM forward_port WHERE forward_id = ? LIMIT 1`, forwardID)
if counts["updateservice"] != 1 {
t.Fatalf("expected one UpdateService attempt, got %d (%v)", counts["updateservice"], counts)
}
if counts["deleteservice"] == 0 {
t.Fatalf("expected DeleteService cleanup after address-in-use (%v)", counts)
}
if counts["addservice"] < 2 {
t.Fatalf("expected AddService retry path to run at least twice total, got %d (%v)", counts["addservice"], counts)
}
foundBindAddr := false
for _, addr := range addServiceAddrs {
if addr == "10.42.0.9:"+strconv.Itoa(boundPort) {
foundBindAddr = true
break
}
}
if !foundBindAddr {
t.Fatalf("expected forward runtime to keep node listen addr 10.42.0.9:%d, got %v", boundPort, addServiceAddrs)
}
storedRemoteAddr := mustQueryString(t, repo, `SELECT remote_addr FROM forward WHERE id = ?`, forwardID)
if storedRemoteAddr != "9.9.9.9:8443" {
t.Fatalf("expected remote_addr update to persist, got %q", storedRemoteAddr)
}
}
func jsonNumber(v int64) string {
return strconv.FormatInt(v, 10)
}
@@ -1150,6 +1317,30 @@ func TestNonAdminCannotSetSpeedIdOrPort(t *testing.T) {
assertCode(t, res, 0)
})
t.Run("non-admin can update when request keeps existing speedId", func(t *testing.T) {
if err := repo.DB().Exec(`UPDATE forward SET speed_id = ? WHERE id = ?`, speedID, forwardID).Error; err != nil {
t.Fatalf("assign forward speed limit: %v", err)
}
updatePayload := map[string]interface{}{
"id": forwardID,
"name": "perm-forward-keep-speed",
"tunnelId": tunnelID,
"remoteAddr": "9.10.11.12:443",
"speedId": speedID,
}
updateBody, err := json.Marshal(updatePayload)
if err != nil {
t.Fatalf("marshal update payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
req.Header.Set("Authorization", userToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
})
t.Run("non-admin can create with speedId null and inPort 0", func(t *testing.T) {
createPayload := map[string]interface{}{
"name": "perm-forward-null-values",
@@ -8,6 +8,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"sort"
"strings"
"sync"
"testing"
@@ -538,6 +539,134 @@ func TestBatchAssignInsertRollbackWhenLimiterDispatchFailsContract(t *testing.T)
}
}
func TestTunnelUpdateRecoversFromAddressInUseContract(t *testing.T) {
secret := "contract-jwt-secret"
router, r := setupContractRouter(t, secret)
server := httptest.NewServer(router)
defer server.Close()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "tunnel-bind-retry", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "tunnel-bind-retry")
if err := r.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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "tunnel-bind-entry", "tunnel-bind-entry-secret", "10.41.0.1", "10.41.0.1", "", "43000-43010", "", "v1", 1, 1, 1, now, now, 1, "10.41.0.1", "[::]", 0).Error; err != nil {
t.Fatalf("insert entry node: %v", err)
}
entryNodeID := mustLastInsertID(t, r, "tunnel-bind-entry")
if err := r.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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "tunnel-bind-exit", "tunnel-bind-exit-secret", "10.41.0.2", "10.41.0.2", "", "43100-43110", "eth0", "v1", 1, 1, 1, now, now, 1, "10.41.0.9", "[::]", 0).Error; err != nil {
t.Fatalf("insert exit node: %v", err)
}
exitNodeID := mustLastInsertID(t, r, "tunnel-bind-exit")
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 43001, 'round', 1, 'tls')
`, tunnelID, entryNodeID).Error; err != nil {
t.Fatalf("insert entry chain_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol, connect_ip)
VALUES(?, 3, ?, 43101, 'round', 1, 'tls', ?)
`, tunnelID, exitNodeID, "10.41.0.99").Error; err != nil {
t.Fatalf("insert exit chain_tunnel: %v", err)
}
var commandMu sync.Mutex
commandCounts := map[string]int{}
var addServiceAddrs []string
stopEntry := startMockNodeSessionWithCommandRecorder(t, server.URL, "tunnel-bind-entry-secret", func(cmdType string, data json.RawMessage) (bool, string) {
commandMu.Lock()
defer commandMu.Unlock()
commandCounts["entry:"+strings.ToLower(strings.TrimSpace(cmdType))]++
return false, ""
})
defer stopEntry()
stopExit := startMockNodeSessionWithCommandRecorder(t, server.URL, "tunnel-bind-exit-secret", func(cmdType string, data json.RawMessage) (bool, string) {
key := "exit:" + strings.ToLower(strings.TrimSpace(cmdType))
commandMu.Lock()
commandCounts[key]++
attempt := commandCounts[key]
if strings.EqualFold(strings.TrimSpace(cmdType), "AddService") {
var services []map[string]interface{}
if err := json.Unmarshal(data, &services); err == nil {
for _, svc := range services {
if addr, _ := svc["addr"].(string); strings.TrimSpace(addr) != "" {
addServiceAddrs = append(addServiceAddrs, addr)
}
}
}
}
commandMu.Unlock()
if strings.EqualFold(strings.TrimSpace(cmdType), "AddService") && attempt == 1 {
return true, "listen tcp 10.41.0.99:43101: bind: address already in use"
}
return false, ""
})
defer stopExit()
waitNodeStatus(t, r, entryNodeID, 1)
waitNodeStatus(t, r, exitNodeID, 1)
payload := map[string]interface{}{
"id": tunnelID,
"name": "tunnel-bind-retry",
"type": 2,
"flow": 99999,
"trafficRatio": 1.0,
"status": 1,
"inNodeId": []map[string]interface{}{
{"nodeId": entryNodeID, "protocol": "tls", "strategy": "round"},
},
"chainNodes": []interface{}{},
"outNodeId": []map[string]interface{}{
{"nodeId": exitNodeID, "protocol": "tls", "strategy": "round", "port": 43101, "connectIp": "10.41.0.99"},
},
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(body))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
commandMu.Lock()
defer commandMu.Unlock()
if commandCounts["exit:addservice"] != 2 {
t.Fatalf("expected exit AddService twice, got %d (%v)", commandCounts["exit:addservice"], sortedCommandCounts(commandCounts))
}
if commandCounts["exit:deleteservice"] == 0 {
t.Fatalf("expected exit DeleteService retry cleanup to run (%v)", sortedCommandCounts(commandCounts))
}
if len(addServiceAddrs) < 2 {
t.Fatalf("expected recorded AddService addresses, got %v", addServiceAddrs)
}
for _, addr := range addServiceAddrs {
if addr != "10.41.0.99:43101" {
t.Fatalf("expected connectIp to stay preferred in AddService addr, got %q", addr)
}
}
}
func startMockNodeSessionWithCommandFailures(t *testing.T, baseURL string, nodeSecret string, failCommands map[string]string) func() {
t.Helper()
@@ -633,3 +762,112 @@ func startMockNodeSessionWithCommandFailures(t *testing.T, baseURL string, nodeS
})
}
}
func startMockNodeSessionWithCommandRecorder(t *testing.T, baseURL string, nodeSecret string, onCommand func(cmdType string, data json.RawMessage) (bool, string)) func() {
t.Helper()
u, err := url.Parse(baseURL)
if err != nil {
t.Fatalf("parse provider url: %v", err)
}
if strings.EqualFold(u.Scheme, "https") {
u.Scheme = "wss"
} else {
u.Scheme = "ws"
}
u.Path = "/system-info"
q := u.Query()
q.Set("type", "1")
q.Set("secret", nodeSecret)
q.Set("version", "v1")
q.Set("http", "1")
q.Set("tls", "1")
q.Set("socks", "1")
u.RawQuery = q.Encode()
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
if err != nil {
t.Fatalf("dial mock node websocket: %v", err)
}
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for {
_, raw, readErr := conn.ReadMessage()
if readErr != nil {
return
}
plain := raw
var wrap struct {
Encrypted bool `json:"encrypted"`
Data string `json:"data"`
}
if err := json.Unmarshal(raw, &wrap); err == nil && wrap.Encrypted && strings.TrimSpace(wrap.Data) != "" {
crypto, cryptoErr := security.NewAESCrypto(nodeSecret)
if cryptoErr == nil {
if dec, decErr := crypto.Decrypt(wrap.Data); decErr == nil {
plain = []byte(dec)
}
}
}
var cmd struct {
Type string `json:"type"`
RequestID string `json:"requestId"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(plain, &cmd); err != nil {
continue
}
if strings.TrimSpace(cmd.RequestID) == "" {
continue
}
shouldFail := false
failMsg := ""
if onCommand != nil {
shouldFail, failMsg = onCommand(strings.TrimSpace(cmd.Type), cmd.Data)
}
respType := fmt.Sprintf("%sResponse", cmd.Type)
respPayload := map[string]interface{}{
"type": respType,
"success": !shouldFail,
"message": "OK",
"requestId": cmd.RequestID,
}
if shouldFail {
if strings.TrimSpace(failMsg) == "" {
failMsg = "mock command failed"
}
respPayload["message"] = failMsg
}
respBytes, err := json.Marshal(respPayload)
if err != nil {
continue
}
_ = conn.WriteMessage(websocket.TextMessage, respBytes)
}
}()
var stopOnce sync.Once
return func() {
stopOnce.Do(func() {
_ = conn.Close()
wg.Wait()
})
}
}
func sortedCommandCounts(counts map[string]int) []string {
items := make([]string, 0, len(counts))
for key, value := range counts {
items = append(items, fmt.Sprintf("%s=%d", key, value))
}
sort.Strings(items)
return items
}
@@ -0,0 +1,105 @@
package contract_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"go-backend/internal/auth"
"go-backend/internal/http/response"
)
func TestUserTunnelListReturnsStoredStatusContract(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(201, 'user_tunnel_status_user', 'pwd', 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(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(301, 'user-tunnel-status-enabled', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
`, now, now).Error; err != nil {
t.Fatalf("insert tunnel enabled: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(302, 'user-tunnel-status-disabled', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 1)
`, now, now).Error; err != nil {
t.Fatalf("insert tunnel disabled: %v", err)
}
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(401, 201, 301, NULL, 10, 500, 0, 0, 1, 2727251700000, 1)
`).Error; err != nil {
t.Fatalf("insert enabled user_tunnel: %v", err)
}
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(402, 201, 302, NULL, 10, 500, 0, 0, 1, 2727251700000, 0)
`).Error; err != nil {
t.Fatalf("insert disabled user_tunnel: %v", err)
}
body := bytes.NewBufferString(`{"userId":201}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/list", body)
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 code 0, got %d (%s)", out.Code, out.Msg)
}
items, ok := out.Data.([]interface{})
if !ok {
t.Fatalf("expected array data, got %T", out.Data)
}
if len(items) != 2 {
t.Fatalf("expected 2 items, got %d", len(items))
}
statusByTunnelID := make(map[int64]int, len(items))
for _, item := range items {
obj, ok := item.(map[string]interface{})
if !ok {
t.Fatalf("expected object item, got %T", item)
}
tunnelID, ok := obj["tunnelId"].(float64)
if !ok {
t.Fatalf("expected tunnelId to be float64, got %T", obj["tunnelId"])
}
status, ok := obj["status"].(float64)
if !ok {
t.Fatalf("expected status to be float64, got %T", obj["status"])
}
statusByTunnelID[int64(tunnelID)] = int(status)
}
if statusByTunnelID[301] != 1 {
t.Fatalf("expected enabled tunnel status 1, got %d", statusByTunnelID[301])
}
if statusByTunnelID[302] != 0 {
t.Fatalf("expected disabled tunnel status 0, got %d", statusByTunnelID[302])
}
}
@@ -0,0 +1,13 @@
# 013 Forward Delete NotFound Compatibility Fix
## Checklist
- [x] Confirm forward update failure path caused by delete fallback short-circuiting on the first not-found service name.
- [x] Update forward service deletion logic to continue across all candidate runtime names until one is actually deleted or every candidate is exhausted.
- [x] Add regression tests covering mixed not-found and legacy-name delete recovery during forward control/update flows.
- [x] Run focused backend handler tests and record the result.
## Test Record
- Command: `cd go-backend && go test ./internal/http/handler/...`
- Result: passed.
@@ -0,0 +1,13 @@
# 014 Forward Port Occupancy Validation
## Checklist
- [x] Confirm current forward create/update only validates node port range and misses DB-backed occupancy checks for local nodes.
- [x] Add shared forward port occupancy validation for create/update paths before runtime dispatch.
- [x] Add focused tests covering create/update validation when another forward already uses the same node+port.
- [x] Run focused backend handler tests and record the result.
## Test Record
- Command: `cd go-backend && go test ./internal/http/handler/...`
- Result: passed.
@@ -0,0 +1,13 @@
# 015 Forward Runtime Port Residual Cleanup
## Checklist
- [x] Confirm 2.1.6 used service names with `_0` runtime base while later versions may target resolved `user_tunnel_id`, leaving old runtime services behind after direct upgrade.
- [x] Extend self-occupy recovery to clean residual candidate service names and retry update/add when the port is only occupied by self-owned legacy runtime services.
- [x] Add regression tests covering address-in-use recovery with legacy `_0` runtime residue.
- [x] Run focused backend handler tests and record the result.
## Test Record
- Command: `cd go-backend && go test ./internal/http/handler/...`
- Result: passed.
@@ -0,0 +1,31 @@
# 016 Tunnel Runtime Bind Conflict Retry
## Checklist
- [x] Confirm tunnel `connectIp` precedence remains `connectIp > node tcp_listen_addr` for runtime service listen address.
- [x] Add tunnel runtime `address already in use` recovery that deletes the stale service and retries `AddService`.
- [x] Keep non-bind failures unchanged and avoid altering tunnel chain apply semantics.
- [x] Add regression tests for tunnel service address precedence and bind-conflict retry behavior.
- [x] Run focused backend handler tests and record the result.
- [ ] Add a contract test that simulates node-side `address already in use` during tunnel update and verifies retry success.
- [ ] Investigate whether forward update `address already in use` reports are only tunnel-redeploy linkage or also an independent forward path.
- [x] Add a contract test that simulates node-side `address already in use` during tunnel update and verifies retry success.
- [x] Investigate whether forward update `address already in use` reports are only tunnel-redeploy linkage or also an independent forward path.
## Test Record
- Command: `cd go-backend && go test ./internal/http/handler/...`
- Result: passed.
- Command: `cd go-backend && go test ./tests/contract/... -run 'TestTunnelUpdateRecoversFromAddressInUseContract|TestForwardCreateRollbackWhenServiceDispatchReturnsAddressInUseContract|TestForwardUpdateIgnoresDeletedSpeedLimitContract'`
- Result: passed.
- Command: `cd go-backend && go test ./tests/contract/... -run 'TestForwardUpdateRecoversFromAddressInUseContract|TestTunnelUpdateRecoversFromAddressInUseContract'`
- Result: passed.
- Command: `cd go-backend && go test ./internal/http/handler/... && go test ./tests/contract/... -run 'TestForwardUpdateRecoversFromAddressInUseContract|TestTunnelUpdateRecoversFromAddressInUseContract'`
- Result: passed.
## Investigation Note
- Forward update still has its own independent `address already in use` recovery path in `syncForwardServicesWithWarnings` / `rebindForwardServiceOnSelfOccupiedPort`; tunnel update linkage is not the only possible source of the symptom.
- Tunnel update also triggers downstream forward `UpdateService` for bound forwards, so users can still observe the same error around a tunnel edit even when the failing runtime is on the tunnel side.
- Real node output can collapse spaces into variants like `address alreadyin use` / `cannotassignrequestedaddress`; bind-conflict detection now normalizes whitespace before classifying the error.
- Forward self-heal cleanup now deletes every candidate runtime name variant instead of stopping after the first successful delete, which avoids leaving sibling `_tcp`/`_udp` services behind to keep the port occupied.
+16
View File
@@ -0,0 +1,16 @@
# 017 PR 284 UI Follow-up Fixes
## Checklist
- [x] Review the current frontend route and component state related to PR 284 follow-up fixes.
- [x] Restore the intended H5 simple-layout route behavior for panel sharing.
- [x] Improve date text parsing to support separator-free and flexible formats without ambiguous fallbacks.
- [x] Add config-page back navigation with a safer history fallback and shared icon usage.
- [x] Run focused frontend verification for the updated files and record the result.
## Test Record
- Command: `cd vite-frontend && npm install`
- Result: passed.
- Command: `cd vite-frontend && npm run build`
- Result: passed.
@@ -0,0 +1,12 @@
# 018 User Tunnel Disable Status Sync
## Checklist
- [x] Inspect the user tunnel permission edit flow and identify why disabling an assigned tunnel appears ineffective.
- [x] Return the real `user_tunnel.status` value from the admin permission list API instead of a hardcoded enabled state.
- [x] Add contract coverage for the user tunnel permission list status mapping and run focused backend verification.
## Test Record
- Command: `cd go-backend && go test ./tests/contract/...`
- Result: passed.
@@ -0,0 +1,21 @@
# 019 Federation Share Traffic Bigint Migration
## Checklist
- [x] Inspect federation share creation failure and identify the PostgreSQL `int4` overflow source.
- [x] Audit other traffic-related legacy PostgreSQL columns that may still be `integer` despite Go models using `int64`.
- [x] Add a schema migration that widens legacy traffic/quota columns from `integer` to `bigint`.
- [x] Add migration tests covering the new schema version branch and error propagation.
- [x] Run focused backend verification for the migration changes.
## Notes
- The reported failing value `536870912000` is 500 GiB in bytes and overflows PostgreSQL `int4`.
- The fix widens historical PostgreSQL traffic columns in `user`, `forward`, `statistics_flow`, `tunnel`, `user_tunnel`, and `peer_share` to `BIGINT` when needed.
## Test Record
- Command: `cd go-backend && go test ./internal/store/repo/...`
- Result: passed.
- Command: `cd go-backend && go test ./tests/contract/...`
- Result: passed.
+164
View File
@@ -0,0 +1,164 @@
# 020 AJAX No-refresh UX
## Objective
- Implement issue `#276` as a focused frontend UX improvement initiative, not a full data-layer rewrite.
- Keep the existing `axios + local React state + custom hooks` architecture, and extend it with polling, realtime hardening, and local state patching where it improves responsiveness.
- Deliver the work in phases so the highest-value improvements ship first: dashboard auto-refresh and node realtime resilience, then local list updates after mutations, then batch progress and search/filter polish.
## Non-goals
- Do not introduce `@tanstack/react-query`, SWR, or other new frontend data libraries for this issue.
- Do not rewrite page architecture, routing, or modal flows that already submit asynchronously without browser reloads.
- Do not require backend changes unless a batch-progress requirement cannot be met with the current API surface.
- Do not change the raw JWT auth convention used by `vite-frontend/src/api/network.ts`.
## Current State
- `vite-frontend/src/pages/node/use-node-realtime.ts` and `vite-frontend/src/pages/node.tsx` already provide websocket-driven node status, system info, and upgrade progress updates.
- `vite-frontend/src/pages/forward.tsx`, `vite-frontend/src/pages/tunnel.tsx`, `vite-frontend/src/pages/user.tsx`, and `vite-frontend/src/pages/node.tsx` already submit forms asynchronously, so the main remaining gap is consistency of post-submit local refresh behavior.
- `vite-frontend/src/pages/dashboard/use-dashboard-data.ts` currently fetches dashboard data only once on mount, so traffic charts and counters do not auto-refresh.
- Several mutation handlers still rely on page-level reload functions such as `loadData()`, `loadUsers()`, or `loadNodes()` instead of patching only the changed records.
- Batch progress UI exists for node upgrade but not for other batch actions such as forward and tunnel operations.
## Design Principles
- Prefer local state patching after successful mutations when the changed record set is known.
- Prefer targeted refetches over full-page refetches when the server is the source of truth for a small dependent dataset.
- Use polling only where realtime transport does not already exist.
- Pause or reduce background refresh work when the page is hidden to avoid unnecessary traffic.
- Keep UI feedback explicit: loading states, toast feedback, and visible progress for long-running batch actions.
## Checklist
- [x] Refactor dashboard data loading into reusable refresh callbacks in `vite-frontend/src/pages/dashboard/use-dashboard-data.ts`.
- [x] Add dashboard traffic polling with visibility-aware pause/resume and safe notification deduplication.
- [x] Harden node realtime reconnection behavior in `vite-frontend/src/pages/node/use-node-realtime.ts` and define a fallback refresh path if websocket recovery fails.
- [x] Add shared local-list patch helpers for replace/remove/upsert patterns used by page-level mutation handlers.
- [x] Convert forward create/edit/delete/service-toggle flows in `vite-frontend/src/pages/forward.tsx` from whole-page refetches to local or targeted updates where safe.
- [x] Convert tunnel create/edit/delete flows in `vite-frontend/src/pages/tunnel.tsx` from whole-page refetches to local or targeted updates where safe.
- [x] Convert user create/edit/delete and user-tunnel permission mutation flows in `vite-frontend/src/pages/user.tsx` to local or targeted updates where safe.
- [x] Extend batch action UX to show visible progress or staged feedback for forward and tunnel batch operations.
- [x] Normalize search/filter behavior and document where client-side instant filtering is appropriate versus where server-side pagination must remain authoritative.
- [ ] Run focused frontend verification and record the result in this plan after implementation.
## Implementation Plan
### Phase 1 - Dashboard auto-refresh and node realtime resilience
#### 1. Dashboard traffic/statistics auto-refresh
- Extract `loadPackageData()` and `loadAnnouncement()` in `vite-frontend/src/pages/dashboard/use-dashboard-data.ts` into stable callbacks so the hook can refresh data without re-running the whole mount sequence.
- Add a 5-second polling loop for package, flow, and chart data returned by `getUserPackageInfo()`.
- Keep announcement loading low-frequency or first-load only unless the API contract clearly expects live updates.
- Pause polling when `document.visibilityState !== "visible"`, then trigger an immediate refresh when the tab becomes visible again.
- Preserve current loading UX for first load, but use a silent refresh path for polling so the page does not flicker.
#### 2. Dashboard notification safety
- Audit `checkExpirationNotifications()` in `vite-frontend/src/pages/dashboard/use-dashboard-data.ts` so polling does not repeatedly emit expiration warnings.
- Continue using notification deduplication, but base it on stable expiration identifiers rather than every poll cycle.
- Ensure refreshes that only change traffic counters do not retrigger expiry toasts.
#### 3. Node realtime hardening
- Review `vite-frontend/src/pages/node/use-node-realtime.ts` reconnect logic, which currently stops after a fixed retry budget.
- Replace the hard stop with controlled backoff reconnect behavior, or explicitly trigger a degraded polling fallback once retry exhaustion is reached.
- If a fallback list refresh is introduced, merge incoming node metadata with existing `systemInfo`, `connectionStatus`, and upgrade-progress state so live metrics are not wiped during recovery.
- Keep the existing offline debounce behavior in `vite-frontend/src/pages/node/use-node-offline-timers.ts`.
### Phase 2 - Local mutation updates and partial refreshes
#### 4. Shared list-patching helpers
- Add small reusable helpers for common state operations such as:
- replace one item by `id`
- remove one or many items by `id`
- upsert a created or updated item into an ordered list
- preserve derived UI-only fields during server payload merges
- Keep these helpers local to the frontend codebase and avoid introducing a generic state-management abstraction.
#### 5. Forward page partial refresh conversion
- Target `vite-frontend/src/pages/forward.tsx` mutation handlers first because the page already contains some optimistic/local patterns.
- Preserve the current local behavior for service toggles, but review rollback handling so final UI state matches backend truth after success or failure.
- Change create/edit/delete flows to patch `forwards` state directly when the response payload is sufficient.
- Use targeted refetches only when an operation changes dependent datasets that are not reliably derivable from the local page state.
- Re-check grouped ordering, collapsed-state persistence, and selected-row state after local mutations.
#### 6. Tunnel page partial refresh conversion
- Update `vite-frontend/src/pages/tunnel.tsx` so create/edit/delete mutate `tunnels` state directly instead of always calling `loadData()`.
- Keep node reference data refresh separate from tunnel list refresh so a tunnel mutation does not force a full page data reload.
- Preserve existing drag-sort behavior and ensure local patching keeps `inx` and stored order consistent.
#### 7. User page partial refresh conversion
- Update `vite-frontend/src/pages/user.tsx` so create/edit/delete patch the `users` list when the current page can be updated safely.
- Update user-tunnel permission flows to patch `userTunnels` directly after assign, edit, remove, and flow-reset operations.
- Respect server-side pagination semantics for the user list; if the server response does not provide enough data for a safe local patch, use a targeted page refetch rather than a full multi-dataset refresh.
- Keep current modal and toast behavior unchanged unless the local update path exposes stale-state issues.
### Phase 3 - Batch progress UX and search/filter polish
#### 8. Batch progress UX
- Use the node upgrade progress model in `vite-frontend/src/pages/node.tsx` as the UI reference for long-running operations.
- Review `vite-frontend/src/pages/forward/batch-actions.ts` and tunnel batch handlers to determine whether current APIs expose enough intermediate state for real progress.
- If only final summary APIs are available, implement staged client-side progress feedback such as `processing X/Y`, current action label, success count, and failure count.
- If the UX requirement cannot be met without backend support, document the missing backend contract and split the work into frontend and backend follow-ups.
#### 9. Search and filter responsiveness
- Preserve instant client-side filtering on pages that already hold the authoritative dataset locally, including node, tunnel, and forward pages.
- Audit the user page separately because it depends on server-side pagination and keyword search.
- If user-page instant filtering is desired, choose one of two explicit strategies:
- keep server-side pagination authoritative and add debounce for keyword-triggered requests, or
- load a larger local dataset only if product requirements accept the cost.
- Do not silently mix partial client filtering with incomplete paginated datasets.
## Risks and Mitigations
- Repeated dashboard polling may spam expiry toasts.
- Mitigation: deduplicate notifications based on expiration identity and only emit on meaningful state changes.
- Node recovery refreshes may wipe websocket-derived metrics.
- Mitigation: merge fetched node metadata into existing live state instead of replacing the whole record blindly.
- Local mutation patching may desynchronize grouped, sorted, or selected views.
- Mitigation: patch canonical source arrays first, then recompute derived memoized groupings from state.
- Batch APIs may not expose progress details.
- Mitigation: implement client-side staged progress where possible and document backend gaps where not.
- User-page local updates may conflict with pagination semantics.
- Mitigation: prefer targeted page refetch over unsafe optimistic filtering or cross-page list mutation.
## Verification Plan
- Dashboard:
- Open `dashboard` and confirm traffic counters and chart data refresh at least once every 5 seconds without manual reload.
- Confirm hidden-tab pause and visible-tab immediate refresh behavior.
- Confirm expiry toasts do not repeat on every polling cycle.
- Nodes:
- Confirm websocket-driven online/offline transitions still work.
- Simulate websocket interruption and verify reconnect or fallback refresh behavior.
- Confirm recovery does not clear existing live metrics unexpectedly.
- Forwards, tunnels, users:
- Create, edit, delete, enable, disable, and reset flows without browser reload.
- Confirm the affected rows update immediately and other unrelated rows stay stable.
- Confirm selection state, ordering, and modal close behavior remain correct after local patching.
- Batch actions:
- Confirm visible progress or staged status feedback exists during long-running operations.
- Confirm success and failure summaries remain accurate after completion.
- Build:
- Run `cd vite-frontend && npm run build`.
## Rollout Notes
- Ship Phase 1 first because it matches the issue approval priority and provides the clearest user-visible gain.
- Keep each phase in reviewable commits so regressions in local list patching can be isolated quickly.
- If backend support becomes necessary for real batch progress, land the frontend scaffolding separately and track the backend dependency explicitly.
## Test Record
- Command: `cd vite-frontend && npm install`
- Result: passed.
- Command: `cd vite-frontend && npm run build`
- Result: passed.
+6
View File
@@ -0,0 +1,6 @@
# Node Remarks, Tags, and Expiry Plan
- [x] Review issue #246 and inspect current node backend/frontend flow
- [x] Extend node persistence and API payloads with remark, tags, and expiry fields
- [x] Update node management UI to edit, display, and search the new metadata
- [x] Verify the backend and frontend still build successfully
@@ -0,0 +1,6 @@
# Node Expiry Highlights And Dashboard Reminders Plan
- [x] Review current node page and dashboard data flow for expiry-related hooks
- [x] Add node expiry status helpers plus expiring-soon filter/highlight in node management
- [x] Load node expiry data on the dashboard for admins and render reminder card
- [x] Verify frontend build and mark the plan complete
@@ -0,0 +1,6 @@
# Forward Page Tunnel Traffic Ratio Plan
- [x] Review `/forward/list` data flow and rule page render points for tunnel ratio support
- [x] Extend backend forward list payload with tunnel traffic ratio and cover it with a contract test
- [x] Update forward page types, mapping, grouped metadata, and visible ratio UI across list modes
- [x] Verify targeted backend tests and frontend build, then mark the plan complete
+1 -1
View File
@@ -188,7 +188,7 @@ function App() {
/>
<Route
element={
<ProtectedRoute>
<ProtectedRoute useSimpleLayout={true}>
<PanelSharingPage />
</ProtectedRoute>
}
+2
View File
@@ -65,6 +65,8 @@ export const getUserPackageInfo = () =>
export const createNode = (data: NodeMutationPayload) =>
Network.post("/node/create", data);
export const getNodeList = () => Network.post<NodeApiItem[]>("/node/list");
export const getDashboardNodeExpiryList = () =>
Network.post<NodeApiItem[]>("/node/list", {});
export const updateNode = (data: NodeMutationPayload) =>
Network.post("/node/update", data);
export const deleteNode = (id: number) => Network.post("/node/delete", { id });
+7
View File
@@ -3,6 +3,9 @@ export interface NodeApiItem {
name: string;
status: number;
inx?: number;
remark?: string;
tags?: string;
expiryTime?: number;
syncError?: string;
[key: string]: unknown;
}
@@ -44,6 +47,7 @@ export interface ForwardApiItem {
name: string;
status: number;
tunnelName?: string;
tunnelTrafficRatio?: number;
inIp?: string;
inPort?: number;
remoteAddr?: string;
@@ -214,6 +218,9 @@ export interface NodeMutationPayload {
name?: string;
status?: number;
inx?: number;
remark?: string;
tags?: string;
expiryTime?: number;
serverIp?: string;
serverIpV4?: string;
serverIpV6?: string;
+26
View File
@@ -259,3 +259,29 @@ export const SettingsIcon = ({
/>
</svg>
);
export const BackIcon = ({
size = 24,
width,
height,
...props
}: IconSvgProps) => (
<svg
aria-hidden="true"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<path
d="M15 19l-7-7 7-7"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
);
+2 -7
View File
@@ -2,6 +2,7 @@ import React from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/shadcn-bridge/heroui/button";
import { BackIcon } from "@/components/icons";
import { BrandLogo } from "@/components/brand-logo";
import { siteConfig } from "@/config/site";
import { useScrollTopOnPathChange } from "@/hooks/useScrollTopOnPathChange";
@@ -25,13 +26,7 @@ export default function H5SimpleLayout({
<header className="bg-white dark:bg-black shadow-sm border-b border-gray-200 dark:border-gray-600 h-14 safe-top flex-shrink-0 flex items-center justify-between px-4 relative z-10">
<div className="flex items-center gap-2">
<Button isIconOnly size="sm" variant="light" onPress={handleBack}>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path
clipRule="evenodd"
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
fillRule="evenodd"
/>
</svg>
<BackIcon className="w-5 h-5" />
</Button>
<BrandLogo size={20} />
<h1 className="text-sm font-bold text-foreground">
+26 -1
View File
@@ -26,7 +26,7 @@ import {
updateAnnouncement,
type AnnouncementData,
} from "@/api";
import { SettingsIcon } from "@/components/icons";
import { BackIcon, SettingsIcon } from "@/components/icons";
import { isAdmin } from "@/utils/auth";
import { getCachedConfigs, configCache, updateSiteConfig } from "@/config/site";
import {
@@ -234,6 +234,21 @@ export default function ConfigPage() {
Partial<Record<BrandPreviewKey, boolean>>
>({});
const canGoBack =
typeof window !== "undefined" &&
typeof window.history.state?.idx === "number" &&
window.history.state.idx > 0;
const handleBack = () => {
if (canGoBack) {
navigate(-1);
return;
}
navigate("/profile", { replace: true });
};
// 权限检查
useEffect(() => {
if (!isAdmin()) {
@@ -846,6 +861,16 @@ export default function ConfigPage() {
<div className="p-6 max-w-4xl mx-auto">
{/* 页面标题 */}
<div className="flex items-center gap-3 mb-6">
<Button
isIconOnly
aria-label="返回上一页"
className="min-w-0 w-9 h-9"
size="sm"
variant="flat"
onPress={handleBack}
>
<BackIcon className="w-5 h-5" />
</Button>
<SettingsIcon className="w-8 h-8 text-primary" />
<div>
<h1 className="text-2xl font-bold">网站配置</h1>
+145
View File
@@ -17,6 +17,7 @@ import { MetricCard } from "@/pages/dashboard/components/metric-card";
import {
useDashboardData,
type DashboardForward as Forward,
type DashboardNodeExpiryItem,
type DashboardUserTunnel as UserTunnel,
} from "@/pages/dashboard/use-dashboard-data";
@@ -34,6 +35,7 @@ export default function DashboardPage() {
userTunnels,
forwardList,
statisticsFlows,
nodeExpiryReminders,
isAdmin,
announcement,
} = useDashboardData();
@@ -70,6 +72,103 @@ export default function DashboardPage() {
return value.toString();
};
const getNodeExpiryStatus = (expiryTime?: number) => {
if (!expiryTime) {
return {
label: "永久有效",
badgeClassName:
"bg-default-100 text-default-700 dark:bg-default-50 dark:text-default-300",
};
}
const diffMs = expiryTime - Date.now();
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
if (diffDays <= 0) {
return {
label: "已过期",
badgeClassName:
"bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-300",
};
}
if (diffDays === 1) {
return {
label: "明天到期",
badgeClassName:
"bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300",
};
}
return {
label: `${diffDays}天后到期`,
badgeClassName:
diffDays <= 7
? "bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300"
: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300",
};
};
const parseNodeTags = (tags?: string) => {
if (!tags) return [];
return tags
.split(",")
.map((tag) => tag.trim())
.filter(Boolean)
.slice(0, 3);
};
const renderNodeExpiryCard = (node: DashboardNodeExpiryItem) => {
const expiryStatus = getNodeExpiryStatus(node.expiryTime);
const tags = parseNodeTags(node.tags);
return (
<div
key={node.id}
className="rounded-xl border border-amber-200/80 bg-gradient-to-br from-amber-50 via-white to-orange-50 p-4 shadow-sm dark:border-amber-500/20 dark:from-amber-950/20 dark:via-background dark:to-orange-950/10"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-foreground truncate">
{node.name}
</div>
<div className="mt-1 text-xs text-default-500">节点 ID: {node.id}</div>
</div>
<span
className={`shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium ${expiryStatus.badgeClassName}`}
>
{expiryStatus.label}
</span>
</div>
<div className="mt-3 text-sm text-default-700 dark:text-default-300">
{node.expiryTime
? new Date(node.expiryTime).toLocaleString()
: "未设置到期时间"}
</div>
{node.remark?.trim() && (
<p className="mt-3 line-clamp-2 text-xs leading-5 text-default-600 dark:text-default-400">
{node.remark.trim()}
</p>
)}
{tags.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{tags.map((tag) => (
<span
key={`${node.id}-${tag}`}
className="rounded-full bg-white/80 px-2 py-1 text-[11px] text-default-600 ring-1 ring-amber-200/80 dark:bg-white/5 dark:text-default-300 dark:ring-amber-500/20"
>
#{tag}
</span>
))}
</div>
)}
</div>
);
};
// 处理24小时流量统计数据
const processFlowChartData = () => {
// 生成最近24小时的时间数组(从当前小时往前推24小时)
@@ -661,6 +760,52 @@ export default function DashboardPage() {
statisticsFlowsCount={statisticsFlows.length}
/>
{isAdmin && nodeExpiryReminders.length > 0 && (
<Card className="mb-6 lg:mb-8 border border-amber-200/80 bg-gradient-to-br from-amber-50/90 via-background to-orange-50/70 shadow-md dark:border-amber-500/20 dark:from-amber-950/10 dark:to-orange-950/10">
<CardHeader className="pb-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between w-full">
<div className="flex items-center gap-2">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300">
<svg
aria-hidden="true"
className="h-5 w-5"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.981-1.742 2.981H4.42c-1.53 0-2.492-1.647-1.743-2.98l5.58-9.92zM11 13a1 1 0 10-2 0 1 1 0 002 0zm-1-7a1 1 0 00-1 1v3a1 1 0 102 0V7a1 1 0 00-1-1z"
fillRule="evenodd"
/>
</svg>
</div>
<div>
<h2 className="text-lg lg:text-xl font-semibold text-foreground">
节点到期提醒
</h2>
<p className="text-sm text-default-500">
展示 7 天内到期或已过期的节点,便于提前续费或清理
</p>
</div>
</div>
<span className="inline-flex w-fit items-center rounded-full bg-white/80 px-3 py-1 text-xs font-medium text-amber-700 ring-1 ring-amber-200/80 dark:bg-white/5 dark:text-amber-300 dark:ring-amber-500/20">
{nodeExpiryReminders.length} 个提醒
</span>
</div>
</CardHeader>
<CardBody className="pt-0">
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
{nodeExpiryReminders.slice(0, 6).map(renderNodeExpiryCard)}
</div>
{nodeExpiryReminders.length > 6 && (
<p className="mt-4 text-xs text-default-500">
还有 {nodeExpiryReminders.length - 6} 个节点未展开显示,可前往节点页面继续处理。
</p>
)}
</CardBody>
</Card>
)}
{/* 隧道权限 - 管理员不显示 */}
{!isAdmin && (
<Card className="mb-6 lg:mb-8 border border-gray-200 dark:border-default-200 shadow-md">
@@ -1,10 +1,11 @@
import type { ForwardApiItem } from "@/api/types";
import type { ForwardApiItem, NodeApiItem } from "@/api/types";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import {
getAnnouncement,
getDashboardNodeExpiryList,
getUserPackageInfo,
type AnnouncementData,
} from "@/api";
@@ -52,12 +53,38 @@ export interface DashboardStatisticsFlow {
time: string;
}
export interface DashboardNodeExpiryItem {
id: number;
name: string;
remark?: string;
tags?: string;
expiryTime?: number;
}
const DASHBOARD_POLL_INTERVAL_MS = 5000;
const EXPIRATION_NOTIFICATION_STORAGE_KEY =
"dashboard:last-expiration-notification";
const buildExpirationNotificationKey = (
userInfo: DashboardUserInfo,
tunnels: DashboardUserTunnel[],
) => {
const userExpTime = userInfo.expTime ?? "permanent";
const tunnelExpirationKey = [...tunnels]
.map((tunnel) => `${tunnel.tunnelId}:${tunnel.expTime ?? "permanent"}`)
.sort()
.join("|");
return `user:${userExpTime};tunnels:${tunnelExpirationKey}`;
};
interface DashboardDataState {
loading: boolean;
userInfo: DashboardUserInfo;
userTunnels: DashboardUserTunnel[];
forwardList: DashboardForward[];
statisticsFlows: DashboardStatisticsFlow[];
nodeExpiryReminders: DashboardNodeExpiryItem[];
isAdmin: boolean;
announcement: AnnouncementData | null;
}
@@ -66,8 +93,10 @@ const checkExpirationNotifications = (
userInfo: DashboardUserInfo,
tunnels: DashboardUserTunnel[],
) => {
const notificationKey = `expiration-${userInfo.expTime}-${tunnels.map((t) => t.expTime).join(",")}`;
const lastNotified = localStorage.getItem("lastNotified");
const notificationKey = buildExpirationNotificationKey(userInfo, tunnels);
const lastNotified = localStorage.getItem(
EXPIRATION_NOTIFICATION_STORAGE_KEY,
);
if (lastNotified === notificationKey) {
return;
@@ -148,7 +177,7 @@ const checkExpirationNotifications = (
});
if (hasNotification) {
localStorage.setItem("lastNotified", notificationKey);
localStorage.setItem(EXPIRATION_NOTIFICATION_STORAGE_KEY, notificationKey);
}
};
@@ -174,6 +203,28 @@ const normalizeTunnelPermissions = (items: DashboardUserTunnel[]) => {
}));
};
const normalizeNodeExpiryReminders = (items: NodeApiItem[]) => {
const now = Date.now();
const warningWindowMs = 7 * 24 * 60 * 60 * 1000;
return (items || [])
.map((item) => ({
id: item.id,
name: item.name || "",
remark: typeof item.remark === "string" ? item.remark : "",
tags: typeof item.tags === "string" ? item.tags : "",
expiryTime:
typeof item.expiryTime === "number" && item.expiryTime > 0
? item.expiryTime
: undefined,
}))
.filter((item) => {
if (!item.expiryTime) return false;
return item.expiryTime <= now + warningWindowMs;
})
.sort((a, b) => (a.expiryTime || 0) - (b.expiryTime || 0));
};
export const useDashboardData = (): DashboardDataState => {
const [loading, setLoading] = useState(true);
const [userInfo, setUserInfo] = useState<DashboardUserInfo>(
@@ -184,64 +235,171 @@ export const useDashboardData = (): DashboardDataState => {
const [statisticsFlows, setStatisticsFlows] = useState<
DashboardStatisticsFlow[]
>([]);
const [nodeExpiryReminders, setNodeExpiryReminders] = useState<
DashboardNodeExpiryItem[]
>([]);
const [isAdmin, setIsAdmin] = useState(false);
const [announcement, setAnnouncement] = useState<AnnouncementData | null>(
null,
);
const isMountedRef = useRef(true);
const packageRequestInFlightRef = useRef(false);
const nodeExpiryRequestInFlightRef = useRef(false);
useEffect(() => {
const loadAnnouncement = async () => {
try {
const res = await getAnnouncement();
const applyPackageData = useCallback((data: {
userInfo?: DashboardUserInfo;
tunnelPermissions?: DashboardUserTunnel[];
forwards?: ForwardApiItem[];
statisticsFlows?: DashboardStatisticsFlow[];
}) => {
const normalizedTunnelPermissions = normalizeTunnelPermissions(
data.tunnelPermissions || [],
);
const normalizedForwards = normalizeForwards(data.forwards || []);
if (res.code === 0 && res.data && res.data.enabled === 1) {
setAnnouncement(res.data);
}
} catch {}
};
if (!isMountedRef.current) {
return;
}
setUserInfo(data.userInfo || ({} as DashboardUserInfo));
setUserTunnels(normalizedTunnelPermissions);
setForwardList(normalizedForwards);
setStatisticsFlows(data.statisticsFlows || []);
checkExpirationNotifications(
data.userInfo || ({} as DashboardUserInfo),
normalizedTunnelPermissions,
);
}, []);
const loadPackageData = useCallback(
async ({ silent = false, notifyOnError = false } = {}) => {
if (packageRequestInFlightRef.current) {
return;
}
packageRequestInFlightRef.current = true;
if (!silent && isMountedRef.current) {
setLoading(true);
}
const loadPackageData = async () => {
setLoading(true);
try {
const res = await getUserPackageInfo();
if (res.code === 0) {
const data = res.data;
const normalizedTunnelPermissions = normalizeTunnelPermissions(
data.tunnelPermissions || [],
);
const normalizedForwards = normalizeForwards(data.forwards || []);
setUserInfo(data.userInfo || ({} as DashboardUserInfo));
setUserTunnels(normalizedTunnelPermissions);
setForwardList(normalizedForwards);
setStatisticsFlows(data.statisticsFlows || []);
checkExpirationNotifications(
data.userInfo,
normalizedTunnelPermissions,
);
} else {
applyPackageData(res.data || {});
} else if (notifyOnError) {
toast.error(res.msg || "获取套餐信息失败");
}
} catch {
toast.error("获取套餐信息失败");
if (notifyOnError) {
toast.error("获取套餐信息失败");
}
} finally {
setLoading(false);
packageRequestInFlightRef.current = false;
if (!silent && isMountedRef.current) {
setLoading(false);
}
}
},
[applyPackageData],
);
const loadAnnouncement = useCallback(async () => {
try {
const res = await getAnnouncement();
if (!isMountedRef.current) {
return;
}
if (res.code === 0 && res.data && res.data.enabled === 1) {
setAnnouncement(res.data);
} else {
setAnnouncement(null);
}
} catch {
if (isMountedRef.current) {
setAnnouncement(null);
}
}
}, []);
const loadNodeExpiryData = useCallback(async () => {
if (nodeExpiryRequestInFlightRef.current) {
return;
}
nodeExpiryRequestInFlightRef.current = true;
try {
const res = await getDashboardNodeExpiryList();
if (!isMountedRef.current) {
return;
}
if (res.code === 0 && Array.isArray(res.data)) {
setNodeExpiryReminders(normalizeNodeExpiryReminders(res.data));
}
} catch {
} finally {
nodeExpiryRequestInFlightRef.current = false;
}
}, []);
useEffect(() => {
isMountedRef.current = true;
const adminFlag = getAdminFlag();
setIsAdmin(adminFlag);
void loadPackageData({ notifyOnError: true });
void loadAnnouncement();
if (adminFlag) {
void loadNodeExpiryData();
}
localStorage.setItem("e", "/dashboard");
return () => {
isMountedRef.current = false;
};
}, [loadAnnouncement, loadNodeExpiryData, loadPackageData]);
useEffect(() => {
if (typeof document === "undefined") {
return;
}
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
void loadPackageData({ silent: true });
if (isAdmin) {
void loadNodeExpiryData();
}
}
};
setLoading(true);
setUserInfo({} as DashboardUserInfo);
setUserTunnels([]);
setForwardList([]);
setStatisticsFlows([]);
setIsAdmin(getAdminFlag());
const interval = window.setInterval(() => {
if (document.visibilityState !== "visible") {
return;
}
loadPackageData();
loadAnnouncement();
localStorage.setItem("e", "/dashboard");
}, []);
void loadPackageData({ silent: true });
if (isAdmin) {
void loadNodeExpiryData();
}
}, DASHBOARD_POLL_INTERVAL_MS);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.clearInterval(interval);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [isAdmin, loadNodeExpiryData, loadPackageData]);
return {
loading,
@@ -249,6 +407,7 @@ export const useDashboardData = (): DashboardDataState => {
userTunnels,
forwardList,
statisticsFlows,
nodeExpiryReminders,
isAdmin,
announcement,
};
+258 -47
View File
@@ -1,6 +1,6 @@
import type { SpeedLimitApiItem } from "@/api/types";
import type { ForwardApiItem, SpeedLimitApiItem } from "@/api/types";
import { useState, useEffect, useMemo, useRef } from "react";
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
import toast from "react-hot-toast";
import {
DndContext,
@@ -48,6 +48,7 @@ import { Chip } from "@/shadcn-bridge/heroui/chip";
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
import { Switch } from "@/shadcn-bridge/heroui/switch";
import { Alert } from "@/shadcn-bridge/heroui/alert";
import { Progress } from "@/shadcn-bridge/heroui/progress";
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
import {
createForward,
@@ -103,6 +104,7 @@ interface Forward {
name: string;
tunnelId: number;
tunnelName: string;
tunnelTrafficRatio?: number;
inIp: string;
inPort: number;
remoteAddr: string;
@@ -163,9 +165,16 @@ interface ForwardUserGroup {
interface ForwardTunnelGroup {
tunnelKey: string;
tunnelName: string;
tunnelTrafficRatio?: number;
items: Forward[];
}
interface BatchProgressState {
active: boolean;
label: string;
percent: number;
}
type ForwardGroupOrderMap = Record<string, string[]>;
type ForwardGroupCollapsedMap = Record<string, boolean>;
@@ -238,6 +247,32 @@ const compareForwardTunnelGroupKeyAsc = (a: string, b: string): number => {
return compareForwardTunnelNameAsc(a, b);
};
const normalizeTunnelTrafficRatio = (value: unknown): number => {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return value;
}
if (typeof value === "string") {
const parsed = Number(value);
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
}
return 1;
};
const formatTunnelTrafficRatio = (value?: number): string => {
const ratio = normalizeTunnelTrafficRatio(value);
if (Number.isInteger(ratio)) {
return `${ratio}x`;
}
return `${parseFloat(ratio.toFixed(2))}x`;
};
const buildForwardGroupOrderLocalKey = (tokenUserId: number): string => {
return `${FORWARD_GROUP_ORDER_LOCAL_STORAGE_PREFIX}:u:${tokenUserId}`;
};
@@ -490,6 +525,43 @@ const isSameGroupCollapsedMap = (
return true;
};
const normalizeForwardItems = (items: Forward[]): Forward[] => {
return items.map((forward) => ({
...forward,
serviceRunning: forward.status === 1,
}));
};
const mapForwardApiItems = (items: ForwardApiItem[]): Forward[] => {
return (items || []).map((forward) => ({
id: forward.id,
name: forward.name,
tunnelId: forward.tunnelId ?? 0,
tunnelName: forward.tunnelName || "",
tunnelTrafficRatio: normalizeTunnelTrafficRatio(
forward.tunnelTrafficRatio,
),
inIp: forward.inIp || "",
inPort: forward.inPort ?? 0,
remoteAddr: forward.remoteAddr || "",
strategy: typeof forward.strategy === "string" ? forward.strategy : "fifo",
status: typeof forward.status === "number" ? forward.status : 0,
inFlow: forward.inFlow ?? 0,
outFlow: forward.outFlow ?? 0,
createdTime:
typeof forward.createdTime === "string" ? forward.createdTime : "",
userName:
typeof forward.userName === "string" ? forward.userName : undefined,
userId: typeof forward.userId === "number" ? forward.userId : undefined,
inx: typeof forward.inx === "number" ? forward.inx : undefined,
speedId:
typeof forward.speedId === "number" || forward.speedId === null
? forward.speedId
: undefined,
serviceRunning: forward.status === 1,
}));
};
export default function ForwardPage() {
const [loading, setLoading] = useState(true);
const [forwards, setForwards] = useState<Forward[]>([]);
@@ -603,6 +675,11 @@ export default function ForwardPage() {
null,
);
const [batchLoading, setBatchLoading] = useState(false);
const [batchProgress, setBatchProgress] = useState<BatchProgressState>({
active: false,
label: "",
percent: 0,
});
const [groupOrderMap, setGroupOrderMap] = useState<ForwardGroupOrderMap>({});
const [collapsedTunnelGroups, setCollapsedTunnelGroups] =
useState<ForwardGroupCollapsedMap>({});
@@ -959,7 +1036,7 @@ export default function ForwardPage() {
return Number.isFinite(shareId) && shareId > 0 ? shareId : null;
};
const mergeFederationShareFlow = async (
const mergeFederationShareFlow = useCallback(async (
forwardsData: Forward[],
): Promise<Forward[]> => {
if (forwardsData.length === 0) {
@@ -1135,7 +1212,7 @@ export default function ForwardPage() {
} catch {
return forwardsData;
}
};
}, []);
const getForwardDisplayFlow = (forward: Forward): number => {
const directFlow = (forward.inFlow || 0) + (forward.outFlow || 0);
@@ -1147,10 +1224,6 @@ export default function ForwardPage() {
return forward.federationShareFlow || 0;
};
useEffect(() => {
loadData();
}, []);
// 切换显示模式并保存到localStorage
const handleViewModeChange = () => {
const newMode = viewMode === "grouped" ? "direct" : "grouped";
@@ -1161,12 +1234,59 @@ export default function ForwardPage() {
} catch {}
};
const applyForwardList = useCallback(
async (items: Forward[]) => {
const mergedForwards = await mergeFederationShareFlow(
normalizeForwardItems(items),
);
setForwards(mergedForwards);
const currentUserId = JwtUtil.getUserIdFromToken();
const { order, fromDatabase } = buildForwardOrder(
mergedForwards,
currentUserId,
);
setForwardOrder(order);
if (fromDatabase) {
saveOrder(FORWARD_ORDER_KEY, order);
}
},
[mergeFederationShareFlow],
);
const refreshForwardList = useCallback(
async (lod = true) => {
if (lod) {
setLoading(true);
}
try {
const forwardsRes = await getForwardList();
if (forwardsRes.code === 0) {
await applyForwardList(mapForwardApiItems(forwardsRes.data || []));
} else {
toast.error(forwardsRes.msg || "获取规则列表失败");
}
} catch {
toast.error("获取规则列表失败");
} finally {
if (lod) {
setLoading(false);
}
}
},
[applyForwardList],
);
// 加载所有数据
const loadData = async (lod = true) => {
const loadData = useCallback(async (lod = true) => {
setLoading(lod);
try {
const [forwardsRes, tunnelsRes, speedLimitsRes] = await Promise.all([
getForwardList(),
const [tunnelsRes, speedLimitsRes] = await Promise.all([
userTunnel(),
getSpeedLimitList(),
]);
@@ -1175,32 +1295,7 @@ export default function ForwardPage() {
getNodeList(),
]);
if (forwardsRes.code === 0) {
const forwardsData =
forwardsRes.data?.map((forward: any) => ({
...forward,
serviceRunning: forward.status === 1,
})) || [];
const mergedForwards = await mergeFederationShareFlow(forwardsData);
setForwards(mergedForwards);
// 初始化拖拽排序顺序
const currentUserId = JwtUtil.getUserIdFromToken();
const { order, fromDatabase } = buildForwardOrder(
mergedForwards,
currentUserId,
);
setForwardOrder(order);
if (fromDatabase) {
saveOrder(FORWARD_ORDER_KEY, order);
}
} else {
toast.error(forwardsRes.msg || "获取规则列表失败");
}
await refreshForwardList(false);
if (tunnelsRes.code === 0) {
setTunnels(tunnelsRes.data || []);
@@ -1226,7 +1321,11 @@ export default function ForwardPage() {
} finally {
setLoading(false);
}
};
}, [refreshForwardList]);
useEffect(() => {
loadData();
}, [loadData]);
// 表单验证
const noLimitSpeedLimitIds = useMemo(() => {
@@ -1385,7 +1484,20 @@ export default function ForwardPage() {
if (res.code === 0) {
toast.success("删除成功");
setDeleteModalOpen(false);
loadData();
setForwardToDelete(null);
setForwards((prev) => prev.filter((forward) => forward.id !== forwardToDelete.id));
setForwardOrder((prev) => {
const next = prev.filter((id) => id !== forwardToDelete.id);
saveOrder(FORWARD_ORDER_KEY, next);
return next;
});
setSelectedIds((prev) => {
const next = new Set(prev);
next.delete(forwardToDelete.id);
return next;
});
} else {
// 删除失败,询问是否强制删除
const confirmed = window.confirm(
@@ -1398,7 +1510,22 @@ export default function ForwardPage() {
if (forceRes.code === 0) {
toast.success("强制删除成功");
setDeleteModalOpen(false);
loadData();
setForwardToDelete(null);
setForwards((prev) =>
prev.filter((forward) => forward.id !== forwardToDelete.id),
);
setForwardOrder((prev) => {
const next = prev.filter((id) => id !== forwardToDelete.id);
saveOrder(FORWARD_ORDER_KEY, next);
return next;
});
setSelectedIds((prev) => {
const next = new Set(prev);
next.delete(forwardToDelete.id);
return next;
});
} else {
toast.error(forceRes.msg || "强制删除失败");
}
@@ -1496,7 +1623,7 @@ export default function ForwardPage() {
}
toast.success(isEdit ? "修改成功" : "创建成功");
setModalOpen(false);
loadData();
await refreshForwardList(false);
} else {
toast.error(res.msg || "操作失败");
}
@@ -2091,7 +2218,7 @@ export default function ForwardPage() {
toast.success("导入执行完成");
await loadData(false);
await refreshForwardList(false);
} catch {
toast.error("导入过程中发生错误");
} finally {
@@ -2296,6 +2423,11 @@ export default function ForwardPage() {
const handleBatchDelete = async () => {
if (selectedIds.size === 0) return;
setBatchLoading(true);
setBatchProgress({
active: true,
label: `正在删除 ${selectedIds.size} 项规则...`,
percent: 30,
});
try {
const outcome = await executeForwardBatchDelete(Array.from(selectedIds));
@@ -2306,14 +2438,20 @@ export default function ForwardPage() {
}
if (outcome.shouldRefresh) {
setBatchProgress({
active: true,
label: outcome.progressLabel || "正在刷新规则列表...",
percent: outcome.progressPercent ?? 75,
});
setSelectedIds(new Set());
setSelectMode(false);
if (outcome.closeDeleteModal) {
setBatchDeleteModalOpen(false);
}
loadData(false);
await refreshForwardList(false);
}
} finally {
setBatchProgress({ active: false, label: "", percent: 0 });
setBatchLoading(false);
}
};
@@ -2321,6 +2459,11 @@ export default function ForwardPage() {
const handleBatchToggleService = async (enable: boolean) => {
if (selectedIds.size === 0) return;
setBatchLoading(true);
setBatchProgress({
active: true,
label: `正在${enable ? "启用" : "停用"} ${selectedIds.size} 项规则...`,
percent: 30,
});
try {
const outcome = await executeForwardBatchToggleService(
Array.from(selectedIds),
@@ -2334,11 +2477,17 @@ export default function ForwardPage() {
}
if (outcome.shouldRefresh) {
setBatchProgress({
active: true,
label: outcome.progressLabel || "正在刷新规则列表...",
percent: outcome.progressPercent ?? 75,
});
setSelectedIds(new Set());
setSelectMode(false);
loadData(false);
await refreshForwardList(false);
}
} finally {
setBatchProgress({ active: false, label: "", percent: 0 });
setBatchLoading(false);
}
};
@@ -2346,6 +2495,11 @@ export default function ForwardPage() {
const handleBatchRedeploy = async () => {
if (selectedIds.size === 0) return;
setBatchLoading(true);
setBatchProgress({
active: true,
label: `正在重新下发 ${selectedIds.size} 项规则...`,
percent: 30,
});
try {
const outcome = await executeForwardBatchRedeploy(
Array.from(selectedIds),
@@ -2358,11 +2512,17 @@ export default function ForwardPage() {
}
if (outcome.shouldRefresh) {
setBatchProgress({
active: true,
label: outcome.progressLabel || "正在刷新规则列表...",
percent: outcome.progressPercent ?? 75,
});
setSelectedIds(new Set());
setSelectMode(false);
loadData(false);
await refreshForwardList(false);
}
} finally {
setBatchProgress({ active: false, label: "", percent: 0 });
setBatchLoading(false);
}
};
@@ -2370,6 +2530,11 @@ export default function ForwardPage() {
const handleBatchChangeTunnel = async () => {
if (selectedIds.size === 0 || !batchTargetTunnelId) return;
setBatchLoading(true);
setBatchProgress({
active: true,
label: `正在为 ${selectedIds.size} 项规则切换隧道...`,
percent: 30,
});
try {
const outcome = await executeForwardBatchChangeTunnel(
Array.from(selectedIds),
@@ -2383,6 +2548,11 @@ export default function ForwardPage() {
}
if (outcome.shouldRefresh) {
setBatchProgress({
active: true,
label: outcome.progressLabel || "正在刷新规则列表...",
percent: outcome.progressPercent ?? 75,
});
setSelectedIds(new Set());
setSelectMode(false);
if (outcome.closeChangeTunnelModal) {
@@ -2391,9 +2561,10 @@ export default function ForwardPage() {
if (outcome.resetTargetTunnel) {
setBatchTargetTunnelId(null);
}
loadData(false);
await refreshForwardList(false);
}
} finally {
setBatchProgress({ active: false, label: "", percent: 0 });
setBatchLoading(false);
}
};
@@ -2597,6 +2768,9 @@ export default function ForwardPage() {
existingGroup.tunnelMap.set(tunnelKey, {
tunnelKey,
tunnelName,
tunnelTrafficRatio: normalizeTunnelTrafficRatio(
forward.tunnelTrafficRatio,
),
items: [forward],
});
@@ -2611,6 +2785,15 @@ export default function ForwardPage() {
) {
existingTunnelGroup.tunnelName = tunnelName;
}
if (
normalizeTunnelTrafficRatio(existingTunnelGroup.tunnelTrafficRatio) === 1 &&
normalizeTunnelTrafficRatio(forward.tunnelTrafficRatio) !== 1
) {
existingTunnelGroup.tunnelTrafficRatio = normalizeTunnelTrafficRatio(
forward.tunnelTrafficRatio,
);
}
});
const groups = Array.from(userGroupMap.values()).map((group) => {
@@ -2787,6 +2970,14 @@ export default function ForwardPage() {
</svg>
</Button>
<span className={titleClassName}>{tunnel.tunnelName}</span>
<Chip
className="h-5 border-none bg-secondary/15 px-1.5 text-[11px] font-semibold text-secondary-700"
color="secondary"
size="sm"
variant="flat"
>
{formatTunnelTrafficRatio(tunnel.tunnelTrafficRatio)}
</Chip>
</div>
<div className="flex items-center gap-2">
<span className={countClassName}>{tunnel.items.length} 条规则</span>
@@ -3182,6 +3373,9 @@ export default function ForwardPage() {
<span className="font-medium text-secondary-700">
{forward.tunnelName}
</span>
<span className="ml-1 text-secondary-600/80">
{formatTunnelTrafficRatio(forward.tunnelTrafficRatio)}
</span>
</Chip>
</TableCell>
<TableCell className="max-w-[220px]">
@@ -3337,7 +3531,7 @@ export default function ForwardPage() {
{forward.name}
</h3>
<p className="text-xs text-default-500 truncate">
{normalizeForwardTunnelName(forward.tunnelName)}
{`${normalizeForwardTunnelName(forward.tunnelName)} · ${formatTunnelTrafficRatio(forward.tunnelTrafficRatio)}`}
</p>
</div>
<div className="flex items-center gap-1.5 ml-2">
@@ -3817,6 +4011,23 @@ export default function ForwardPage() {
</div>
</div>
{batchProgress.active && (
<div className="mb-4">
<Alert
color="primary"
description={batchProgress.label}
variant="flat"
/>
<Progress
aria-label={batchProgress.label}
className="mt-3"
color="primary"
size="sm"
value={batchProgress.percent}
/>
</div>
)}
{/* 根据显示模式渲染不同内容 */}
{compactMode ? (
viewMode === "grouped" ? (
@@ -13,6 +13,8 @@ export interface ForwardBatchActionOutcome {
toastVariant: "success" | "error";
toastMessage: string;
shouldRefresh: boolean;
progressPercent?: number;
progressLabel?: string;
closeDeleteModal?: boolean;
closeChangeTunnelModal?: boolean;
resetTargetTunnel?: boolean;
@@ -63,6 +65,8 @@ export const executeForwardBatchDelete = async (
return {
...buildBatchToast(summary, `成功删除 ${summary.successCount} 项`),
shouldRefresh: true,
progressPercent: 100,
progressLabel: `删除完成:成功 ${summary.successCount} 项`,
closeDeleteModal: true,
};
} catch (error) {
@@ -103,6 +107,8 @@ export const executeForwardBatchToggleService = async (
: `成功停用 ${summary.successCount} 项`,
),
shouldRefresh: true,
progressPercent: 100,
progressLabel: `${enable ? "启用" : "停用"}完成:成功 ${summary.successCount} 项`,
};
} catch (error) {
return {
@@ -132,6 +138,8 @@ export const executeForwardBatchRedeploy = async (
return {
...buildBatchToast(summary, `成功重新下发 ${summary.successCount} 项`),
shouldRefresh: true,
progressPercent: 100,
progressLabel: `重新下发完成:成功 ${summary.successCount} 项`,
};
} catch (error) {
return {
@@ -165,6 +173,8 @@ export const executeForwardBatchChangeTunnel = async (
return {
...buildBatchToast(summary, `成功换隧道 ${summary.successCount} 项`),
shouldRefresh: true,
progressPercent: 100,
progressLabel: `批量换隧道完成:成功 ${summary.successCount} 项`,
closeChangeTunnelModal: true,
resetTargetTunnel: true,
};
+332 -13
View File
@@ -65,10 +65,15 @@ import { useNodeRealtime } from "@/pages/node/use-node-realtime";
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
import { loadStoredOrder, saveOrder } from "@/utils/order-storage";
const NODE_FALLBACK_REFRESH_INTERVAL_MS = 15000;
interface Node {
id: number;
inx?: number;
name: string;
remark?: string;
tags?: string;
expiryTime?: number;
ip: string;
serverIp: string;
serverIpV4?: string;
@@ -103,6 +108,9 @@ interface Node {
interface NodeForm {
id: number | null;
name: string;
remark: string;
tags: string;
expiryTime: number;
serverHost: string;
serverIpV4: string;
serverIpV6: string;
@@ -116,6 +124,95 @@ interface NodeForm {
socks: number; // 0 关 1 开
}
const formatNodeExpiry = (timestamp?: number): string => {
if (!timestamp || timestamp <= 0) return "永久有效";
return new Date(timestamp).toLocaleString();
};
const EXPIRING_SOON_DAYS = 7;
type NodeExpiryState = "permanent" | "healthy" | "expiringSoon" | "expired";
type NodeFilterMode = "all" | "expiringSoon" | "expired" | "withExpiry";
const getNodeExpiryMeta = (timestamp?: number) => {
if (!timestamp || timestamp <= 0) {
return {
state: "permanent" as NodeExpiryState,
label: "永久有效",
tone: "default" as const,
accentClassName: "",
bannerClassName: "",
isHighlighted: false,
sortWeight: 3,
};
}
const diffMs = timestamp - Date.now();
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
if (diffDays <= 0) {
return {
state: "expired" as NodeExpiryState,
label: "已过期",
tone: "danger" as const,
accentClassName:
"border-red-300/80 bg-red-50/70 shadow-red-100 dark:border-red-500/40 dark:bg-red-950/20",
bannerClassName:
"bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-300",
isHighlighted: true,
sortWeight: 0,
};
}
if (diffDays <= EXPIRING_SOON_DAYS) {
return {
state: "expiringSoon" as NodeExpiryState,
label: diffDays === 1 ? "明天到期" : `${diffDays}天后到期`,
tone: "warning" as const,
accentClassName:
"border-amber-300/80 bg-amber-50/80 shadow-amber-100 dark:border-amber-500/40 dark:bg-amber-950/20",
bannerClassName:
"bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300",
isHighlighted: true,
sortWeight: 1,
};
}
return {
state: "healthy" as NodeExpiryState,
label: `${diffDays}天后到期`,
tone: "success" as const,
accentClassName: "",
bannerClassName: "",
isHighlighted: false,
sortWeight: 2,
};
};
const normalizeNodeTags = (tags?: string): string[] => {
if (!tags) return [];
return tags
.split(",")
.map((tag) => tag.trim())
.filter(Boolean);
};
const mergeNodeRealtimeState = (
incomingNode: Node,
existingNode?: Node,
): Node => {
return {
...incomingNode,
systemInfo: existingNode?.systemInfo ?? incomingNode.systemInfo ?? null,
copyLoading: existingNode?.copyLoading ?? incomingNode.copyLoading ?? false,
upgradeLoading:
existingNode?.upgradeLoading ?? incomingNode.upgradeLoading ?? false,
rollbackLoading:
existingNode?.rollbackLoading ?? incomingNode.rollbackLoading ?? false,
};
};
const SortableItem = ({
id,
children,
@@ -165,6 +262,10 @@ export default function NodePage() {
"node-search-keyword",
"",
);
const [nodeFilterMode, setNodeFilterMode] = useLocalStorageState<NodeFilterMode>(
"node-expiry-filter-mode",
"all",
);
const [isSearchVisible, setIsSearchVisible] = useState(false);
const [dialogVisible, setDialogVisible] = useState(false);
const [dialogTitle, setDialogTitle] = useState("");
@@ -178,6 +279,9 @@ export default function NodePage() {
const [form, setForm] = useState<NodeForm>({
id: null,
name: "",
remark: "",
tags: "",
expiryTime: 0,
serverHost: "",
serverIpV4: "",
serverIpV6: "",
@@ -251,8 +355,13 @@ export default function NodePage() {
});
// 加载节点列表
const loadNodes = useCallback(async () => {
setLoading(true);
const loadNodes = useCallback(async (options?: { silent?: boolean }) => {
const silent = options?.silent ?? false;
if (!silent) {
setLoading(true);
}
try {
const res = await getNodeList();
@@ -270,7 +379,13 @@ export default function NodePage() {
copyLoading: false,
}));
setNodeList(nodesData);
setNodeList((prev) => {
const previousById = new Map(prev.map((node) => [node.id, node]));
return nodesData.map((node) =>
mergeNodeRealtimeState(node, previousById.get(node.id)),
);
});
// 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序
const hasDbOrdering = nodesData.some(
@@ -292,12 +407,18 @@ export default function NodePage() {
);
}
} else {
toast.error(res.msg || "加载节点列表失败");
if (!silent) {
toast.error(res.msg || "加载节点列表失败");
}
}
} catch {
toast.error("网络错误,请重试");
if (!silent) {
toast.error("网络错误,请重试");
}
} finally {
setLoading(false);
if (!silent) {
setLoading(false);
}
}
}, []);
@@ -370,7 +491,7 @@ export default function NodePage() {
}
};
const { wsConnected, wsConnecting } = useNodeRealtime({
const { wsConnected, wsConnecting, usingPollingFallback } = useNodeRealtime({
onMessage: handleWebSocketMessage,
});
@@ -378,6 +499,22 @@ export default function NodePage() {
loadNodes();
}, [loadNodes]);
useEffect(() => {
if (!usingPollingFallback) {
return;
}
void loadNodes({ silent: true });
const interval = window.setInterval(() => {
void loadNodes({ silent: true });
}, NODE_FALLBACK_REFRESH_INTERVAL_MS);
return () => {
window.clearInterval(interval);
};
}, [loadNodes, usingPollingFallback]);
// 格式化速度
const formatSpeed = (bytesPerSecond: number): string => {
if (bytesPerSecond === 0) return "0 B/s";
@@ -586,6 +723,9 @@ export default function NodePage() {
setForm({
id: node.id,
name: node.name,
remark: node.remark || "",
tags: node.tags || "",
expiryTime: node.expiryTime || 0,
serverHost: normalizedHost,
serverIpV4: normalizedV4,
serverIpV6: normalizedV6,
@@ -821,6 +961,9 @@ export default function NodePage() {
const { serverHost, ...rest } = form;
const data = {
...rest,
remark: form.remark.trim(),
tags: form.tags.trim(),
expiryTime: form.expiryTime,
extraIPs: form.extraIPs,
serverIp:
form.serverIpV4?.trim() ||
@@ -842,6 +985,9 @@ export default function NodePage() {
? {
...n,
name: form.name,
remark: form.remark.trim(),
tags: form.tags.trim(),
expiryTime: form.expiryTime,
serverIp:
form.serverIpV4?.trim() ||
form.serverIpV6?.trim() ||
@@ -878,6 +1024,9 @@ export default function NodePage() {
setForm({
id: null,
name: "",
remark: "",
tags: "",
expiryTime: 0,
serverHost: "",
serverIpV4: "",
serverIpV6: "",
@@ -1010,6 +1159,20 @@ export default function NodePage() {
}),
);
const nodeExpiryStats = useMemo(() => {
return nodeList.reduce(
(acc, node) => {
const meta = getNodeExpiryMeta(node.expiryTime);
if (meta.state === "expired") acc.expired += 1;
if (meta.state === "expiringSoon") acc.expiringSoon += 1;
if (node.expiryTime && node.expiryTime > 0) acc.withExpiry += 1;
return acc;
},
{ expired: 0, expiringSoon: 0, withExpiry: 0 },
);
}, [nodeList]);
// 根据排序顺序获取节点列表
const sortedNodes = useMemo((): Node[] => {
if (!nodeList || nodeList.length === 0) return [];
@@ -1022,13 +1185,40 @@ export default function NodePage() {
filteredNodes = filteredNodes.filter(
(n) =>
(n.name && n.name.toLowerCase().includes(lowerKeyword)) ||
(n.remark && n.remark.toLowerCase().includes(lowerKeyword)) ||
(n.tags && n.tags.toLowerCase().includes(lowerKeyword)) ||
(n.serverIp && n.serverIp.toLowerCase().includes(lowerKeyword)) ||
(n.serverIpV4 && n.serverIpV4.toLowerCase().includes(lowerKeyword)) ||
(n.serverIpV6 && n.serverIpV6.toLowerCase().includes(lowerKeyword)),
);
}
if (nodeFilterMode !== "all") {
filteredNodes = filteredNodes.filter((node) => {
const expiryMeta = getNodeExpiryMeta(node.expiryTime);
switch (nodeFilterMode) {
case "expiringSoon":
return expiryMeta.state === "expiringSoon";
case "expired":
return expiryMeta.state === "expired";
case "withExpiry":
return !!node.expiryTime && node.expiryTime > 0;
default:
return true;
}
});
}
const sortedByDb = [...filteredNodes].sort((a, b) => {
const expiryDiff =
getNodeExpiryMeta(a.expiryTime).sortWeight -
getNodeExpiryMeta(b.expiryTime).sortWeight;
if (expiryDiff !== 0) {
return expiryDiff;
}
const aInx = a.inx ?? 0;
const bInx = b.inx ?? 0;
@@ -1060,7 +1250,7 @@ export default function NodePage() {
}
return sortedByDb;
}, [nodeList, nodeOrder, searchKeyword]);
}, [nodeFilterMode, nodeList, nodeOrder, searchKeyword]);
const sortableNodeIds = useMemo(
() => sortedNodes.map((n) => n.id),
@@ -1070,15 +1260,41 @@ export default function NodePage() {
return (
<AnimatedPage className="px-3 lg:px-6 py-8">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between mb-6 gap-3">
<div className="flex-1 max-w-sm flex items-center gap-2">
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center">
<SearchBar
isVisible={isSearchVisible}
placeholder="搜索节点名称或IP"
placeholder="搜索名称、IP、备注或标签"
value={searchKeyword}
onChange={setSearchKeyword}
onClose={() => setIsSearchVisible(false)}
onOpen={() => setIsSearchVisible(true)}
/>
<Select
className="w-full max-w-sm lg:w-56"
label="到期筛选"
selectedKeys={[nodeFilterMode]}
size="sm"
variant="bordered"
onSelectionChange={(keys) => {
const selected = Array.from(keys)[0] as NodeFilterMode | undefined;
setNodeFilterMode(selected || "all");
}}
>
<SelectItem key="all" textValue="全部节点">
全部节点
</SelectItem>
<SelectItem key="expiringSoon" textValue="7天内到期">
7天内到期 ({nodeExpiryStats.expiringSoon})
</SelectItem>
<SelectItem key="expired" textValue="已过期">
已过期 ({nodeExpiryStats.expired})
</SelectItem>
<SelectItem key="withExpiry" textValue="已设置到期时间">
已设置到期时间 ({nodeExpiryStats.withExpiry})
</SelectItem>
</Select>
</div>
<div className="min-h-9 min-w-0 max-w-full overflow-x-auto touch-pan-x">
@@ -1162,7 +1378,11 @@ export default function NodePage() {
className="mb-4"
color="warning"
description={
wsConnecting ? "监控连接中..." : "监控连接已断开,正在重连..."
wsConnecting
? "监控连接中..."
: usingPollingFallback
? "监控连接已断开,已切换为列表自动刷新兜底模式。"
: "监控连接已断开,正在重连..."
}
variant="flat"
/>
@@ -1185,13 +1405,14 @@ export default function NodePage() {
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{sortedNodes.map((node) => {
const isRemoteNode = node.isRemote === 1;
const expiryMeta = getNodeExpiryMeta(node.expiryTime);
return (
<SortableItem key={node.id} id={node.id}>
{(listeners) => (
<Card
key={node.id}
className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden"
className={`group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden ${expiryMeta.accentClassName}`}
>
<CardHeader className="pb-2 md:pb-2">
<div className="flex justify-between items-start w-full">
@@ -1259,8 +1480,60 @@ export default function NodePage() {
{getRemoteSyncErrorMessage(node.syncError)}
</div>
)}
{expiryMeta.isHighlighted && (
<div
className={`mb-3 flex items-center justify-between gap-3 rounded-md px-2 py-1.5 text-xs font-medium ${expiryMeta.bannerClassName}`}
>
<span>节点到期提醒</span>
<span>{expiryMeta.label}</span>
</div>
)}
{/* 基础信息 */}
<div className="space-y-2 mb-4">
{(node.remark?.trim() || node.tags?.trim()) && (
<div className="rounded-lg border border-divider/80 bg-default-50/80 px-3 py-2">
{node.remark?.trim() && (
<div className="text-xs leading-5 text-default-700 whitespace-pre-wrap break-words">
{node.remark.trim()}
</div>
)}
{normalizeNodeTags(node.tags).length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{normalizeNodeTags(node.tags).map((tag) => (
<Chip
key={`${node.id}-${tag}`}
className="text-[11px]"
color="secondary"
size="sm"
variant="flat"
>
#{tag}
</Chip>
))}
</div>
)}
</div>
)}
{node.expiryTime && node.expiryTime > 0 && (
<div className="flex justify-between text-sm">
<span className="text-default-600">到期时间</span>
<div className="text-right ml-2">
<div className="text-xs text-warning-700 dark:text-warning-400">
{formatNodeExpiry(node.expiryTime)}
</div>
<div className="mt-1">
<Chip
className="text-[11px]"
color={expiryMeta.tone}
size="sm"
variant="flat"
>
{expiryMeta.label}
</Chip>
</div>
</div>
</div>
)}
<div className="flex justify-between items-center text-sm min-w-0">
<span className="text-default-600 flex-shrink-0">
IP
@@ -1562,8 +1835,54 @@ export default function NodePage() {
}
/>
<Textarea
description="可记录供应商、用途、续费说明等补充信息"
label="备注"
maxRows={4}
minRows={3}
placeholder="例如: 搬瓦工年付,2026-12 续费,日本中转"
value={form.remark}
variant="bordered"
onChange={(e) =>
setForm((prev) => ({ ...prev, remark: e.target.value }))
}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
description="多个标签用逗号分隔,可用于搜索过滤"
label="标签"
placeholder="例如: 年付,日本中转,测试机"
value={form.tags}
variant="bordered"
onChange={(e) =>
setForm((prev) => ({ ...prev, tags: e.target.value }))
}
/>
<Input
description="留空表示永久有效,可用于记录节点到期时间"
label="到期时间"
type="datetime-local"
value={
form.expiryTime > 0
? new Date(form.expiryTime).toISOString().slice(0, 16)
: ""
}
variant="bordered"
onChange={(e) =>
setForm((prev) => ({
...prev,
expiryTime: e.target.value
? new Date(e.target.value).getTime()
: 0,
}))
}
/>
</div>
<Input
description="可选:不带协议、不带端口。至少填写一个 IPv4/IPv6/域名"
description="可选:不带协议、不带端口。建议在 IPv4 和 IPv6 都未填写时使用。至少填写一个 IPv4/IPv6/域名"
errorMessage={errors.serverHost}
isInvalid={!!errors.serverHost}
label="服务器域名/主机名"
@@ -15,6 +15,11 @@ interface UseNodeRealtimeOptions {
enabled?: boolean;
}
const MAX_STANDARD_RECONNECT_ATTEMPTS = 5;
const STANDARD_RECONNECT_DELAY_MS = 3000;
const MAX_STANDARD_RECONNECT_DELAY_MS = 15000;
const FALLBACK_RECONNECT_DELAY_MS = 30000;
const getRealtimeWsUrl = (): string => {
const baseUrl =
axios.defaults.baseURL ||
@@ -34,14 +39,13 @@ export const useNodeRealtime = ({
}: UseNodeRealtimeOptions) => {
const [wsConnected, setWsConnected] = useState(false);
const [wsConnecting, setWsConnecting] = useState(false);
const [usingPollingFallback, setUsingPollingFallback] = useState(false);
const websocketRef = useRef<WebSocket | null>(null);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const reconnectAttemptsRef = useRef(0);
const onMessageRef = useRef(onMessage);
const maxReconnectAttempts = 5;
useEffect(() => {
onMessageRef.current = onMessage;
}, [onMessage]);
@@ -58,6 +62,7 @@ export const useNodeRealtime = ({
reconnectAttemptsRef.current = 0;
setWsConnected(false);
setWsConnecting(false);
setUsingPollingFallback(false);
if (!websocketRef.current) {
return;
@@ -103,6 +108,7 @@ export const useNodeRealtime = ({
reconnectAttemptsRef.current = 0;
setWsConnected(true);
setWsConnecting(false);
setUsingPollingFallback(false);
};
websocketRef.current.onmessage = (event) => {
@@ -122,15 +128,27 @@ export const useNodeRealtime = ({
setWsConnected(false);
setWsConnecting(false);
if (!enabled || reconnectAttemptsRef.current >= maxReconnectAttempts) {
if (!enabled) {
return;
}
reconnectAttemptsRef.current += 1;
const exhaustedStandardRetries =
reconnectAttemptsRef.current >= MAX_STANDARD_RECONNECT_ATTEMPTS;
setUsingPollingFallback(exhaustedStandardRetries);
const reconnectDelay = exhaustedStandardRetries
? FALLBACK_RECONNECT_DELAY_MS
: Math.min(
STANDARD_RECONNECT_DELAY_MS * reconnectAttemptsRef.current,
MAX_STANDARD_RECONNECT_DELAY_MS,
);
reconnectTimerRef.current = setTimeout(() => {
reconnectTimerRef.current = null;
connect();
}, 3000 * reconnectAttemptsRef.current);
}, reconnectDelay);
};
} catch {
setWsConnected(false);
@@ -153,6 +171,7 @@ export const useNodeRealtime = ({
return {
wsConnected,
wsConnecting,
usingPollingFallback,
reconnectRealtime: connect,
disconnectRealtime: disconnect,
};
+2 -14
View File
@@ -9,6 +9,7 @@ import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import { Switch } from "@/shadcn-bridge/heroui/switch";
import { reinitializeBaseURL } from "@/api/network";
import { getConfigByName, updateConfig } from "@/api";
import { BackIcon } from "@/components/icons";
import {
type UpdateReleaseChannel,
getUpdateReleaseChannel,
@@ -168,20 +169,7 @@ export const SettingsPage = () => {
variant="light"
onPress={() => navigate(-1)}
>
<svg
aria-hidden="true"
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M15 19l-7-7 7-7"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
<BackIcon className="w-5 h-5" />
</Button>
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
面板设置
+156 -44
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo, useRef } from "react";
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
import toast from "react-hot-toast";
import {
DndContext,
@@ -36,6 +36,7 @@ import { Spinner } from "@/shadcn-bridge/heroui/spinner";
import { Divider } from "@/shadcn-bridge/heroui/divider";
import { Alert } from "@/shadcn-bridge/heroui/alert";
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
import { Progress } from "@/shadcn-bridge/heroui/progress";
import {
createTunnel,
getTunnelList,
@@ -115,6 +116,29 @@ interface TunnelForm {
status: number;
}
interface BatchProgressState {
active: boolean;
label: string;
percent: number;
}
const TUNNEL_ORDER_KEY = "tunnel-order";
const mapTunnelApiItems = (items: any[]): Tunnel[] => {
return (items || []).map((tunnel) => ({
...tunnel,
inx: tunnel.inx ?? 0,
inNodeId: Array.isArray(tunnel.inNodeId) ? tunnel.inNodeId : [],
outNodeId: Array.isArray(tunnel.outNodeId) ? tunnel.outNodeId : [],
chainNodes: Array.isArray(tunnel.chainNodes) ? tunnel.chainNodes : [],
inIp: tunnel.inIp || "",
flow: tunnel.flow ?? 1,
trafficRatio: tunnel.trafficRatio ?? 1,
status: typeof tunnel.status === "number" ? tunnel.status : 0,
createdTime: tunnel.createdTime || "",
}));
};
export default function TunnelPage() {
const [loading, setLoading] = useState(true);
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
@@ -203,6 +227,11 @@ export default function TunnelPage() {
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);
const [batchLoading, setBatchLoading] = useState(false);
const [batchProgress, setBatchProgress] = useState<BatchProgressState>({
active: false,
label: "",
percent: 0,
});
useEffect(() => {
return () => {
@@ -211,60 +240,78 @@ export default function TunnelPage() {
};
}, []);
useEffect(() => {
loadData();
const applyTunnelList = useCallback((items: Tunnel[]) => {
setTunnels(items);
const hasDbOrdering = items.some(
(tunnel) => tunnel.inx !== undefined && tunnel.inx !== 0,
);
if (hasDbOrdering) {
const dbOrder = [...items]
.sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0))
.map((tunnel) => tunnel.id);
setTunnelOrder(dbOrder);
return;
}
setTunnelOrder(
loadStoredOrder(
TUNNEL_ORDER_KEY,
items.map((tunnel) => tunnel.id),
),
);
}, []);
// 加载所有数据
const loadData = async () => {
setLoading(true);
const refreshTunnelList = useCallback(async (withLoading = true) => {
if (withLoading) {
setLoading(true);
}
try {
const [tunnelsRes, nodesRes] = await Promise.all([
getTunnelList(),
getNodeList(),
]);
const tunnelsRes = await getTunnelList();
if (tunnelsRes.code === 0) {
const tunnelsData: Tunnel[] = (tunnelsRes.data || []).map((t: any) => ({
...t,
inx: t.inx ?? 0,
}));
setTunnels(tunnelsData);
// 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序
const hasDbOrdering = tunnelsData.some(
(t) => t.inx !== undefined && t.inx !== 0,
);
if (hasDbOrdering) {
const dbOrder = [...tunnelsData]
.sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0))
.map((t) => t.id);
setTunnelOrder(dbOrder);
} else {
setTunnelOrder(
loadStoredOrder(
"tunnel-order",
tunnelsData.map((t) => t.id),
),
);
}
applyTunnelList(mapTunnelApiItems(tunnelsRes.data || []));
} else {
toast.error(tunnelsRes.msg || "获取隧道列表失败");
}
} catch {
toast.error("获取隧道列表失败");
} finally {
if (withLoading) {
setLoading(false);
}
}
}, [applyTunnelList]);
const refreshNodes = useCallback(async () => {
try {
const nodesRes = await getNodeList();
if (nodesRes.code === 0) {
setNodes(nodesRes.data || []);
} else {
}
} catch {}
}, []);
// 加载所有数据
const loadData = useCallback(async () => {
setLoading(true);
try {
await Promise.all([refreshTunnelList(false), refreshNodes()]);
} catch {
toast.error("加载数据失败");
} finally {
setLoading(false);
}
};
}, [refreshNodes, refreshTunnelList]);
useEffect(() => {
loadData();
}, [loadData]);
// 表单验证
const validateForm = (): boolean => {
@@ -327,7 +374,21 @@ export default function TunnelPage() {
toast.success("删除成功");
setDeleteModalOpen(false);
setTunnelToDelete(null);
loadData();
setTunnels((prev) =>
prev.filter((tunnel) => tunnel.id !== tunnelToDelete.id),
);
setTunnelOrder((prev) => {
const next = prev.filter((id) => id !== tunnelToDelete.id);
saveOrder(TUNNEL_ORDER_KEY, next);
return next;
});
setSelectedIds((prev) => {
const next = new Set(prev);
next.delete(tunnelToDelete.id);
return next;
});
} else {
toast.error(response.msg || "删除失败");
}
@@ -500,7 +561,7 @@ export default function TunnelPage() {
if (response.code === 0) {
toast.success(isEdit ? "更新成功" : "创建成功");
setModalOpen(false);
loadData();
await refreshTunnelList(false);
} else {
toast.error(response.msg || (isEdit ? "更新失败" : "创建失败"));
}
@@ -725,7 +786,7 @@ export default function TunnelPage() {
setTunnelOrder(newOrder);
saveOrder("tunnel-order", newOrder);
saveOrder(TUNNEL_ORDER_KEY, newOrder);
// 持久化到数据库
try {
@@ -779,6 +840,11 @@ export default function TunnelPage() {
const handleBatchDelete = async () => {
if (selectedIds.size === 0) return;
setBatchLoading(true);
setBatchProgress({
active: true,
label: `正在删除 ${selectedIds.size} 条隧道...`,
percent: 30,
});
try {
const res = await batchDeleteTunnels(Array.from(selectedIds));
@@ -787,21 +853,39 @@ export default function TunnelPage() {
if (result.failCount === 0) {
toast.success(`成功删除 ${result.successCount} 项`);
setBatchProgress({
active: true,
label: `删除完成:成功 ${result.successCount} 项`,
percent: 100,
});
setTunnels((prev) => prev.filter((tunnel) => !selectedIds.has(tunnel.id)));
setTunnelOrder((prev) => {
const next = prev.filter((id) => !selectedIds.has(id));
saveOrder(TUNNEL_ORDER_KEY, next);
return next;
});
} else {
toast.error(
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
);
setBatchProgress({
active: true,
label: `部分完成:成功 ${result.successCount} 项,正在刷新列表...`,
percent: 75,
});
await refreshTunnelList(false);
}
setSelectedIds(new Set());
setSelectMode(false);
setBatchDeleteModalOpen(false);
loadData();
} else {
toast.error(res.msg || "删除失败");
}
} catch (error) {
toast.error(extractApiErrorMessage(error, "删除失败"));
} finally {
setBatchProgress({ active: false, label: "", percent: 0 });
setBatchLoading(false);
}
};
@@ -809,6 +893,11 @@ export default function TunnelPage() {
const handleBatchRedeploy = async () => {
if (selectedIds.size === 0) return;
setBatchLoading(true);
setBatchProgress({
active: true,
label: `正在重新下发 ${selectedIds.size} 条隧道...`,
percent: 30,
});
try {
const res = await batchRedeployTunnels(Array.from(selectedIds));
@@ -824,13 +913,19 @@ export default function TunnelPage() {
}
setSelectedIds(new Set());
setSelectMode(false);
loadData();
setBatchProgress({
active: true,
label: `重新下发完成:成功 ${result.successCount} 项,正在刷新列表...`,
percent: 100,
});
await refreshTunnelList(false);
} else {
toast.error(res.msg || "下发失败");
}
} catch (error) {
toast.error(extractApiErrorMessage(error, "下发失败"));
} finally {
setBatchProgress({ active: false, label: "", percent: 0 });
setBatchLoading(false);
}
};
@@ -1038,6 +1133,23 @@ export default function TunnelPage() {
</div>
</div>
{batchProgress.active && (
<div className="mb-4">
<Alert
color="primary"
description={batchProgress.label}
variant="flat"
/>
<Progress
aria-label={batchProgress.label}
className="mt-3"
color="primary"
size="sm"
value={batchProgress.percent}
/>
</div>
)}
{/* 隧道卡片网格 */}
{tunnels.length > 0 ? (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
@@ -1435,7 +1547,7 @@ export default function TunnelPage() {
</div>
<Textarea
description="支持多个IP,每行一个地址,为空时使用入口节点ip"
description="入口IP由系统自动从入口节点采集,无需手动填写。支持多个IP,每行一个地址,留空则使用入口节点IP"
errorMessage={errors.inIp}
isInvalid={!!errors.inIp}
label="入口IP"
+170 -32
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from "react";
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
import toast from "react-hot-toast";
import { parseDate } from "@internationalized/date";
@@ -66,6 +66,10 @@ import {
} from "@/components/icons";
import { PageLoadingState } from "@/components/page-state";
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
import {
removeItemsById,
replaceItemById,
} from "@/utils/list-state";
// 工具函数
const formatFlow = (value: number, unit: string = "bytes"): string => {
@@ -122,6 +126,43 @@ const calculateTunnelUsedFlow = (tunnel: UserTunnel): number => {
return inFlow + outFlow;
};
const USER_SEARCH_DEBOUNCE_MS = 250;
const normalizeUserItem = (item: Partial<User>): User => {
return {
id: Number(item.id ?? 0),
name: item.name,
user: String(item.user ?? ""),
status: Number(item.status ?? 0),
flow: Number(item.flow ?? 0),
num: Number(item.num ?? 0),
expTime: item.expTime,
flowResetTime: item.flowResetTime ?? 0,
createdTime: item.createdTime,
inFlow: Number(item.inFlow ?? 0),
outFlow: Number(item.outFlow ?? 0),
};
};
const normalizeUserTunnelItem = (item: Partial<UserTunnel>): UserTunnel => {
return {
id: Number(item.id ?? 0),
userId: Number(item.userId ?? 0),
tunnelId: Number(item.tunnelId ?? 0),
tunnelName: String(item.tunnelName ?? ""),
status: Number(item.status ?? 0),
flow: Number(item.flow ?? 0),
num: Number(item.num ?? 0),
expTime: Number(item.expTime ?? 0),
flowResetTime: Number(item.flowResetTime ?? 0),
speedId: item.speedId ?? null,
speedLimitName: item.speedLimitName,
inFlow: Number(item.inFlow ?? 0),
outFlow: Number(item.outFlow ?? 0),
tunnelFlow: item.tunnelFlow,
};
};
export default function UserPage() {
// 状态管理
const [users, setUsers] = useState<User[]>([]);
@@ -136,6 +177,7 @@ export default function UserPage() {
size: 10,
total: 0,
});
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 用户表单相关状态
const {
@@ -259,26 +301,25 @@ export default function UserPage() {
return !speedLimitIds.has(speedId);
};
// 生命周期
useEffect(() => {
loadUsers();
loadTunnels();
loadSpeedLimits();
loadUserGroups();
}, [pagination.current, pagination.size, searchKeyword]);
// 数据加载函数
const loadUsers = async () => {
const loadUsers = useCallback(
async (keywordOverride?: string) => {
setLoading(true);
try {
const keyword = keywordOverride ?? searchKeyword;
const response = await getAllUsers({
current: pagination.current,
size: pagination.size,
keyword: searchKeyword,
keyword,
});
if (response.code === 0) {
setUsers(Array.isArray(response.data) ? response.data : []);
const nextUsers = Array.isArray(response.data)
? response.data.map((item) => normalizeUserItem(item))
: [];
setUsers(nextUsers);
setPagination((prev) => ({ ...prev, total: nextUsers.length }));
} else {
toast.error(response.msg || "获取用户列表失败");
}
@@ -287,9 +328,11 @@ export default function UserPage() {
} finally {
setLoading(false);
}
};
},
[pagination.current, pagination.size],
);
const loadTunnels = async () => {
const loadTunnels = useCallback(async () => {
try {
const response = await getTunnelList();
@@ -297,9 +340,9 @@ export default function UserPage() {
setTunnels(Array.isArray(response.data) ? response.data : []);
}
} catch {}
};
}, []);
const loadSpeedLimits = async () => {
const loadSpeedLimits = useCallback(async () => {
try {
const response = await getSpeedLimitList();
@@ -315,9 +358,9 @@ export default function UserPage() {
setSpeedLimits(speedLimitList);
}
} catch {}
};
}, []);
const loadUserGroups = async () => {
const loadUserGroups = useCallback(async () => {
try {
const response = await getUserGroupList();
@@ -325,15 +368,19 @@ export default function UserPage() {
setUserGroups(Array.isArray(response.data) ? response.data : []);
}
} catch {}
};
}, []);
const loadUserTunnels = async (userId: number) => {
const loadUserTunnels = useCallback(async (userId: number) => {
setTunnelListLoading(true);
try {
const response = await getUserTunnelList({ userId });
if (response.code === 0) {
setUserTunnels(Array.isArray(response.data) ? response.data : []);
setUserTunnels(
Array.isArray(response.data)
? response.data.map((item) => normalizeUserTunnelItem(item))
: [],
);
} else {
toast.error(response.msg || "获取隧道权限列表失败");
}
@@ -342,12 +389,52 @@ export default function UserPage() {
} finally {
setTunnelListLoading(false);
}
};
}, []);
// 生命周期
useEffect(() => {
void loadTunnels();
void loadSpeedLimits();
void loadUserGroups();
}, [loadSpeedLimits, loadTunnels, loadUserGroups]);
useEffect(() => {
void loadUsers();
}, [loadUsers]);
useEffect(() => {
if (searchDebounceRef.current) {
clearTimeout(searchDebounceRef.current);
}
searchDebounceRef.current = setTimeout(() => {
setPagination((prev) => {
if (prev.current === 1) {
void loadUsers(searchKeyword);
return prev;
}
return { ...prev, current: 1 };
});
}, USER_SEARCH_DEBOUNCE_MS);
return () => {
if (searchDebounceRef.current) {
clearTimeout(searchDebounceRef.current);
searchDebounceRef.current = null;
}
};
}, [loadUsers, searchKeyword]);
// 用户管理操作
const handleSearch = () => {
if (searchDebounceRef.current) {
clearTimeout(searchDebounceRef.current);
searchDebounceRef.current = null;
}
setPagination((prev) => ({ ...prev, current: 1 }));
loadUsers();
void loadUsers(searchKeyword);
};
const handleAdd = () => {
@@ -405,9 +492,17 @@ export default function UserPage() {
if (response.code === 0) {
toast.success("删除成功");
loadUsers();
onDeleteModalClose();
setUsers((prev) => removeItemsById(prev, [userToDelete.id]));
setPagination((prev) => ({
...prev,
total: Math.max(prev.total - 1, 0),
}));
setUserToDelete(null);
if (currentUser?.id === userToDelete.id) {
setCurrentUser(null);
setUserTunnels([]);
}
} else {
toast.error(response.msg || "删除失败");
}
@@ -442,7 +537,26 @@ export default function UserPage() {
if (response.code === 0) {
toast.success(isEdit ? "更新成功" : "创建成功");
onUserModalClose();
loadUsers();
const responseUser = normalizeUserItem((response as any).data || {});
if (
isEdit &&
responseUser.id > 0 &&
pagination.current === 1 &&
!searchKeyword.trim()
) {
setUsers((prev) => replaceItemById(prev, responseUser));
} else if (
!isEdit &&
responseUser.id > 0 &&
pagination.current === 1 &&
!searchKeyword.trim()
) {
setUsers((prev) => [responseUser, ...prev].slice(0, pagination.size));
setPagination((prev) => ({ ...prev, total: prev.total + 1 }));
} else {
await loadUsers();
}
} else {
toast.error(response.msg || (isEdit ? "更新失败" : "创建失败"));
}
@@ -500,7 +614,7 @@ export default function UserPage() {
}
toast.success(response.msg || "分配成功");
setBatchTunnelSelections(new Map());
loadUserTunnels(currentUser.id);
await loadUserTunnels(currentUser.id);
} else {
toast.error(response.msg || "分配失败");
}
@@ -546,7 +660,19 @@ export default function UserPage() {
toast.success("更新成功");
onEditTunnelModalClose();
if (currentUser) {
loadUserTunnels(currentUser.id);
const nextTunnel = normalizeUserTunnelItem({
...editTunnelForm,
speedId: normalizeSpeedId(editTunnelForm.speedId),
speedLimitName:
normalizeSpeedId(editTunnelForm.speedId) !== null
? speedLimits.find(
(speedLimit) =>
speedLimit.id === normalizeSpeedId(editTunnelForm.speedId),
)?.name
: undefined,
});
setUserTunnels((prev) => replaceItemById(prev, nextTunnel));
}
} else {
toast.error(response.msg || "更新失败");
@@ -572,7 +698,7 @@ export default function UserPage() {
if (response.code === 0) {
toast.success("删除成功");
if (currentUser) {
loadUserTunnels(currentUser.id);
setUserTunnels((prev) => removeItemsById(prev, [tunnelToDelete.id]));
}
onDeleteTunnelModalClose();
setTunnelToDelete(null);
@@ -603,8 +729,14 @@ export default function UserPage() {
if (response.code === 0) {
toast.success("流量重置成功");
onResetFlowModalClose();
const targetUserId = userToReset.id;
setUsers((prev) =>
prev.map((user) =>
user.id === targetUserId ? { ...user, inFlow: 0, outFlow: 0 } : user,
),
);
setUserToReset(null);
loadUsers(); // 重新加载用户列表
} else {
toast.error(response.msg || "重置失败");
}
@@ -634,10 +766,16 @@ export default function UserPage() {
if (response.code === 0) {
toast.success("隧道流量重置成功");
onResetTunnelFlowModalClose();
const targetTunnelId = tunnelToReset.id;
setUserTunnels((prev) =>
prev.map((userTunnel) =>
userTunnel.id === targetTunnelId
? { ...userTunnel, inFlow: 0, outFlow: 0 }
: userTunnel,
),
);
setTunnelToReset(null);
if (currentUser) {
loadUserTunnels(currentUser.id); // 重新加载隧道权限列表
}
} else {
toast.error(response.msg || "重置失败");
}
@@ -38,15 +38,33 @@ function parseDateText(value: string) {
return null;
}
const matched = trimmed.match(/^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})$/);
const digitsOnly = trimmed.replace(/\D/g, "");
if (!matched) {
if (/^\d+$/.test(trimmed)) {
if (digitsOnly.length !== 8) {
return null;
}
const year = Number(digitsOnly.slice(0, 4));
const month = Number(digitsOnly.slice(4, 6));
const day = Number(digitsOnly.slice(6, 8));
if (!isValidCalendarDate(year, month, day)) {
return null;
}
return { day, month, year };
}
const numberParts = trimmed.match(/\d+/g);
if (!numberParts || numberParts.length !== 3 || numberParts[0].length !== 4) {
return null;
}
const year = Number(matched[1]);
const month = Number(matched[2]);
const day = Number(matched[3]);
const year = Number(numberParts[0]);
const month = Number(numberParts[1]);
const day = Number(numberParts[2]);
if (!isValidCalendarDate(year, month, day)) {
return null;
+33
View File
@@ -0,0 +1,33 @@
export const removeItemsById = <T extends { id: number }>(
items: T[],
ids: Iterable<number>,
): T[] => {
const idSet = new Set(ids);
return items.filter((item) => !idSet.has(item.id));
};
export const replaceItemById = <T extends { id: number }>(
items: T[],
nextItem: T,
): T[] => {
return items.map((item) => (item.id === nextItem.id ? nextItem : item));
};
export const upsertItemById = <T extends { id: number }>(
items: T[],
nextItem: T,
options?: { prepend?: boolean },
): T[] => {
const existingIndex = items.findIndex((item) => item.id === nextItem.id);
if (existingIndex >= 0) {
return replaceItemById(items, nextItem);
}
if (options?.prepend) {
return [nextItem, ...items];
}
return [...items, nextItem];
};
+1
View File
@@ -2,6 +2,7 @@
/// <reference types="vite-plugin-pwa/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE?: string;
readonly VITE_APP_VERSION?: string;
readonly VITE_GITHUB_REPO?: string;
}