mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-14 16:54:24 +08:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 413081f72a | |||
| 508a37a84c | |||
| d60655045a | |||
| 31ef861504 | |||
| f1bdb2e2ef | |||
| bd30b61018 | |||
| e0dd70a054 | |||
| 4966a8aad1 | |||
| 3e11549370 | |||
| addf83a249 | |||
| c3e35fd416 | |||
| 775dfe19f1 | |||
| db3b2f651b | |||
| 669323f926 | |||
| 7202b69e4e | |||
| 31977a62e6 | |||
| 87479c2ac1 | |||
| ffda0fb71a | |||
| 9c0e7341c3 | |||
| 1db5452be9 | |||
| c10f894afd | |||
| 7fb75baa73 | |||
| 15e6cd69eb | |||
| f6eb88d75e | |||
| f45b580984 | |||
| 4f50c47550 | |||
| 2e1d75dc36 | |||
| f496f58a4d | |||
| 32474bec20 | |||
| 581cda7edc | |||
| 96aebb8d61 | |||
| 735fd40786 | |||
| a3b0bf4898 | |||
| 9703e4a081 | |||
| a43653f252 | |||
| 348900de01 | |||
| b93c259fac | |||
| c8c1841058 | |||
| 2ff52e3275 | |||
| a00b20abf3 | |||
| b815be54b8 |
@@ -0,0 +1,32 @@
|
||||
# Session Context
|
||||
|
||||
Session ID: ses_349a99cf4ffeQMqVmv7PqyI3y6
|
||||
Commit Message: 普通用户添加转发的时候应该不能有权限设置限速和设置自定义端口,请检查并规划设计
|
||||
|
||||
## Prompts
|
||||
|
||||
### Prompt 1
|
||||
|
||||
普通用户添加转发的时候应该不能有权限设置限速和设置自定义端口,请检查并规划设计
|
||||
|
||||
## Summary
|
||||
|
||||
## 分析结果
|
||||
|
||||
### 当前状态
|
||||
- **角色系统**: `roleID == 0` 为管理员,`roleID != 0` 为普通用户
|
||||
- **问题**: 普通用户在创建/编辑转发时可设置 `speedId`(限速) 和 `inPort`(自定义端口)
|
||||
|
||||
### 实施方案
|
||||
|
||||
**后端修改** (`go-backend/internal/http/handler/mutations.go`):
|
||||
- `forwardCreate`: 检查 `roleID != 0` 时拒绝 `speedId` 和 `inPort`
|
||||
- `forwardUpdate`: 检查 `actorRole != 0` 时拒绝 `speedId` 和 `inPort`
|
||||
|
||||
**前端修改** (`vite-frontend/src/pages/forward.tsx`):
|
||||
- 限速规则选择器和入口端口输入框使用 `{isAdmin && (...)}` 条件渲染
|
||||
|
||||
**测试**:
|
||||
- 添加契约测试验证权限限制
|
||||
|
||||
计划文档: `plans/009-restrict-user-forward-permissions.md`
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
普通用户添加转发的时候应该不能有权限设置限速和设置自定义端口,请检查并规划设计
|
||||
@@ -0,0 +1,19 @@
|
||||
## 分析结果
|
||||
|
||||
### 当前状态
|
||||
- **角色系统**: `roleID == 0` 为管理员,`roleID != 0` 为普通用户
|
||||
- **问题**: 普通用户在创建/编辑转发时可设置 `speedId`(限速) 和 `inPort`(自定义端口)
|
||||
|
||||
### 实施方案
|
||||
|
||||
**后端修改** (`go-backend/internal/http/handler/mutations.go`):
|
||||
- `forwardCreate`: 检查 `roleID != 0` 时拒绝 `speedId` 和 `inPort`
|
||||
- `forwardUpdate`: 检查 `actorRole != 0` 时拒绝 `speedId` 和 `inPort`
|
||||
|
||||
**前端修改** (`vite-frontend/src/pages/forward.tsx`):
|
||||
- 限速规则选择器和入口端口输入框使用 `{isAdmin && (...)}` 条件渲染
|
||||
|
||||
**测试**:
|
||||
- 添加契约测试验证权限限制
|
||||
|
||||
计划文档: `plans/009-restrict-user-forward-permissions.md`
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
提交全部变更并且push,创建pr合并,发布新tag,2.1.7-beta6
|
||||
@@ -0,0 +1,5 @@
|
||||
完成:
|
||||
- 已提交并推送变更
|
||||
- PR #259 已合并到 main
|
||||
- 已创建并推送 tag `2.1.7-beta6`
|
||||
- 已删除远程分支
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
转发选择限速后保存,然后把限速删除,再次编辑转发报错会报错限速不存在,请保存的时候限速不存在直接删除限速,不阻断提示限速不存在
|
||||
@@ -0,0 +1,20 @@
|
||||
我已经定位到问题点了,确实在后端 `forward/update` 的限速校验会把“已被删除的限速ID”当成硬错误返回,导致你说的二次编辑报 `限速规则不存在`。
|
||||
|
||||
**定位结果**
|
||||
- 触发点在 `go-backend/internal/http/handler/mutations.go:1259` 附近(`forwardUpdate`)和 `go-backend/internal/http/handler/mutations.go:1147` 附近(`forwardCreate`)。
|
||||
- 当前逻辑是:只要请求里带了 `speedId`,且 `SpeedLimitExists=false`,就直接 `ErrDefault("限速规则不存在")` 返回。
|
||||
- 前端编辑提交在 `vite-frontend/src/pages/forward.tsx:1403` 会一直带 `speedId`(包括编辑场景),所以很容易复现你说的问题。
|
||||
|
||||
**实施计划(不改动其他业务)**
|
||||
- 新建计划文档:`plans/006-forward-save-missing-speed-limit-auto-clear.md`(按现有 001~005 递增)。
|
||||
- 后端仅改转发保存逻辑,不改用户隧道限速逻辑:
|
||||
- `forwardCreate`:若 `speedId` 不存在,自动当成 `nil`(即“不限速”)继续保存,不返回“限速不存在”错误。
|
||||
- `forwardUpdate`:按“最终要写入的 speed_id”做一次兜底校验;若不存在则自动清空为 `NULL` 再继续保存,确保编辑不阻断。
|
||||
- 不新增“限速不存在”阻断提示(符合你要求)。
|
||||
- 增加/更新合约测试:`go-backend/tests/contract/forward_contract_test.go`
|
||||
- 覆盖场景:转发绑定限速后,删除该限速,再编辑转发保存,应返回成功且 `forward.speed_id` 被清空。
|
||||
- 验证命令(实施后执行):
|
||||
- `cd go-backend && go test ./tests/contract/... -run Forward`
|
||||
- 如有必要再跑 `cd go-backend && go test ./...`
|
||||
|
||||
如果你确认,我下一步就按这个方案进入实现。
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
提交全部变更并且push,创建pr合并,发布新tag,2.1.7-beta5
|
||||
@@ -0,0 +1,7 @@
|
||||
✅ 已完成:
|
||||
- 提交变更:feat: auto-fallback to default bind IP when invalid bind address detected
|
||||
- 推送分支:opencode/mighty-nebula → origin
|
||||
- 创建PR:#258(已自动合并)
|
||||
- 发布tag:2.1.7-beta5
|
||||
|
||||
PR: https://github.com/Sagit-chu/flvx/pull/258
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
提交全部变更并且push,创建pr合并,发布新tag,2.1.7-beta4
|
||||
@@ -0,0 +1,6 @@
|
||||
Tag `2.1.7-beta4` 已在 GitHub 远程创建成功。
|
||||
|
||||
**完成摘要:**
|
||||
- 提交: `7efb49b` - fix: handle forward service rebind on self-occupied port
|
||||
- PR: https://github.com/Sagit-chu/flvx/pull/257 (已合并)
|
||||
- Tag: `2.1.7-beta4` 已推送到远程
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,17 @@
|
||||
转发编辑保存报错create service 3_1_0_tcp failed: listen tcp4 13.228.170.187:16765: bind: cannot assign requested address
|
||||
引发条件:当编辑选择入口IP原来是默认地址,选择成和默认地址一样的地址的时候报错
|
||||
|
||||
|
||||
---
|
||||
|
||||
保持“只要用户选了IP就一定显式绑定”,如果选择的IP后发现端口占用,检测是否是当前转发占用的,如果是那就释放掉,避免原来是默认监听::所有地址后永远无法切换
|
||||
|
||||
---
|
||||
|
||||
按计划实现
|
||||
|
||||
---
|
||||
|
||||
节点 test-sg 下发失败: create service 3_1_0_tcp failed: listen tcp4 13.228.170.187:56025: bind: cannot assign requested address
|
||||
|
||||
还是不行啊,由默认入口IP创建的转发,然后编辑选择了唯一的一个IP后报错了
|
||||
@@ -0,0 +1,23 @@
|
||||
你这个新报错我确认了:这次**不是端口占用问题**,是 **IP 本机不可绑定** 问题。
|
||||
|
||||
- 报错是 `bind: cannot assign requested address`,含义是节点机器网卡上没有 `13.228.170.187` 这个本地地址;释放旧服务也不会解决。
|
||||
- 你说“唯一一个IP可选”,很可能来自前端当前候选逻辑把 `serverIp/serverIpV4/serverIpV6` 当成监听候选(`vite-frontend/src/pages/forward.tsx:612` 一带),但这些字段在 NAT/云环境下常是“对外地址”,不一定是本机可 bind 地址。
|
||||
- 我之前加的自占用释放逻辑会在冲突时尝试删旧服务再加新服务,但对 `cannot assign requested address` 这种错误会一直失败(因为 IP 本身不可绑定)。
|
||||
|
||||
我建议下一步这样改(这次是关键):
|
||||
|
||||
- 后端只把 `address already in use` 走“自占用释放重绑”流程;`cannot assign requested address` 不走该流程。
|
||||
位置:`go-backend/internal/http/handler/control_plane.go`
|
||||
- 前端监听 IP 下拉改为“可绑定地址优先”:优先 `extraIPs` + 明确配置的监听地址,**不再默认把 `serverIp*` 作为可绑定监听IP**。
|
||||
位置:`vite-frontend/src/pages/forward.tsx`
|
||||
- 后端增加快速校验并返回更明确提示:
|
||||
`监听IP不是节点本机地址,请在节点 extraIPs 配置可绑定IP后再选择`。
|
||||
位置:`go-backend/internal/http/handler/control_plane.go` + repo 查询辅助。
|
||||
- 你的临时可用绕过:到节点配置里把真实网卡 IP 填到 `extraIPs`,然后在编辑转发时选该 IP。
|
||||
|
||||
一个需要你拍板的点(推荐第1个):
|
||||
|
||||
1. **推荐**:前端不再展示 `serverIp/serverIpV4/serverIpV6` 为监听IP候选,只展示 `extraIPs`(和明确监听地址)。
|
||||
2. 继续展示 `serverIp*`,但加“可能不可绑定”标签和失败提示。
|
||||
|
||||
你回我 `1` 或 `2`,我就按这个继续落地。
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
提交全部变更并且push,创建pr合并,发布新tag,2.1.7-beta3
|
||||
@@ -0,0 +1,8 @@
|
||||
完成!已成功:
|
||||
|
||||
1. **提交变更**: 5个文件(修复绑定IP保留问题 + 新增单元测试)
|
||||
2. **推送分支**: opencode/mighty-nebula → origin
|
||||
3. **创建PR**: #256 (已合并)
|
||||
4. **发布tag**: 2.1.7-beta3
|
||||
|
||||
PR #256: https://github.com/Sagit-chu/flvx/pull/256
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
编辑转发后保存报错 下发失败: create service 3_1_0_tcp failed: listen tcp4 13.228.170.187:16765: bind: cannot assign requested address,这个bug是因为增加了自定义IP功能后导致的,监听IP 不选择就会报错,应该根据原来的监听状态保存
|
||||
@@ -0,0 +1,23 @@
|
||||
你这个判断是对的,我已经把链路看清了,确实是“新增自定义监听 IP 后,编辑保存把原监听状态覆盖掉”导致的。
|
||||
|
||||
- 前端编辑表单会把 `forward.inIp` 直接回填到 `form.inIp`(`vite-frontend/src/pages/forward.tsx:1302`)。
|
||||
- 保存时会把 `form.inIp` 带到更新请求(`vite-frontend/src/pages/forward.tsx:1391`)。
|
||||
- 后端更新时无论是否真的改了监听 IP,都会用请求里的 `inIp` 全量重建 `forward_port`(`go-backend/internal/http/handler/mutations.go:1288`、`go-backend/internal/http/handler/mutations.go:1305`、`go-backend/internal/http/handler/mutations.go:3019`)。
|
||||
- 而列表里的 `forward.inIp` 可能是“展示回退值”(隧道 IP/节点 IP),不一定是原始 `forward_port.in_ip`(`go-backend/internal/store/repo/repository.go:731`、`go-backend/internal/store/repo/repository.go:2778`、`go-backend/internal/store/repo/repository.go:2780`)。
|
||||
- 这样就会在“没手动选监听 IP”时,把服务改成绑定不可用地址,触发 `bind: cannot assign requested address`。
|
||||
|
||||
我建议的修复方案(实现时会做成双保险):
|
||||
|
||||
- 前端:编辑模式下仅当用户“主动改过监听 IP”时才提交 `inIp` 字段;未触碰则不传,保持原状态。
|
||||
- 后端:`forward/update` 中如果请求没带 `inIp`,就保留原 `forward_port.in_ip`(不清空、不覆盖);若带了 `inIp`(包括空串),按用户意图更新。
|
||||
- 回归测试:
|
||||
- 后端新增用例:未传 `inIp` 时更新不改变监听绑定。
|
||||
- 后端新增用例:显式传空串时清空监听绑定。
|
||||
- (可选)前端补充一个轻量逻辑测试/至少手工验证路径。
|
||||
|
||||
另外按仓库规则,这次会新增计划文档为:`plans/003-forward-edit-bind-ip-preserve.md`(当前是只读 Plan Mode,我还没动文件)。
|
||||
|
||||
一个需要你拍板的小点(我给默认建议):
|
||||
- **推荐默认**:编辑时如果改了隧道但没有手动选“监听IP”,按“默认监听(空)”处理,不继承旧隧道的监听 IP。
|
||||
这样能避免把旧隧道的 IP 误带到新隧道。
|
||||
你是否同意按这个规则?
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,13 @@
|
||||
提交全部变更并且push,创建pr合并,发布新tag,beta版本
|
||||
|
||||
---
|
||||
|
||||
不对,tag是2.1.7-beta2
|
||||
|
||||
---
|
||||
|
||||
2.1.8-beta1 标签没有被删除成功
|
||||
|
||||
---
|
||||
|
||||
2.1.8-alpha1 标签没有被删除成功
|
||||
@@ -0,0 +1 @@
|
||||
已删除 `2.1.8-alpha1` 标签。当前最新版本为 `2.1.7-beta2`。
|
||||
@@ -0,0 +1,41 @@
|
||||
# Session Context
|
||||
|
||||
Session ID: ses_34d1fa4b1ffeH4sVbjXkh5P6IY
|
||||
Commit Message: 实施
|
||||
|
||||
## Prompts
|
||||
|
||||
### Prompt 1
|
||||
|
||||
转发导入,ny兼容格式,不要选择入口,改成选择隧道,修改兼容一下
|
||||
|
||||
### Prompt 2
|
||||
|
||||
自动分配端口(推荐)
|
||||
|
||||
### Prompt 3
|
||||
|
||||
实施
|
||||
|
||||
## Summary
|
||||
|
||||
已实施完成,这次改动把 `ny` 导入完全对齐到“选隧道”流程,并按你指定支持“端口缺失自动分配”。
|
||||
|
||||
- `ny` 解析兼容增强:支持字段别名、可选端口
|
||||
- 修改 `vite-frontend/src/pages/forward/import-format.ts`
|
||||
- `listen_port` 现在允许为 `null`(缺失即自动分配)
|
||||
- 兼容字段:`dest/dst/target/targets`、`listen_port/listenPort/port/in_port/inPort`、`name/forward_name/forwardName`
|
||||
- `dest` 支持数组或逗号分隔字符串,统一归一化
|
||||
- 导入 UI 文案已同步更新(强调按所选隧道导入 + 端口可省略)
|
||||
- 修改 `vite-frontend/src/pages/forward.tsx:4623`、`vite-frontend/src/pages/forward.tsx:4699`
|
||||
- 测试已补齐并通过
|
||||
- 修改 `vite-frontend/src/pages/forward/import-format.test.ts`
|
||||
- 新增用例:缺失端口自动分配、别名字段兼容、`inPort: null` 映射
|
||||
- 计划文档已新增并完成勾选
|
||||
- `plans/002-ny-import-tunnel-selection-compat.md`
|
||||
|
||||
验证结果:
|
||||
- `npm run build`(`vite-frontend`)通过
|
||||
- `node --test src/pages/forward/import-format.test.ts` 7/7 通过
|
||||
|
||||
如果你要,我下一步可以直接帮你提交一个规范 commit。
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
|
||||
转发导入,ny兼容格式,不要选择入口,改成选择隧道,修改兼容一下
|
||||
|
||||
---
|
||||
|
||||
自动分配端口(推荐)
|
||||
|
||||
---
|
||||
|
||||
实施
|
||||
@@ -0,0 +1,20 @@
|
||||
已实施完成,这次改动把 `ny` 导入完全对齐到“选隧道”流程,并按你指定支持“端口缺失自动分配”。
|
||||
|
||||
- `ny` 解析兼容增强:支持字段别名、可选端口
|
||||
- 修改 `vite-frontend/src/pages/forward/import-format.ts`
|
||||
- `listen_port` 现在允许为 `null`(缺失即自动分配)
|
||||
- 兼容字段:`dest/dst/target/targets`、`listen_port/listenPort/port/in_port/inPort`、`name/forward_name/forwardName`
|
||||
- `dest` 支持数组或逗号分隔字符串,统一归一化
|
||||
- 导入 UI 文案已同步更新(强调按所选隧道导入 + 端口可省略)
|
||||
- 修改 `vite-frontend/src/pages/forward.tsx:4623`、`vite-frontend/src/pages/forward.tsx:4699`
|
||||
- 测试已补齐并通过
|
||||
- 修改 `vite-frontend/src/pages/forward/import-format.test.ts`
|
||||
- 新增用例:缺失端口自动分配、别名字段兼容、`inPort: null` 映射
|
||||
- 计划文档已新增并完成勾选
|
||||
- `plans/002-ny-import-tunnel-selection-compat.md`
|
||||
|
||||
验证结果:
|
||||
- `npm run build`(`vite-frontend`)通过
|
||||
- `node --test src/pages/forward/import-format.test.ts` 7/7 通过
|
||||
|
||||
如果你要,我下一步可以直接帮你提交一个规范 commit。
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
|
||||
提交全部变更并且push,创建pr合并,发布新tag,alpha版本
|
||||
|
||||
---
|
||||
|
||||
不对,撤销tag,发布2.1.7-beta1
|
||||
@@ -0,0 +1,3 @@
|
||||
已完成:
|
||||
- 删除标签 `2.1.8-alpha1`(本地+远程)
|
||||
- 创建并推送标签 `2.1.7-beta1` 到 `origin/main`
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
提交全部变更并且push,创建pr合并,发布新tag,alpha版本
|
||||
@@ -0,0 +1,13 @@
|
||||
完成!
|
||||
|
||||
**总结:**
|
||||
- ✅ 提交变更:`feat: add ny format support for forward import with node-based tunnel matching`
|
||||
- ✅ 推送到远程分支 `opencode/mighty-nebula`
|
||||
- ✅ 创建 PR #250 并自动合并到 main
|
||||
- ✅ 发布新 tag `2.1.7-alpha9`
|
||||
|
||||
**新版本特性:**
|
||||
- 新增 ny 格式转发数据导入支持(JSON格式,多目标地址负载均衡)
|
||||
- 入口节点选择功能,自动匹配端口转发类型隧道
|
||||
- 多隧道匹配时提供选择界面
|
||||
- 新增 import-format.ts 解析模块及测试文件
|
||||
@@ -0,0 +1,49 @@
|
||||
# Session Context
|
||||
|
||||
Session ID: ses_34d7fcdb8ffekb7lvyJwVvoS8T
|
||||
Commit Message: 增加功能:转发规则批量导入格式,兼容ny的导入格式
|
||||
ny格式如下:
|
||||
{"dest":["151.241.129.52:23609"],"list
|
||||
|
||||
## Prompts
|
||||
|
||||
### Prompt 1
|
||||
|
||||
增加功能:转发规则批量导入格式,兼容ny的导入格式
|
||||
ny格式如下:
|
||||
{"dest":["151.241.129.52:23609"],"listen_port":20224,"name":"灵玥-JP-Lpt【三网通用】"}{"dest":["64.81.33.2:24577"],"listen_port":41034,"name":"Yolo-US-Lpt【三网通用】"}
|
||||
|
||||
ny导入的时候是选择入口的,flvx转发导入是选择隧道的,需要兼容一下,请设计计划
|
||||
|
||||
## Summary
|
||||
|
||||
计划文档已设计完成。以下是计划摘要:
|
||||
|
||||
## 计划摘要
|
||||
|
||||
### 格式对比
|
||||
| 特性 | flvx格式 | ny格式 |
|
||||
|------|----------|--------|
|
||||
| 格式 | 管道分隔文本 | 紧凑JSON对象 |
|
||||
| 示例 | `ip:port\|name\|port` | `{"dest":["ip:port"],"listen_port":port,"name":"name"}` |
|
||||
| 多目标 | 逗号分隔 | dest数组 |
|
||||
| 选择器 | 选择隧道 | 选择入口节点→匹配隧道 |
|
||||
|
||||
### 核心设计
|
||||
1. **格式选择器**:手动选择"flvx格式"或"ny格式"
|
||||
2. **ny格式流程**:选择入口节点 → 匹配端口转发隧道 → 匹配0个报错、1个直接用、多个弹窗选
|
||||
3. **不自动创建隧道**:无匹配时提示用户先创建
|
||||
|
||||
### 任务清单
|
||||
1. 新增`importFormat`、`selectedEntryNode`、`matchedTunnels`等状态
|
||||
2. 重构导入模态框UI,添加格式选择器
|
||||
3. 实现ny格式紧凑JSON解析函数
|
||||
4. 实现入口节点选择与隧道匹配逻辑
|
||||
5. 多隧道匹配时的选择器弹窗
|
||||
6. 整合ny格式到`executeImport`函数
|
||||
7. 更新提示文案
|
||||
|
||||
### 改动范围
|
||||
- `vite-frontend/src/pages/forward.tsx` - 主要改动文件
|
||||
|
||||
是否需要我调整计划内容?
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
|
||||
增加功能:转发规则批量导入格式,兼容ny的导入格式
|
||||
ny格式如下:
|
||||
{"dest":["151.241.129.52:23609"],"listen_port":20224,"name":"灵玥-JP-Lpt【三网通用】"}{"dest":["64.81.33.2:24577"],"listen_port":41034,"name":"Yolo-US-Lpt【三网通用】"}
|
||||
|
||||
ny导入的时候是选择入口的,flvx转发导入是选择隧道的,需要兼容一下,请设计计划
|
||||
@@ -0,0 +1,30 @@
|
||||
计划文档已设计完成。以下是计划摘要:
|
||||
|
||||
## 计划摘要
|
||||
|
||||
### 格式对比
|
||||
| 特性 | flvx格式 | ny格式 |
|
||||
|------|----------|--------|
|
||||
| 格式 | 管道分隔文本 | 紧凑JSON对象 |
|
||||
| 示例 | `ip:port\|name\|port` | `{"dest":["ip:port"],"listen_port":port,"name":"name"}` |
|
||||
| 多目标 | 逗号分隔 | dest数组 |
|
||||
| 选择器 | 选择隧道 | 选择入口节点→匹配隧道 |
|
||||
|
||||
### 核心设计
|
||||
1. **格式选择器**:手动选择"flvx格式"或"ny格式"
|
||||
2. **ny格式流程**:选择入口节点 → 匹配端口转发隧道 → 匹配0个报错、1个直接用、多个弹窗选
|
||||
3. **不自动创建隧道**:无匹配时提示用户先创建
|
||||
|
||||
### 任务清单
|
||||
1. 新增`importFormat`、`selectedEntryNode`、`matchedTunnels`等状态
|
||||
2. 重构导入模态框UI,添加格式选择器
|
||||
3. 实现ny格式紧凑JSON解析函数
|
||||
4. 实现入口节点选择与隧道匹配逻辑
|
||||
5. 多隧道匹配时的选择器弹窗
|
||||
6. 整合ny格式到`executeImport`函数
|
||||
7. 更新提示文案
|
||||
|
||||
### 改动范围
|
||||
- `vite-frontend/src/pages/forward.tsx` - 主要改动文件
|
||||
|
||||
是否需要我调整计划内容?
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
提交全部变更并且push,创建pr合并,发布新tag,alpha版本
|
||||
@@ -0,0 +1,6 @@
|
||||
✅ 完成:
|
||||
- **提交**: `fix: use configured connectIp for tunnel chain diagnosis` (1c10347)
|
||||
- **PR**: #248 已合并 → https://github.com/Sagit-chu/flvx/pull/248
|
||||
- **Tag**: `2.1.7-alpha8` 已创建并推送
|
||||
|
||||
变更内容:修复了隧道链诊断时使用配置的 connectIp、forward 绑定 IP 支持带端口的地址、添加了全面的测试覆盖。
|
||||
@@ -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.
|
||||
|
||||
@@ -245,6 +245,12 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
|
||||
}
|
||||
warnings := make([]string, 0)
|
||||
|
||||
// Resolve user tunnel first so runtime service name can carry the real user_tunnel id.
|
||||
userTunnelID, utLimiterID, utSpeed, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Determine limiter from forward's SpeedID first, fallback to UserTunnel's limiter
|
||||
var limiterID *int64
|
||||
var speed *int
|
||||
@@ -260,17 +266,11 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
|
||||
|
||||
if limiterID == nil {
|
||||
// Fall back to UserTunnel speed limit
|
||||
var utLimiterID *int64
|
||||
var utSpeed *int
|
||||
_, utLimiterID, utSpeed, err = h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
limiterID = utLimiterID
|
||||
speed = utSpeed
|
||||
}
|
||||
|
||||
serviceBase := buildForwardServiceBase(forward.ID, forward.UserID, 0)
|
||||
serviceBase := buildForwardServiceBaseWithResolvedUserTunnel(forward.ID, forward.UserID, userTunnelID)
|
||||
tunnelTLSProtocol, err := h.isTunnelSelectedTLSProtocol(forward.TunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -290,6 +290,11 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
|
||||
services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, strings.TrimSpace(fp.InIP), limiterID, tunnelTLSProtocol)
|
||||
_, err = h.sendNodeCommand(node.ID, method, services, true, false)
|
||||
if err != nil && allowFallbackAdd && method == "UpdateService" {
|
||||
if isNotFoundError(err) {
|
||||
if delErr := h.deleteForwardServicesOnNode(forward, node.ID); delErr != nil && !isNotFoundError(delErr) {
|
||||
return warnings, fmt.Errorf("节点 %s 清理旧服务失败: %w", node.Name, delErr)
|
||||
}
|
||||
}
|
||||
_, err = h.sendNodeCommand(node.ID, "AddService", services, true, false)
|
||||
}
|
||||
if err != nil && strings.EqualFold(strings.TrimSpace(method), "UpdateService") && isAddressAlreadyInUseError(err) {
|
||||
@@ -354,7 +359,12 @@ func (h *Handler) rebindForwardServiceOnSelfOccupiedPort(forward *forwardRecord,
|
||||
return fmt.Errorf("端口 %d 已被其他转发占用", port)
|
||||
}
|
||||
|
||||
if err := h.deleteForwardServicesOnNode(forward, node.ID); err != nil {
|
||||
bases, err := h.forwardServiceBaseCandidates(forward)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := h.deleteForwardServiceBasesOnNode(node.ID, bases); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -372,41 +382,45 @@ func (h *Handler) deleteForwardServicesOnNode(forward *forwardRecord, nodeID int
|
||||
if h == nil || forward == nil {
|
||||
return errors.New("invalid forward delete context")
|
||||
}
|
||||
|
||||
userTunnelID, _, _, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
|
||||
bases, err := h.forwardServiceBaseCandidates(forward)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.deleteForwardServiceBasesOnNode(nodeID, bases)
|
||||
|
||||
}
|
||||
|
||||
func (h *Handler) forwardServiceBaseCandidates(forward *forwardRecord) ([]string, error) {
|
||||
if h == nil || forward == nil {
|
||||
return nil, errors.New("invalid forward service base context")
|
||||
}
|
||||
userTunnelID, _, _, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userTunnelIDs, err := h.listUserTunnelIDs(forward.UserID, forward.TunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
allUserTunnelIDs, err := h.listUserTunnelIDsByUser(forward.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
candidateTunnelIDs := make([]int64, 0, len(userTunnelIDs)+len(allUserTunnelIDs))
|
||||
candidateTunnelIDs = append(candidateTunnelIDs, userTunnelIDs...)
|
||||
candidateTunnelIDs = append(candidateTunnelIDs, allUserTunnelIDs...)
|
||||
bases := buildForwardServiceBaseCandidates(forward.ID, forward.UserID, userTunnelID, candidateTunnelIDs)
|
||||
return buildForwardServiceBaseCandidates(forward.ID, forward.UserID, userTunnelID, candidateTunnelIDs), nil
|
||||
|
||||
var lastErr error
|
||||
for _, base := range bases {
|
||||
names := buildForwardControlServiceNames(base, "DeleteService")
|
||||
}
|
||||
|
||||
func (h *Handler) deleteForwardServiceBasesOnNode(nodeID int64, bases []string) error {
|
||||
return deleteForwardServiceCandidates(bases, func(name string) error {
|
||||
payload := map[string]interface{}{
|
||||
"services": names,
|
||||
"services": []string{name},
|
||||
}
|
||||
_, cmdErr := h.sendNodeCommand(nodeID, "DeleteService", payload, false, true)
|
||||
if cmdErr == nil {
|
||||
return nil
|
||||
}
|
||||
lastErr = cmdErr
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
return nil
|
||||
_, err := h.sendNodeCommand(nodeID, "DeleteService", payload, false, false)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) controlForwardServices(forward *forwardRecord, commandType string, tolerateNotFound bool) error {
|
||||
@@ -437,40 +451,26 @@ func (h *Handler) controlForwardServices(forward *forwardRecord, commandType str
|
||||
candidateTunnelIDs = append(candidateTunnelIDs, allUserTunnelIDs...)
|
||||
bases := buildForwardServiceBaseCandidates(forward.ID, forward.UserID, userTunnelID, candidateTunnelIDs)
|
||||
seen := map[int64]struct{}{}
|
||||
healed := false
|
||||
for _, fp := range ports {
|
||||
if _, ok := seen[fp.NodeID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[fp.NodeID] = struct{}{}
|
||||
|
||||
var lastNotFoundErr error
|
||||
nodeHandled := false
|
||||
nodeHandled, lastNotFoundErr, err := h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, base := range bases {
|
||||
variants := []string{base + "_tcp", base + "_udp"}
|
||||
if shouldTryLegacySingleService(commandType) || strings.EqualFold(strings.TrimSpace(commandType), "DeleteService") {
|
||||
variants = append(variants, base)
|
||||
if !nodeHandled && lastNotFoundErr != nil && !healed && shouldSelfHealForwardServiceControl(commandType) {
|
||||
if healErr := h.syncForwardServices(forward, "UpdateService", true); healErr != nil {
|
||||
return healErr
|
||||
}
|
||||
|
||||
candidateHandled := false
|
||||
for _, name := range variants {
|
||||
payload := map[string]interface{}{
|
||||
"services": []string{name},
|
||||
}
|
||||
_, err := h.sendNodeCommand(fp.NodeID, commandType, payload, false, false)
|
||||
if err == nil {
|
||||
candidateHandled = true
|
||||
continue
|
||||
}
|
||||
if !isNotFoundError(err) {
|
||||
return err
|
||||
}
|
||||
lastNotFoundErr = err
|
||||
}
|
||||
|
||||
if candidateHandled {
|
||||
nodeHandled = true
|
||||
break
|
||||
healed = true
|
||||
nodeHandled, lastNotFoundErr, err = h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,6 +488,65 @@ func (h *Handler) controlForwardServices(forward *forwardRecord, commandType str
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) controlForwardServicesOnNode(nodeID int64, bases []string, commandType string) (bool, error, error) {
|
||||
return controlForwardServiceCommand(bases, commandType, func(name string) error {
|
||||
payload := map[string]interface{}{
|
||||
"services": []string{name},
|
||||
}
|
||||
_, err := h.sendNodeCommand(nodeID, commandType, payload, false, false)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func controlForwardServiceCommand(bases []string, commandType string, send func(name string) error) (bool, error, error) {
|
||||
var lastNotFoundErr error
|
||||
for _, base := range bases {
|
||||
variants := []string{base + "_tcp", base + "_udp"}
|
||||
if shouldTryLegacySingleService(commandType) || strings.EqualFold(strings.TrimSpace(commandType), "DeleteService") {
|
||||
variants = append(variants, base)
|
||||
}
|
||||
|
||||
candidateHandled := false
|
||||
for _, name := range variants {
|
||||
err := send(name)
|
||||
if err == nil {
|
||||
candidateHandled = true
|
||||
continue
|
||||
}
|
||||
if !isNotFoundError(err) {
|
||||
return false, lastNotFoundErr, err
|
||||
}
|
||||
lastNotFoundErr = err
|
||||
}
|
||||
|
||||
if candidateHandled {
|
||||
return true, nil, nil
|
||||
}
|
||||
}
|
||||
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"
|
||||
}
|
||||
|
||||
func (h *Handler) applyNodeProtocolChange(nodeID int64, httpVal, tlsVal, socksVal int) error {
|
||||
_, err := h.sendNodeCommand(nodeID, "SetProtocol", map[string]interface{}{
|
||||
"http": httpVal,
|
||||
@@ -1362,6 +1421,13 @@ func buildForwardServiceBase(forwardID, userID, userTunnelID int64) string {
|
||||
return fmt.Sprintf("%d_%d_%d", forwardID, userID, userTunnelID)
|
||||
}
|
||||
|
||||
func buildForwardServiceBaseWithResolvedUserTunnel(forwardID, userID, resolvedUserTunnelID int64) string {
|
||||
if resolvedUserTunnelID <= 0 {
|
||||
return buildForwardServiceBase(forwardID, userID, 0)
|
||||
}
|
||||
return buildForwardServiceBase(forwardID, userID, resolvedUserTunnelID)
|
||||
}
|
||||
|
||||
func buildForwardServiceBaseCandidates(forwardID, userID, preferredUserTunnelID int64, userTunnelIDs []int64) []string {
|
||||
orderedIDs := make([]int64, 0, len(userTunnelIDs)+2)
|
||||
seen := make(map[int64]struct{}, len(userTunnelIDs)+2)
|
||||
@@ -1413,10 +1479,11 @@ func isAlreadyExistsMessage(message string) bool {
|
||||
if msg == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(msg, "address already in use") {
|
||||
if isAddressAlreadyInUseMessage(msg) {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(msg, "already exists") || strings.Contains(msg, "已存在")
|
||||
compact := compactErrorMessage(msg)
|
||||
return strings.Contains(msg, "already exists") || strings.Contains(msg, "已存在") || strings.Contains(compact, "alreadyexists")
|
||||
}
|
||||
|
||||
func isBindAddressInUseError(err error) bool {
|
||||
@@ -1441,7 +1508,10 @@ func isAddressAlreadyInUseMessage(msg string) bool {
|
||||
if msg == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(msg, "address already in use")
|
||||
if strings.Contains(msg, "address already in use") {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(compactErrorMessage(msg), "addressalreadyinuse")
|
||||
}
|
||||
|
||||
func isCannotAssignRequestedAddressError(err error) bool {
|
||||
@@ -1452,7 +1522,18 @@ func isCannotAssignRequestedAddressError(err error) bool {
|
||||
if msg == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(msg, "cannot assign requested address")
|
||||
if strings.Contains(msg, "cannot assign requested address") {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(compactErrorMessage(msg), "cannotassignrequestedaddress")
|
||||
}
|
||||
|
||||
func compactErrorMessage(msg string) string {
|
||||
msg = strings.TrimSpace(msg)
|
||||
if msg == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(strings.Fields(strings.ToLower(msg)), "")
|
||||
}
|
||||
|
||||
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, bindIP string, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestBuildForwardControlServiceNamesPauseResume(t *testing.T) {
|
||||
@@ -43,6 +45,20 @@ func TestBuildForwardServiceBaseCandidatesWithZeroPreferred(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForwardServiceBaseWithResolvedUserTunnel(t *testing.T) {
|
||||
got := buildForwardServiceBaseWithResolvedUserTunnel(12, 34, 56)
|
||||
if got != "12_34_56" {
|
||||
t.Fatalf("expected 12_34_56, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForwardServiceBaseWithResolvedUserTunnelFallbackToZero(t *testing.T) {
|
||||
got := buildForwardServiceBaseWithResolvedUserTunnel(12, 34, 0)
|
||||
if got != "12_34_0" {
|
||||
t.Fatalf("expected 12_34_0, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldTryLegacySingleService(t *testing.T) {
|
||||
if !shouldTryLegacySingleService("PauseService") {
|
||||
t.Fatalf("PauseService should require legacy fallback")
|
||||
@@ -55,6 +71,184 @@ func TestShouldTryLegacySingleService(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldSelfHealForwardServiceControl(t *testing.T) {
|
||||
if !shouldSelfHealForwardServiceControl("PauseService") {
|
||||
t.Fatalf("PauseService should trigger self-heal")
|
||||
}
|
||||
if !shouldSelfHealForwardServiceControl(" resumeService ") {
|
||||
t.Fatalf("ResumeService should trigger self-heal")
|
||||
}
|
||||
if shouldSelfHealForwardServiceControl("DeleteService") {
|
||||
t.Fatalf("DeleteService should not trigger self-heal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlForwardServiceCommandHandledOnKnownVariant(t *testing.T) {
|
||||
bases := []string{"12_34_56"}
|
||||
called := make([]string, 0)
|
||||
handled, lastNotFoundErr, err := controlForwardServiceCommand(bases, "PauseService", func(name string) error {
|
||||
called = append(called, name)
|
||||
if name == "12_34_56_udp" {
|
||||
return nil
|
||||
}
|
||||
return errors.New("service " + name + " not found")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !handled {
|
||||
t.Fatalf("expected handled=true")
|
||||
}
|
||||
if lastNotFoundErr != nil {
|
||||
t.Fatalf("expected lastNotFoundErr=nil when handled")
|
||||
}
|
||||
wantCalls := []string{"12_34_56_tcp", "12_34_56_udp", "12_34_56"}
|
||||
if !reflect.DeepEqual(called, wantCalls) {
|
||||
t.Fatalf("expected calls %v, got %v", wantCalls, called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlForwardServiceCommandReturnsLastNotFoundWhenAllMissing(t *testing.T) {
|
||||
bases := []string{"12_34_56"}
|
||||
handled, lastNotFoundErr, err := controlForwardServiceCommand(bases, "PauseService", func(name string) error {
|
||||
return errors.New("service " + name + " not found")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if handled {
|
||||
t.Fatalf("expected handled=false")
|
||||
}
|
||||
if lastNotFoundErr == nil {
|
||||
t.Fatalf("expected lastNotFoundErr when all variants are missing")
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if name == "12_34_56_tcp" {
|
||||
return errors.New("network timeout")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected hard error")
|
||||
}
|
||||
if handled {
|
||||
t.Fatalf("expected handled=false on hard error")
|
||||
}
|
||||
if lastNotFoundErr != nil {
|
||||
t.Fatalf("did not expect not-found error alongside hard error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAlreadyExistsMessage(t *testing.T) {
|
||||
if !isAlreadyExistsMessage("service demo already exists") {
|
||||
t.Fatalf("expected already exists message to be tolerated")
|
||||
@@ -62,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) {
|
||||
@@ -86,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")
|
||||
}
|
||||
@@ -95,11 +298,83 @@ func TestIsCannotAssignRequestedAddressError(t *testing.T) {
|
||||
if !isCannotAssignRequestedAddressError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannot assign requested address")) {
|
||||
t.Fatalf("cannot assign requested address should be detected")
|
||||
}
|
||||
if !isCannotAssignRequestedAddressError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannotassignrequestedaddress")) {
|
||||
t.Fatalf("missing-space cannotassignrequestedaddress variant should be detected")
|
||||
}
|
||||
if isCannotAssignRequestedAddressError(errors.New("listen tcp [::]:10001: bind: address already in use")) {
|
||||
t.Fatalf("address already in use should not be treated as cannot-assign")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryTunnelServiceAddWithCleanupRetriesOnAddressInUse(t *testing.T) {
|
||||
addCalls := 0
|
||||
cleanupCalls := 0
|
||||
err := retryTunnelServiceAddWithCleanup(
|
||||
func() error {
|
||||
addCalls++
|
||||
if addCalls == 1 {
|
||||
return errors.New("listen tcp 10.0.0.1:32000: bind: address already in use")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
func() error {
|
||||
cleanupCalls++
|
||||
return nil
|
||||
},
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("expected retry to succeed, got %v", err)
|
||||
}
|
||||
if addCalls != 2 {
|
||||
t.Fatalf("expected 2 add attempts, got %d", addCalls)
|
||||
}
|
||||
if cleanupCalls != 1 {
|
||||
t.Fatalf("expected 1 cleanup attempt, got %d", cleanupCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryTunnelServiceAddWithCleanupSkipsCleanupOnNonBindError(t *testing.T) {
|
||||
addCalls := 0
|
||||
cleanupCalls := 0
|
||||
err := retryTunnelServiceAddWithCleanup(
|
||||
func() error {
|
||||
addCalls++
|
||||
return errors.New("network timeout")
|
||||
},
|
||||
func() error {
|
||||
cleanupCalls++
|
||||
return nil
|
||||
},
|
||||
0,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("expected hard error")
|
||||
}
|
||||
if addCalls != 1 {
|
||||
t.Fatalf("expected 1 add attempt, got %d", addCalls)
|
||||
}
|
||||
if cleanupCalls != 0 {
|
||||
t.Fatalf("expected 0 cleanup attempts, got %d", cleanupCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryTunnelServiceAddWithCleanupReturnsCleanupError(t *testing.T) {
|
||||
cleanupErr := errors.New("delete failed")
|
||||
err := retryTunnelServiceAddWithCleanup(
|
||||
func() error {
|
||||
return errors.New("listen tcp 10.0.0.1:32000: bind: address already in use")
|
||||
},
|
||||
func() error {
|
||||
return cleanupErr
|
||||
},
|
||||
0,
|
||||
)
|
||||
if !errors.Is(err, cleanupErr) {
|
||||
t.Fatalf("expected cleanup error %v, got %v", cleanupErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForwardServiceConfigs_UsesBindIPForListen(t *testing.T) {
|
||||
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
|
||||
node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"}
|
||||
|
||||
@@ -43,6 +43,19 @@ func TestBuildTunnelChainServiceConfig_UsesConnectIPForListen(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTunnelChainServiceConfig_FallsBackToNodeListenAddr(t *testing.T) {
|
||||
node := &nodeRecord{TCPListenAddr: "10.8.0.5"}
|
||||
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21002}
|
||||
services := buildTunnelChainServiceConfig(99, chain, node)
|
||||
if len(services) != 1 {
|
||||
t.Fatalf("expected 1 service, got %d", len(services))
|
||||
}
|
||||
addr, _ := services[0]["addr"].(string)
|
||||
if addr != "10.8.0.5:21002" {
|
||||
t.Fatalf("expected node listen addr 10.8.0.5:21002, got %q", addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTunnelChainServiceConfig_DefaultListenAddrWhenConnectIPEmpty(t *testing.T) {
|
||||
node := &nodeRecord{TCPListenAddr: "[::]"}
|
||||
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21001}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,10 @@ func (h *Handler) nodeCreate(w http.ResponseWriter, r *http.Request) {
|
||||
defaultString(asString(req["port"]), "1000-65535"),
|
||||
nullableText(asString(req["interfaceName"])),
|
||||
nullableText(""),
|
||||
nullableText(strings.TrimSpace(asString(req["remark"]))),
|
||||
nullableText(strings.TrimSpace(asString(req["tags"]))),
|
||||
nullableUnixMilli(asInt64(req["expiryTime"], 0)),
|
||||
nullableText(normalizeNodeRenewalCycle(asString(req["renewalCycle"]))),
|
||||
asInt(req["http"], 0),
|
||||
asInt(req["tls"], 0),
|
||||
asInt(req["socks"], 0),
|
||||
@@ -324,6 +330,10 @@ func (h *Handler) nodeUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
defaultString(asString(req["port"]), "1000-65535"),
|
||||
nullableText(asString(req["interfaceName"])),
|
||||
nullableText(asString(req["extraIPs"])),
|
||||
nullableText(strings.TrimSpace(asString(req["remark"]))),
|
||||
nullableText(strings.TrimSpace(asString(req["tags"]))),
|
||||
nullableUnixMilli(asInt64(req["expiryTime"], 0)),
|
||||
nullableText(normalizeNodeRenewalCycle(asString(req["renewalCycle"]))),
|
||||
newHTTP,
|
||||
newTLS,
|
||||
newSocks,
|
||||
@@ -1154,14 +1164,10 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if roleID != 0 {
|
||||
if _, ok := req["speedId"]; ok {
|
||||
if speedIDVal, ok := req["speedId"]; ok && speedIDVal != nil {
|
||||
response.WriteJSON(w, response.Err(-1, "普通用户无法设置限速规则"))
|
||||
return
|
||||
}
|
||||
if _, ok := req["inPort"]; ok {
|
||||
response.WriteJSON(w, response.Err(-1, "普通用户无法设置自定义端口"))
|
||||
return
|
||||
}
|
||||
}
|
||||
speedID := asAnyToInt64Ptr(req["speedId"])
|
||||
speedID, err = h.normalizeSpeedLimitReference(speedID)
|
||||
@@ -1191,6 +1197,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")
|
||||
@@ -1273,17 +1287,13 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if strategy == "" {
|
||||
strategy = forward.Strategy
|
||||
}
|
||||
if actorRole != 0 {
|
||||
if _, ok := req["speedId"]; ok {
|
||||
response.WriteJSON(w, response.Err(-1, "普通用户无法修改限速规则"))
|
||||
return
|
||||
}
|
||||
if _, ok := req["inPort"]; ok {
|
||||
response.WriteJSON(w, response.Err(-1, "普通用户无法修改自定义端口"))
|
||||
return
|
||||
}
|
||||
rawSpeedID, hasSpeedID := req["speedId"]
|
||||
requestedSpeedID := asAnyToInt64Ptr(rawSpeedID)
|
||||
if actorRole != 0 && hasSpeedID && requestedSpeedID != nil && !sameSpeedLimitSelection(forward.SpeedID, requestedSpeedID) {
|
||||
response.WriteJSON(w, response.Err(-1, "普通用户无法修改限速规则"))
|
||||
return
|
||||
}
|
||||
speedID := asAnyToInt64Ptr(req["speedId"])
|
||||
speedID := requestedSpeedID
|
||||
speedID, err = h.normalizeSpeedLimitReference(speedID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
@@ -1326,6 +1336,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 {
|
||||
@@ -2576,7 +2594,7 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
||||
createdChains = append(createdChains, chainNode.NodeID)
|
||||
|
||||
serviceData := buildTunnelChainServiceConfig(state.TunnelID, chainNode, state.Nodes[chainNode.NodeID])
|
||||
if _, err := h.sendNodeCommand(chainNode.NodeID, "AddService", serviceData, true, false); err != nil {
|
||||
if err := h.addTunnelServiceOnNode(chainNode.NodeID, state.TunnelID, serviceData); err != nil {
|
||||
return createdChains, createdServices, fmt.Errorf("转发链节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)
|
||||
}
|
||||
createdServices = append(createdServices, chainNode.NodeID)
|
||||
@@ -2588,7 +2606,7 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
||||
continue
|
||||
}
|
||||
serviceData := buildTunnelChainServiceConfig(state.TunnelID, outNode, state.Nodes[outNode.NodeID])
|
||||
if _, err := h.sendNodeCommand(outNode.NodeID, "AddService", serviceData, true, false); err != nil {
|
||||
if err := h.addTunnelServiceOnNode(outNode.NodeID, state.TunnelID, serviceData); err != nil {
|
||||
return createdChains, createdServices, fmt.Errorf("出口节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[outNode.NodeID]), err)
|
||||
}
|
||||
createdServices = append(createdServices, outNode.NodeID)
|
||||
@@ -2597,6 +2615,44 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
||||
return createdChains, createdServices, nil
|
||||
}
|
||||
|
||||
func retryTunnelServiceAddWithCleanup(add func() error, cleanup func() error, wait time.Duration) error {
|
||||
if add == nil {
|
||||
return errors.New("invalid tunnel service add callback")
|
||||
}
|
||||
err := add()
|
||||
if err == nil || !isAddressAlreadyInUseError(err) {
|
||||
return err
|
||||
}
|
||||
if cleanup == nil {
|
||||
return err
|
||||
}
|
||||
if cleanupErr := cleanup(); cleanupErr != nil {
|
||||
return cleanupErr
|
||||
}
|
||||
if wait > 0 {
|
||||
time.Sleep(wait)
|
||||
}
|
||||
return add()
|
||||
}
|
||||
|
||||
func (h *Handler) addTunnelServiceOnNode(nodeID, tunnelID int64, serviceData []map[string]interface{}) error {
|
||||
if h == nil {
|
||||
return errors.New("invalid tunnel service context")
|
||||
}
|
||||
serviceName := fmt.Sprintf("%d_tls", tunnelID)
|
||||
return retryTunnelServiceAddWithCleanup(
|
||||
func() error {
|
||||
_, err := h.sendNodeCommand(nodeID, "AddService", serviceData, true, false)
|
||||
return err
|
||||
},
|
||||
func() error {
|
||||
_, err := h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{serviceName}}, false, true)
|
||||
return err
|
||||
},
|
||||
tunnelServiceBindRetryDelay,
|
||||
)
|
||||
}
|
||||
|
||||
func (h *Handler) rollbackTunnelRuntime(chainNodeIDs, serviceNodeIDs []int64, tunnelID int64) {
|
||||
if h == nil || tunnelID <= 0 {
|
||||
return
|
||||
@@ -3332,6 +3388,14 @@ func (h *Handler) normalizeSpeedLimitReference(speedID *int64) (*int64, error) {
|
||||
return speedID, nil
|
||||
}
|
||||
|
||||
func sameSpeedLimitSelection(current sql.NullInt64, requested *int64) bool {
|
||||
if requested == nil {
|
||||
return !current.Valid
|
||||
}
|
||||
|
||||
return current.Valid && current.Int64 == *requested
|
||||
}
|
||||
|
||||
func asAnySlice(v interface{}) []interface{} {
|
||||
if v == nil {
|
||||
return nil
|
||||
@@ -3478,6 +3542,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
|
||||
@@ -3513,3 +3593,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
|
||||
}
|
||||
|
||||
@@ -60,6 +60,10 @@ func (ForwardPort) TableName() string { return "forward_port" }
|
||||
type Node struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
Name string `gorm:"type:varchar(100);not null"`
|
||||
Remark sql.NullString `gorm:"column:remark;type:text"`
|
||||
Tags sql.NullString `gorm:"column:tags;type:text"`
|
||||
ExpiryTime sql.NullInt64 `gorm:"column:expiry_time"`
|
||||
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 +340,10 @@ type UserBackup struct {
|
||||
type NodeBackup struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Remark string `json:"remark,omitempty"`
|
||||
Tags string `json:"tags,omitempty"`
|
||||
ExpiryTime int64 `json:"expiryTime,omitempty"`
|
||||
RenewalCycle string `json:"renewalCycle,omitempty"`
|
||||
Secret string `json:"secret"`
|
||||
ServerIP string `json:"serverIp"`
|
||||
ServerIPv4 string `json:"serverIpV4,omitempty"`
|
||||
@@ -573,6 +581,7 @@ type UserTunnelDetail struct {
|
||||
UserID int64
|
||||
TunnelID int64
|
||||
TunnelName string
|
||||
Status int
|
||||
TunnelFlow int
|
||||
Flow int64
|
||||
InFlow int64
|
||||
|
||||
@@ -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", "Tags", "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,11 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) {
|
||||
for _, n := range nodes {
|
||||
items = append(items, map[string]interface{}{
|
||||
"id": n.ID, "inx": n.Inx, "name": n.Name,
|
||||
"ip": n.ServerIP, "serverIp": n.ServerIP,
|
||||
"remark": nullableString(n.Remark),
|
||||
"tags": nullableString(n.Tags),
|
||||
"expiryTime": nullableInt64(n.ExpiryTime),
|
||||
"renewalCycle": nullableString(n.RenewalCycle),
|
||||
"ip": n.ServerIP, "serverIp": n.ServerIP,
|
||||
"serverIpV4": nullableString(n.ServerIPV4),
|
||||
"serverIpV6": nullableString(n.ServerIPV6),
|
||||
"extraIPs": nullableString(n.ExtraIPs),
|
||||
@@ -700,25 +704,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 +740,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 +773,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 +806,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 +1820,15 @@ func (r *Repository) exportNodes() ([]model.NodeBackup, error) {
|
||||
for _, n := range nodes {
|
||||
b := model.NodeBackup{
|
||||
ID: n.ID, Name: n.Name, Secret: n.Secret, ServerIP: n.ServerIP,
|
||||
Remark: n.Remark.String, Tags: n.Tags.String, 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 +2178,10 @@ func importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error)
|
||||
item := model.Node{
|
||||
ID: n.ID,
|
||||
Name: n.Name,
|
||||
Remark: sql.NullString{String: n.Remark, Valid: n.Remark != ""},
|
||||
Tags: sql.NullString{String: n.Tags, Valid: n.Tags != ""},
|
||||
ExpiryTime: sql.NullInt64{Int64: n.ExpiryTime, Valid: n.ExpiryTime > 0},
|
||||
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 +2206,7 @@ func importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error)
|
||||
err := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"name", "secret", "server_ip", "server_ip_v4", "server_ip_v6", "port", "interface_name", "version",
|
||||
"name", "remark", "tags", "expiry_time", "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 +2626,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 +2695,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 +2765,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", "Tags", "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,16 @@ func (r *Repository) GetUserDefaultsForTunnel(userID int64) (flow int64, num int
|
||||
return user.Flow, user.Num, user.ExpTime, user.FlowResetTime, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version interface{}, httpFlag, tlsFlag, socksFlag int, now int64, status int, tcpAddr, udpAddr string, inx, isRemote int, remoteURL, remoteToken, remoteConfig, extraIPs interface{}) error {
|
||||
func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version, remark, tags, expiryTime, 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),
|
||||
Tags: nullStringFromInterface(tags),
|
||||
ExpiryTime: nullInt64FromInterface(expiryTime),
|
||||
RenewalCycle: nullStringFromInterface(renewalCycle),
|
||||
Secret: secret,
|
||||
ServerIP: serverIP,
|
||||
ServerIPV4: nullStringFromInterface(serverIPV4),
|
||||
@@ -239,7 +244,7 @@ func (r *Repository) GetNodeStatusFields(nodeID int64) (status, httpFlag, tlsFla
|
||||
return node.Status, node.HTTP, node.TLS, node.Socks, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName, extraIPs interface{}, httpFlag, tlsFlag, socksFlag int, tcpAddr, udpAddr string, now int64) error {
|
||||
func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName, extraIPs, remark, tags, expiryTime, 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 +252,10 @@ func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, ser
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]interface{}{
|
||||
"name": name,
|
||||
"remark": nullStringFromInterface(remark),
|
||||
"tags": nullStringFromInterface(tags),
|
||||
"expiry_time": nullInt64FromInterface(expiryTime),
|
||||
"renewal_cycle": nullStringFromInterface(renewalCycle),
|
||||
"server_ip": serverIP,
|
||||
"server_ip_v4": nullStringFromInterface(serverIPV4),
|
||||
"server_ip_v6": nullStringFromInterface(serverIPV6),
|
||||
@@ -1477,3 +1486,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) {
|
||||
@@ -1097,4 +1316,69 @@ func TestNonAdminCannotSetSpeedIdOrPort(t *testing.T) {
|
||||
router.ServeHTTP(res, req)
|
||||
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",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "1.2.3.4:443",
|
||||
"strategy": "fifo",
|
||||
"speedId": nil,
|
||||
"inPort": 0,
|
||||
}
|
||||
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 update with speedId null", func(t *testing.T) {
|
||||
updatePayload := map[string]interface{}{
|
||||
"id": forwardID,
|
||||
"name": "perm-forward-null-speed",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "9.10.11.12:443",
|
||||
"speedId": nil,
|
||||
}
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -538,6 +539,134 @@ func TestBatchAssignInsertRollbackWhenLimiterDispatchFailsContract(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelUpdateRecoversFromAddressInUseContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, r := setupContractRouter(t, secret)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "tunnel-bind-retry", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, r, "tunnel-bind-retry")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "tunnel-bind-entry", "tunnel-bind-entry-secret", "10.41.0.1", "10.41.0.1", "", "43000-43010", "", "v1", 1, 1, 1, now, now, 1, "10.41.0.1", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert entry node: %v", err)
|
||||
}
|
||||
entryNodeID := mustLastInsertID(t, r, "tunnel-bind-entry")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "tunnel-bind-exit", "tunnel-bind-exit-secret", "10.41.0.2", "10.41.0.2", "", "43100-43110", "eth0", "v1", 1, 1, 1, now, now, 1, "10.41.0.9", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert exit node: %v", err)
|
||||
}
|
||||
exitNodeID := mustLastInsertID(t, r, "tunnel-bind-exit")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 43001, 'round', 1, 'tls')
|
||||
`, tunnelID, entryNodeID).Error; err != nil {
|
||||
t.Fatalf("insert entry chain_tunnel: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol, connect_ip)
|
||||
VALUES(?, 3, ?, 43101, 'round', 1, 'tls', ?)
|
||||
`, tunnelID, exitNodeID, "10.41.0.99").Error; err != nil {
|
||||
t.Fatalf("insert exit chain_tunnel: %v", err)
|
||||
}
|
||||
|
||||
var commandMu sync.Mutex
|
||||
commandCounts := map[string]int{}
|
||||
var addServiceAddrs []string
|
||||
stopEntry := startMockNodeSessionWithCommandRecorder(t, server.URL, "tunnel-bind-entry-secret", func(cmdType string, data json.RawMessage) (bool, string) {
|
||||
commandMu.Lock()
|
||||
defer commandMu.Unlock()
|
||||
commandCounts["entry:"+strings.ToLower(strings.TrimSpace(cmdType))]++
|
||||
return false, ""
|
||||
})
|
||||
defer stopEntry()
|
||||
stopExit := startMockNodeSessionWithCommandRecorder(t, server.URL, "tunnel-bind-exit-secret", func(cmdType string, data json.RawMessage) (bool, string) {
|
||||
key := "exit:" + strings.ToLower(strings.TrimSpace(cmdType))
|
||||
commandMu.Lock()
|
||||
commandCounts[key]++
|
||||
attempt := commandCounts[key]
|
||||
if strings.EqualFold(strings.TrimSpace(cmdType), "AddService") {
|
||||
var services []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &services); err == nil {
|
||||
for _, svc := range services {
|
||||
if addr, _ := svc["addr"].(string); strings.TrimSpace(addr) != "" {
|
||||
addServiceAddrs = append(addServiceAddrs, addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
commandMu.Unlock()
|
||||
if strings.EqualFold(strings.TrimSpace(cmdType), "AddService") && attempt == 1 {
|
||||
return true, "listen tcp 10.41.0.99:43101: bind: address already in use"
|
||||
}
|
||||
return false, ""
|
||||
})
|
||||
defer stopExit()
|
||||
waitNodeStatus(t, r, entryNodeID, 1)
|
||||
waitNodeStatus(t, r, exitNodeID, 1)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"id": tunnelID,
|
||||
"name": "tunnel-bind-retry",
|
||||
"type": 2,
|
||||
"flow": 99999,
|
||||
"trafficRatio": 1.0,
|
||||
"status": 1,
|
||||
"inNodeId": []map[string]interface{}{
|
||||
{"nodeId": entryNodeID, "protocol": "tls", "strategy": "round"},
|
||||
},
|
||||
"chainNodes": []interface{}{},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": exitNodeID, "protocol": "tls", "strategy": "round", "port": 43101, "connectIp": "10.41.0.99"},
|
||||
},
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
|
||||
commandMu.Lock()
|
||||
defer commandMu.Unlock()
|
||||
if commandCounts["exit:addservice"] != 2 {
|
||||
t.Fatalf("expected exit AddService twice, got %d (%v)", commandCounts["exit:addservice"], sortedCommandCounts(commandCounts))
|
||||
}
|
||||
if commandCounts["exit:deleteservice"] == 0 {
|
||||
t.Fatalf("expected exit DeleteService retry cleanup to run (%v)", sortedCommandCounts(commandCounts))
|
||||
}
|
||||
if len(addServiceAddrs) < 2 {
|
||||
t.Fatalf("expected recorded AddService addresses, got %v", addServiceAddrs)
|
||||
}
|
||||
for _, addr := range addServiceAddrs {
|
||||
if addr != "10.41.0.99:43101" {
|
||||
t.Fatalf("expected connectIp to stay preferred in AddService addr, got %q", addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startMockNodeSessionWithCommandFailures(t *testing.T, baseURL string, nodeSecret string, failCommands map[string]string) func() {
|
||||
t.Helper()
|
||||
|
||||
@@ -633,3 +762,112 @@ func startMockNodeSessionWithCommandFailures(t *testing.T, baseURL string, nodeS
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func startMockNodeSessionWithCommandRecorder(t *testing.T, baseURL string, nodeSecret string, onCommand func(cmdType string, data json.RawMessage) (bool, string)) func() {
|
||||
t.Helper()
|
||||
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse provider url: %v", err)
|
||||
}
|
||||
if strings.EqualFold(u.Scheme, "https") {
|
||||
u.Scheme = "wss"
|
||||
} else {
|
||||
u.Scheme = "ws"
|
||||
}
|
||||
u.Path = "/system-info"
|
||||
q := u.Query()
|
||||
q.Set("type", "1")
|
||||
q.Set("secret", nodeSecret)
|
||||
q.Set("version", "v1")
|
||||
q.Set("http", "1")
|
||||
q.Set("tls", "1")
|
||||
q.Set("socks", "1")
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial mock node websocket: %v", err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
_, raw, readErr := conn.ReadMessage()
|
||||
if readErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
plain := raw
|
||||
var wrap struct {
|
||||
Encrypted bool `json:"encrypted"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &wrap); err == nil && wrap.Encrypted && strings.TrimSpace(wrap.Data) != "" {
|
||||
crypto, cryptoErr := security.NewAESCrypto(nodeSecret)
|
||||
if cryptoErr == nil {
|
||||
if dec, decErr := crypto.Decrypt(wrap.Data); decErr == nil {
|
||||
plain = []byte(dec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cmd struct {
|
||||
Type string `json:"type"`
|
||||
RequestID string `json:"requestId"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(plain, &cmd); err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(cmd.RequestID) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
shouldFail := false
|
||||
failMsg := ""
|
||||
if onCommand != nil {
|
||||
shouldFail, failMsg = onCommand(strings.TrimSpace(cmd.Type), cmd.Data)
|
||||
}
|
||||
|
||||
respType := fmt.Sprintf("%sResponse", cmd.Type)
|
||||
respPayload := map[string]interface{}{
|
||||
"type": respType,
|
||||
"success": !shouldFail,
|
||||
"message": "OK",
|
||||
"requestId": cmd.RequestID,
|
||||
}
|
||||
if shouldFail {
|
||||
if strings.TrimSpace(failMsg) == "" {
|
||||
failMsg = "mock command failed"
|
||||
}
|
||||
respPayload["message"] = failMsg
|
||||
}
|
||||
|
||||
respBytes, err := json.Marshal(respPayload)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_ = conn.WriteMessage(websocket.TextMessage, respBytes)
|
||||
}
|
||||
}()
|
||||
|
||||
var stopOnce sync.Once
|
||||
return func() {
|
||||
stopOnce.Do(func() {
|
||||
_ = conn.Close()
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func sortedCommandCounts(counts map[string]int) []string {
|
||||
items := make([]string, 0, len(counts))
|
||||
for key, value := range counts {
|
||||
items = append(items, fmt.Sprintf("%s=%d", key, value))
|
||||
}
|
||||
sort.Strings(items)
|
||||
return items
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
func TestUserTunnelListReturnsStoredStatusContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(201, 'user_tunnel_status_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(301, 'user-tunnel-status-enabled', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel enabled: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(302, 'user-tunnel-status-disabled', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 1)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel disabled: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(401, 201, 301, NULL, 10, 500, 0, 0, 1, 2727251700000, 1)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert enabled user_tunnel: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(402, 201, 302, NULL, 10, 500, 0, 0, 1, 2727251700000, 0)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert disabled user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
body := bytes.NewBufferString(`{"userId":201}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/list", body)
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected code 0, got %d (%s)", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
items, ok := out.Data.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected array data, got %T", out.Data)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("expected 2 items, got %d", len(items))
|
||||
}
|
||||
|
||||
statusByTunnelID := make(map[int64]int, len(items))
|
||||
for _, item := range items {
|
||||
obj, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected object item, got %T", item)
|
||||
}
|
||||
tunnelID, ok := obj["tunnelId"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("expected tunnelId to be float64, got %T", obj["tunnelId"])
|
||||
}
|
||||
status, ok := obj["status"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("expected status to be float64, got %T", obj["status"])
|
||||
}
|
||||
statusByTunnelID[int64(tunnelID)] = int(status)
|
||||
}
|
||||
|
||||
if statusByTunnelID[301] != 1 {
|
||||
t.Fatalf("expected enabled tunnel status 1, got %d", statusByTunnelID[301])
|
||||
}
|
||||
if statusByTunnelID[302] != 0 {
|
||||
t.Fatalf("expected disabled tunnel status 0, got %d", statusByTunnelID[302])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# 011 转发服务名升级兼容与节点滚动升级
|
||||
|
||||
## 目标
|
||||
- 修复旧版本升级后编辑转发/隧道出现 `service not found`(service不存在)的问题。
|
||||
- 在后端加入兼容自愈逻辑,允许旧命名与新命名共存过渡。
|
||||
- 给出低风险节点升级顺序,避免一次性全量切换带来的中断。
|
||||
|
||||
## Checklist
|
||||
- [x] 定位回归路径:服务名从 `forward_user_0` 迁移到真实 `user_tunnel_id` 后,与旧运行态不一致导致控制失败。
|
||||
- [x] 在 `UpdateService` 的兼容路径加入旧服务清理后重建逻辑。
|
||||
- [x] 在 `Pause/Resume` 控制路径加入首次 not found 后自愈重试逻辑。
|
||||
- [x] 增加回归测试覆盖兼容行为。
|
||||
- [x] 执行 `go-backend` 相关测试并记录结果。
|
||||
- [x] 输出运维侧“后端先行 + agent 灰度升级 + 批量重部署”操作步骤。
|
||||
|
||||
## 变更说明(实施中)
|
||||
- 后端控制面将在检测到升级期的服务名不一致时进行自动自愈,降低人工干预和手工重建成本。
|
||||
|
||||
## 测试记录
|
||||
- 命令:`cd go-backend && go test ./internal/http/handler/...`
|
||||
- 结果:通过。
|
||||
|
||||
## 运维升级顺序(推荐)
|
||||
1. 先发布本次后端兼容补丁(无需等待所有 agent 同步升级)。
|
||||
2. 按 10%-20% 灰度分批升级 agent(低风险节点 -> 非高峰节点 -> 全量)。
|
||||
3. 每批升级后执行一次“转发批量重部署”,将运行态统一到新服务命名。
|
||||
4. 观察日志中 `service .* not found` 是否清零,再推进下一批。
|
||||
5. 全量稳定后保留兼容逻辑至少一个小版本周期,再评估收敛。
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -188,7 +188,7 @@ function App() {
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProtectedRoute useSimpleLayout={true}>
|
||||
<PanelSharingPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -3,6 +3,10 @@ export interface NodeApiItem {
|
||||
name: string;
|
||||
status: number;
|
||||
inx?: number;
|
||||
remark?: string;
|
||||
tags?: string;
|
||||
expiryTime?: number;
|
||||
renewalCycle?: "month" | "quarter" | "year" | "";
|
||||
syncError?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -44,6 +48,7 @@ export interface ForwardApiItem {
|
||||
name: string;
|
||||
status: number;
|
||||
tunnelName?: string;
|
||||
tunnelTrafficRatio?: number;
|
||||
inIp?: string;
|
||||
inPort?: number;
|
||||
remoteAddr?: string;
|
||||
@@ -214,6 +219,10 @@ export interface NodeMutationPayload {
|
||||
name?: string;
|
||||
status?: number;
|
||||
inx?: number;
|
||||
remark?: string;
|
||||
tags?: string;
|
||||
expiryTime?: number;
|
||||
renewalCycle?: "month" | "quarter" | "year" | "";
|
||||
serverIp?: string;
|
||||
serverIpV4?: string;
|
||||
serverIpV6?: string;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,118 @@ 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 parseNodeTags = (tags?: string) => {
|
||||
if (!tags) return [];
|
||||
return tags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
};
|
||||
|
||||
const renderNodeExpiryCard = (node: DashboardNodeExpiryItem) => {
|
||||
const renewalSnapshot = getNodeRenewalSnapshot(
|
||||
node.expiryTime,
|
||||
node.renewalCycle,
|
||||
);
|
||||
const expiryStatus = getNodeExpiryStatus(
|
||||
renewalSnapshot.nextDueTime,
|
||||
renewalSnapshot.state,
|
||||
);
|
||||
const tags = parseNodeTags(node.tags);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
className="rounded-xl border border-amber-200/80 bg-gradient-to-br from-amber-50 via-white to-orange-50 p-4 shadow-sm dark:border-amber-500/20 dark:from-amber-950/20 dark:via-background dark:to-orange-950/10"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-foreground truncate">
|
||||
{node.name}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-default-500">节点 ID: {node.id}</div>
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium ${expiryStatus.badgeClassName}`}
|
||||
>
|
||||
{expiryStatus.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-sm text-default-700 dark:text-default-300">
|
||||
{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>
|
||||
)}
|
||||
|
||||
{tags.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={`${node.id}-${tag}`}
|
||||
className="rounded-full bg-white/80 px-2 py-1 text-[11px] text-default-600 ring-1 ring-amber-200/80 dark:bg-white/5 dark:text-default-300 dark:ring-amber-500/20"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 处理24小时流量统计数据
|
||||
const processFlowChartData = () => {
|
||||
// 生成最近24小时的时间数组(从当前小时往前推24小时)
|
||||
@@ -616,7 +735,7 @@ export default function DashboardPage() {
|
||||
</svg>
|
||||
}
|
||||
iconClassName="bg-purple-100 dark:bg-purple-500/20"
|
||||
title="转发配额"
|
||||
title="规则配额"
|
||||
value={formatNumber(userInfo.num || 0)}
|
||||
/>
|
||||
|
||||
@@ -650,7 +769,7 @@ export default function DashboardPage() {
|
||||
</svg>
|
||||
}
|
||||
iconClassName="bg-orange-100 dark:bg-orange-500/20"
|
||||
title="已用转发"
|
||||
title="已用规则"
|
||||
value={forwardList.length}
|
||||
/>
|
||||
</div>
|
||||
@@ -661,6 +780,52 @@ export default function DashboardPage() {
|
||||
statisticsFlowsCount={statisticsFlows.length}
|
||||
/>
|
||||
|
||||
{isAdmin && nodeExpiryReminders.length > 0 && (
|
||||
<Card className="mb-6 lg:mb-8 border border-amber-200/80 bg-gradient-to-br from-amber-50/90 via-background to-orange-50/70 shadow-md dark:border-amber-500/20 dark:from-amber-950/10 dark:to-orange-950/10">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="h-5 w-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.981-1.742 2.981H4.42c-1.53 0-2.492-1.647-1.743-2.98l5.58-9.92zM11 13a1 1 0 10-2 0 1 1 0 002 0zm-1-7a1 1 0 00-1 1v3a1 1 0 102 0V7a1 1 0 00-1-1z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg lg:text-xl font-semibold text-foreground">
|
||||
节点到期提醒
|
||||
</h2>
|
||||
<p className="text-sm text-default-500">
|
||||
展示 7 天内需要续费或已经逾期的节点,基于月付/季付/年付周期自动推算
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="inline-flex w-fit items-center rounded-full bg-white/80 px-3 py-1 text-xs font-medium text-amber-700 ring-1 ring-amber-200/80 dark:bg-white/5 dark:text-amber-300 dark:ring-amber-500/20">
|
||||
{nodeExpiryReminders.length} 个提醒
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0">
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
{nodeExpiryReminders.slice(0, 6).map(renderNodeExpiryCard)}
|
||||
</div>
|
||||
{nodeExpiryReminders.length > 6 && (
|
||||
<p className="mt-4 text-xs text-default-500">
|
||||
还有 {nodeExpiryReminders.length - 6} 个节点未展开显示,可前往节点页面继续处理。
|
||||
</p>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 隧道权限 - 管理员不显示 */}
|
||||
{!isAdmin && (
|
||||
<Card className="mb-6 lg:mb-8 border border-gray-200 dark:border-default-200 shadow-md">
|
||||
@@ -753,7 +918,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 +926,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 +949,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 +966,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 +990,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 +1004,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,47 @@ export interface DashboardStatisticsFlow {
|
||||
time: string;
|
||||
}
|
||||
|
||||
export interface DashboardNodeExpiryItem {
|
||||
id: number;
|
||||
name: string;
|
||||
remark?: string;
|
||||
tags?: 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 +103,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 +187,7 @@ const checkExpirationNotifications = (
|
||||
});
|
||||
|
||||
if (hasNotification) {
|
||||
localStorage.setItem("lastNotified", notificationKey);
|
||||
localStorage.setItem(EXPIRATION_NOTIFICATION_STORAGE_KEY, notificationKey);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -174,6 +213,37 @@ const normalizeTunnelPermissions = (items: DashboardUserTunnel[]) => {
|
||||
}));
|
||||
};
|
||||
|
||||
const normalizeNodeExpiryReminders = (items: NodeApiItem[]) => {
|
||||
const now = Date.now();
|
||||
const warningWindowMs = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
return (items || [])
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name || "",
|
||||
remark: typeof item.remark === "string" ? item.remark : "",
|
||||
tags: typeof item.tags === "string" ? item.tags : "",
|
||||
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 +254,171 @@ export const useDashboardData = (): DashboardDataState => {
|
||||
const [statisticsFlows, setStatisticsFlows] = useState<
|
||||
DashboardStatisticsFlow[]
|
||||
>([]);
|
||||
const [nodeExpiryReminders, setNodeExpiryReminders] = useState<
|
||||
DashboardNodeExpiryItem[]
|
||||
>([]);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [announcement, setAnnouncement] = useState<AnnouncementData | null>(
|
||||
null,
|
||||
);
|
||||
const isMountedRef = useRef(true);
|
||||
const packageRequestInFlightRef = useRef(false);
|
||||
const nodeExpiryRequestInFlightRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadAnnouncement = async () => {
|
||||
try {
|
||||
const res = await getAnnouncement();
|
||||
const applyPackageData = useCallback((data: {
|
||||
userInfo?: DashboardUserInfo;
|
||||
tunnelPermissions?: DashboardUserTunnel[];
|
||||
forwards?: ForwardApiItem[];
|
||||
statisticsFlows?: DashboardStatisticsFlow[];
|
||||
}) => {
|
||||
const normalizedTunnelPermissions = normalizeTunnelPermissions(
|
||||
data.tunnelPermissions || [],
|
||||
);
|
||||
const normalizedForwards = normalizeForwards(data.forwards || []);
|
||||
|
||||
if (res.code === 0 && res.data && res.data.enabled === 1) {
|
||||
setAnnouncement(res.data);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUserInfo(data.userInfo || ({} as DashboardUserInfo));
|
||||
setUserTunnels(normalizedTunnelPermissions);
|
||||
setForwardList(normalizedForwards);
|
||||
setStatisticsFlows(data.statisticsFlows || []);
|
||||
|
||||
checkExpirationNotifications(
|
||||
data.userInfo || ({} as DashboardUserInfo),
|
||||
normalizedTunnelPermissions,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const loadPackageData = useCallback(
|
||||
async ({ silent = false, notifyOnError = false } = {}) => {
|
||||
if (packageRequestInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
packageRequestInFlightRef.current = true;
|
||||
|
||||
if (!silent && isMountedRef.current) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
const loadPackageData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getUserPackageInfo();
|
||||
|
||||
if (res.code === 0) {
|
||||
const data = res.data;
|
||||
const normalizedTunnelPermissions = normalizeTunnelPermissions(
|
||||
data.tunnelPermissions || [],
|
||||
);
|
||||
const normalizedForwards = normalizeForwards(data.forwards || []);
|
||||
|
||||
setUserInfo(data.userInfo || ({} as DashboardUserInfo));
|
||||
setUserTunnels(normalizedTunnelPermissions);
|
||||
setForwardList(normalizedForwards);
|
||||
setStatisticsFlows(data.statisticsFlows || []);
|
||||
|
||||
checkExpirationNotifications(
|
||||
data.userInfo,
|
||||
normalizedTunnelPermissions,
|
||||
);
|
||||
} else {
|
||||
applyPackageData(res.data || {});
|
||||
} else if (notifyOnError) {
|
||||
toast.error(res.msg || "获取套餐信息失败");
|
||||
}
|
||||
} catch {
|
||||
toast.error("获取套餐信息失败");
|
||||
if (notifyOnError) {
|
||||
toast.error("获取套餐信息失败");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
packageRequestInFlightRef.current = false;
|
||||
|
||||
if (!silent && isMountedRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[applyPackageData],
|
||||
);
|
||||
|
||||
const loadAnnouncement = useCallback(async () => {
|
||||
try {
|
||||
const res = await getAnnouncement();
|
||||
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.code === 0 && res.data && res.data.enabled === 1) {
|
||||
setAnnouncement(res.data);
|
||||
} else {
|
||||
setAnnouncement(null);
|
||||
}
|
||||
} catch {
|
||||
if (isMountedRef.current) {
|
||||
setAnnouncement(null);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadNodeExpiryData = useCallback(async () => {
|
||||
if (nodeExpiryRequestInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
nodeExpiryRequestInFlightRef.current = true;
|
||||
|
||||
try {
|
||||
const res = await getDashboardNodeExpiryList();
|
||||
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.code === 0 && Array.isArray(res.data)) {
|
||||
setNodeExpiryReminders(normalizeNodeExpiryReminders(res.data));
|
||||
}
|
||||
} catch {
|
||||
} finally {
|
||||
nodeExpiryRequestInFlightRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
const adminFlag = getAdminFlag();
|
||||
|
||||
setIsAdmin(adminFlag);
|
||||
|
||||
void loadPackageData({ notifyOnError: true });
|
||||
void loadAnnouncement();
|
||||
if (adminFlag) {
|
||||
void loadNodeExpiryData();
|
||||
}
|
||||
localStorage.setItem("e", "/dashboard");
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, [loadAnnouncement, loadNodeExpiryData, loadPackageData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
void loadPackageData({ silent: true });
|
||||
if (isAdmin) {
|
||||
void loadNodeExpiryData();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setLoading(true);
|
||||
setUserInfo({} as DashboardUserInfo);
|
||||
setUserTunnels([]);
|
||||
setForwardList([]);
|
||||
setStatisticsFlows([]);
|
||||
setIsAdmin(getAdminFlag());
|
||||
const interval = window.setInterval(() => {
|
||||
if (document.visibilityState !== "visible") {
|
||||
return;
|
||||
}
|
||||
|
||||
loadPackageData();
|
||||
loadAnnouncement();
|
||||
localStorage.setItem("e", "/dashboard");
|
||||
}, []);
|
||||
void loadPackageData({ silent: true });
|
||||
if (isAdmin) {
|
||||
void loadNodeExpiryData();
|
||||
}
|
||||
}, DASHBOARD_POLL_INTERVAL_MS);
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [isAdmin, loadNodeExpiryData, loadPackageData]);
|
||||
|
||||
return {
|
||||
loading,
|
||||
@@ -249,6 +426,7 @@ export const useDashboardData = (): DashboardDataState => {
|
||||
userTunnels,
|
||||
forwardList,
|
||||
statisticsFlows,
|
||||
nodeExpiryReminders,
|
||||
isAdmin,
|
||||
announcement,
|
||||
};
|
||||
|
||||
+450
-189
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,
|
||||
};
|
||||
|
||||
@@ -59,16 +59,28 @@ import {
|
||||
getRemoteSyncErrorMessage,
|
||||
} from "@/pages/node/display";
|
||||
import { tryCopyInstallCommand } from "@/pages/node/install-command";
|
||||
import {
|
||||
formatNodeRenewalTime,
|
||||
getNodeRenewalCycleLabel,
|
||||
getNodeRenewalSnapshot,
|
||||
type NodeRenewalCycle,
|
||||
} from "@/pages/node/renewal";
|
||||
import { buildNodeSystemInfo } from "@/pages/node/system-info";
|
||||
import { useNodeOfflineTimers } from "@/pages/node/use-node-offline-timers";
|
||||
import { useNodeRealtime } from "@/pages/node/use-node-realtime";
|
||||
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
|
||||
import { loadStoredOrder, saveOrder } from "@/utils/order-storage";
|
||||
|
||||
const NODE_FALLBACK_REFRESH_INTERVAL_MS = 15000;
|
||||
|
||||
interface Node {
|
||||
id: number;
|
||||
inx?: number;
|
||||
name: string;
|
||||
remark?: string;
|
||||
tags?: string;
|
||||
expiryTime?: number;
|
||||
renewalCycle?: NodeRenewalCycle;
|
||||
ip: string;
|
||||
serverIp: string;
|
||||
serverIpV4?: string;
|
||||
@@ -103,6 +115,10 @@ interface Node {
|
||||
interface NodeForm {
|
||||
id: number | null;
|
||||
name: string;
|
||||
remark: string;
|
||||
tags: string;
|
||||
expiryTime: number;
|
||||
renewalCycle: NodeRenewalCycle;
|
||||
serverHost: string;
|
||||
serverIpV4: string;
|
||||
serverIpV6: string;
|
||||
@@ -116,6 +132,97 @@ interface NodeForm {
|
||||
socks: number; // 0 关 1 开
|
||||
}
|
||||
|
||||
const EXPIRING_SOON_DAYS = 7;
|
||||
|
||||
type NodeExpiryState = "permanent" | "healthy" | "expiringSoon" | "expired";
|
||||
|
||||
type NodeFilterMode = "all" | "expiringSoon" | "expired" | "withExpiry";
|
||||
|
||||
const getNodeReminderEnabled = (node: Node): boolean => {
|
||||
return !!node.expiryTime && node.expiryTime > 0 && !!node.renewalCycle;
|
||||
};
|
||||
|
||||
const getNodeExpiryMeta = (timestamp?: number, cycle?: NodeRenewalCycle) => {
|
||||
const renewal = getNodeRenewalSnapshot(timestamp, cycle, EXPIRING_SOON_DAYS);
|
||||
|
||||
if (renewal.state === "unset") {
|
||||
return {
|
||||
state: "permanent" as NodeExpiryState,
|
||||
label: "未设置续费周期",
|
||||
tone: "default" as const,
|
||||
accentClassName: "",
|
||||
bannerClassName: "",
|
||||
isHighlighted: false,
|
||||
sortWeight: 3,
|
||||
nextDueTime: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (renewal.state === "expired") {
|
||||
return {
|
||||
state: "expired" as NodeExpiryState,
|
||||
label: "已过期",
|
||||
tone: "danger" as const,
|
||||
accentClassName:
|
||||
"border-red-300/80 bg-red-50/70 shadow-red-100 dark:border-red-500/40 dark:bg-red-950/20",
|
||||
bannerClassName:
|
||||
"bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-300",
|
||||
isHighlighted: true,
|
||||
sortWeight: 0,
|
||||
nextDueTime: renewal.nextDueTime,
|
||||
};
|
||||
}
|
||||
|
||||
if (renewal.state === "dueSoon") {
|
||||
return {
|
||||
state: "expiringSoon" as NodeExpiryState,
|
||||
label: renewal.label,
|
||||
tone: "warning" as const,
|
||||
accentClassName:
|
||||
"border-amber-300/80 bg-amber-50/80 shadow-amber-100 dark:border-amber-500/40 dark:bg-amber-950/20",
|
||||
bannerClassName:
|
||||
"bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300",
|
||||
isHighlighted: true,
|
||||
sortWeight: 1,
|
||||
nextDueTime: renewal.nextDueTime,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: "healthy" as NodeExpiryState,
|
||||
label: renewal.label,
|
||||
tone: "success" as const,
|
||||
accentClassName: "",
|
||||
bannerClassName: "",
|
||||
isHighlighted: false,
|
||||
sortWeight: 2,
|
||||
nextDueTime: renewal.nextDueTime,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeNodeTags = (tags?: string): string[] => {
|
||||
if (!tags) return [];
|
||||
return tags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const mergeNodeRealtimeState = (
|
||||
incomingNode: Node,
|
||||
existingNode?: Node,
|
||||
): Node => {
|
||||
return {
|
||||
...incomingNode,
|
||||
systemInfo: existingNode?.systemInfo ?? incomingNode.systemInfo ?? null,
|
||||
copyLoading: existingNode?.copyLoading ?? incomingNode.copyLoading ?? false,
|
||||
upgradeLoading:
|
||||
existingNode?.upgradeLoading ?? incomingNode.upgradeLoading ?? false,
|
||||
rollbackLoading:
|
||||
existingNode?.rollbackLoading ?? incomingNode.rollbackLoading ?? false,
|
||||
};
|
||||
};
|
||||
|
||||
const SortableItem = ({
|
||||
id,
|
||||
children,
|
||||
@@ -165,6 +272,10 @@ export default function NodePage() {
|
||||
"node-search-keyword",
|
||||
"",
|
||||
);
|
||||
const [nodeFilterMode, setNodeFilterMode] = useLocalStorageState<NodeFilterMode>(
|
||||
"node-expiry-filter-mode",
|
||||
"all",
|
||||
);
|
||||
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||
const [dialogVisible, setDialogVisible] = useState(false);
|
||||
const [dialogTitle, setDialogTitle] = useState("");
|
||||
@@ -178,6 +289,10 @@ export default function NodePage() {
|
||||
const [form, setForm] = useState<NodeForm>({
|
||||
id: null,
|
||||
name: "",
|
||||
remark: "",
|
||||
tags: "",
|
||||
expiryTime: 0,
|
||||
renewalCycle: "",
|
||||
serverHost: "",
|
||||
serverIpV4: "",
|
||||
serverIpV6: "",
|
||||
@@ -251,8 +366,13 @@ export default function NodePage() {
|
||||
});
|
||||
|
||||
// 加载节点列表
|
||||
const loadNodes = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const loadNodes = useCallback(async (options?: { silent?: boolean }) => {
|
||||
const silent = options?.silent ?? false;
|
||||
|
||||
if (!silent) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await getNodeList();
|
||||
|
||||
@@ -270,7 +390,13 @@ export default function NodePage() {
|
||||
copyLoading: false,
|
||||
}));
|
||||
|
||||
setNodeList(nodesData);
|
||||
setNodeList((prev) => {
|
||||
const previousById = new Map(prev.map((node) => [node.id, node]));
|
||||
|
||||
return nodesData.map((node) =>
|
||||
mergeNodeRealtimeState(node, previousById.get(node.id)),
|
||||
);
|
||||
});
|
||||
|
||||
// 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序
|
||||
const hasDbOrdering = nodesData.some(
|
||||
@@ -292,12 +418,18 @@ export default function NodePage() {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
toast.error(res.msg || "加载节点列表失败");
|
||||
if (!silent) {
|
||||
toast.error(res.msg || "加载节点列表失败");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error("网络错误,请重试");
|
||||
if (!silent) {
|
||||
toast.error("网络错误,请重试");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (!silent) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -370,7 +502,7 @@ export default function NodePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const { wsConnected, wsConnecting } = useNodeRealtime({
|
||||
const { wsConnected, wsConnecting, usingPollingFallback } = useNodeRealtime({
|
||||
onMessage: handleWebSocketMessage,
|
||||
});
|
||||
|
||||
@@ -378,6 +510,22 @@ export default function NodePage() {
|
||||
loadNodes();
|
||||
}, [loadNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!usingPollingFallback) {
|
||||
return;
|
||||
}
|
||||
|
||||
void loadNodes({ silent: true });
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
void loadNodes({ silent: true });
|
||||
}, NODE_FALLBACK_REFRESH_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [loadNodes, usingPollingFallback]);
|
||||
|
||||
// 格式化速度
|
||||
const formatSpeed = (bytesPerSecond: number): string => {
|
||||
if (bytesPerSecond === 0) return "0 B/s";
|
||||
@@ -527,6 +675,10 @@ export default function NodePage() {
|
||||
newErrors.name = "节点名称长度不能超过50位";
|
||||
}
|
||||
|
||||
if ((form.renewalCycle && !form.expiryTime) || (!form.renewalCycle && form.expiryTime)) {
|
||||
newErrors.expiryTime = "请同时设置续费周期和续费基准时间";
|
||||
}
|
||||
|
||||
const v4 = form.serverIpV4.trim();
|
||||
const v6 = form.serverIpV6.trim();
|
||||
const host = form.serverHost.trim();
|
||||
@@ -586,6 +738,10 @@ export default function NodePage() {
|
||||
setForm({
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
remark: node.remark || "",
|
||||
tags: node.tags || "",
|
||||
expiryTime: node.expiryTime || 0,
|
||||
renewalCycle: node.renewalCycle || "",
|
||||
serverHost: normalizedHost,
|
||||
serverIpV4: normalizedV4,
|
||||
serverIpV6: normalizedV6,
|
||||
@@ -821,6 +977,10 @@ export default function NodePage() {
|
||||
const { serverHost, ...rest } = form;
|
||||
const data = {
|
||||
...rest,
|
||||
remark: form.remark.trim(),
|
||||
tags: form.tags.trim(),
|
||||
expiryTime: form.expiryTime,
|
||||
renewalCycle: form.renewalCycle,
|
||||
extraIPs: form.extraIPs,
|
||||
serverIp:
|
||||
form.serverIpV4?.trim() ||
|
||||
@@ -842,6 +1002,10 @@ export default function NodePage() {
|
||||
? {
|
||||
...n,
|
||||
name: form.name,
|
||||
remark: form.remark.trim(),
|
||||
tags: form.tags.trim(),
|
||||
expiryTime: form.expiryTime,
|
||||
renewalCycle: form.renewalCycle,
|
||||
serverIp:
|
||||
form.serverIpV4?.trim() ||
|
||||
form.serverIpV6?.trim() ||
|
||||
@@ -878,6 +1042,10 @@ export default function NodePage() {
|
||||
setForm({
|
||||
id: null,
|
||||
name: "",
|
||||
remark: "",
|
||||
tags: "",
|
||||
expiryTime: 0,
|
||||
renewalCycle: "",
|
||||
serverHost: "",
|
||||
serverIpV4: "",
|
||||
serverIpV6: "",
|
||||
@@ -1010,6 +1178,22 @@ export default function NodePage() {
|
||||
}),
|
||||
);
|
||||
|
||||
const nodeExpiryStats = useMemo(() => {
|
||||
return nodeList.reduce(
|
||||
(acc, node) => {
|
||||
const meta = getNodeExpiryMeta(node.expiryTime, node.renewalCycle);
|
||||
|
||||
if (meta.state === "expired") acc.expired += 1;
|
||||
if (meta.state === "expiringSoon") acc.expiringSoon += 1;
|
||||
if (getNodeReminderEnabled(node)) {
|
||||
acc.withExpiry += 1;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ expired: 0, expiringSoon: 0, withExpiry: 0 },
|
||||
);
|
||||
}, [nodeList]);
|
||||
|
||||
// 根据排序顺序获取节点列表
|
||||
const sortedNodes = useMemo((): Node[] => {
|
||||
if (!nodeList || nodeList.length === 0) return [];
|
||||
@@ -1022,13 +1206,40 @@ export default function NodePage() {
|
||||
filteredNodes = filteredNodes.filter(
|
||||
(n) =>
|
||||
(n.name && n.name.toLowerCase().includes(lowerKeyword)) ||
|
||||
(n.remark && n.remark.toLowerCase().includes(lowerKeyword)) ||
|
||||
(n.tags && n.tags.toLowerCase().includes(lowerKeyword)) ||
|
||||
(n.serverIp && n.serverIp.toLowerCase().includes(lowerKeyword)) ||
|
||||
(n.serverIpV4 && n.serverIpV4.toLowerCase().includes(lowerKeyword)) ||
|
||||
(n.serverIpV6 && n.serverIpV6.toLowerCase().includes(lowerKeyword)),
|
||||
);
|
||||
}
|
||||
|
||||
if (nodeFilterMode !== "all") {
|
||||
filteredNodes = filteredNodes.filter((node) => {
|
||||
const expiryMeta = getNodeExpiryMeta(node.expiryTime, node.renewalCycle);
|
||||
|
||||
switch (nodeFilterMode) {
|
||||
case "expiringSoon":
|
||||
return expiryMeta.state === "expiringSoon";
|
||||
case "expired":
|
||||
return expiryMeta.state === "expired";
|
||||
case "withExpiry":
|
||||
return getNodeReminderEnabled(node);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const sortedByDb = [...filteredNodes].sort((a, b) => {
|
||||
const expiryDiff =
|
||||
getNodeExpiryMeta(a.expiryTime, a.renewalCycle).sortWeight -
|
||||
getNodeExpiryMeta(b.expiryTime, b.renewalCycle).sortWeight;
|
||||
|
||||
if (expiryDiff !== 0) {
|
||||
return expiryDiff;
|
||||
}
|
||||
|
||||
const aInx = a.inx ?? 0;
|
||||
const bInx = b.inx ?? 0;
|
||||
|
||||
@@ -1060,7 +1271,7 @@ export default function NodePage() {
|
||||
}
|
||||
|
||||
return sortedByDb;
|
||||
}, [nodeList, nodeOrder, searchKeyword]);
|
||||
}, [nodeFilterMode, nodeList, nodeOrder, searchKeyword]);
|
||||
|
||||
const sortableNodeIds = useMemo(
|
||||
() => sortedNodes.map((n) => n.id),
|
||||
@@ -1070,15 +1281,41 @@ export default function NodePage() {
|
||||
return (
|
||||
<AnimatedPage className="px-3 lg:px-6 py-8">
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between mb-6 gap-3">
|
||||
<div className="flex-1 max-w-sm flex items-center gap-2">
|
||||
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center">
|
||||
<SearchBar
|
||||
isVisible={isSearchVisible}
|
||||
placeholder="搜索节点名称或IP"
|
||||
placeholder="搜索名称、IP、备注或标签"
|
||||
value={searchKeyword}
|
||||
onChange={setSearchKeyword}
|
||||
onClose={() => setIsSearchVisible(false)}
|
||||
onOpen={() => setIsSearchVisible(true)}
|
||||
/>
|
||||
|
||||
<Select
|
||||
className="w-full max-w-sm lg:w-56"
|
||||
label="到期筛选"
|
||||
selectedKeys={[nodeFilterMode]}
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selected = Array.from(keys)[0] as NodeFilterMode | undefined;
|
||||
|
||||
setNodeFilterMode(selected || "all");
|
||||
}}
|
||||
>
|
||||
<SelectItem key="all" textValue="全部节点">
|
||||
全部节点
|
||||
</SelectItem>
|
||||
<SelectItem key="expiringSoon" textValue="7天内到期">
|
||||
7天内续费 ({nodeExpiryStats.expiringSoon})
|
||||
</SelectItem>
|
||||
<SelectItem key="expired" textValue="已过期">
|
||||
已逾期 ({nodeExpiryStats.expired})
|
||||
</SelectItem>
|
||||
<SelectItem key="withExpiry" textValue="已设置到期时间">
|
||||
已启用续费提醒 ({nodeExpiryStats.withExpiry})
|
||||
</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="min-h-9 min-w-0 max-w-full overflow-x-auto touch-pan-x">
|
||||
@@ -1162,7 +1399,11 @@ export default function NodePage() {
|
||||
className="mb-4"
|
||||
color="warning"
|
||||
description={
|
||||
wsConnecting ? "监控连接中..." : "监控连接已断开,正在重连..."
|
||||
wsConnecting
|
||||
? "监控连接中..."
|
||||
: usingPollingFallback
|
||||
? "监控连接已断开,已切换为列表自动刷新兜底模式。"
|
||||
: "监控连接已断开,正在重连..."
|
||||
}
|
||||
variant="flat"
|
||||
/>
|
||||
@@ -1185,13 +1426,17 @@ export default function NodePage() {
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{sortedNodes.map((node) => {
|
||||
const isRemoteNode = node.isRemote === 1;
|
||||
const expiryMeta = getNodeExpiryMeta(
|
||||
node.expiryTime,
|
||||
node.renewalCycle,
|
||||
);
|
||||
|
||||
return (
|
||||
<SortableItem key={node.id} id={node.id}>
|
||||
{(listeners) => (
|
||||
<Card
|
||||
key={node.id}
|
||||
className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden"
|
||||
className={`group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden ${expiryMeta.accentClassName}`}
|
||||
>
|
||||
<CardHeader className="pb-2 md:pb-2">
|
||||
<div className="flex justify-between items-start w-full">
|
||||
@@ -1259,8 +1504,63 @@ export default function NodePage() {
|
||||
{getRemoteSyncErrorMessage(node.syncError)}
|
||||
</div>
|
||||
)}
|
||||
{expiryMeta.isHighlighted && (
|
||||
<div
|
||||
className={`mb-3 flex items-center justify-between gap-3 rounded-md px-2 py-1.5 text-xs font-medium ${expiryMeta.bannerClassName}`}
|
||||
>
|
||||
<span>节点到期提醒</span>
|
||||
<span>{expiryMeta.label}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* 基础信息 */}
|
||||
<div className="space-y-2 mb-4">
|
||||
{(node.remark?.trim() || node.tags?.trim()) && (
|
||||
<div className="rounded-lg border border-divider/80 bg-default-50/80 px-3 py-2">
|
||||
{node.remark?.trim() && (
|
||||
<div className="text-xs leading-5 text-default-700 whitespace-pre-wrap break-words">
|
||||
{node.remark.trim()}
|
||||
</div>
|
||||
)}
|
||||
{normalizeNodeTags(node.tags).length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{normalizeNodeTags(node.tags).map((tag) => (
|
||||
<Chip
|
||||
key={`${node.id}-${tag}`}
|
||||
className="text-[11px]"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
#{tag}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{node.expiryTime && node.expiryTime > 0 && node.renewalCycle && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">下次续费</span>
|
||||
<div className="text-right ml-2">
|
||||
<div className="text-xs text-warning-700 dark:text-warning-400">
|
||||
{formatNodeRenewalTime(expiryMeta.nextDueTime)}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<Chip
|
||||
className="text-[11px]"
|
||||
color={expiryMeta.tone}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{expiryMeta.label}
|
||||
</Chip>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-default-500">
|
||||
{getNodeRenewalCycleLabel(node.renewalCycle)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center text-sm min-w-0">
|
||||
<span className="text-default-600 flex-shrink-0">
|
||||
IP
|
||||
@@ -1562,8 +1862,101 @@ export default function NodePage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
description="可记录供应商、用途、续费说明等补充信息"
|
||||
label="备注"
|
||||
maxRows={4}
|
||||
minRows={3}
|
||||
placeholder="例如: 搬瓦工年付,2026-12 续费,日本中转"
|
||||
value={form.remark}
|
||||
variant="bordered"
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, remark: e.target.value }))
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
description="多个标签用逗号分隔,可用于搜索过滤"
|
||||
label="标签"
|
||||
placeholder="例如: 年付,日本中转,测试机"
|
||||
value={form.tags}
|
||||
variant="bordered"
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, tags: e.target.value }))
|
||||
}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="续费周期"
|
||||
placeholder="选择续费周期"
|
||||
selectedKeys={form.renewalCycle ? [form.renewalCycle] : []}
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selected = Array.from(keys)[0] as
|
||||
| NodeRenewalCycle
|
||||
| undefined;
|
||||
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
renewalCycle: selected || "",
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectItem key="month" textValue="month">
|
||||
月付
|
||||
</SelectItem>
|
||||
<SelectItem key="quarter" textValue="quarter">
|
||||
季付
|
||||
</SelectItem>
|
||||
<SelectItem key="year" textValue="year">
|
||||
年付
|
||||
</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
description="可选:不带协议、不带端口。至少填写一个 IPv4/IPv6/域名"
|
||||
description="填写最近一次续费时间或周期起始时间,系统会按月/季/年自动推算下次续费"
|
||||
errorMessage={errors.expiryTime}
|
||||
isInvalid={!!errors.expiryTime}
|
||||
label="续费基准时间"
|
||||
type="datetime-local"
|
||||
value={
|
||||
form.expiryTime > 0
|
||||
? new Date(form.expiryTime).toISOString().slice(0, 16)
|
||||
: ""
|
||||
}
|
||||
variant="bordered"
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
expiryTime: e.target.value
|
||||
? new Date(e.target.value).getTime()
|
||||
: 0,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
|
||||
<Alert
|
||||
color="primary"
|
||||
description="例如选择月付并填写 2026-03-01,系统会自动按每月同日推算下次续费时间。"
|
||||
variant="flat"
|
||||
/>
|
||||
|
||||
<Input
|
||||
description="留空表示未设置续费提醒;只有同时设置周期和基准时间才会参与提醒与筛选"
|
||||
label="说明"
|
||||
readOnly
|
||||
value={
|
||||
form.renewalCycle && form.expiryTime > 0
|
||||
? `当前按${getNodeRenewalCycleLabel(form.renewalCycle)}循环计算`
|
||||
: "未启用循环续费提醒"
|
||||
}
|
||||
variant="bordered"
|
||||
/>
|
||||
|
||||
<Input
|
||||
description="可选:不带协议、不带端口。建议在 IPv4 和 IPv6 都未填写时使用。至少填写一个 IPv4/IPv6/域名"
|
||||
errorMessage={errors.serverHost}
|
||||
isInvalid={!!errors.serverHost}
|
||||
label="服务器域名/主机名"
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
|
||||
import { Switch } from "@/shadcn-bridge/heroui/switch";
|
||||
import { reinitializeBaseURL } from "@/api/network";
|
||||
import { getConfigByName, updateConfig } from "@/api";
|
||||
import { BackIcon } from "@/components/icons";
|
||||
import {
|
||||
type UpdateReleaseChannel,
|
||||
getUpdateReleaseChannel,
|
||||
@@ -129,7 +130,7 @@ export const SettingsPage = () => {
|
||||
);
|
||||
|
||||
if (response.code === 0) {
|
||||
toast.success(`转发页面精简模式已${enabled ? "开启" : "关闭"}`);
|
||||
toast.success(`规则页面精简模式已${enabled ? "开启" : "关闭"}`);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("forwardCompactModeChanged", {
|
||||
detail: { enabled },
|
||||
@@ -168,20 +169,7 @@ export const SettingsPage = () => {
|
||||
variant="light"
|
||||
onPress={() => navigate(-1)}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M15 19l-7-7 7-7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</svg>
|
||||
<BackIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
面板设置
|
||||
@@ -232,10 +220,10 @@ export const SettingsPage = () => {
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
转发页面精简模式
|
||||
规则页面精简模式
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
开启后,转发页面列表使用 2.1.6-alpha8 样式。
|
||||
开启后,规则页面列表使用 2.1.6-alpha8 样式。{" "}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
|
||||
+410
-276
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useMemo, useRef } from "react";
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
DndContext,
|
||||
@@ -36,6 +36,7 @@ import { Spinner } from "@/shadcn-bridge/heroui/spinner";
|
||||
import { Divider } from "@/shadcn-bridge/heroui/divider";
|
||||
import { Alert } from "@/shadcn-bridge/heroui/alert";
|
||||
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
|
||||
import { Progress } from "@/shadcn-bridge/heroui/progress";
|
||||
import {
|
||||
createTunnel,
|
||||
getTunnelList,
|
||||
@@ -115,6 +116,29 @@ interface TunnelForm {
|
||||
status: number;
|
||||
}
|
||||
|
||||
interface BatchProgressState {
|
||||
active: boolean;
|
||||
label: string;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
const TUNNEL_ORDER_KEY = "tunnel-order";
|
||||
|
||||
const mapTunnelApiItems = (items: any[]): Tunnel[] => {
|
||||
return (items || []).map((tunnel) => ({
|
||||
...tunnel,
|
||||
inx: tunnel.inx ?? 0,
|
||||
inNodeId: Array.isArray(tunnel.inNodeId) ? tunnel.inNodeId : [],
|
||||
outNodeId: Array.isArray(tunnel.outNodeId) ? tunnel.outNodeId : [],
|
||||
chainNodes: Array.isArray(tunnel.chainNodes) ? tunnel.chainNodes : [],
|
||||
inIp: tunnel.inIp || "",
|
||||
flow: tunnel.flow ?? 1,
|
||||
trafficRatio: tunnel.trafficRatio ?? 1,
|
||||
status: typeof tunnel.status === "number" ? tunnel.status : 0,
|
||||
createdTime: tunnel.createdTime || "",
|
||||
}));
|
||||
};
|
||||
|
||||
export default function TunnelPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
||||
@@ -203,6 +227,11 @@ export default function TunnelPage() {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
const [batchProgress, setBatchProgress] = useState<BatchProgressState>({
|
||||
active: false,
|
||||
label: "",
|
||||
percent: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -211,60 +240,78 @@ export default function TunnelPage() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
const applyTunnelList = useCallback((items: Tunnel[]) => {
|
||||
setTunnels(items);
|
||||
|
||||
const hasDbOrdering = items.some(
|
||||
(tunnel) => tunnel.inx !== undefined && tunnel.inx !== 0,
|
||||
);
|
||||
|
||||
if (hasDbOrdering) {
|
||||
const dbOrder = [...items]
|
||||
.sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0))
|
||||
.map((tunnel) => tunnel.id);
|
||||
|
||||
setTunnelOrder(dbOrder);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setTunnelOrder(
|
||||
loadStoredOrder(
|
||||
TUNNEL_ORDER_KEY,
|
||||
items.map((tunnel) => tunnel.id),
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 加载所有数据
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
const refreshTunnelList = useCallback(async (withLoading = true) => {
|
||||
if (withLoading) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const [tunnelsRes, nodesRes] = await Promise.all([
|
||||
getTunnelList(),
|
||||
getNodeList(),
|
||||
]);
|
||||
const tunnelsRes = await getTunnelList();
|
||||
|
||||
if (tunnelsRes.code === 0) {
|
||||
const tunnelsData: Tunnel[] = (tunnelsRes.data || []).map((t: any) => ({
|
||||
...t,
|
||||
inx: t.inx ?? 0,
|
||||
}));
|
||||
|
||||
setTunnels(tunnelsData);
|
||||
|
||||
// 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序
|
||||
const hasDbOrdering = tunnelsData.some(
|
||||
(t) => t.inx !== undefined && t.inx !== 0,
|
||||
);
|
||||
|
||||
if (hasDbOrdering) {
|
||||
const dbOrder = [...tunnelsData]
|
||||
.sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0))
|
||||
.map((t) => t.id);
|
||||
|
||||
setTunnelOrder(dbOrder);
|
||||
} else {
|
||||
setTunnelOrder(
|
||||
loadStoredOrder(
|
||||
"tunnel-order",
|
||||
tunnelsData.map((t) => t.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
applyTunnelList(mapTunnelApiItems(tunnelsRes.data || []));
|
||||
} else {
|
||||
toast.error(tunnelsRes.msg || "获取隧道列表失败");
|
||||
}
|
||||
} catch {
|
||||
toast.error("获取隧道列表失败");
|
||||
} finally {
|
||||
if (withLoading) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [applyTunnelList]);
|
||||
|
||||
const refreshNodes = useCallback(async () => {
|
||||
try {
|
||||
const nodesRes = await getNodeList();
|
||||
|
||||
if (nodesRes.code === 0) {
|
||||
setNodes(nodesRes.data || []);
|
||||
} else {
|
||||
}
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
// 加载所有数据
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await Promise.all([refreshTunnelList(false), refreshNodes()]);
|
||||
} catch {
|
||||
toast.error("加载数据失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [refreshNodes, refreshTunnelList]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// 表单验证
|
||||
const validateForm = (): boolean => {
|
||||
@@ -327,7 +374,21 @@ export default function TunnelPage() {
|
||||
toast.success("删除成功");
|
||||
setDeleteModalOpen(false);
|
||||
setTunnelToDelete(null);
|
||||
loadData();
|
||||
setTunnels((prev) =>
|
||||
prev.filter((tunnel) => tunnel.id !== tunnelToDelete.id),
|
||||
);
|
||||
setTunnelOrder((prev) => {
|
||||
const next = prev.filter((id) => id !== tunnelToDelete.id);
|
||||
|
||||
saveOrder(TUNNEL_ORDER_KEY, next);
|
||||
return next;
|
||||
});
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
|
||||
next.delete(tunnelToDelete.id);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
toast.error(response.msg || "删除失败");
|
||||
}
|
||||
@@ -500,7 +561,7 @@ export default function TunnelPage() {
|
||||
if (response.code === 0) {
|
||||
toast.success(isEdit ? "更新成功" : "创建成功");
|
||||
setModalOpen(false);
|
||||
loadData();
|
||||
await refreshTunnelList(false);
|
||||
} else {
|
||||
toast.error(response.msg || (isEdit ? "更新失败" : "创建失败"));
|
||||
}
|
||||
@@ -725,7 +786,7 @@ export default function TunnelPage() {
|
||||
|
||||
setTunnelOrder(newOrder);
|
||||
|
||||
saveOrder("tunnel-order", newOrder);
|
||||
saveOrder(TUNNEL_ORDER_KEY, newOrder);
|
||||
|
||||
// 持久化到数据库
|
||||
try {
|
||||
@@ -779,6 +840,11 @@ export default function TunnelPage() {
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
setBatchLoading(true);
|
||||
setBatchProgress({
|
||||
active: true,
|
||||
label: `正在删除 ${selectedIds.size} 条隧道...`,
|
||||
percent: 30,
|
||||
});
|
||||
try {
|
||||
const res = await batchDeleteTunnels(Array.from(selectedIds));
|
||||
|
||||
@@ -787,21 +853,39 @@ export default function TunnelPage() {
|
||||
|
||||
if (result.failCount === 0) {
|
||||
toast.success(`成功删除 ${result.successCount} 项`);
|
||||
setBatchProgress({
|
||||
active: true,
|
||||
label: `删除完成:成功 ${result.successCount} 项`,
|
||||
percent: 100,
|
||||
});
|
||||
setTunnels((prev) => prev.filter((tunnel) => !selectedIds.has(tunnel.id)));
|
||||
setTunnelOrder((prev) => {
|
||||
const next = prev.filter((id) => !selectedIds.has(id));
|
||||
|
||||
saveOrder(TUNNEL_ORDER_KEY, next);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
toast.error(
|
||||
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
|
||||
);
|
||||
setBatchProgress({
|
||||
active: true,
|
||||
label: `部分完成:成功 ${result.successCount} 项,正在刷新列表...`,
|
||||
percent: 75,
|
||||
});
|
||||
await refreshTunnelList(false);
|
||||
}
|
||||
setSelectedIds(new Set());
|
||||
setSelectMode(false);
|
||||
setBatchDeleteModalOpen(false);
|
||||
loadData();
|
||||
} else {
|
||||
toast.error(res.msg || "删除失败");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(extractApiErrorMessage(error, "删除失败"));
|
||||
} finally {
|
||||
setBatchProgress({ active: false, label: "", percent: 0 });
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -809,6 +893,11 @@ export default function TunnelPage() {
|
||||
const handleBatchRedeploy = async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
setBatchLoading(true);
|
||||
setBatchProgress({
|
||||
active: true,
|
||||
label: `正在重新下发 ${selectedIds.size} 条隧道...`,
|
||||
percent: 30,
|
||||
});
|
||||
try {
|
||||
const res = await batchRedeployTunnels(Array.from(selectedIds));
|
||||
|
||||
@@ -824,13 +913,19 @@ export default function TunnelPage() {
|
||||
}
|
||||
setSelectedIds(new Set());
|
||||
setSelectMode(false);
|
||||
loadData();
|
||||
setBatchProgress({
|
||||
active: true,
|
||||
label: `重新下发完成:成功 ${result.successCount} 项,正在刷新列表...`,
|
||||
percent: 100,
|
||||
});
|
||||
await refreshTunnelList(false);
|
||||
} else {
|
||||
toast.error(res.msg || "下发失败");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(extractApiErrorMessage(error, "下发失败"));
|
||||
} finally {
|
||||
setBatchProgress({ active: false, label: "", percent: 0 });
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -1038,6 +1133,23 @@ export default function TunnelPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{batchProgress.active && (
|
||||
<div className="mb-4">
|
||||
<Alert
|
||||
color="primary"
|
||||
description={batchProgress.label}
|
||||
variant="flat"
|
||||
/>
|
||||
<Progress
|
||||
aria-label={batchProgress.label}
|
||||
className="mt-3"
|
||||
color="primary"
|
||||
size="sm"
|
||||
value={batchProgress.percent}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 隧道卡片网格 */}
|
||||
{tunnels.length > 0 ? (
|
||||
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
||||
@@ -1435,7 +1547,7 @@ export default function TunnelPage() {
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
description="支持多个IP,每行一个地址,为空时使用入口节点ip"
|
||||
description="入口IP由系统自动从入口节点采集,无需手动填写。支持多个IP,每行一个地址,留空则使用入口节点IP"
|
||||
errorMessage={errors.inIp}
|
||||
isInvalid={!!errors.inIp}
|
||||
label="入口IP"
|
||||
@@ -1843,10 +1955,10 @@ export default function TunnelPage() {
|
||||
isMultiNodeGroup
|
||||
? "多节点跳使用节点默认IP"
|
||||
: groupSelectedNodeIds.length === 0
|
||||
? "请先选择节点"
|
||||
: groupIpOptions.length > 0
|
||||
? "选择连接IP"
|
||||
: "所选节点无共同可选IP"
|
||||
? "请先选择节点"
|
||||
: groupIpOptions.length > 0
|
||||
? "选择连接IP"
|
||||
: "所选节点无共同可选IP"
|
||||
}
|
||||
selectedKeys={[
|
||||
selectedGroupConnectIp || "__default__",
|
||||
@@ -1906,243 +2018,259 @@ export default function TunnelPage() {
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-2">
|
||||
{/* 节点选择 - 移动端100%,桌面端50% */}
|
||||
<div className="col-span-1 md:col-span-2">
|
||||
<Select
|
||||
classNames={{
|
||||
label: "text-xs",
|
||||
value: "text-sm",
|
||||
}}
|
||||
disabledKeys={[
|
||||
...nodes
|
||||
.filter((node) => node.status !== 1)
|
||||
.map((node) => node.id.toString()),
|
||||
...form.inNodeId.map((ct) =>
|
||||
ct.nodeId.toString(),
|
||||
),
|
||||
...getSelectedChainNodeIds().map((id) =>
|
||||
id.toString(),
|
||||
),
|
||||
]}
|
||||
dropdownPlacement="top"
|
||||
errorMessage={errors.outNodeId}
|
||||
isInvalid={!!errors.outNodeId}
|
||||
label="节点"
|
||||
placeholder="请选择出口节点(可多选)"
|
||||
selectedKeys={
|
||||
form.outNodeId
|
||||
? form.outNodeId
|
||||
.filter((ct) => ct.nodeId !== -1)
|
||||
.map((ct) => ct.nodeId.toString())
|
||||
: []
|
||||
}
|
||||
selectionMode="multiple"
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedIds = toSelectedNodeIds(keys);
|
||||
{/* 节点选择 - 移动端100%,桌面端50% */}
|
||||
<div className="col-span-1 md:col-span-2">
|
||||
<Select
|
||||
classNames={{
|
||||
label: "text-xs",
|
||||
value: "text-sm",
|
||||
}}
|
||||
disabledKeys={[
|
||||
...nodes
|
||||
.filter((node) => node.status !== 1)
|
||||
.map((node) => node.id.toString()),
|
||||
...form.inNodeId.map((ct) =>
|
||||
ct.nodeId.toString(),
|
||||
),
|
||||
...getSelectedChainNodeIds().map((id) =>
|
||||
id.toString(),
|
||||
),
|
||||
]}
|
||||
dropdownPlacement="top"
|
||||
errorMessage={errors.outNodeId}
|
||||
isInvalid={!!errors.outNodeId}
|
||||
label="节点"
|
||||
placeholder="请选择出口节点(可多选)"
|
||||
selectedKeys={
|
||||
form.outNodeId
|
||||
? form.outNodeId
|
||||
.filter((ct) => ct.nodeId !== -1)
|
||||
.map((ct) => ct.nodeId.toString())
|
||||
: []
|
||||
}
|
||||
selectionMode="multiple"
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedIds = toSelectedNodeIds(keys);
|
||||
|
||||
setForm((prev) => {
|
||||
const currentOutNodes = prev.outNodeId || [];
|
||||
const protocol =
|
||||
currentOutNodes[0]?.protocol || "tls";
|
||||
const strategy =
|
||||
currentOutNodes[0]?.strategy || "round";
|
||||
const realNodes = currentOutNodes.filter(
|
||||
(ct) => ct.nodeId !== -1,
|
||||
);
|
||||
setForm((prev) => {
|
||||
const currentOutNodes =
|
||||
prev.outNodeId || [];
|
||||
const protocol =
|
||||
currentOutNodes[0]?.protocol || "tls";
|
||||
const strategy =
|
||||
currentOutNodes[0]?.strategy || "round";
|
||||
const realNodes = currentOutNodes.filter(
|
||||
(ct) => ct.nodeId !== -1,
|
||||
);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
outNodeId: mergeOrderedNodes(
|
||||
realNodes,
|
||||
selectedIds,
|
||||
(nodeId) => ({
|
||||
nodeId,
|
||||
chainType: 3,
|
||||
protocol,
|
||||
strategy,
|
||||
}),
|
||||
),
|
||||
};
|
||||
});
|
||||
}}
|
||||
>
|
||||
{nodes.map((node) => (
|
||||
<SelectItem
|
||||
key={node.id}
|
||||
textValue={`${node.name}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{node.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Chip
|
||||
color={
|
||||
node.status === 1
|
||||
? "success"
|
||||
: "default"
|
||||
}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
return {
|
||||
...prev,
|
||||
outNodeId: mergeOrderedNodes(
|
||||
realNodes,
|
||||
selectedIds,
|
||||
(nodeId) => ({
|
||||
nodeId,
|
||||
chainType: 3,
|
||||
protocol,
|
||||
strategy,
|
||||
}),
|
||||
),
|
||||
};
|
||||
});
|
||||
}}
|
||||
>
|
||||
{nodes.map((node) => (
|
||||
<SelectItem
|
||||
key={node.id}
|
||||
textValue={`${node.name}`}
|
||||
>
|
||||
{node.status === 1 ? "在线" : "离线"}
|
||||
</Chip>
|
||||
{form.inNodeId.some(
|
||||
(ct) => ct.nodeId === node.id,
|
||||
) && (
|
||||
<Chip
|
||||
color="warning"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
已选为入口
|
||||
</Chip>
|
||||
)}
|
||||
{getSelectedChainNodeIds().includes(
|
||||
node.id,
|
||||
) && (
|
||||
<Chip
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
已选为转发链
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{node.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Chip
|
||||
color={
|
||||
node.status === 1
|
||||
? "success"
|
||||
: "default"
|
||||
}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{node.status === 1
|
||||
? "在线"
|
||||
: "离线"}
|
||||
</Chip>
|
||||
{form.inNodeId.some(
|
||||
(ct) => ct.nodeId === node.id,
|
||||
) && (
|
||||
<Chip
|
||||
color="warning"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
已选为入口
|
||||
</Chip>
|
||||
)}
|
||||
{getSelectedChainNodeIds().includes(
|
||||
node.id,
|
||||
) && (
|
||||
<Chip
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
已选为转发链
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 协议选择 - 25% */}
|
||||
<Select
|
||||
classNames={{
|
||||
label: "text-xs",
|
||||
value: "text-sm",
|
||||
}}
|
||||
errorMessage={errors.protocol}
|
||||
isInvalid={!!errors.protocol}
|
||||
label="协议"
|
||||
placeholder="选择协议"
|
||||
selectedKeys={[
|
||||
(() => {
|
||||
if (
|
||||
!form.outNodeId ||
|
||||
form.outNodeId.length === 0
|
||||
)
|
||||
return "tls";
|
||||
{/* 协议选择 - 25% */}
|
||||
<Select
|
||||
classNames={{
|
||||
label: "text-xs",
|
||||
value: "text-sm",
|
||||
}}
|
||||
errorMessage={errors.protocol}
|
||||
isInvalid={!!errors.protocol}
|
||||
label="协议"
|
||||
placeholder="选择协议"
|
||||
selectedKeys={[
|
||||
(() => {
|
||||
if (
|
||||
!form.outNodeId ||
|
||||
form.outNodeId.length === 0
|
||||
)
|
||||
return "tls";
|
||||
|
||||
return form.outNodeId[0].protocol || "tls";
|
||||
})(),
|
||||
]}
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(keys)[0] as string;
|
||||
return form.outNodeId[0].protocol || "tls";
|
||||
})(),
|
||||
]}
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(
|
||||
keys,
|
||||
)[0] as string;
|
||||
|
||||
if (selectedKey) {
|
||||
setForm((prev) => {
|
||||
const currentOutNodes = prev.outNodeId || [];
|
||||
const currentStrategy =
|
||||
currentOutNodes.length > 0
|
||||
? currentOutNodes[0].strategy || "round"
|
||||
: "round";
|
||||
if (selectedKey) {
|
||||
setForm((prev) => {
|
||||
const currentOutNodes =
|
||||
prev.outNodeId || [];
|
||||
const currentStrategy =
|
||||
currentOutNodes.length > 0
|
||||
? currentOutNodes[0].strategy ||
|
||||
"round"
|
||||
: "round";
|
||||
|
||||
if (currentOutNodes.length === 0) {
|
||||
// 如果还没有出口节点,创建一个占位节点保存设置
|
||||
return {
|
||||
...prev,
|
||||
outNodeId: [
|
||||
{
|
||||
nodeId: -1,
|
||||
chainType: 3,
|
||||
protocol: selectedKey,
|
||||
strategy: currentStrategy,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (currentOutNodes.length === 0) {
|
||||
// 如果还没有出口节点,创建一个占位节点保存设置
|
||||
return {
|
||||
...prev,
|
||||
outNodeId: [
|
||||
{
|
||||
nodeId: -1,
|
||||
chainType: 3,
|
||||
protocol: selectedKey,
|
||||
strategy: currentStrategy,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// 更新所有出口节点的协议
|
||||
return {
|
||||
...prev,
|
||||
outNodeId: currentOutNodes.map((ct) => ({
|
||||
...ct,
|
||||
protocol: selectedKey,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectItem key="tls">TLS</SelectItem>
|
||||
<SelectItem key="wss">WSS</SelectItem>
|
||||
<SelectItem key="tcp">TCP</SelectItem>
|
||||
<SelectItem key="mtls">MTLS</SelectItem>
|
||||
<SelectItem key="mwss">MWSS</SelectItem>
|
||||
<SelectItem key="mtcp">MTCP</SelectItem>
|
||||
</Select>
|
||||
// 更新所有出口节点的协议
|
||||
return {
|
||||
...prev,
|
||||
outNodeId: currentOutNodes.map(
|
||||
(ct) => ({
|
||||
...ct,
|
||||
protocol: selectedKey,
|
||||
}),
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectItem key="tls">TLS</SelectItem>
|
||||
<SelectItem key="wss">WSS</SelectItem>
|
||||
<SelectItem key="tcp">TCP</SelectItem>
|
||||
<SelectItem key="mtls">MTLS</SelectItem>
|
||||
<SelectItem key="mwss">MWSS</SelectItem>
|
||||
<SelectItem key="mtcp">MTCP</SelectItem>
|
||||
</Select>
|
||||
|
||||
{/* 负载策略 - 25% */}
|
||||
<Select
|
||||
classNames={{
|
||||
label: "text-xs",
|
||||
value: "text-sm",
|
||||
}}
|
||||
label="负载策略"
|
||||
placeholder="选择策略"
|
||||
selectedKeys={[
|
||||
(() => {
|
||||
if (
|
||||
!form.outNodeId ||
|
||||
form.outNodeId.length === 0
|
||||
)
|
||||
return "round";
|
||||
{/* 负载策略 - 25% */}
|
||||
<Select
|
||||
classNames={{
|
||||
label: "text-xs",
|
||||
value: "text-sm",
|
||||
}}
|
||||
label="负载策略"
|
||||
placeholder="选择策略"
|
||||
selectedKeys={[
|
||||
(() => {
|
||||
if (
|
||||
!form.outNodeId ||
|
||||
form.outNodeId.length === 0
|
||||
)
|
||||
return "round";
|
||||
|
||||
return form.outNodeId[0].strategy || "round";
|
||||
})(),
|
||||
]}
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(keys)[0] as string;
|
||||
return (
|
||||
form.outNodeId[0].strategy || "round"
|
||||
);
|
||||
})(),
|
||||
]}
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(
|
||||
keys,
|
||||
)[0] as string;
|
||||
|
||||
if (selectedKey) {
|
||||
setForm((prev) => {
|
||||
const currentOutNodes = prev.outNodeId || [];
|
||||
const currentProtocol =
|
||||
currentOutNodes.length > 0
|
||||
? currentOutNodes[0].protocol || "tls"
|
||||
: "tls";
|
||||
if (selectedKey) {
|
||||
setForm((prev) => {
|
||||
const currentOutNodes =
|
||||
prev.outNodeId || [];
|
||||
const currentProtocol =
|
||||
currentOutNodes.length > 0
|
||||
? currentOutNodes[0].protocol || "tls"
|
||||
: "tls";
|
||||
|
||||
if (currentOutNodes.length === 0) {
|
||||
return {
|
||||
...prev,
|
||||
outNodeId: [
|
||||
{
|
||||
nodeId: -1,
|
||||
chainType: 3,
|
||||
protocol: currentProtocol,
|
||||
strategy: selectedKey,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (currentOutNodes.length === 0) {
|
||||
return {
|
||||
...prev,
|
||||
outNodeId: [
|
||||
{
|
||||
nodeId: -1,
|
||||
chainType: 3,
|
||||
protocol: currentProtocol,
|
||||
strategy: selectedKey,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
outNodeId: currentOutNodes.map((ct) => ({
|
||||
...ct,
|
||||
strategy: selectedKey,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectItem key="fifo">主备</SelectItem>
|
||||
<SelectItem key="round">轮询</SelectItem>
|
||||
<SelectItem key="rand">随机</SelectItem>
|
||||
</Select>
|
||||
return {
|
||||
...prev,
|
||||
outNodeId: currentOutNodes.map(
|
||||
(ct) => ({
|
||||
...ct,
|
||||
strategy: selectedKey,
|
||||
}),
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectItem key="fifo">主备</SelectItem>
|
||||
<SelectItem key="round">轮询</SelectItem>
|
||||
<SelectItem key="rand">随机</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 连接IP - 出口节点 */}
|
||||
@@ -2179,9 +2307,13 @@ export default function TunnelPage() {
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(keys)[0] as string;
|
||||
const selectedKey = Array.from(
|
||||
keys,
|
||||
)[0] as string;
|
||||
const value =
|
||||
selectedKey === "__default__" ? "" : selectedKey;
|
||||
selectedKey === "__default__"
|
||||
? ""
|
||||
: selectedKey;
|
||||
|
||||
setForm((prev) => {
|
||||
const currentOutNodes = prev.outNodeId || [];
|
||||
@@ -2211,7 +2343,9 @@ export default function TunnelPage() {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectItem key="__default__">默认连接IP</SelectItem>
|
||||
<SelectItem key="__default__">
|
||||
默认连接IP
|
||||
</SelectItem>
|
||||
{commonOutIpOptions.map((ip) => (
|
||||
<SelectItem key={ip}>{ip}</SelectItem>
|
||||
))}
|
||||
@@ -2887,7 +3021,7 @@ export default function TunnelPage() {
|
||||
<ModalBody>
|
||||
<p>
|
||||
确定要删除选中的 {selectedIds.size}{" "}
|
||||
项隧道吗?此操作不可撤销,相关转发也将被删除。
|
||||
项隧道吗?此操作不可撤销,相关规则也将被删除。
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { parseDate } from "@internationalized/date";
|
||||
|
||||
@@ -66,6 +66,10 @@ import {
|
||||
} from "@/components/icons";
|
||||
import { PageLoadingState } from "@/components/page-state";
|
||||
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
|
||||
import {
|
||||
removeItemsById,
|
||||
replaceItemById,
|
||||
} from "@/utils/list-state";
|
||||
|
||||
// 工具函数
|
||||
const formatFlow = (value: number, unit: string = "bytes"): string => {
|
||||
@@ -122,6 +126,43 @@ const calculateTunnelUsedFlow = (tunnel: UserTunnel): number => {
|
||||
return inFlow + outFlow;
|
||||
};
|
||||
|
||||
const USER_SEARCH_DEBOUNCE_MS = 250;
|
||||
|
||||
const normalizeUserItem = (item: Partial<User>): User => {
|
||||
return {
|
||||
id: Number(item.id ?? 0),
|
||||
name: item.name,
|
||||
user: String(item.user ?? ""),
|
||||
status: Number(item.status ?? 0),
|
||||
flow: Number(item.flow ?? 0),
|
||||
num: Number(item.num ?? 0),
|
||||
expTime: item.expTime,
|
||||
flowResetTime: item.flowResetTime ?? 0,
|
||||
createdTime: item.createdTime,
|
||||
inFlow: Number(item.inFlow ?? 0),
|
||||
outFlow: Number(item.outFlow ?? 0),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeUserTunnelItem = (item: Partial<UserTunnel>): UserTunnel => {
|
||||
return {
|
||||
id: Number(item.id ?? 0),
|
||||
userId: Number(item.userId ?? 0),
|
||||
tunnelId: Number(item.tunnelId ?? 0),
|
||||
tunnelName: String(item.tunnelName ?? ""),
|
||||
status: Number(item.status ?? 0),
|
||||
flow: Number(item.flow ?? 0),
|
||||
num: Number(item.num ?? 0),
|
||||
expTime: Number(item.expTime ?? 0),
|
||||
flowResetTime: Number(item.flowResetTime ?? 0),
|
||||
speedId: item.speedId ?? null,
|
||||
speedLimitName: item.speedLimitName,
|
||||
inFlow: Number(item.inFlow ?? 0),
|
||||
outFlow: Number(item.outFlow ?? 0),
|
||||
tunnelFlow: item.tunnelFlow,
|
||||
};
|
||||
};
|
||||
|
||||
export default function UserPage() {
|
||||
// 状态管理
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
@@ -136,6 +177,7 @@ export default function UserPage() {
|
||||
size: 10,
|
||||
total: 0,
|
||||
});
|
||||
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// 用户表单相关状态
|
||||
const {
|
||||
@@ -259,26 +301,25 @@ export default function UserPage() {
|
||||
return !speedLimitIds.has(speedId);
|
||||
};
|
||||
|
||||
// 生命周期
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
loadTunnels();
|
||||
loadSpeedLimits();
|
||||
loadUserGroups();
|
||||
}, [pagination.current, pagination.size, searchKeyword]);
|
||||
|
||||
// 数据加载函数
|
||||
const loadUsers = async () => {
|
||||
const loadUsers = useCallback(
|
||||
async (keywordOverride?: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const keyword = keywordOverride ?? searchKeyword;
|
||||
const response = await getAllUsers({
|
||||
current: pagination.current,
|
||||
size: pagination.size,
|
||||
keyword: searchKeyword,
|
||||
keyword,
|
||||
});
|
||||
|
||||
if (response.code === 0) {
|
||||
setUsers(Array.isArray(response.data) ? response.data : []);
|
||||
const nextUsers = Array.isArray(response.data)
|
||||
? response.data.map((item) => normalizeUserItem(item))
|
||||
: [];
|
||||
|
||||
setUsers(nextUsers);
|
||||
setPagination((prev) => ({ ...prev, total: nextUsers.length }));
|
||||
} else {
|
||||
toast.error(response.msg || "获取用户列表失败");
|
||||
}
|
||||
@@ -287,9 +328,11 @@ export default function UserPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
},
|
||||
[pagination.current, pagination.size],
|
||||
);
|
||||
|
||||
const loadTunnels = async () => {
|
||||
const loadTunnels = useCallback(async () => {
|
||||
try {
|
||||
const response = await getTunnelList();
|
||||
|
||||
@@ -297,9 +340,9 @@ export default function UserPage() {
|
||||
setTunnels(Array.isArray(response.data) ? response.data : []);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadSpeedLimits = async () => {
|
||||
const loadSpeedLimits = useCallback(async () => {
|
||||
try {
|
||||
const response = await getSpeedLimitList();
|
||||
|
||||
@@ -315,9 +358,9 @@ export default function UserPage() {
|
||||
setSpeedLimits(speedLimitList);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadUserGroups = async () => {
|
||||
const loadUserGroups = useCallback(async () => {
|
||||
try {
|
||||
const response = await getUserGroupList();
|
||||
|
||||
@@ -325,15 +368,19 @@ export default function UserPage() {
|
||||
setUserGroups(Array.isArray(response.data) ? response.data : []);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadUserTunnels = async (userId: number) => {
|
||||
const loadUserTunnels = useCallback(async (userId: number) => {
|
||||
setTunnelListLoading(true);
|
||||
try {
|
||||
const response = await getUserTunnelList({ userId });
|
||||
|
||||
if (response.code === 0) {
|
||||
setUserTunnels(Array.isArray(response.data) ? response.data : []);
|
||||
setUserTunnels(
|
||||
Array.isArray(response.data)
|
||||
? response.data.map((item) => normalizeUserTunnelItem(item))
|
||||
: [],
|
||||
);
|
||||
} else {
|
||||
toast.error(response.msg || "获取隧道权限列表失败");
|
||||
}
|
||||
@@ -342,12 +389,52 @@ export default function UserPage() {
|
||||
} finally {
|
||||
setTunnelListLoading(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 生命周期
|
||||
useEffect(() => {
|
||||
void loadTunnels();
|
||||
void loadSpeedLimits();
|
||||
void loadUserGroups();
|
||||
}, [loadSpeedLimits, loadTunnels, loadUserGroups]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadUsers();
|
||||
}, [loadUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchDebounceRef.current) {
|
||||
clearTimeout(searchDebounceRef.current);
|
||||
}
|
||||
|
||||
searchDebounceRef.current = setTimeout(() => {
|
||||
setPagination((prev) => {
|
||||
if (prev.current === 1) {
|
||||
void loadUsers(searchKeyword);
|
||||
return prev;
|
||||
}
|
||||
|
||||
return { ...prev, current: 1 };
|
||||
});
|
||||
}, USER_SEARCH_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (searchDebounceRef.current) {
|
||||
clearTimeout(searchDebounceRef.current);
|
||||
searchDebounceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [loadUsers, searchKeyword]);
|
||||
|
||||
// 用户管理操作
|
||||
const handleSearch = () => {
|
||||
if (searchDebounceRef.current) {
|
||||
clearTimeout(searchDebounceRef.current);
|
||||
searchDebounceRef.current = null;
|
||||
}
|
||||
|
||||
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||
loadUsers();
|
||||
void loadUsers(searchKeyword);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
@@ -405,9 +492,17 @@ export default function UserPage() {
|
||||
|
||||
if (response.code === 0) {
|
||||
toast.success("删除成功");
|
||||
loadUsers();
|
||||
onDeleteModalClose();
|
||||
setUsers((prev) => removeItemsById(prev, [userToDelete.id]));
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
total: Math.max(prev.total - 1, 0),
|
||||
}));
|
||||
setUserToDelete(null);
|
||||
if (currentUser?.id === userToDelete.id) {
|
||||
setCurrentUser(null);
|
||||
setUserTunnels([]);
|
||||
}
|
||||
} else {
|
||||
toast.error(response.msg || "删除失败");
|
||||
}
|
||||
@@ -442,7 +537,26 @@ export default function UserPage() {
|
||||
if (response.code === 0) {
|
||||
toast.success(isEdit ? "更新成功" : "创建成功");
|
||||
onUserModalClose();
|
||||
loadUsers();
|
||||
const responseUser = normalizeUserItem((response as any).data || {});
|
||||
|
||||
if (
|
||||
isEdit &&
|
||||
responseUser.id > 0 &&
|
||||
pagination.current === 1 &&
|
||||
!searchKeyword.trim()
|
||||
) {
|
||||
setUsers((prev) => replaceItemById(prev, responseUser));
|
||||
} else if (
|
||||
!isEdit &&
|
||||
responseUser.id > 0 &&
|
||||
pagination.current === 1 &&
|
||||
!searchKeyword.trim()
|
||||
) {
|
||||
setUsers((prev) => [responseUser, ...prev].slice(0, pagination.size));
|
||||
setPagination((prev) => ({ ...prev, total: prev.total + 1 }));
|
||||
} else {
|
||||
await loadUsers();
|
||||
}
|
||||
} else {
|
||||
toast.error(response.msg || (isEdit ? "更新失败" : "创建失败"));
|
||||
}
|
||||
@@ -500,7 +614,7 @@ export default function UserPage() {
|
||||
}
|
||||
toast.success(response.msg || "分配成功");
|
||||
setBatchTunnelSelections(new Map());
|
||||
loadUserTunnels(currentUser.id);
|
||||
await loadUserTunnels(currentUser.id);
|
||||
} else {
|
||||
toast.error(response.msg || "分配失败");
|
||||
}
|
||||
@@ -546,7 +660,19 @@ export default function UserPage() {
|
||||
toast.success("更新成功");
|
||||
onEditTunnelModalClose();
|
||||
if (currentUser) {
|
||||
loadUserTunnels(currentUser.id);
|
||||
const nextTunnel = normalizeUserTunnelItem({
|
||||
...editTunnelForm,
|
||||
speedId: normalizeSpeedId(editTunnelForm.speedId),
|
||||
speedLimitName:
|
||||
normalizeSpeedId(editTunnelForm.speedId) !== null
|
||||
? speedLimits.find(
|
||||
(speedLimit) =>
|
||||
speedLimit.id === normalizeSpeedId(editTunnelForm.speedId),
|
||||
)?.name
|
||||
: undefined,
|
||||
});
|
||||
|
||||
setUserTunnels((prev) => replaceItemById(prev, nextTunnel));
|
||||
}
|
||||
} else {
|
||||
toast.error(response.msg || "更新失败");
|
||||
@@ -572,7 +698,7 @@ export default function UserPage() {
|
||||
if (response.code === 0) {
|
||||
toast.success("删除成功");
|
||||
if (currentUser) {
|
||||
loadUserTunnels(currentUser.id);
|
||||
setUserTunnels((prev) => removeItemsById(prev, [tunnelToDelete.id]));
|
||||
}
|
||||
onDeleteTunnelModalClose();
|
||||
setTunnelToDelete(null);
|
||||
@@ -603,8 +729,14 @@ export default function UserPage() {
|
||||
if (response.code === 0) {
|
||||
toast.success("流量重置成功");
|
||||
onResetFlowModalClose();
|
||||
const targetUserId = userToReset.id;
|
||||
|
||||
setUsers((prev) =>
|
||||
prev.map((user) =>
|
||||
user.id === targetUserId ? { ...user, inFlow: 0, outFlow: 0 } : user,
|
||||
),
|
||||
);
|
||||
setUserToReset(null);
|
||||
loadUsers(); // 重新加载用户列表
|
||||
} else {
|
||||
toast.error(response.msg || "重置失败");
|
||||
}
|
||||
@@ -634,10 +766,16 @@ export default function UserPage() {
|
||||
if (response.code === 0) {
|
||||
toast.success("隧道流量重置成功");
|
||||
onResetTunnelFlowModalClose();
|
||||
const targetTunnelId = tunnelToReset.id;
|
||||
|
||||
setUserTunnels((prev) =>
|
||||
prev.map((userTunnel) =>
|
||||
userTunnel.id === targetTunnelId
|
||||
? { ...userTunnel, inFlow: 0, outFlow: 0 }
|
||||
: userTunnel,
|
||||
),
|
||||
);
|
||||
setTunnelToReset(null);
|
||||
if (currentUser) {
|
||||
loadUserTunnels(currentUser.id); // 重新加载隧道权限列表
|
||||
}
|
||||
} else {
|
||||
toast.error(response.msg || "重置失败");
|
||||
}
|
||||
@@ -845,7 +983,7 @@ export default function UserPage() {
|
||||
{/* 其他信息 */}
|
||||
<div className="space-y-1.5 pt-2 border-t border-divider">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">转发数量</span>
|
||||
<span className="text-default-600">规则数量</span>
|
||||
<span className="font-medium text-xs">
|
||||
{user.num}
|
||||
</span>
|
||||
@@ -1001,7 +1139,7 @@ export default function UserPage() {
|
||||
/>
|
||||
<Input
|
||||
isRequired
|
||||
label="转发数量"
|
||||
label="规则数量"
|
||||
max="99999"
|
||||
min="1"
|
||||
type="number"
|
||||
@@ -1138,7 +1276,7 @@ export default function UserPage() {
|
||||
<h3 className="text-lg font-semibold mb-4">分配新权限</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-default-500 bg-default-100 dark:bg-default-50 p-3 rounded-lg border border-default-200 dark:border-default-100/30">
|
||||
流量限制、转发数量、到期时间、流量重置时间将自动继承用户设置
|
||||
流量限制、规则数量、到期时间、流量重置时间将自动继承用户设置
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 max-h-72 overflow-y-auto pr-1">
|
||||
@@ -1296,7 +1434,7 @@ export default function UserPage() {
|
||||
<TableHeader>
|
||||
<TableColumn>隧道名称</TableColumn>
|
||||
<TableColumn>流量统计</TableColumn>
|
||||
<TableColumn>转发数量</TableColumn>
|
||||
<TableColumn>规则数量</TableColumn>
|
||||
<TableColumn>状态</TableColumn>
|
||||
<TableColumn>限速规则</TableColumn>
|
||||
<TableColumn>重置时间</TableColumn>
|
||||
@@ -1453,7 +1591,7 @@ export default function UserPage() {
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="转发数量"
|
||||
label="规则数量"
|
||||
max="99999"
|
||||
min="1"
|
||||
type="number"
|
||||
@@ -1668,7 +1806,7 @@ export default function UserPage() {
|
||||
的权限吗?
|
||||
</p>
|
||||
<p className="text-small text-default-500 mt-1">
|
||||
删除后该用户将无法使用此隧道创建转发,此操作不可撤销。
|
||||
删除后该用户将无法使用此隧道创建规则,此操作不可撤销。{" "}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -38,15 +38,33 @@ function parseDateText(value: string) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matched = trimmed.match(/^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})$/);
|
||||
const digitsOnly = trimmed.replace(/\D/g, "");
|
||||
|
||||
if (!matched) {
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
if (digitsOnly.length !== 8) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const year = Number(digitsOnly.slice(0, 4));
|
||||
const month = Number(digitsOnly.slice(4, 6));
|
||||
const day = Number(digitsOnly.slice(6, 8));
|
||||
|
||||
if (!isValidCalendarDate(year, month, day)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { day, month, year };
|
||||
}
|
||||
|
||||
const numberParts = trimmed.match(/\d+/g);
|
||||
|
||||
if (!numberParts || numberParts.length !== 3 || numberParts[0].length !== 4) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const year = Number(matched[1]);
|
||||
const month = Number(matched[2]);
|
||||
const day = Number(matched[3]);
|
||||
const year = Number(numberParts[0]);
|
||||
const month = Number(numberParts[1]);
|
||||
const day = Number(numberParts[2]);
|
||||
|
||||
if (!isValidCalendarDate(year, month, day)) {
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
export const removeItemsById = <T extends { id: number }>(
|
||||
items: T[],
|
||||
ids: Iterable<number>,
|
||||
): T[] => {
|
||||
const idSet = new Set(ids);
|
||||
|
||||
return items.filter((item) => !idSet.has(item.id));
|
||||
};
|
||||
|
||||
export const replaceItemById = <T extends { id: number }>(
|
||||
items: T[],
|
||||
nextItem: T,
|
||||
): T[] => {
|
||||
return items.map((item) => (item.id === nextItem.id ? nextItem : item));
|
||||
};
|
||||
|
||||
export const upsertItemById = <T extends { id: number }>(
|
||||
items: T[],
|
||||
nextItem: T,
|
||||
options?: { prepend?: boolean },
|
||||
): T[] => {
|
||||
const existingIndex = items.findIndex((item) => item.id === nextItem.id);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
return replaceItemById(items, nextItem);
|
||||
}
|
||||
|
||||
if (options?.prepend) {
|
||||
return [nextItem, ...items];
|
||||
}
|
||||
|
||||
return [...items, nextItem];
|
||||
};
|
||||
Vendored
+1
@@ -2,6 +2,7 @@
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE?: string;
|
||||
readonly VITE_APP_VERSION?: string;
|
||||
readonly VITE_GITHUB_REPO?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user