Compare commits

...

46 Commits

Author SHA1 Message Date
sagit 32ee511eac fix(node): compact card metadata for mobile (#300)
## Summary
- move secondary node metadata into a compact info dropdown so cards fit
better on smaller screens
- replace the full connection status chip in the header with a status
dot while keeping detailed status inside the info panel
- keep remarks and renewal metadata accessible without permanently
increasing card height

## Testing
- not run
2026-03-10 20:13:11 +08:00
sagitchu f410640862 fix: calculate info popover placement relative to card container
Changed info popover position calculation to use the card container
as reference instead of viewport, ensuring consistent placement within
card boundaries.
2026-03-10 20:11:49 +08:00
sagitchu 6427b830ea refactor(node): improve card metadata popup with hover trigger
Entire-Checkpoint: 7a033056acc0
2026-03-10 17:19:21 +08:00
sagitchu 5e7bf3ba5c fix(node): compact card metadata for mobile
Entire-Checkpoint: b3c79b0fd2f9
2026-03-10 09:48:11 +08:00
sagit 27d6691232 feat: 支持节点 wss/https 并在失败时回退 ws(兼容旧 ws) (#294)
## 变更说明

本 PR 聚焦节点与面板通信协议兼容性增强:

- 支持节点优先使用 `wss`(WebSocket over TLS)连接后端
- 保持原有 `ws` 连接逻辑完全兼容,不破坏现有节点
- 当 `wss` 握手失败时自动回退到 `ws`
- HTTP 上报链路支持 `https/http` 自动识别与回退
- 兼容旧格式地址输入,避免已有配置失效

## 兼容性

- 旧节点配置(`ws`)可继续正常工作
- 新场景可直接使用 `wss/https`(含 CDN 场景)
- 回退策略可避免因 TLS/CDN 配置差异导致节点离线

## 额外说明

- 本 PR 仅包含协议兼容与回退相关改动
- 不包含安装脚本仓库指向调整相关提交
2026-03-09 18:21:25 +08:00
sagit cc4b8a916a Merge branch 'main' into pr/wss-https-fallback 2026-03-09 18:20:07 +08:00
sagit d9dd5131b2 feat(node): split node page into local and remote sections (#292)
## Summary
- split node page into local/remote sections (tab switch)
- keep per-tab search and count
- keep sorting/actions behavior under the selected section
- include remote usage detail rendering improvements on remote cards

## Scope
- only touches `vite-frontend/src/pages/node.tsx`

## Verify
- `cd vite-frontend && npm install && npm run build` passed locally

## Notes
- this PR intentionally avoids unrelated layout/sidebar customizations
and keeps changes focused on node page UX
2026-03-09 17:56:58 +08:00
sagitchu a98c9f4f59 merge(main): resolve node page conflicts with renewal UX updates 2026-03-09 17:52:18 +08:00
sagit 5cb935e0e5 fix(frontend): refine node renewal UI and filters (#299)
* style: restore Prettier formatting in renewal.ts

* fix(frontend): refine node renewal UI and filters

Entire-Checkpoint: 88c8ae47fb7d

* refactor(nodes): remove unused tags field from node model

* refactor(node): improve card layout and reorder elements

Entire-Checkpoint: fb89f3ebef5c
2026-03-09 08:37:57 +00:00
sagit 6f59e4be0c Merge branch 'main' into pr/wss-https-fallback 2026-03-09 12:38:47 +08:00
sagitchu 647446a2a2 fix(node): isolate tab search and selection state 2026-03-09 12:17:25 +08:00
sagit 42d6249af5 style: restore Prettier formatting in renewal.ts (#298) 2026-03-09 10:29:33 +08:00
sagit de9ab51def feat: node management enhancements and UX improvements (#297)
* feat(nodes): add renewal cycle and auto-advance scheduling

- Add renewal_cycle field to nodes for tracking paid vs free cycles
- Implement auto-advance scheduling when renewal is processed
- Add migration test for renewal_cycle column
- Update dashboard to show renewal cycle count
- Add renewal status display on node detail page

* fix(frontend): show rule count in compact view mode for forward pages

* style(frontend): format code and improve node card layout

Entire-Checkpoint: 08b760b76538

* fix: restore renewal.ts from main to resolve build errors
2026-03-09 10:01:55 +08:00
qimaoww a2ec08f033 fix(tunnel): restore upstream ipv6 address normalization logic 2026-03-09 03:33:40 +08:00
qimaoww f8809d73fb Merge branch 'main' into pr/wss-https-fallback 2026-03-08 22:38:26 +08:00
sagit 413081f72a feat(nodes): add renewal cycle and auto-advance scheduling (#296)
- Add renewal_cycle field to nodes for tracking paid vs free cycles
- Implement auto-advance scheduling when renewal is processed
- Add migration test for renewal_cycle column
- Update dashboard to show renewal cycle count
- Add renewal status display on node detail page
2026-03-08 22:05:25 +08:00
qimaoww e5339a8072 chore: improve websocket wss->ws fallback diagnostics 2026-03-08 20:07:37 +08:00
qimaoww fbb4d82a44 feat: add wss/https auto-detect with fallback for node-backend comm 2026-03-08 20:07:37 +08:00
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
Su-cyber-art 61b71a11c7 feat(node): split node page into local and remote sections 2026-03-08 18:45:56 +08:00
Su-cyber-art 4c69ff491d feat(node): render remote usage details on remote node cards
(cherry picked from commit 67294fa76422f994de8d95ec88f8f03e02ea6b9f)
2026-03-08 18:42:40 +08:00
Su-cyber-art 0ad4904e20 feat(node): split local and remote nodes into separate tabs
(cherry picked from commit 30793b2997040dd29ff5fe6397d0bcab174ecc9e)
2026-03-08 18:42:36 +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
sagit 7fb75baa73 feat: allow user custom inport with range validation (#274)
## Summary
- Allow users to specify custom inlet ports within a defined range
- Add port range validation for tunnel configurations
- Implement UI controls for custom port selection
- Add contract tests for custom port functionality
2026-03-05 17:04:58 +08:00
sagitchu 15e6cd69eb feat: allow user custom inport with range validation
Entire-Checkpoint: fcd76aac10e9
2026-03-05 17:03:19 +08:00
sagit f6eb88d75e refactor: 统一术语,将'转发'改为'规则' (#273)
- 更新所有页面中的'转发'术语为'规则'
- 统一UI文案,提升用户体验一致性
- 关联 #269 #271

Entire-Checkpoint: 5646e42aa33b
2026-03-05 15:07:42 +08:00
sagit f45b580984 fix: prevent effect execution when forwards list is empty (#272)
## Summary
- 添加空列表检查,防止在forwards为空时执行effect
- 避免不必要的groupOrder状态更新和持久化操作
2026-03-05 14:09:19 +08:00
sagitchu 4f50c47550 fix: prevent effect execution when forwards list is empty
Entire-Checkpoint: 69eeade13bf9
2026-03-05 14:07:25 +08:00
106 changed files with 38157 additions and 1143 deletions
@@ -0,0 +1,30 @@
# Session Context
Session ID: ses_32f7e3b05ffeK8IxNvZAZISGWO
Commit Message: 编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送
## Prompts
### Prompt 1
编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送
### Prompt 2
编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送
### Prompt 3
编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送
### Prompt 4
编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送
### Prompt 5
编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送
## Summary
已成功构建并推送 `ghcr.io/sagit-chu/vite-frontend:beta` (linux/amd64)
File diff suppressed because one or more lines are too long
@@ -0,0 +1,17 @@
编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送
---
编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送
---
编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送
---
编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送
---
编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送
@@ -0,0 +1 @@
已成功构建并推送 `ghcr.io/sagit-chu/vite-frontend:beta` (linux/amd64)
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
提交全部变更并且push,创建pr
@@ -0,0 +1,6 @@
✅ 已完成!
- 提交: `e7548e7` - style(frontend): format code and improve node card layout
- 推送到: `origin/opencode/quiet-canyon`
- PR 创建成功: https://github.com/Sagit-chu/flvx/pull/297
注意: 有 12 个未跟踪的 `.entire/metadata/ses_*` 文件,这些看起来是会话临时文件,未包含在提交中。
@@ -0,0 +1,17 @@
# Session Context
Session ID: ses_32fb9de62ffeXG3aOGlPDusGE8
Commit Message: 规则标签页下,精简模式,和非精简模式一样要显示规则数量
## Prompts
### Prompt 1
规则标签页下,精简模式,和非精简模式一样要显示规则数量
## Summary
I have successfully modified `vite-frontend/src/pages/forward.tsx` to include the rule count ("{sortedForwards.length} 条规则") when the forward page is in the `compactMode` (精简模式) and `viewMode === "grouped"`. This ensures consistency with the regular view modes where the total rule count is already displayed.
The fix was committed and pushed to the repository in the recent step:
`fix(frontend): show rule count in compact view mode for forward pages`
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
这个没改对,页面没变化,请检查
@@ -0,0 +1,4 @@
I have successfully modified `vite-frontend/src/pages/forward.tsx` to include the rule count ("{sortedForwards.length} 条规则") when the forward page is in the `compactMode` (精简模式) and `viewMode === "grouped"`. This ensures consistency with the regular view modes where the total rule count is already displayed.
The fix was committed and pushed to the repository in the recent step:
`fix(frontend): show rule count in compact view mode for forward pages`
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
节点添加备注和下次续费后,卡片会被撑开,和其他卡片高度不统一,有什么优化方法,或者隐藏一些内容
---
1. 统一限制卡片高度
2. 限制「备注」只显示 1 行
3. 下次续费只显示距离到期
@@ -0,0 +1,30 @@
我已经详细查阅了节点卡片 (`vite-frontend/src/pages/node.tsx`) 的代码结构。由于现在处于**计划模式 (Plan Mode)**,我目前只能读取和分析,不能直接修改代码。
我已经为你准备了详细的修改方案,这套方案同时满足你提到的 3 个要求:
### 修改计划:
**1. 统一限制卡片高度**
* **目标代码位置**:修改包裹 `Card` 的外层 `SortableItem` 的 `className`,以及 `Card` 内部的结构。
* **具体修改**:
* 给 `SortableItem` 内部渲染的 `div` 增加 `flex flex-col h-full`,使其占满整个 Grid 格子的高度。
* 给 `Card` 增加 `h-full flex flex-col` 类名。
* 在 `<CardBody>` 内部,将顶部的基础信息区域(包含备注、续费等)和底部的控制区域(系统监控及按钮)分开。给包含底部按钮区域的 div 添加 `mt-auto`,这样它就会自动被推到卡片的最底部,使所有的操作按钮和监控进度条在同一行水平对齐,从而强制拉伸中间的空白区域以保证卡片等高。
**2. 限制「备注」只显示 1 行**
* **目标代码位置**:备注内容显示的 div (`{node.remark.trim()}`)。
* **具体修改**:
* 将包含备注文本的 `div` 上的 `whitespace-pre-wrap break-words` 替换为 `line-clamp-1 break-all`。
* 在这个文本外层包一个 `title={node.remark.trim()}` 属性(原生 Tooltip),这样用户只要把鼠标悬停在被截断的备注上,就能看到完整的备注内容。
**3. 下次续费只显示距离到期**
* **目标代码位置**:`{node.expiryTime && node.expiryTime > 0 && node.renewalCycle && ( ... )}` 渲染逻辑内。
* **具体修改**:
* 精简现有的 3 行布局(具体时间、剩余时间 Chip、续费周期)。
* 将其压缩为 1 行显示,只展示带颜色的 Chip(里面包含了“还有 x 天”的 `expiryMeta.label`)。
* 为了保留完整信息,我们给这个 Chip 加上 `title` 属性,比如鼠标移上去会显示完整的:`下次续费:2026-12-01 (年付)`。
* 这会把原先极度占用垂直空间的 3 行内容直接缩减到单行。
---
如果这个方案符合你的预期,你可以退出计划模式(或者直接回复同意执行),我会在下一次交互中立即为你实施这些代码修改!
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
提交全部变更并且push,创建pr并且合并,合并后推送新tag,在已有的tag 递增 alpha版本
---
提交全部变更并且push,创建pr并且合并,合并后推送新tag,在已有的tag 递增 alpha版本
@@ -0,0 +1,3 @@
当前没有新的变更需要提交。工作目录干净,只有 `.entire/metadata/` 临时文件。最新 tag 是 `2.1.8-alpha4`。
如有新变更需要提交,请先进行修改。
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
提交全部变更并且push
---
合并pr,发布新tag 2.1.8-alpha3
@@ -0,0 +1,3 @@
完成:
- PR #293 已合并
- Tag `2.1.8-alpha3` 已发布
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
设计一下这个issue实现
https://github.com/Sagit-chu/flvx/issues/280
@@ -0,0 +1,52 @@
我看过 issue #280,也把前后端相关实现路径过了一遍。这个需求适合做成“后端补字段,前端直接消费”的小改动,不建议只在前端二次拼装。
- 现在 `/forward/list` 的数据来自 `h.repo.ListForwards()`,对应查询在 `go-backend/internal/store/repo/repository.go:700`,当前只 `LEFT JOIN tunnel` 取了 `tunnel_name`,还没把 `tunnel.traffic_ratio` 带出来。
- 前端规则页自己的 `Forward` 类型定义在 `vite-frontend/src/pages/forward.tsx:102`,`mapForwardApiItems` 在 `vite-frontend/src/pages/forward.tsx:507` 负责把接口结果映射成本地状态;这里目前也没有倍率字段。
- 规则列表的主要展示点有 3 处:表格隧道列 `vite-frontend/src/pages/forward.tsx:3306`、卡片副标题 `vite-frontend/src/pages/forward.tsx:3469`、分组标题 `vite-frontend/src/pages/forward.tsx:2919`。如果只改一处,体验会不一致。
- 隧道页已经有统一的倍率展示格式 `vite-frontend/src/pages/tunnel.tsx:1336`,直接沿用 `"{trafficRatio}x"` 最稳。
建议实现方案
- 后端优先:在 `go-backend/internal/store/repo/repository.go:705` 的 `fwdRow` 增加 `TrafficRatio float64`,并在 `Select(...)` 中补上 `COALESCE(tunnel.traffic_ratio, 1.0) AS traffic_ratio`。
- 后端返回:在构造 `item` 的地方把 `trafficRatio` 以 `tunnelTrafficRatio` 键返回,保持前端语义清晰,避免和规则自身字段混淆。
- 前端类型:给 `vite-frontend/src/api/types.ts:45` 的 `ForwardApiItem` 增加 `tunnelTrafficRatio?: number`;给 `vite-frontend/src/pages/forward.tsx:102` 的本地 `Forward` interface 同步增加该字段。
- 前端映射:在 `vite-frontend/src/pages/forward.tsx:507` 的 `mapForwardApiItems` 里做默认值归一化,比如没有值时回退到 `1`,避免旧数据或异常响应导致显示空白。
- 前端展示:优先把倍率放进“隧道名称展示块”里,而不是新增独立列。原因是当前表格列已经偏宽,issue 也接受“名称旁边显示”;卡片和分组视图也更容易保持一致。建议形式:
- 表格:`TunnelName` 主文案 + 小号次级 `1x`
- 卡片:副标题显示为 `隧道名 · 1x`
- 分组标题:`隧道名` 后加一个轻量 Chip / muted 文本 `1x`
为什么不建议纯前端拼装
- `forward.tsx` 虽然在 `loadData` 里已经请求了 `getTunnelList()` 和 `userTunnel()`,但 `refreshForwardList()` 是独立刷新的,规则状态刷新时不保证隧道缓存始终同步,容易出现倍率滞后。
- 当前分组逻辑主要基于规则数据本身,若依赖前端再按 `tunnelId` 做二次映射,会把一个本来单接口即可闭环的信息拆成两份状态,后面维护更麻烦。
- 后端本来已经 `JOIN tunnel` 取名字,只是顺手再取一个倍率字段,成本最低。
推荐实施步骤
- 后端:扩展 `/forward/list` 查询与返回结构,默认倍率回退 `1.0`。
- 合约测试:在 `go-backend/tests/contract/forward_contract_test.go` 增加对 `/forward/list` 返回 `tunnelTrafficRatio` 的断言,至少覆盖一个非默认值例如 `2.5`。
- 前端类型:同步更新 `ForwardApiItem` 和页面内 `Forward`。
- 前端映射:在 `mapForwardApiItems` 里规范化 `tunnelTrafficRatio`。
- 前端 UI:统一更新表格、卡片、分组标题 3 处展示,避免 direct/grouped 两种模式信息不一致。
- 回归验证:确认管理员和普通用户视角下,`/forward/list` 权限过滤不受影响,因为过滤逻辑在 `go-backend/internal/http/handler/handler.go:395` 之后仅按 `userId` 过滤,不会受新增字段影响。
风险和注意点
- `ForwardApiItem` 现在是宽松结构,短期不会因新增字段报错,但如果只改页面本地类型不改 API 类型,后面别处复用时会产生类型漂移。
- 分组标题 `ForwardTunnelGroup` 目前只有 `tunnelName`,如果要在分组头也显示倍率,最好给分组结构补一个 `tunnelTrafficRatio?: number`,并在构建分组时从首条规则或同组规则里带出。
- 需要统一倍率格式,避免出现 `1`, `1.0`, `1.00` 混用。建议做一个小格式化函数:整数显示 `1x`,小数保留必要精度如 `0.5x`、`1.25x`。
- 若某条规则关联的隧道已删除,后端 `COALESCE(..., 1.0)` 能保证页面仍可显示默认 `1x`,不会变成空。
验收标准
- `/api/v1/forward/list` 每条规则都带 `tunnelTrafficRatio`。
- 规则页 direct 表格模式能看到倍率。
- grouped 卡片模式和分组标题也能看到倍率,信息一致。
- 隧道缺失或旧数据情况下仍稳定显示 `1x`。
- 普通用户只能看到自己的规则,但倍率字段仍正常返回。
如果你要我下一步继续,我建议按这个实现顺序做:
1. 先补后端查询和 contract test
2. 再补前端类型与统一展示
3. 最后跑 `go test ./tests/contract/...` 和前端 build 做回归
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
提交全部变更并且push,并且修改pr内容关联
https://github.com/Sagit-chu/flvx/issues/246
@@ -0,0 +1,15 @@
已完成。
- 提交了当前代码变更,commit 是 `31ef861`
- 已 push 到远端分支 `opencode/quiet-canyon`
- 已更新 PR `https://github.com/Sagit-chu/flvx/pull/293`
- PR 标题改为 `feat(nodes): add node metadata and expiry reminders`
- PR 内容已改成这次节点备注/标签/到期提醒相关说明,并加入 `Closes #246` 关联 issue
补充一点:
- 工作区里还有未提交的 `.entire/metadata/...` 会话元数据文件,它们没有被加入这次提交
如果你要,我下一步可以继续:
1. 帮你检查这条 PR 的 diff 和描述是否还需要再精简
2. 帮你补充 reviewer-friendly 的 PR checklist
3. 帮你查看 CI / checks 状态
@@ -0,0 +1,14 @@
# Session Context
Session ID: ses_332cb6fa9ffehf0BwC73iSeZUR
Commit Message: 你好
## Prompts
### Prompt 1
你好
## Summary
你好!我在这儿。要我帮你看代码、改功能、排查报错,还是跑命令都可以。
@@ -0,0 +1,125 @@
{
"info": {
"id": "ses_332cb6fa9ffehf0BwC73iSeZUR",
"slug": "proud-pixel",
"projectID": "03c3dd2e5791a80ef100ef53744e013d779d671c",
"directory": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon",
"title": "问候",
"version": "1.2.21",
"summary": {
"additions": 0,
"deletions": 0,
"files": 0
},
"time": {
"created": 1772969300054,
"updated": 1772969309055
}
},
"messages": [
{
"info": {
"role": "user",
"time": {
"created": 1772969305082
},
"summary": {
"diffs": []
},
"agent": "build",
"model": {
"providerID": "api",
"modelID": "gpt-5.4"
},
"variant": "high",
"id": "msg_27c12106663448898b7e25726a28ce04",
"sessionID": "ses_332cb6fa9ffehf0BwC73iSeZUR"
},
"parts": [
{
"type": "text",
"text": "你好",
"time": {
"start": 1772969305.0665522,
"end": 1772969305.066553
},
"id": "prt_ccd34a3fa0015dQBm2oO5f6vPN",
"sessionID": "ses_332cb6fa9ffehf0BwC73iSeZUR",
"messageID": "msg_27c12106663448898b7e25726a28ce04"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1772969305091,
"completed": 1772969309611
},
"parentID": "msg_27c12106663448898b7e25726a28ce04",
"modelID": "gpt-5.4",
"providerID": "api",
"mode": "build",
"agent": "build",
"path": {
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon",
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon"
},
"cost": 0,
"tokens": {
"total": 17138,
"input": 2210,
"output": 80,
"reasoning": 45,
"cache": {
"read": 14848,
"write": 0
}
},
"variant": "high",
"finish": "stop",
"id": "msg_ccd34a4030014dwF4qM30pkR3k",
"sessionID": "ses_332cb6fa9ffehf0BwC73iSeZUR"
},
"parts": [
{
"type": "step-start",
"snapshot": "d23faf4e134b40b649cf9ec6e7a21a632c4c61b1",
"id": "prt_ccd34b520001tv5ON6ERsriuUJ",
"sessionID": "ses_332cb6fa9ffehf0BwC73iSeZUR",
"messageID": "msg_ccd34a4030014dwF4qM30pkR3k"
},
{
"type": "text",
"text": "你好!我在这儿。要我帮你看代码、改功能、排查报错,还是跑命令都可以。",
"time": {
"start": 1772969309550,
"end": 1772969309550
},
"id": "prt_ccd34b521001WeBauxJnuorFa5",
"sessionID": "ses_332cb6fa9ffehf0BwC73iSeZUR",
"messageID": "msg_ccd34a4030014dwF4qM30pkR3k"
},
{
"type": "step-finish",
"reason": "stop",
"snapshot": "d23faf4e134b40b649cf9ec6e7a21a632c4c61b1",
"cost": 0,
"tokens": {
"total": 17138,
"input": 2210,
"output": 80,
"reasoning": 45,
"cache": {
"read": 14848,
"write": 0
}
},
"id": "prt_ccd34b56f001tUZXybdhjVzB22",
"sessionID": "ses_332cb6fa9ffehf0BwC73iSeZUR",
"messageID": "msg_ccd34a4030014dwF4qM30pkR3k"
}
]
}
]
}
@@ -0,0 +1 @@
你好
@@ -0,0 +1 @@
你好!我在这儿。要我帮你看代码、改功能、排查报错,还是跑命令都可以。
@@ -0,0 +1,14 @@
# Session Context
Session ID: ses_332ce68b1ffe0R9vUQZ6yq5gUd
Commit Message: 在agent.md中加上,尽量使用能使用的skills和mcp
## Prompts
### Prompt 1
在agent.md中加上,尽量使用能使用的skills和mcp
## Summary
已添加到 `AGENTS.md` 的 CONVENTIONS 部分。
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
在agent.md中加上,尽量使用能使用的skills和mcp
@@ -0,0 +1 @@
已添加到 `AGENTS.md` 的 CONVENTIONS 部分。
@@ -0,0 +1,11 @@
# Session Context
Session ID: ses_332d83674ffeNMa9Z9UerwU3tq
Commit Message: 提交全部变更并且push,创建pr
## Prompts
### Prompt 1
提交全部变更并且push,创建pr
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
提交全部变更并且push,创建pr
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
提交全部变更并且push,创建pr
@@ -0,0 +1,8 @@
已完成!
**提交摘要:**
- 10 个文件变更(939 行新增,173 行删除)
- 新增计划文档 `plans/020-ajax-no-refresh-ux.md`
- 新增工具函数 `vite-frontend/src/utils/list-state.ts`
**PR 已创建:** https://github.com/Sagit-chu/flvx/pull/293
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
You are auditing the FLVX frontend for GitHub issue #276 (AJAX no-refresh UX improvements). Read-only only. Focus on existing implementation status for: 1) dashboard traffic auto-refresh, 2) node status real-time updates, 3) form submissions without full page reload, 4) local/partial list refresh after CRUD/toggle, 5) batch operation progress UI, 6) search/filter instant response. Inspect relevant files under vite-frontend/src/pages and src/api. Return a concise report with: a) what already exists, b) what is missing or partial, c) specific file paths and functions to touch for an implementation plan, d) notable risks or edge cases. Do not edit anything.
@@ -0,0 +1,230 @@
Concise audit for issue #276, focused on `vite-frontend/src/pages` and `src/api`.
a) What already exists
1. Dashboard traffic auto-refresh
- Dashboard data is loaded via async API calls in a hook, not by page reload:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/dashboard/use-dashboard-data.ts`
- `useDashboardData()` calls `getUserPackageInfo()` and `getAnnouncement()`.
- The 24h traffic chart is already rendered from `statisticsFlows`:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/dashboard.tsx`
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/dashboard/components/flow-chart-card.tsx`
2. Node status real-time updates
- This is the strongest existing area.
- WebSocket realtime hook already exists:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node/use-node-realtime.ts`
- `useNodeRealtime()`
- Offline debounce exists:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node/use-node-offline-timers.ts`
- Node page consumes websocket messages and updates local state without reload:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx`
- `handleWebSocketMessage()`
- `loadNodes()`
- Realtime also includes system info updates and upgrade progress updates.
3. Form submissions without full page reload
- Broadly already implemented across main CRUD pages using async handlers + modal close + state refresh.
- Examples:
- Nodes: `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx`
- `handleSubmit()`
- Tunnels: `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/tunnel.tsx`
- `handleSubmit()`
- Forwards: `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/forward.tsx`
- `handleSubmit()`
- Users: `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/user.tsx`
- `handleSubmitUser()`
- I did not find page reload patterns in these CRUD pages (`location.reload` / `window.location.reload` absent there).
4. Local/partial list refresh after CRUD/toggle
- Some local optimistic/partial updates already exist:
- Node delete updates local state directly:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx`
- `confirmDelete()`
- Node edit updates local item directly:
- `handleSubmit()` edit branch
- Forward service toggle uses optimistic local update + rollback on failure:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/forward.tsx`
- `handleServiceToggle()`
- Sorting persists locally and updates item `inx` in-memory after success:
- Node `handleDragEnd()`
- Tunnel `handleDragEnd()`
- Forward `handleDragEnd()`
5. Batch operation progress UI
- Node upgrade has actual per-node progress UI:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx`
- `upgradeProgress` state
- websocket `type === "upgrade_progress"` handling in `handleWebSocketMessage()`
- `<Progress />` render around lines ~1307+
- Forward batch actions are factored and return structured outcomes:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/forward/batch-actions.ts`
6. Search/filter instant response
- Instant client-side filtering already exists in:
- Nodes: `sortedNodes` memo filters by `searchKeyword`
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx`
- Tunnels: `sortedTunnels`
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/tunnel.tsx`
- Forwards: `orderedForwards`
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/forward.tsx`
- Forward also has instant client-side filter dropdowns (`filterUserId`, `filterTunnelId`).
b) What is missing or partial
1. Dashboard traffic auto-refresh: missing
- `useDashboardData()` only fetches once on mount.
- No polling, websocket, refetch trigger, focus refresh, or manual refresh hook.
- So “AJAX no-refresh” exists only for initial load, not ongoing refresh.
2. Node realtime: partial but good
- Realtime status/system-info exists only for nodes.
- It reconnects with limited retries (5 attempts) and then stops.
- `loadNodes()` initially sets `systemInfo: null`, so a full reload can temporarily wipe metrics until WS catches up again.
- No obvious fallback polling if websocket dies permanently.
3. Form submission no full reload: mostly done
- CRUD forms are already async and modal-driven.
- But post-submit refresh style is inconsistent:
- some do local patch,
- many do full page-level list refetch (`loadData()`, `loadUsers()`, `loadNodes()`).
4. Local/partial list refresh after CRUD/toggle: partial/inconsistent
- Forward toggle is good and local.
- Node edit/delete are relatively local.
- But many operations still refetch the whole page dataset:
- Tunnel create/update/delete -> `loadData()`
- Forward create/update/delete -> `loadData()`
- User create/update/delete -> `loadUsers()`
- User tunnel assignment/update/remove -> `loadUserTunnels()`
- This is AJAX and avoids full browser reload, but not “partial/local refresh” in the stronger UX sense.
5. Batch operation progress UI: largely missing outside node upgrade
- Tunnel batch delete/redeploy: only loading spinner/toast + full reload, no per-item progress/result panel.
- Forward batch actions: summarized via toast and optional refresh, but no visible progress timeline/progress list.
- User batch tunnel assignment: no progress UI beyond loading state.
6. Search/filter instant response: mixed
- Nodes/tunnels/forwards are instant client-side.
- Users are not fully instant:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/user.tsx`
- `loadUsers()` is server-driven and tied to `useEffect([pagination.current, pagination.size, searchKeyword])`
- Enter key also manually calls `handleSearch()`
- So user search is AJAX but not purely local/instant in the same way.
c) Specific file paths and functions to touch for an implementation plan
Primary targets
1. Dashboard auto-refresh
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/dashboard/use-dashboard-data.ts`
- `useDashboardData()`
- extract `loadPackageData()` / `loadAnnouncement()` into stable callbacks
- add polling or visibility/focus-based refetch
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/dashboard.tsx`
- if adding manual refresh indicator/button, this is the page entry
- API source already exists:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/api/index.ts`
- `getUserPackageInfo()`, `getAnnouncement()`
2. Node realtime hardening / fallback
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node/use-node-realtime.ts`
- `useNodeRealtime()`
- retry policy, reconnect strategy, possible fallback refresh trigger
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx`
- `handleWebSocketMessage()`
- `loadNodes()`
- `handleSubmit()`
- `confirmDelete()`
- `handleBatchDelete()`
3. Replace whole-list reloads with local/partial updates
- Tunnels:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/tunnel.tsx`
- `loadData()`
- `handleSubmit()`
- `confirmDelete()`
- `handleBatchDelete()`
- `handleBatchRedeploy()`
- Forwards:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/forward.tsx`
- `loadData()`
- `handleSubmit()`
- `confirmDelete()`
- `handleServiceToggle()`
- `handleBatchDelete()`
- `handleBatchToggleService()`
- `handleBatchRedeploy()`
- `handleBatchChangeTunnel()`
- Users:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/user.tsx`
- `loadUsers()`
- `handleSubmitUser()`
- `handleConfirmDelete()`
- `loadUserTunnels()`
- `handleBatchAssignTunnel()`
- `handleUpdateTunnel()`
- `handleConfirmRemoveTunnel()`
- `handleConfirmResetFlow()`
- `handleConfirmResetTunnelFlow()`
4. Batch progress/result UX
- Nodes already have a model to copy:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx`
- `upgradeProgress`
- websocket `upgrade_progress`
- Forward batch abstraction:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/forward/batch-actions.ts`
- likely extend return shape if backend can expose richer progress/events
- API endpoints involved:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/api/index.ts`
- `batchDeleteForwards()`
- `batchPauseForwards()`
- `batchResumeForwards()`
- `batchRedeployForwards()`
- `batchChangeTunnel()`
- `batchDeleteTunnels()`
- `batchRedeployTunnels()`
- `batchDeleteNodes()`
- `batchUpgradeNodes()`
5. Search/filter behavior normalization
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/user.tsx`
- `loadUsers()`
- `handleSearch()`
- search input around lines ~718+
- Nodes/tunnels/forwards already have memo-based client filtering and are the pattern to follow.
d) Notable risks / edge cases
1. Dashboard polling could spam toasts
- `checkExpirationNotifications()` runs inside package load flow.
- If polling is added naively, expiration toasts could repeat or localStorage notification keys could churn unexpectedly.
2. Dashboard chart semantics
- `statisticsFlows` appears to be a snapshot list, not a stream API.
- Frequent polling may redraw aggressively and may need dedupe/staleness handling.
3. Node websocket auth/security
- Realtime URL sends token in query string (`secret=${getToken()}`) in:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node/use-node-realtime.ts`
- Any changes should be careful not to break backend expectations.
4. Realtime/local state merge complexity
- `loadNodes()` normalizes nodes and clears `systemInfo`.
- If partial refetch is introduced, merging fetched node metadata with live websocket state must avoid wiping online metrics/progress.
5. Batch APIs may not provide granular progress
- Current batch helpers mostly return summary counts, not per-item progress.
- True progress UI may require backend support, not only frontend work.
6. Forward toggle consistency
- `handleServiceToggle()` optimistically flips `serviceRunning` and later patches `status`.
- If backend returns partial failure or delayed actual state, UI may drift until a refresh.
7. User search behavior currently depends on backend pagination/search
- Moving to fully instant local filtering may conflict with server-side pagination semantics and large datasets.
Bottom line
- Already present: async modal CRUD, no browser reloads, strong node realtime, optimistic forward toggle, instant search/filter on node/tunnel/forward pages.
- Missing/partial for #276: dashboard traffic auto-refresh, consistent partial/local state updates after CRUD, batch progress UI outside node upgrade, and more unified instant-search UX on the user page.
@@ -0,0 +1,41 @@
# Session Context
Session ID: ses_332fdb603ffeYxhpzgAB64sSTN
Commit Message: 开始实施
## Prompts
### Prompt 1
https://github.com/Sagit-chu/flvx/issues/276
计划一下这个issue实现
### Prompt 2
写出 plans/020-ajax-no-refresh-ux.md 的完整实施计划内容
### Prompt 3
开始实施
## Summary
我先把 Phase 1 落地了,重点做了仪表盘自动刷新和节点实时连接兜底,并把计划文档同步更新了。
- `vite-frontend/src/pages/dashboard/use-dashboard-data.ts:55` 现在加入了 5 秒静默轮询、标签页可见性恢复刷新,以及更稳定的加载回调拆分,避免每次刷新都走整套初始化逻辑。
- `vite-frontend/src/pages/dashboard/use-dashboard-data.ts:82` 把过期提醒的去重 key 改成基于用户/隧道到期信息的稳定标识,轮询刷新流量时不会反复弹 toast。
- `vite-frontend/src/pages/node/use-node-realtime.ts:13` 补强了 WebSocket 重连策略:前几次按递增延迟重连,超过阈值后进入长间隔 fallback 模式,而不是彻底停掉。
- `vite-frontend/src/pages/node.tsx:68` 增加了节点列表 merge 逻辑;fallback 刷新时会保留已有 `systemInfo`、升级/复制/回退 loading 状态,避免列表刷新把实时信息清空。
- `vite-frontend/src/pages/node.tsx:1160` 节点页断线提示现在会区分“正在重连”和“已切换为列表自动刷新兜底模式”。
- `vite-frontend/src/vite-env.d.ts:4` 补上了 `VITE_API_BASE` 类型声明,顺手消掉这次构建会碰到的环境变量类型报错。
- `plans/020-ajax-no-refresh-ux.md:19` 已把本次完成的 3 个 Phase 1 checklist 勾掉。
验证情况:
- 运行了 `cd vite-frontend && npm install`
- 运行了 `cd vite-frontend && npm run build`
- 前端构建已通过
这次额外生成了依赖文件:
- `vite-frontend/package-lock.json`
下一步自然就是继续 Phase 2,把 `forward` / `tunnel` / `user` 的“操作后整页数据重拉”改成局部 patch。你要我继续的话,我建议从 `vite-frontend/src/pages/forward.tsx` 开始,因为它已经有一部分本地更新模式,改起来最顺。
File diff suppressed because one or more lines are too long
@@ -0,0 +1,10 @@
https://github.com/Sagit-chu/flvx/issues/276
计划一下这个issue实现
---
写出 plans/020-ajax-no-refresh-ux.md 的完整实施计划内容
---
开始实施
@@ -0,0 +1,19 @@
我先把 Phase 1 落地了,重点做了仪表盘自动刷新和节点实时连接兜底,并把计划文档同步更新了。
- `vite-frontend/src/pages/dashboard/use-dashboard-data.ts:55` 现在加入了 5 秒静默轮询、标签页可见性恢复刷新,以及更稳定的加载回调拆分,避免每次刷新都走整套初始化逻辑。
- `vite-frontend/src/pages/dashboard/use-dashboard-data.ts:82` 把过期提醒的去重 key 改成基于用户/隧道到期信息的稳定标识,轮询刷新流量时不会反复弹 toast。
- `vite-frontend/src/pages/node/use-node-realtime.ts:13` 补强了 WebSocket 重连策略:前几次按递增延迟重连,超过阈值后进入长间隔 fallback 模式,而不是彻底停掉。
- `vite-frontend/src/pages/node.tsx:68` 增加了节点列表 merge 逻辑;fallback 刷新时会保留已有 `systemInfo`、升级/复制/回退 loading 状态,避免列表刷新把实时信息清空。
- `vite-frontend/src/pages/node.tsx:1160` 节点页断线提示现在会区分“正在重连”和“已切换为列表自动刷新兜底模式”。
- `vite-frontend/src/vite-env.d.ts:4` 补上了 `VITE_API_BASE` 类型声明,顺手消掉这次构建会碰到的环境变量类型报错。
- `plans/020-ajax-no-refresh-ux.md:19` 已把本次完成的 3 个 Phase 1 checklist 勾掉。
验证情况:
- 运行了 `cd vite-frontend && npm install`
- 运行了 `cd vite-frontend && npm run build`
- 前端构建已通过
这次额外生成了依赖文件:
- `vite-frontend/package-lock.json`
下一步自然就是继续 Phase 2,把 `forward` / `tunnel` / `user` 的“操作后整页数据重拉”改成局部 patch。你要我继续的话,我建议从 `vite-frontend/src/pages/forward.tsx` 开始,因为它已经有一部分本地更新模式,改起来最顺。
+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.
@@ -6,6 +6,7 @@ import (
"fmt"
"net"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
@@ -359,7 +360,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 +383,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 +527,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 +1480,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 +1509,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 +1523,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{} {
@@ -1572,13 +1613,14 @@ func buildForwarderNodes(targets []string) []map[string]interface{} {
}
func processServerAddress(serverAddr string) string {
serverAddr = strings.TrimSpace(serverAddr)
serverAddr = normalizeServerAddressInput(serverAddr)
if serverAddr == "" {
return serverAddr
}
if strings.HasPrefix(serverAddr, "[") {
return serverAddr
}
idx := strings.LastIndex(serverAddr, ":")
if idx < 0 {
if looksLikeIPv6(serverAddr) {
@@ -1597,6 +1639,27 @@ func processServerAddress(serverAddr string) string {
return serverAddr
}
func normalizeServerAddressInput(serverAddr string) string {
serverAddr = strings.TrimSpace(serverAddr)
if serverAddr == "" {
return serverAddr
}
if idx := strings.Index(serverAddr, "://"); idx > 0 {
if parsed, err := url.Parse(serverAddr); err == nil {
if host := strings.TrimSpace(parsed.Host); host != "" {
return host
}
}
serverAddr = serverAddr[idx+3:]
}
if idx := strings.IndexAny(serverAddr, "/?#"); idx >= 0 {
serverAddr = serverAddr[:idx]
}
return strings.TrimSpace(serverAddr)
}
func looksLikeIPv6(address string) bool {
return strings.Count(address, ":") >= 2
}
@@ -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: "[::]"}
@@ -231,3 +420,68 @@ func TestBuildForwardServiceConfigs_BindIPAlreadyContainsPort(t *testing.T) {
}
}
}
func TestProcessServerAddress_StripsURLSchemeAndPath(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{
name: "https with path",
in: "https://panel.example.com:8443/api/v1",
want: "panel.example.com:8443",
},
{
name: "wss with query",
in: "wss://panel.example.com:443/system-info?x=1",
want: "panel.example.com:443",
},
{
name: "http without port",
in: "http://panel.example.com",
want: "panel.example.com",
},
{
name: "manual host with trailing path",
in: "panel.example.com:8080/path",
want: "panel.example.com:8080",
},
}
for _, tt := range tests {
if got := processServerAddress(tt.in); got != tt.want {
t.Fatalf("%s: expected %q, got %q", tt.name, tt.want, got)
}
}
}
func TestProcessServerAddress_NormalizesIPv6(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{
name: "ipv6 host only",
in: "2001:db8::1",
want: "[2001:db8::1]",
},
{
name: "ipv6 host and port",
in: "https://[2001:db8::1]:8443/path",
want: "[2001:db8::1]:8443",
},
{
name: "already bracketed",
in: "[2001:db8::2]:9000",
want: "[2001:db8::2]:9000",
},
}
for _, tt := range tests {
if got := processServerAddress(tt.in); got != tt.want {
t.Fatalf("%s: expected %q, got %q", tt.name, tt.want, got)
}
}
}
@@ -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,
+38 -1
View File
@@ -18,11 +18,12 @@ func (h *Handler) StartBackgroundJobs() {
ctx, cancel := context.WithCancel(context.Background())
h.jobsCancel = cancel
h.jobsStarted = true
h.jobsWG.Add(2)
h.jobsWG.Add(3)
h.jobsMu.Unlock()
go h.runHourlyStatsLoop(ctx)
go h.runDailyMaintenanceLoop(ctx)
go h.runNodeRenewalCycleLoop(ctx)
}
func (h *Handler) StopBackgroundJobs() {
@@ -176,3 +177,39 @@ func (h *Handler) disableExpiredUserTunnels(nowMs int64) {
_ = h.repo.DisableUserTunnel(item.ID)
}
}
func (h *Handler) runNodeRenewalCycleLoop(ctx context.Context) {
defer h.jobsWG.Done()
for {
wait := durationUntilNextNodeRenewalCycle(time.Now())
timer := time.NewTimer(wait)
select {
case <-ctx.Done():
if !timer.Stop() {
<-timer.C
}
return
case <-timer.C:
h.runNodeRenewalCycleJob(time.Now())
}
}
}
func durationUntilNextNodeRenewalCycle(now time.Time) time.Duration {
next := now.Truncate(6 * time.Hour).Add(6 * time.Hour)
return next.Sub(now)
}
func (h *Handler) runNodeRenewalCycleJob(now time.Time) {
if h == nil || h.repo == nil {
return
}
advanced, err := h.repo.AdvanceNodeRenewalCycles(now.UnixMilli())
if err != nil {
return
}
_ = advanced
}
@@ -0,0 +1,55 @@
package handler
import (
"database/sql"
"testing"
"time"
"go-backend/internal/store/repo"
)
func TestRunNodeRenewalCycleJob_AdvancesOverdueAnchorTimes(t *testing.T) {
dbPath := t.TempDir() + "/renewal-test.db"
r, err := repo.Open(dbPath)
if err != nil {
t.Fatalf("open repo: %v", err)
}
t.Cleanup(func() {
_ = r.Close()
})
now := time.Date(2026, 3, 8, 12, 0, 0, 0, time.UTC)
nowMs := now.UnixMilli()
nodeID := int64(101)
err = r.DB().Exec(`
INSERT INTO node (id, name, secret, server_ip, port, http, tls, socks, created_time, status, renewal_cycle, expiry_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, nodeID, "no-cycle-node", "test-secret", "192.168.1.1", "1000-65535", 1, 1, 1, nowMs, 1, "", nil).Error
if err != nil {
t.Fatalf("insert test node: %v", err)
}
quarterNodeID := int64(102)
err = r.DB().Exec(`
INSERT INTO node (id, name, secret, server_ip, port, http, tls, socks, created_time, status, renewal_cycle, expiry_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, quarterNodeID, "quarter-node", "test-secret", "192.168.1.1", "1000-65535", 1, 1, 1, nowMs, 1, "quarter", now.AddDate(0, -4, 0).UnixMilli()).Error
if err != nil {
t.Fatalf("insert test node: %v", err)
}
h := &Handler{repo: r}
h.runNodeRenewalCycleJob(now)
var anchor sql.NullInt64
err = r.DB().Raw(`SELECT expiry_time FROM node WHERE id = ?`, quarterNodeID).Row().Scan(&anchor)
if err != nil {
t.Fatalf("query expiry_time: %v", err)
}
expectedAnchor := now.AddDate(0, 2, 0).UnixMilli()
if !anchor.Valid || anchor.Int64 != expectedAnchor {
t.Fatalf("expected anchor %d (2026-05-08), got %d", expectedAnchor, anchor.Int64)
}
}
+173 -22
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"]))),
nullableUnixMilli(asInt64(req["expiryTime"], 0)),
nullableText(normalizeNodeRenewalCycle(asString(req["renewalCycle"]))),
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"]))),
nullableUnixMilli(asInt64(req["expiryTime"], 0)),
nullableText(normalizeNodeRenewalCycle(asString(req["renewalCycle"]))),
newHTTP,
newTLS,
newSocks,
@@ -1158,13 +1166,6 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-1, "普通用户无法设置限速规则"))
return
}
if inPortVal, ok := req["inPort"]; ok {
port := asInt(inPortVal, 0)
if port > 0 {
response.WriteJSON(w, response.Err(-1, "普通用户无法设置自定义端口"))
return
}
}
}
speedID := asAnyToInt64Ptr(req["speedId"])
speedID, err = h.normalizeSpeedLimitReference(speedID)
@@ -1194,6 +1195,14 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if err := validateLocalNodePort(node, port); err != nil {
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")
@@ -1276,20 +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
}
if inPortVal, ok := req["inPort"]; ok {
port := asInt(inPortVal, 0)
if port > 0 {
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()))
@@ -1332,6 +1334,14 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if err := validateLocalNodePort(node, port); err != nil {
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 {
@@ -2582,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)
@@ -2594,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)
@@ -2603,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
@@ -3338,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
@@ -3484,6 +3540,22 @@ func nullableText(s string) interface{} {
return s
}
func nullableUnixMilli(v int64) interface{} {
if v <= 0 {
return nil
}
return v
}
func normalizeNodeRenewalCycle(v string) string {
switch strings.ToLower(strings.TrimSpace(v)) {
case "month", "quarter", "year":
return strings.ToLower(strings.TrimSpace(v))
default:
return ""
}
}
func nullableInt(v *int64) interface{} {
if v == nil {
return nil
@@ -3519,3 +3591,82 @@ func asInt64Slice(v interface{}) []int64 {
}
return ids
}
func validateLocalNodePort(node *nodeRecord, port int) error {
if node == nil || node.IsRemote == 1 || port <= 0 {
return nil
}
portRange := strings.TrimSpace(node.PortRange)
if portRange == "" {
return nil
}
minPort, maxPort := parsePortRangeMinMax(portRange)
if minPort <= 0 || maxPort <= 0 {
return nil
}
if port < minPort || port > maxPort {
return fmt.Errorf("端口 %d 超出节点 %s 允许范围 %d-%d", port, node.Name, minPort, maxPort)
}
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 == "" {
return 0, 0
}
minPort, maxPort := 0, 0
parts := strings.Split(input, ",")
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if strings.Contains(part, "-") {
r := strings.SplitN(part, "-", 2)
if len(r) != 2 {
continue
}
start, err1 := strconv.Atoi(strings.TrimSpace(r[0]))
end, err2 := strconv.Atoi(strings.TrimSpace(r[1]))
if err1 != nil || err2 != nil || start <= 0 || end <= 0 {
continue
}
if end < start {
start, end = end, start
}
if minPort == 0 || start < minPort {
minPort = start
}
if maxPort == 0 || end > maxPort {
maxPort = end
}
continue
}
p, err := strconv.Atoi(part)
if err != nil || p <= 0 {
continue
}
if minPort == 0 || p < minPort {
minPort = p
}
if maxPort == 0 || p > maxPort {
maxPort = p
}
}
return minPort, maxPort
}
+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"`
ExpiryTime sql.NullInt64 `gorm:"column:expiry_time"`
RenewalCycle sql.NullString `gorm:"column:renewal_cycle;type:varchar(20)"`
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"`
ExpiryTime int64 `json:"expiryTime,omitempty"`
RenewalCycle string `json:"renewalCycle,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
+270 -25
View File
@@ -260,7 +260,7 @@ func prepareSQLiteLegacyColumns(db *gorm.DB) error {
m := db.Migrator()
if m.HasTable(&model.Node{}) {
for _, field := range []string{"ServerIPV4", "ServerIPV6", "ExtraIPs", "TCPListenAddr", "UDPListenAddr", "Inx", "IsRemote", "RemoteURL", "RemoteToken", "RemoteConfig"} {
for _, field := range []string{"ServerIPV4", "ServerIPV6", "ExtraIPs", "TCPListenAddr", "UDPListenAddr", "Inx", "IsRemote", "RemoteURL", "RemoteToken", "RemoteConfig", "Remark", "ExpiryTime", "RenewalCycle"} {
if m.HasColumn(&model.Node{}, field) {
continue
}
@@ -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),
"expiryTime": nullableInt64(n.ExpiryTime),
"renewalCycle": nullableString(n.RenewalCycle),
"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),
@@ -767,9 +772,21 @@ func (r *Repository) ListUserAccessibleTunnels(userID int64) ([]map[string]inter
if err != nil {
return nil, err
}
tunnelIDs := make([]int64, 0, len(rows))
for _, rw := range rows {
tunnelIDs = append(tunnelIDs, rw.ID)
}
portRangeMap := r.getTunnelEntryPortRanges(tunnelIDs)
items := make([]map[string]interface{}, 0, len(rows))
for _, r := range rows {
items = append(items, map[string]interface{}{"id": r.ID, "name": r.Name})
for _, rw := range rows {
item := map[string]interface{}{"id": rw.ID, "name": rw.Name}
if pr, ok := portRangeMap[rw.ID]; ok {
item["portRangeMin"] = pr.min
item["portRangeMax"] = pr.max
}
items = append(items, item)
}
return items, nil
}
@@ -788,13 +805,146 @@ func (r *Repository) ListEnabledTunnelSummaries() ([]map[string]interface{}, err
if err != nil {
return nil, err
}
tunnelIDs := make([]int64, 0, len(rows))
for _, rw := range rows {
tunnelIDs = append(tunnelIDs, rw.ID)
}
portRangeMap := r.getTunnelEntryPortRanges(tunnelIDs)
items := make([]map[string]interface{}, 0, len(rows))
for _, r := range rows {
items = append(items, map[string]interface{}{"id": r.ID, "name": r.Name})
for _, rw := range rows {
item := map[string]interface{}{"id": rw.ID, "name": rw.Name}
if pr, ok := portRangeMap[rw.ID]; ok {
item["portRangeMin"] = pr.min
item["portRangeMax"] = pr.max
}
items = append(items, item)
}
return items, nil
}
type tunnelPortRange struct {
min int
max int
}
func (r *Repository) getTunnelEntryPortRanges(tunnelIDs []int64) map[int64]tunnelPortRange {
result := make(map[int64]tunnelPortRange)
if len(tunnelIDs) == 0 {
return result
}
type entryNode struct {
TunnelID int64
NodeID int64
}
var entries []entryNode
r.db.Model(&model.ChainTunnel{}).
Select("tunnel_id, node_id").
Where("tunnel_id IN (?) AND chain_type = ?", tunnelIDs, "1").
Find(&entries)
nodeIDs := make([]int64, 0, len(entries))
nodeSet := make(map[int64]struct{})
for _, e := range entries {
if _, exists := nodeSet[e.NodeID]; !exists {
nodeSet[e.NodeID] = struct{}{}
nodeIDs = append(nodeIDs, e.NodeID)
}
}
type nodePort struct {
ID int64
Port string
}
var nodePorts []nodePort
if len(nodeIDs) > 0 {
r.db.Model(&model.Node{}).Select("id, port").Where("id IN (?)", nodeIDs).Find(&nodePorts)
}
nodePortMap := make(map[int64]string)
for _, np := range nodePorts {
nodePortMap[np.ID] = np.Port
}
for _, e := range entries {
portSpec := nodePortMap[e.NodeID]
if portSpec == "" {
continue
}
minP, maxP := parsePortRangeMinMax(portSpec)
if minP <= 0 || maxP <= 0 {
continue
}
pr, exists := result[e.TunnelID]
if !exists {
result[e.TunnelID] = tunnelPortRange{min: minP, max: maxP}
} else {
if minP < pr.min {
pr.min = minP
}
if maxP > pr.max {
pr.max = maxP
}
result[e.TunnelID] = pr
}
}
return result
}
func parsePortRangeMinMax(input string) (int, int) {
input = strings.TrimSpace(input)
if input == "" {
return 0, 0
}
minPort, maxPort := 0, 0
parts := strings.Split(input, ",")
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if strings.Contains(part, "-") {
r := strings.SplitN(part, "-", 2)
if len(r) != 2 {
continue
}
start, end := parseIntPort(r[0]), parseIntPort(r[1])
if start <= 0 || end <= 0 {
continue
}
if end < start {
start, end = end, start
}
if minPort == 0 || start < minPort {
minPort = start
}
if maxPort == 0 || end > maxPort {
maxPort = end
}
continue
}
p := parseIntPort(part)
if p <= 0 {
continue
}
if minPort == 0 || p < minPort {
minPort = p
}
if maxPort == 0 || p > maxPort {
maxPort = p
}
}
return minPort, maxPort
}
func parseIntPort(s string) int {
var p int
fmt.Sscanf(strings.TrimSpace(s), "%d", &p)
return p
}
func (r *Repository) ListTunnels() ([]map[string]interface{}, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
@@ -1669,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, RenewalCycle: n.RenewalCycle.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
}
@@ -2023,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 != ""},
ExpiryTime: sql.NullInt64{Int64: n.ExpiryTime, Valid: n.ExpiryTime > 0},
RenewalCycle: sql.NullString{String: n.RenewalCycle, Valid: n.RenewalCycle != ""},
Secret: n.Secret,
ServerIP: n.ServerIP,
ServerIPV4: sql.NullString{String: n.ServerIPv4, Valid: true},
@@ -2047,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", "expiry_time", "renewal_cycle", "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",
}),
@@ -2467,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
@@ -2535,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
}
@@ -2599,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,13 +3,61 @@ package repo
import (
"database/sql"
"errors"
"strings"
"testing"
gsqlite "github.com/glebarez/sqlite"
"go-backend/internal/store/model"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestPrepareSQLiteLegacyColumnsAddsNodeMetadataColumns(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 node (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
secret VARCHAR(100) NOT NULL,
server_ip VARCHAR(100) NOT NULL,
port TEXT NOT NULL,
interface_name VARCHAR(200),
version VARCHAR(100),
http INTEGER NOT NULL DEFAULT 0,
tls INTEGER NOT NULL DEFAULT 0,
socks INTEGER NOT NULL DEFAULT 0,
created_time INTEGER NOT NULL,
updated_time INTEGER,
status INTEGER NOT NULL
)
`).Error; err != nil {
t.Fatalf("create legacy node table: %v", err)
}
if err := prepareSQLiteLegacyColumns(db); err != nil {
t.Fatalf("prepareSQLiteLegacyColumns: %v", err)
}
m := db.Migrator()
for _, field := range []string{"Remark", "ExpiryTime", "RenewalCycle"} {
if !m.HasColumn(&model.Node{}, field) {
t.Fatalf("expected node.%s column to exist", field)
}
}
}
func TestMigrateSchemaRunsPostgresIDRepairEvenAtCurrentVersion(t *testing.T) {
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
@@ -250,3 +298,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)
}
}
@@ -3,6 +3,7 @@ package repo
import (
"database/sql"
"errors"
"fmt"
"sort"
"strconv"
"strings"
@@ -196,12 +197,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, expiryTime, renewalCycle 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),
ExpiryTime: nullInt64FromInterface(expiryTime),
RenewalCycle: nullStringFromInterface(renewalCycle),
Secret: secret,
ServerIP: serverIP,
ServerIPV4: nullStringFromInterface(serverIPV4),
@@ -239,7 +243,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, expiryTime, renewalCycle 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 +251,9 @@ func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, ser
Where("id = ?", id).
Updates(map[string]interface{}{
"name": name,
"remark": nullStringFromInterface(remark),
"expiry_time": nullInt64FromInterface(expiryTime),
"renewal_cycle": nullStringFromInterface(renewalCycle),
"server_ip": serverIP,
"server_ip_v4": nullStringFromInterface(serverIPV4),
"server_ip_v6": nullStringFromInterface(serverIPV6),
@@ -1477,3 +1484,59 @@ func (r *Repository) ReplaceUserGroupsByUserID(userID int64, newGroupIDs []int64
}
return affectedGroupIDs, nil
}
func (r *Repository) AdvanceNodeRenewalCycles(now int64) (int, error) {
if r == nil || r.db == nil {
return 0, nil
}
var nodes []model.Node
if err := r.db.Where("renewal_cycle IS NOT NULL AND renewal_cycle != '' AND expiry_time IS NOT NULL").Find(&nodes).Error; err != nil {
return 0, fmt.Errorf("list nodes with renewal cycle: %w", err)
}
advanced := 0
for _, node := range nodes {
if !node.ExpiryTime.Valid || node.ExpiryTime.Int64 <= 0 {
continue
}
cycleMonths := 0
switch node.RenewalCycle.String {
case "month":
cycleMonths = 1
case "quarter":
cycleMonths = 3
case "year":
cycleMonths = 12
default:
continue
}
anchorTime := node.ExpiryTime.Int64
for anchorTime <= now {
nextAnchor := advanceByMonths(anchorTime, cycleMonths)
if nextAnchor <= anchorTime {
break
}
anchorTime = nextAnchor
}
if anchorTime == node.ExpiryTime.Int64 {
continue
}
if err := r.db.Model(&model.Node{}).Where("id = ?", node.ID).Update("expiry_time", anchorTime).Error; err != nil {
continue
}
advanced++
}
return advanced, nil
}
func advanceByMonths(timestamp int64, months int) int64 {
t := time.Unix(timestamp/1000, 0)
next := t.AddDate(0, months, 0)
return next.UnixMilli()
}
@@ -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)
}
@@ -998,9 +1165,9 @@ func TestNonAdminCannotSetSpeedIdOrPort(t *testing.T) {
assertCodeMsg(t, res, -1, "普通用户无法设置限速规则")
})
t.Run("non-admin cannot set inPort on create", func(t *testing.T) {
t.Run("non-admin cannot set inPort out of range on create", func(t *testing.T) {
createPayload := map[string]interface{}{
"name": "perm-forward-port",
"name": "perm-forward-port-out",
"tunnelId": tunnelID,
"remoteAddr": "1.2.3.4:443",
"strategy": "fifo",
@@ -1015,7 +1182,33 @@ func TestNonAdminCannotSetSpeedIdOrPort(t *testing.T) {
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCodeMsg(t, res, -1, "普通用户无法设置自定义端口")
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.Errorf("expected port out of range error, got code=%d msg=%s", out.Code, out.Msg)
}
})
t.Run("non-admin can set inPort within range on create", func(t *testing.T) {
createPayload := map[string]interface{}{
"name": "perm-forward-port-in",
"tunnelId": tunnelID,
"remoteAddr": "1.2.3.4:443",
"strategy": "fifo",
"inPort": 30005,
}
createBody, err := json.Marshal(createPayload)
if err != nil {
t.Fatalf("marshal create payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
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 without speedId and inPort", func(t *testing.T) {
@@ -1059,7 +1252,7 @@ func TestNonAdminCannotSetSpeedIdOrPort(t *testing.T) {
assertCodeMsg(t, res, -1, "普通用户无法修改限速规则")
})
t.Run("non-admin cannot update inPort", func(t *testing.T) {
t.Run("non-admin cannot update inPort out of range", func(t *testing.T) {
updatePayload := map[string]interface{}{
"id": forwardID,
"name": "perm-forward-updated2",
@@ -1076,7 +1269,33 @@ func TestNonAdminCannotSetSpeedIdOrPort(t *testing.T) {
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCodeMsg(t, res, -1, "普通用户无法修改自定义端口")
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.Errorf("expected port out of range error, got code=%d msg=%s", out.Code, out.Msg)
}
})
t.Run("non-admin can update inPort within range", func(t *testing.T) {
updatePayload := map[string]interface{}{
"id": forwardID,
"name": "perm-forward-updated3",
"tunnelId": tunnelID,
"remoteAddr": "5.6.7.8:443",
"inPort": 30006,
}
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 update without speedId and inPort", func(t *testing.T) {
@@ -1098,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])
}
}
+2 -2
View File
@@ -109,12 +109,12 @@ func main() {
// 加载配置文件
config, err := LoadConfig("config.json")
if err != nil {
fmt.Println("❌ 配置加载失败: %v\n", err)
fmt.Printf("❌ 配置加载失败: %v\n", err)
fmt.Println("请确保当前目录存在 config.json 文件")
os.Exit(1)
}
fmt.Println("✅ 配置加载成功 - addr: %s", config.Addr)
fmt.Printf("✅ 配置加载成功 - addr: %s\n", config.Addr)
log := xlogger.NewLogger()
logger.SetDefault(log)
+203 -77
View File
@@ -6,7 +6,9 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/go-gost/core/observer/stats"
@@ -18,6 +20,15 @@ import (
var httpReportURL string
var configReportURL string
var httpAESCrypto *crypto.AESCrypto // 新增:HTTP上报加密器
var reportURLPreferenceMutex sync.RWMutex
var preferredUploadURL string
var preferredConfigURL string
var reportDo = func(ctx context.Context, req *http.Request, timeout time.Duration) (*http.Response, error) {
client := &http.Client{
Timeout: timeout,
}
return client.Do(req.WithContext(ctx))
}
// TrafficReportItem 流量报告项(压缩格式)
type TrafficReportItem struct {
@@ -27,8 +38,17 @@ type TrafficReportItem struct {
}
func SetHTTPReportURL(addr string, secret string) {
httpReportURL = "http://" + addr + "/flow/upload?secret=" + secret
configReportURL = "http://" + addr + "/flow/config?secret=" + secret
uploadURLs, configURLs := buildReportURLCandidates(addr, secret)
if len(uploadURLs) > 0 {
httpReportURL = strings.Join(uploadURLs, ",")
}
if len(configURLs) > 0 {
configReportURL = strings.Join(configURLs, ",")
}
reportURLPreferenceMutex.Lock()
preferredUploadURL = ""
preferredConfigURL = ""
reportURLPreferenceMutex.Unlock()
// 创建 AES 加密器
var err error
@@ -41,8 +61,173 @@ func SetHTTPReportURL(addr string, secret string) {
}
}
func buildReportURLCandidates(addr string, secret string) (upload []string, config []string) {
normalizedAddr, explicitScheme := normalizeReportAddress(addr)
if normalizedAddr == "" {
normalizedAddr = strings.TrimSpace(addr)
}
schemes := []string{"https", "http"}
if mappedScheme := mapToHTTPScheme(explicitScheme); mappedScheme == "http" {
schemes = []string{"http", "https"}
}
upload = []string{
schemes[0] + "://" + normalizedAddr + "/flow/upload?secret=" + secret,
schemes[1] + "://" + normalizedAddr + "/flow/upload?secret=" + secret,
}
config = []string{
schemes[0] + "://" + normalizedAddr + "/flow/config?secret=" + secret,
schemes[1] + "://" + normalizedAddr + "/flow/config?secret=" + secret,
}
return upload, config
}
func normalizeReportAddress(addr string) (string, string) {
raw := strings.TrimSpace(addr)
if raw == "" {
return "", ""
}
scheme := ""
if idx := strings.Index(raw, "://"); idx > 0 {
scheme = strings.ToLower(strings.TrimSpace(raw[:idx]))
if parsed, err := url.Parse(raw); err == nil {
if host := strings.TrimSpace(parsed.Host); host != "" {
return host, scheme
}
}
raw = raw[idx+3:]
}
if idx := strings.IndexAny(raw, "/?#"); idx >= 0 {
raw = raw[:idx]
}
return strings.TrimSpace(raw), scheme
}
func mapToHTTPScheme(scheme string) string {
switch strings.ToLower(strings.TrimSpace(scheme)) {
case "https", "wss":
return "https"
case "http", "ws":
return "http"
default:
return ""
}
}
func loadPreferredURL(preferred *string) string {
if preferred == nil {
return ""
}
reportURLPreferenceMutex.RLock()
defer reportURLPreferenceMutex.RUnlock()
return *preferred
}
func storePreferredURL(preferred *string, value string) {
if preferred == nil {
return
}
reportURLPreferenceMutex.Lock()
defer reportURLPreferenceMutex.Unlock()
*preferred = value
}
func prioritizeURLs(urls []string, preferred string) []string {
ordered := append([]string(nil), urls...)
if preferred == "" || len(ordered) < 2 {
return ordered
}
for i, targetURL := range ordered {
if targetURL == preferred {
if i > 0 {
ordered[0], ordered[i] = ordered[i], ordered[0]
}
break
}
}
return ordered
}
func postJSONWithFallback(ctx context.Context, urls []string, requestBody []byte, userAgent string, timeout time.Duration, preferred *string) (bool, error) {
if len(urls) == 0 {
return false, fmt.Errorf("上报URL未设置")
}
orderedURLs := prioritizeURLs(urls, loadPreferredURL(preferred))
var errs []string
for i, targetURL := range orderedURLs {
req, err := http.NewRequest("POST", targetURL, bytes.NewBuffer(requestBody))
if err != nil {
errs = append(errs, fmt.Sprintf("%s => 创建请求失败: %v", targetURL, err))
if i < len(orderedURLs)-1 {
fmt.Printf("⚠️ HTTP上报尝试失败,准备回退: %s => 创建请求失败: %v\n", targetURL, err)
}
continue
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", userAgent)
resp, err := reportDo(ctx, req, timeout)
if err != nil {
errs = append(errs, fmt.Sprintf("%s => 请求失败: %v", targetURL, err))
if i < len(orderedURLs)-1 {
fmt.Printf("⚠️ HTTP上报尝试失败,准备回退: %s => 请求失败: %v\n", targetURL, err)
}
continue
}
var responseBytes bytes.Buffer
_, readErr := responseBytes.ReadFrom(resp.Body)
resp.Body.Close()
if readErr != nil {
errs = append(errs, fmt.Sprintf("%s => 读取响应失败: %v", targetURL, readErr))
if i < len(orderedURLs)-1 {
fmt.Printf("⚠️ HTTP上报尝试失败,准备回退: %s => 读取响应失败: %v\n", targetURL, readErr)
}
continue
}
if resp.StatusCode != http.StatusOK {
errs = append(errs, fmt.Sprintf("%s => HTTP响应错误: %d %s", targetURL, resp.StatusCode, resp.Status))
if i < len(orderedURLs)-1 {
fmt.Printf("⚠️ HTTP上报尝试失败,准备回退: %s => HTTP响应错误: %d %s\n", targetURL, resp.StatusCode, resp.Status)
}
continue
}
responseText := strings.TrimSpace(responseBytes.String())
if responseText == "ok" {
if i > 0 {
fmt.Printf("↪️ HTTP上报已自动回退到: %s\n", targetURL)
}
storePreferredURL(preferred, targetURL)
return true, nil
}
errs = append(errs, fmt.Sprintf("%s => 服务器响应: %s (期望: ok)", targetURL, responseText))
if i < len(orderedURLs)-1 {
fmt.Printf("⚠️ HTTP上报尝试失败,准备回退: %s => 服务器响应: %s (期望: ok)\n", targetURL, responseText)
}
}
return false, fmt.Errorf("发送HTTP请求失败: %s", strings.Join(errs, " | "))
}
// sendBatchTrafficReport 批量发送多个服务的流量报告到HTTP接口
func sendBatchTrafficReport(ctx context.Context, reportItems []TrafficReportItem) (bool, error) {
if httpReportURL == "" {
return false, fmt.Errorf("流量上报URL未设置")
}
jsonData, err := json.Marshal(reportItems)
if err != nil {
return false, fmt.Errorf("序列化报告数据失败: %v", err)
@@ -73,46 +258,16 @@ func sendBatchTrafficReport(ctx context.Context, reportItems []TrafficReportItem
requestBody = jsonData
}
req, err := http.NewRequestWithContext(ctx, "POST", httpReportURL, bytes.NewBuffer(requestBody))
if err != nil {
return false, fmt.Errorf("创建HTTP请求失败: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "GOST-Traffic-Reporter/1.0")
client := &http.Client{
Timeout: 5 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
return false, fmt.Errorf("发送HTTP请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false, fmt.Errorf("HTTP响应错误: %d %s", resp.StatusCode, resp.Status)
}
// 读取响应内容
var responseBytes bytes.Buffer
_, err = responseBytes.ReadFrom(resp.Body)
if err != nil {
return false, fmt.Errorf("读取响应内容失败: %v", err)
}
responseText := strings.TrimSpace(responseBytes.String())
// 检查响应是否为"ok"
if responseText == "ok" {
return true, nil
} else {
return false, fmt.Errorf("服务器响应: %s (期望: ok)", responseText)
}
return postJSONWithFallback(
ctx,
strings.Split(httpReportURL, ","),
requestBody,
"GOST-Traffic-Reporter/1.0",
5*time.Second,
&preferredUploadURL,
)
}
// sendConfigReport 发送配置报告到HTTP接口
func sendConfigReport(ctx context.Context) (bool, error) {
if configReportURL == "" {
@@ -150,43 +305,14 @@ func sendConfigReport(ctx context.Context) (bool, error) {
requestBody = configData
}
req, err := http.NewRequestWithContext(ctx, "POST", configReportURL, bytes.NewBuffer(requestBody))
if err != nil {
return false, fmt.Errorf("创建HTTP请求失败: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Config-Reporter/1.0")
client := &http.Client{
Timeout: 10 * time.Second, // 配置上报可以稍长一些
}
resp, err := client.Do(req)
if err != nil {
return false, fmt.Errorf("发送HTTP请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false, fmt.Errorf("HTTP响应错误: %d %s", resp.StatusCode, resp.Status)
}
// 读取响应内容
var responseBytes bytes.Buffer
_, err = responseBytes.ReadFrom(resp.Body)
if err != nil {
return false, fmt.Errorf("读取响应内容失败: %v", err)
}
responseText := strings.TrimSpace(responseBytes.String())
// 检查响应是否为"ok"
if responseText == "ok" {
return true, nil
} else {
return false, fmt.Errorf("服务器响应: %s (期望: ok)", responseText)
}
return postJSONWithFallback(
ctx,
strings.Split(configReportURL, ","),
requestBody,
"Config-Reporter/1.0",
10*time.Second,
&preferredConfigURL,
)
}
// StartConfigReporter 启动配置定时上报器(每10分钟上报一次)
+150
View File
@@ -0,0 +1,150 @@
package service
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"time"
)
func TestBuildReportURLCandidatesSecureFirst(t *testing.T) {
upload, config := buildReportURLCandidates("panel.example.com:443", "abc")
if len(upload) != 2 {
t.Fatalf("expected 2 upload candidates, got %d", len(upload))
}
if len(config) != 2 {
t.Fatalf("expected 2 config candidates, got %d", len(config))
}
if upload[0] != "https://panel.example.com:443/flow/upload?secret=abc" {
t.Fatalf("unexpected upload[0]: %s", upload[0])
}
if upload[1] != "http://panel.example.com:443/flow/upload?secret=abc" {
t.Fatalf("unexpected upload[1]: %s", upload[1])
}
if config[0] != "https://panel.example.com:443/flow/config?secret=abc" {
t.Fatalf("unexpected config[0]: %s", config[0])
}
if config[1] != "http://panel.example.com:443/flow/config?secret=abc" {
t.Fatalf("unexpected config[1]: %s", config[1])
}
}
func TestBuildReportURLCandidatesNormalizeSchemeAddr(t *testing.T) {
upload, config := buildReportURLCandidates("https://panel.example.com:8443/path", "abc")
if upload[0] != "https://panel.example.com:8443/flow/upload?secret=abc" {
t.Fatalf("unexpected upload[0]: %s", upload[0])
}
if upload[1] != "http://panel.example.com:8443/flow/upload?secret=abc" {
t.Fatalf("unexpected upload[1]: %s", upload[1])
}
if config[0] != "https://panel.example.com:8443/flow/config?secret=abc" {
t.Fatalf("unexpected config[0]: %s", config[0])
}
if config[1] != "http://panel.example.com:8443/flow/config?secret=abc" {
t.Fatalf("unexpected config[1]: %s", config[1])
}
}
func TestPostJSONWithFallbackUsesHTTPAfterHTTPSFailure(t *testing.T) {
orig := reportDo
defer func() { reportDo = orig }()
var calls []string
reportDo = func(_ context.Context, req *http.Request, _ time.Duration) (*http.Response, error) {
calls = append(calls, req.URL.String())
if strings.HasPrefix(req.URL.String(), "https://") {
return nil, errors.New("tls handshake failed")
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("ok")),
}, nil
}
ok, err := postJSONWithFallback(
context.Background(),
[]string{
"https://panel.example.com:443/flow/upload?secret=abc",
"http://panel.example.com:443/flow/upload?secret=abc",
},
[]byte(`[]`),
"GOST-Traffic-Reporter/1.0",
5*time.Second,
nil,
)
if !ok || err != nil {
t.Fatalf("expected fallback success, ok=%v err=%v", ok, err)
}
if len(calls) != 2 {
t.Fatalf("expected 2 calls, got %d", len(calls))
}
if !strings.HasPrefix(calls[0], "https://") || !strings.HasPrefix(calls[1], "http://") {
t.Fatalf("unexpected call order: %#v", calls)
}
}
func TestPostJSONWithFallbackRemembersDetectedURL(t *testing.T) {
orig := reportDo
defer func() { reportDo = orig }()
targets := []string{
"https://panel.example.com:443/flow/upload?secret=abc",
"http://panel.example.com:443/flow/upload?secret=abc",
}
var preferred string
var calls []string
reportDo = func(_ context.Context, req *http.Request, _ time.Duration) (*http.Response, error) {
calls = append(calls, req.URL.String())
if strings.HasPrefix(req.URL.String(), "https://") {
return nil, errors.New("tls handshake failed")
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("ok")),
}, nil
}
ok, err := postJSONWithFallback(
context.Background(),
targets,
[]byte(`[]`),
"GOST-Traffic-Reporter/1.0",
5*time.Second,
&preferred,
)
if !ok || err != nil {
t.Fatalf("expected first call success, ok=%v err=%v", ok, err)
}
if preferred != targets[1] {
t.Fatalf("expected preferred url to be remembered as %s, got %s", targets[1], preferred)
}
if len(calls) != 2 {
t.Fatalf("expected 2 calls on first attempt, got %d", len(calls))
}
calls = nil
ok, err = postJSONWithFallback(
context.Background(),
targets,
[]byte(`[]`),
"GOST-Traffic-Reporter/1.0",
5*time.Second,
&preferred,
)
if !ok || err != nil {
t.Fatalf("expected second call success, ok=%v err=%v", ok, err)
}
if len(calls) != 1 {
t.Fatalf("expected second call to use remembered url once, got %d calls", len(calls))
}
if !strings.HasPrefix(calls[0], "http://") {
t.Fatalf("expected remembered http url first, got %s", calls[0])
}
}
+163 -26
View File
@@ -97,20 +97,25 @@ const (
)
type WebSocketReporter struct {
url string
addr string // 保存服务器地址
secret string // 保存密钥
version string // 保存版本号
conn *websocket.Conn
reconnectTime time.Duration
pingInterval time.Duration
configInterval time.Duration
ctx context.Context
cancel context.CancelFunc
connected bool
connecting bool // 新增:正在连接状态
connMutex sync.Mutex // 新增:连接状态锁
aesCrypto *crypto.AESCrypto // 新增:AES加密器
url string
addr string // 保存服务器地址
secret string // 保存密钥
version string // 保存版本号
preferredWSScheme string
conn *websocket.Conn
reconnectTime time.Duration
pingInterval time.Duration
configInterval time.Duration
ctx context.Context
cancel context.CancelFunc
connected bool
connecting bool // 新增:正在连接状态
connMutex sync.Mutex // 新增:连接状态锁
aesCrypto *crypto.AESCrypto // 新增:AES加密器
}
var wsDial = func(dialer *websocket.Dialer, rawURL string) (*websocket.Conn, *http.Response, error) {
return dialer.Dial(rawURL, nil)
}
// NewWebSocketReporter 创建一个新的WebSocket报告器
@@ -223,21 +228,14 @@ func (w *WebSocketReporter) connect() error {
json.Unmarshal(b, &cfg)
}
// 使用最新的配置重新构建 URL
currentURL := "ws://" + w.addr + "/system-info?type=1&secret=" + w.secret + "&version=" + w.version +
"&http=" + strconv.Itoa(cfg.Http) + "&tls=" + strconv.Itoa(cfg.Tls) + "&socks=" + strconv.Itoa(cfg.Socks)
u, err := url.Parse(currentURL)
if err != nil {
return fmt.Errorf("解析URL失败: %v", err)
}
candidates := buildWebSocketCandidates(w.addr, w.secret, w.version, cfg.Http, cfg.Tls, cfg.Socks, w.preferredWSScheme)
dialer := websocket.DefaultDialer
dialer.HandshakeTimeout = 10 * time.Second
conn, _, err := dialer.Dial(u.String(), nil)
conn, usedURL, err := dialWebSocketWithFallback(dialer, candidates)
if err != nil {
return fmt.Errorf("连接WebSocket失败: %v", err)
return err
}
// 如果在连接过程中已经有连接了,关闭新连接
@@ -248,6 +246,9 @@ func (w *WebSocketReporter) connect() error {
w.conn = conn
w.connected = true
if scheme := detectWebSocketScheme(usedURL); scheme != "" {
w.preferredWSScheme = scheme
}
_ = conn.SetReadDeadline(time.Now().Add(reporterReadWait))
conn.SetPingHandler(func(appData string) error {
_ = conn.SetReadDeadline(time.Now().Add(reporterReadWait))
@@ -265,10 +266,145 @@ func (w *WebSocketReporter) connect() error {
return nil
})
fmt.Printf("✅ WebSocket连接建立成功 (http=%d, tls=%d, socks=%d)\n", cfg.Http, cfg.Tls, cfg.Socks)
fmt.Printf("✅ WebSocket连接建立成功 (%s, http=%d, tls=%d, socks=%d)\n", sanitizeWebSocketURL(usedURL), cfg.Http, cfg.Tls, cfg.Socks)
return nil
}
func buildWebSocketCandidates(addr string, secret string, version string, http int, tls int, socks int, preferredScheme string) []string {
normalizedAddr, explicitScheme := normalizeReporterAddress(addr)
if normalizedAddr == "" {
normalizedAddr = strings.TrimSpace(addr)
}
query := "/system-info?type=1&secret=" + secret + "&version=" + version +
"&http=" + strconv.Itoa(http) + "&tls=" + strconv.Itoa(tls) + "&socks=" + strconv.Itoa(socks)
schemes := []string{"wss", "ws"}
if mappedScheme := mapToWebSocketScheme(explicitScheme); mappedScheme != "" {
if mappedScheme == "ws" {
schemes = []string{"ws", "wss"}
}
} else if preferredScheme == "ws" {
schemes = []string{"ws", "wss"}
}
return []string{
schemes[0] + "://" + normalizedAddr + query,
schemes[1] + "://" + normalizedAddr + query,
}
}
func normalizeReporterAddress(addr string) (string, string) {
raw := strings.TrimSpace(addr)
if raw == "" {
return "", ""
}
scheme := ""
if idx := strings.Index(raw, "://"); idx > 0 {
scheme = strings.ToLower(strings.TrimSpace(raw[:idx]))
if parsed, err := url.Parse(raw); err == nil {
if host := strings.TrimSpace(parsed.Host); host != "" {
return host, scheme
}
}
raw = raw[idx+3:]
}
if idx := strings.IndexAny(raw, "/?#"); idx >= 0 {
raw = raw[:idx]
}
return strings.TrimSpace(raw), scheme
}
func mapToWebSocketScheme(scheme string) string {
switch strings.ToLower(strings.TrimSpace(scheme)) {
case "wss", "https":
return "wss"
case "ws", "http":
return "ws"
default:
return ""
}
}
func detectWebSocketScheme(rawURL string) string {
if strings.HasPrefix(rawURL, "wss://") {
return "wss"
}
if strings.HasPrefix(rawURL, "ws://") {
return "ws"
}
return ""
}
func dialWebSocketWithFallback(dialer *websocket.Dialer, candidates []string) (*websocket.Conn, string, error) {
if len(candidates) == 0 {
return nil, "", fmt.Errorf("WebSocket候选地址为空")
}
var errs []string
for i, targetURL := range candidates {
conn, resp, err := wsDial(dialer, targetURL)
if err == nil {
if i > 0 {
fmt.Printf("↪️ WebSocket已自动回退成功: %s\n", sanitizeWebSocketURL(targetURL))
}
return conn, targetURL, nil
}
errMsg := formatWebSocketDialError(err, resp)
errs = append(errs, fmt.Sprintf("%s => %s", sanitizeWebSocketURL(targetURL), errMsg))
if i < len(candidates)-1 {
fmt.Printf(
"⚠️ WebSocket连接失败,准备从 %s 回退到 %s: %s\n",
strings.ToUpper(detectWebSocketScheme(targetURL)),
strings.ToUpper(detectWebSocketScheme(candidates[i+1])),
errMsg,
)
}
}
return nil, "", fmt.Errorf("连接WebSocket失败(已尝试%d种协议): %s", len(candidates), strings.Join(errs, " | "))
}
func sanitizeWebSocketURL(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return rawURL
}
q := u.Query()
if q.Get("secret") != "" {
q.Set("secret", "***")
u.RawQuery = q.Encode()
}
return u.String()
}
func formatWebSocketDialError(err error, resp *http.Response) string {
if err == nil {
return ""
}
if resp == nil {
return err.Error()
}
msg := fmt.Sprintf("%s (HTTP %s)", err, resp.Status)
if resp.Body == nil {
return msg
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 256))
if readErr != nil {
return msg
}
bodyText := strings.TrimSpace(string(body))
if bodyText == "" {
return msg
}
return fmt.Sprintf("%s, body=%q", msg, bodyText)
}
// handleConnection 处理WebSocket连接
func (w *WebSocketReporter) handleConnection() {
defer func() {
@@ -1290,7 +1426,8 @@ func getMemoryInfo() MemoryInfo {
func StartWebSocketReporterWithConfig(addr string, secret string, http int, tls int, socks int, version string) *WebSocketReporter {
// 构建初始 WebSocket URL
fullURL := "ws://" + addr + "/system-info?type=1&secret=" + secret + "&version=" + version + "&http=" + strconv.Itoa(http) + "&tls=" + strconv.Itoa(tls) + "&socks=" + strconv.Itoa(socks)
candidates := buildWebSocketCandidates(addr, secret, version, http, tls, socks, "")
fullURL := candidates[0]
fmt.Printf("🔗 WebSocket连接URL: %s\n", fullURL)
+127
View File
@@ -0,0 +1,127 @@
package socket
import (
"errors"
"io"
"net/http"
"strings"
"testing"
"github.com/gorilla/websocket"
)
func TestBuildWebSocketCandidatesSecureFirst(t *testing.T) {
candidates := buildWebSocketCandidates("panel.example.com:443", "abc", "2.0.2", 1, 0, 1, "")
if len(candidates) != 2 {
t.Fatalf("expected 2 candidates, got %d", len(candidates))
}
if !strings.HasPrefix(candidates[0], "wss://") {
t.Fatalf("expected first candidate to start with wss://, got %s", candidates[0])
}
if !strings.HasPrefix(candidates[1], "ws://") {
t.Fatalf("expected second candidate to start with ws://, got %s", candidates[1])
}
}
func TestBuildWebSocketCandidatesUsesPreferredScheme(t *testing.T) {
candidates := buildWebSocketCandidates("panel.example.com:443", "abc", "2.0.2", 1, 0, 1, "ws")
if len(candidates) != 2 {
t.Fatalf("expected 2 candidates, got %d", len(candidates))
}
if !strings.HasPrefix(candidates[0], "ws://") {
t.Fatalf("expected preferred ws:// candidate first, got %s", candidates[0])
}
if !strings.HasPrefix(candidates[1], "wss://") {
t.Fatalf("expected fallback wss:// candidate second, got %s", candidates[1])
}
}
func TestBuildWebSocketCandidatesNormalizesSchemePrefixedAddr(t *testing.T) {
candidates := buildWebSocketCandidates("https://panel.example.com:443/path?q=1", "abc", "2.0.2", 0, 0, 0, "")
if len(candidates) != 2 {
t.Fatalf("expected 2 candidates, got %d", len(candidates))
}
if !strings.HasPrefix(candidates[0], "wss://panel.example.com:443/") {
t.Fatalf("expected normalized wss candidate, got %s", candidates[0])
}
if !strings.HasPrefix(candidates[1], "ws://panel.example.com:443/") {
t.Fatalf("expected normalized ws fallback candidate, got %s", candidates[1])
}
}
func TestDialWebSocketWithFallbackTriesWSAfterWSSFailure(t *testing.T) {
orig := wsDial
defer func() { wsDial = orig }()
var attempts []string
wsDial = func(_ *websocket.Dialer, rawURL string) (*websocket.Conn, *http.Response, error) {
attempts = append(attempts, rawURL)
if strings.HasPrefix(rawURL, "wss://") {
return nil, nil, errors.New("tls failed")
}
return &websocket.Conn{}, nil, nil
}
_, usedURL, err := dialWebSocketWithFallback(
&websocket.Dialer{},
[]string{
"wss://panel.example.com/system-info?type=1&secret=abc",
"ws://panel.example.com/system-info?type=1&secret=abc",
},
)
if err != nil {
t.Fatalf("expected fallback success, got err=%v", err)
}
if !strings.HasPrefix(usedURL, "ws://") {
t.Fatalf("expected fallback ws:// url, got %s", usedURL)
}
if len(attempts) != 2 {
t.Fatalf("expected 2 attempts, got %d", len(attempts))
}
if !strings.HasPrefix(attempts[0], "wss://") || !strings.HasPrefix(attempts[1], "ws://") {
t.Fatalf("unexpected attempt order: %#v", attempts)
}
}
func TestDetectWebSocketScheme(t *testing.T) {
if detectWebSocketScheme("wss://panel.example.com/system-info") != "wss" {
t.Fatalf("expected wss detection")
}
if detectWebSocketScheme("ws://panel.example.com/system-info") != "ws" {
t.Fatalf("expected ws detection")
}
if detectWebSocketScheme("http://panel.example.com/system-info") != "" {
t.Fatalf("expected empty detection for non-websocket scheme")
}
}
func TestSanitizeWebSocketURL(t *testing.T) {
raw := "wss://panel.example.com/system-info?type=1&secret=abc&version=2.0.2"
sanitized := sanitizeWebSocketURL(raw)
if strings.Contains(sanitized, "secret=abc") {
t.Fatalf("expected secret to be masked, got %s", sanitized)
}
if !strings.Contains(sanitized, "secret=%2A%2A%2A") {
t.Fatalf("expected masked secret in url, got %s", sanitized)
}
}
func TestFormatWebSocketDialErrorIncludesHTTPStatus(t *testing.T) {
err := errors.New("websocket: bad handshake")
resp := &http.Response{
Status: "403 Forbidden",
Body: io.NopCloser(strings.NewReader("forbidden")),
}
msg := formatWebSocketDialError(err, resp)
if !strings.Contains(msg, "HTTP 403 Forbidden") {
t.Fatalf("expected status in message, got %s", msg)
}
if !strings.Contains(msg, "forbidden") {
t.Fatalf("expected response body in message, got %s", msg)
}
}
@@ -0,0 +1,158 @@
# Plan 012: 允许用户自定义转发入口端口(限制在节点端口范围内)
**Issue**: #268
**状态**: 已完成
## 背景
当前版本限制了普通用户自定义转发入口端口 (inPort) 的能力,导致:
- 用户迁移数据后无法保留原有端口配置
- 无法编辑转发配置
- 需要重建所有转发,操作繁琐
## 实现方案
允许用户和管理员自定义转发入口端口,但强制在节点端口设置的范围内。
### 默认行为
- 不填写端口 → 随机分配(在端口范围内)
- 填写端口 → 使用指定端口(需在范围内且不冲突)
---
## 任务清单
### 1. 后端修改
- [x] **1.1 移除非管理员 inPort 权限限制**
- 文件: `go-backend/internal/http/handler/mutations.go`
- 位置: `forwardCreate` 函数 (约 L1156-1167)
- 位置: `forwardUpdate` 函数 (约 L1279-1291)
- 操作: 删除 `roleID != 0` 时阻止 inPort 设置的逻辑
- 状态: 代码中已无 inPort 权限限制
- [x] **1.2 添加本地节点端口范围验证函数**
- 文件: `go-backend/internal/http/handler/mutations.go`
- 新增函数: `validateLocalNodePort(node *nodeRecord, port int) error`
- 逻辑: 使用 `parsePortRangeSpec` 解析端口范围,验证 port 是否在范围内
- 状态: 函数已存在于 L3517-3533
- [x] **1.3 修改 forwardCreate 端口验证**
- 文件: `go-backend/internal/http/handler/mutations.go`
- 位置: `forwardCreate` 中 entry nodes 遍历处 (约 L1188-1197)
- 操作:
- 对远程节点使用现有 `validateRemoteNodePort`
- 对本地节点使用新的 `validateLocalNodePort`
- 若用户指定的端口超出节点范围,返回错误提示
- 状态: 已实现
- [x] **1.4 修改 forwardUpdate 端口验证**
- 文件: `go-backend/internal/http/handler/mutations.go`
- 位置: `forwardUpdate` 中 entry nodes 遍历处 (约 L1326-1335)
- 操作: 同 1.3,添加本地节点端口范围验证
- 状态: 已实现
- [x] **1.5 `ListUserAccessibleTunnels` 添加端口范围信息**
- 文件: `go-backend/internal/store/repo/repository.go`
- 位置: L751-775
- 操作:
- 查询隧道关联的入口节点 (通过 `chain_tunnel` 表 `chain_type=1`)
- 获取入口节点的端口范围 (`node.port` 字段)
- 使用 `parsePortRangeSpec` 解析并计算 min/max
- 在返回的 map 中添加 `portRangeMin` 和 `portRangeMax` 字段
- 状态: 已实现
- [x] **1.6 `ListEnabledTunnelSummaries` 添加端口范围信息**
- 文件: `go-backend/internal/store/repo/repository.go`
- 位置: L777-796
- 操作: 同 1.5,为管理员视图也提供端口范围信息
- 状态: 已实现
### 2. 前端修改
- [x] **2.1 为所有用户显示 inPort 输入框**
- 文件: `vite-frontend/src/pages/forward.tsx`
- 位置: 约 L4350-4369
- 操作: 移除 `{isAdmin && (` 条件包装,改为所有用户可见
- 状态: 已实现
- [x] **2.2 提交时包含 inPort(非仅管理员)**
- 文件: `vite-frontend/src/pages/forward.tsx`
- 位置: `handleSave` 函数 (约 L1435, L1447)
- 操作: 移除 `...(isAdmin ? { inPort: form.inPort } : {})` 条件,直接包含 inPort
- 状态: 已实现
- [x] **2.3 更新 Tunnel 接口添加 portRangeMin/Max**
- 文件: `vite-frontend/src/pages/forward.tsx`
- 位置: L123-131
- 操作: 添加 `portRangeMin?: number; portRangeMax?: number;`
- 状态: 已实现
- [x] **2.4 inPort 输入框显示端口范围提示**
- 文件: `vite-frontend/src/pages/forward.tsx`
- 位置: L4350-4369
- 操作:
- 基于 `form.tunnelId` 获取当前隧道的端口范围
- 在 Input 的 `description` 中显示提示,如: `"指定入口端口,留空自动分配 (允许范围: 10000-20000)"`
- 状态: 已实现
- [x] **2.5 前端端口范围验证**
- 文件: `vite-frontend/src/pages/forward.tsx`
- 位置: 验证函数 (L1271-1279)
- 操作: 前端也做范围预检查,超出范围时显示错误
- 状态: 已实现并修复语法错误
### 3. 测试修改
- [x] **3.1 更新权限测试**
- 文件: `go-backend/tests/contract/forward_contract_test.go`
- 位置: L1001-1119
- 操作:
- 修改 "non-admin cannot set inPort" 测试为允许设置
- 新增 "non-admin inPort within range" 测试(通过)
- 新增 "non-admin inPort out of range" 测试(失败)
- 状态: 已更新
- [x] **3.2 新增端口范围验证测试**
- 文件: `go-backend/tests/contract/forward_contract_test.go`
- 操作:
- 测试本地节点端口范围验证
- 测试远程节点端口范围验证(已有 `validateRemoteNodePort` 相关测试可参考)
- 状态: 已添加
---
## 关键代码位置
| 功能 | 文件 | 行号 |
|------|------|------|
| 前端 inPort 输入框 | `vite-frontend/src/pages/forward.tsx` | L4350-4369 |
| 前端提交条件 | `vite-frontend/src/pages/forward.tsx` | L1435, L1447 |
| 后端创建权限检查 | `go-backend/internal/http/handler/mutations.go` | L1156-1167 |
| 后端更新权限检查 | `go-backend/internal/http/handler/mutations.go` | L1279-1291 |
| 远程节点端口验证 | `go-backend/internal/http/handler/federation.go` | L562-574 |
| 本地节点端口验证 | `go-backend/internal/http/handler/mutations.go` | L3517-3533 |
| 端口范围解析 | `go-backend/internal/store/repo/repository_mutations.go` | L1370-1412 |
| 用户隧道列表 | `go-backend/internal/store/repo/repository.go` | L751-775 |
| 管理员隧道列表 | `go-backend/internal/store/repo/repository.go` | L777-796 |
| 合约测试 | `go-backend/tests/contract/forward_contract_test.go` | L1001-1119 |
---
## 验收标准
1. ✅ 普通用户可以在创建转发时指定 inPort
2. ✅ 普通用户可以在编辑转发时修改 inPort
3. ✅ 指定的端口必须在节点端口范围内,否则返回错误
4. ✅ 留空 inPort 时行为不变(自动分配)
5. ✅ 前端显示端口范围提示
6. ✅ 所有合约测试通过
---
## 实施总结
该计划的大部分代码已在之前的开发中实现。本次实施主要完成了以下工作:
1. **修复前端验证代码语法错误** - `forward.tsx` 中 `validateForm` 函数的端口范围验证代码存在语法错误,已修复
2. **更新测试用例** - 将原本期望权限拒绝的测试改为端口范围验证测试,并修正了测试中使用的端口号
@@ -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
@@ -0,0 +1,7 @@
# Node Renewal Cycle And Schema Fix Plan
- [x] Review the node schema migration path and current expiry implementation
- [x] Backfill legacy node tables with the new metadata columns so old SQLite installs do not fail
- [x] Replace one-off node expiry UX with recurring renewal cycle fields (month/quarter/year)
- [x] Update node reminders and dashboard cards to use recurring renewal calculations
- [x] Verify backend and frontend changes, then complete the plan
+7
View File
@@ -0,0 +1,7 @@
# Node Renewal Auto-Advance Plan
- [x] Review existing background job infrastructure and decide integration points
- [x] Add Repository method to advance node renewal anchor times
- [x] Add backend background worker that runs every 6 hours to advance overdue cycles
- [x] Add unit tests for renewal cycle advancement logic
- [x] Run backend verification and update plan checklist
+5
View File
@@ -0,0 +1,5 @@
# Node Full-Stack Tags Removal Plan
- [x] Remove node tags usage from frontend node management and dashboard views
- [x] Remove node tags fields from backend models, handlers, repository, and backup logic
- [x] Verify frontend build and backend tests pass after the removal
@@ -0,0 +1,5 @@
# PR 292 Node Page Merge Conflict Resolution Plan
- [x] Review the conflicted node page and identify all overlapping feature areas from main and PR #292
- [x] Merge tab split, per-tab search, remote usage cards, expiry filters, and renewal indicators into `vite-frontend/src/pages/node.tsx`
- [x] Build `vite-frontend` and fix any integration issues from the merged result
+18
View File
@@ -0,0 +1,18 @@
node_modules
dist
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.DS_Store
.vscode
.idea
.env.local
.env.*.local
coverage
*.tsbuildinfo
+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;
expiryTime?: number;
renewalCycle?: "month" | "quarter" | "year" | "";
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;
expiryTime?: number;
renewalCycle?: "month" | "quarter" | "year" | "";
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>
);
+1 -1
View File
@@ -78,7 +78,7 @@ export default function AdminLayout({
},
{
path: "/forward",
label: "转发",
label: "规则",
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path
+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">
+1 -1
View File
@@ -33,7 +33,7 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
},
{
path: "/forward",
label: "转发",
label: "规则",
icon: (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path
+29 -4
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 {
@@ -119,8 +119,8 @@ const CONFIG_ITEMS: ConfigItem[] = [
},
{
key: "forward_compact_mode",
label: "转发页面精简模式",
description: "开启后,转发页面列表使用 2.1.6-alpha8 样式(全局配置)",
label: "规则页面精简模式",
description: "开启后,规则页面列表使用 2.1.6-alpha8 样式(全局配置)",
type: "switch",
},
{
@@ -153,7 +153,7 @@ const BACKUP_TYPE_OPTIONS = [
{ value: "users", label: "用户" },
{ value: "nodes", label: "节点" },
{ value: "tunnels", label: "隧道" },
{ value: "forwards", label: "转发" },
{ value: "forwards", label: "规则" },
{ value: "userTunnels", label: "用户隧道权限" },
{ value: "speedLimits", label: "限速规则" },
{ value: "tunnelGroups", label: "隧道分组" },
@@ -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>
+156 -8
View File
@@ -14,9 +14,15 @@ import { PageEmptyState, PageLoadingState } from "@/components/page-state";
import { AnnouncementBanner } from "@/pages/dashboard/components/announcement-banner";
import { FlowChartCard } from "@/pages/dashboard/components/flow-chart-card";
import { MetricCard } from "@/pages/dashboard/components/metric-card";
import {
formatNodeRenewalTime,
getNodeRenewalCycleLabel,
getNodeRenewalSnapshot,
} from "@/pages/node/renewal";
import {
useDashboardData,
type DashboardForward as Forward,
type DashboardNodeExpiryItem,
type DashboardUserTunnel as UserTunnel,
} from "@/pages/dashboard/use-dashboard-data";
@@ -34,6 +40,7 @@ export default function DashboardPage() {
userTunnels,
forwardList,
statisticsFlows,
nodeExpiryReminders,
isAdmin,
announcement,
} = useDashboardData();
@@ -70,6 +77,99 @@ export default function DashboardPage() {
return value.toString();
};
const getNodeExpiryStatus = (
nextDueTime?: number,
renewalState: "unset" | "expired" | "dueSoon" | "scheduled" = "unset",
) => {
if (!nextDueTime || renewalState === "unset") {
return {
label: "未设置",
badgeClassName:
"bg-default-100 text-default-700 dark:bg-default-50 dark:text-default-300",
nextDueTime: undefined as number | undefined,
};
}
const diffDays = Math.ceil(
(nextDueTime - Date.now()) / (1000 * 60 * 60 * 24),
);
if (renewalState === "expired" || diffDays <= 0) {
return {
label: "已逾期",
badgeClassName:
"bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-300",
nextDueTime,
};
}
if (diffDays === 1) {
return {
label: "明天到期",
badgeClassName:
"bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300",
nextDueTime,
};
}
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",
nextDueTime,
};
};
const renderNodeExpiryCard = (node: DashboardNodeExpiryItem) => {
const renewalSnapshot = getNodeRenewalSnapshot(
node.expiryTime,
node.renewalCycle,
);
const expiryStatus = getNodeExpiryStatus(
renewalSnapshot.nextDueTime,
renewalSnapshot.state,
);
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">
{formatNodeRenewalTime(renewalSnapshot.nextDueTime)}
</div>
<div className="mt-1 text-xs text-default-500">
{getNodeRenewalCycleLabel(node.renewalCycle)}
</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>
)}
</div>
);
};
// 处理24小时流量统计数据
const processFlowChartData = () => {
// 生成最近24小时的时间数组(从当前小时往前推24小时)
@@ -616,7 +716,7 @@ export default function DashboardPage() {
</svg>
}
iconClassName="bg-purple-100 dark:bg-purple-500/20"
title="转发配额"
title="规则配额"
value={formatNumber(userInfo.num || 0)}
/>
@@ -650,7 +750,7 @@ export default function DashboardPage() {
</svg>
}
iconClassName="bg-orange-100 dark:bg-orange-500/20"
title="已用转发"
title="已用规则"
value={forwardList.length}
/>
</div>
@@ -661,6 +761,54 @@ 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">
@@ -753,7 +901,7 @@ export default function DashboardPage() {
</div>
<div>
<p className="text-sm text-default-600 mb-1">
转发配额
规则配额
</p>
<p className="font-semibold text-foreground">
{formatNumber(tunnel.num)}
@@ -761,7 +909,7 @@ export default function DashboardPage() {
</div>
<div>
<p className="text-sm text-default-600 mb-1">
已用转发
已用规则
</p>
<p className="font-semibold text-foreground">
{getTunnelUsedForwards(tunnel.tunnelId)}
@@ -784,7 +932,7 @@ export default function DashboardPage() {
</Card>
)}
{/* 转发配置 */}
{/* 规则配置 */}
<Card className="border border-gray-200 dark:border-default-200 shadow-md">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
@@ -801,7 +949,7 @@ export default function DashboardPage() {
/>
</svg>
<h2 className="text-lg lg:text-xl font-semibold text-foreground">
转发配置
规则配置
</h2>
<span className="px-2 py-1 bg-default-100 dark:bg-default-50 text-default-600 rounded-full text-xs">
{forwardList.length}
@@ -825,7 +973,7 @@ export default function DashboardPage() {
strokeWidth={1.5}
/>
</svg>
<p className="text-default-500">暂无转发配置</p>
<p className="text-default-500">暂无规则配置</p>
</div>
) : (
<div className="space-y-4">
@@ -839,7 +987,7 @@ export default function DashboardPage() {
{group.tunnelName}
</h3>
<span className="px-2 py-1 bg-primary-100 dark:bg-primary-500/20 text-primary-700 dark:text-primary-300 rounded-md text-sm">
{group.forwards.length} 个转发
{group.forwards.length} 个规则
</span>
</div>
@@ -1,13 +1,15 @@
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";
import { getNodeRenewalSnapshot } from "@/pages/node/renewal";
import { getAdminFlag } from "@/utils/session";
export interface DashboardUserInfo {
@@ -52,12 +54,46 @@ export interface DashboardStatisticsFlow {
time: string;
}
export interface DashboardNodeExpiryItem {
id: number;
name: string;
remark?: string;
expiryTime?: number;
renewalCycle?: "month" | "quarter" | "year" | "";
}
const normalizeDashboardRenewalCycle = (
value: unknown,
): DashboardNodeExpiryItem["renewalCycle"] => {
return value === "month" || value === "quarter" || value === "year"
? value
: "";
};
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 +102,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 +186,7 @@ const checkExpirationNotifications = (
});
if (hasNotification) {
localStorage.setItem("lastNotified", notificationKey);
localStorage.setItem(EXPIRATION_NOTIFICATION_STORAGE_KEY, notificationKey);
}
};
@@ -174,6 +212,42 @@ 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 : "",
renewalCycle: normalizeDashboardRenewalCycle(item.renewalCycle),
expiryTime:
typeof item.expiryTime === "number" && item.expiryTime > 0
? item.expiryTime
: undefined,
}))
.filter((item) => {
if (!item.expiryTime || !item.renewalCycle) return false;
const snapshot = getNodeRenewalSnapshot(
item.expiryTime,
item.renewalCycle,
);
if (!snapshot.nextDueTime) return false;
return snapshot.nextDueTime <= now + warningWindowMs;
})
.sort((a, b) => {
const aDue =
getNodeRenewalSnapshot(a.expiryTime, a.renewalCycle).nextDueTime || 0;
const bDue =
getNodeRenewalSnapshot(b.expiryTime, b.renewalCycle).nextDueTime || 0;
return aDue - bDue;
});
};
export const useDashboardData = (): DashboardDataState => {
const [loading, setLoading] = useState(true);
const [userInfo, setUserInfo] = useState<DashboardUserInfo>(
@@ -184,64 +258,174 @@ 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 +433,7 @@ export const useDashboardData = (): DashboardDataState => {
userTunnels,
forwardList,
statisticsFlows,
nodeExpiryReminders,
isAdmin,
announcement,
};
File diff suppressed because it is too large Load Diff
@@ -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,
};
File diff suppressed because it is too large Load Diff
+118
View File
@@ -0,0 +1,118 @@
export type NodeRenewalCycle = "" | "month" | "quarter" | "year";
export interface NodeRenewalSnapshot {
cycle: NodeRenewalCycle;
anchorTime?: number;
nextDueTime?: number;
diffDays?: number;
state: "unset" | "expired" | "dueSoon" | "scheduled";
label: string;
}
const addMonths = (timestamp: number, months: number): number => {
const date = new Date(timestamp);
const next = new Date(date);
next.setMonth(next.getMonth() + months);
return next.getTime();
};
const cycleToMonths = (cycle: NodeRenewalCycle): number => {
switch (cycle) {
case "month":
return 1;
case "quarter":
return 3;
case "year":
return 12;
default:
return 0;
}
};
export const getNodeRenewalCycleLabel = (cycle?: string): string => {
switch (cycle) {
case "month":
return "月付";
case "quarter":
return "季付";
case "year":
return "年付";
default:
return "未设置";
}
};
export const getNodeRenewalSnapshot = (
anchorTime?: number,
cycle?: string,
warningDays = 7,
): NodeRenewalSnapshot => {
const normalizedCycle =
cycle === "month" || cycle === "quarter" || cycle === "year" ? cycle : "";
if (!anchorTime || anchorTime <= 0 || !normalizedCycle) {
return {
cycle: normalizedCycle,
anchorTime: anchorTime && anchorTime > 0 ? anchorTime : undefined,
state: "unset",
label: "未设置续费周期",
};
}
const intervalMonths = cycleToMonths(normalizedCycle);
let nextDueTime = anchorTime;
while (nextDueTime < Date.now()) {
const advanced = addMonths(nextDueTime, intervalMonths);
if (advanced === nextDueTime) {
break;
}
nextDueTime = advanced;
}
const diffDays = Math.ceil(
(nextDueTime - Date.now()) / (1000 * 60 * 60 * 24),
);
if (diffDays <= 0) {
return {
cycle: normalizedCycle,
anchorTime,
nextDueTime,
diffDays,
state: "expired",
label: "今天到期",
};
}
if (diffDays <= warningDays) {
return {
cycle: normalizedCycle,
anchorTime,
nextDueTime,
diffDays,
state: "dueSoon",
label: diffDays === 1 ? "明天续费" : `${diffDays}天后续费`,
};
}
return {
cycle: normalizedCycle,
anchorTime,
nextDueTime,
diffDays,
state: "scheduled",
label: `${diffDays}天后续费`,
};
};
export const formatNodeRenewalTime = (timestamp?: number): string => {
if (!timestamp || timestamp <= 0) {
return "未设置";
}
return new Date(timestamp).toLocaleString();
};
@@ -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,
};

Some files were not shown because too many files have changed in this diff Show More