Compare commits

...

32 Commits

Author SHA1 Message Date
sagit 1d08a1ccfc fix: ensure page refresh to login page upon logout (#465) 2026-04-23 20:43:27 +08:00
sagit db25ba2cbe style: move maxConn and speed limit inputs to advanced settings accordion (#464) 2026-04-23 20:29:52 +08:00
sagit a498067261 fix: properly initialize maxConn in forward rules (#463)
* fix: initialize maxConn in forward creation and edit forms

* fix: add maxConn to Forward interface
2026-04-23 20:23:18 +08:00
sagit b5922dccf2 fix: include maxConn when submitting forward forms (#462)
* fix: frontend missing maxConn setting during forward creation

* docs: check off plan
2026-04-23 20:11:20 +08:00
sagit c259645227 feat: implement user and forward max connection limit (#461)
* docs: add implementation plan for max conn limit

* feat: add CLimiters support for websocket reporter

* feat: add max_conn field to user and forward models

* feat: implement max conn limiter dispatching

* feat: add maxConn to user and forward CRUD API

* feat: add max conn UI to user management and forward rules

* fix: load MaxConn in get forward record and add e2e contract test for max conn limit
2026-04-23 17:35:39 +08:00
sagitchu bdc2c4ecbb fix(backend): dynamically start/stop tunnel quality prober based on config 2026-04-23 14:14:24 +08:00
sagitchu a070d0f4d3 fix(backend): allow setting reserved ip addresses as target
Only forbid internal networks (loopback and private ips), removing restrictions on reserved addresses like multicast or unspecified.
2026-04-22 19:00:21 +08:00
sagitchu eecdd62d3a perf(backend): optimize kcp tunnel parameters for high throughput and low latency 2026-04-22 16:29:02 +08:00
sagitchu e6d3b847bb fix(backend): properly identify and clean up orphaned tunnel_%d services 2026-04-22 16:18:57 +08:00
sagitchu 0b49cd720f fix(backend): use proper protocol for chain hop diagnosis and tcp for external targets 2026-04-22 14:02:59 +08:00
sagitchu 4f488ae7ef fix(backend): properly implement tunnel_%d cleanup that was lost during revert 2026-04-22 11:32:49 +08:00
sagitchu 2b2b417f91 fix(gost): ensure JSON floats are correctly converted in GetInt and remove accidental binary 2026-04-22 11:17:59 +08:00
sagitchu 01b4c3e3eb fix: add workbox-window as explicit dependency to resolve pnpm strict resolution in CI 2026-04-22 11:05:37 +08:00
sagitchu e7c967df00 chore: migrate frontend package manager from npm to pnpm 2026-04-22 11:02:43 +08:00
sagitchu efaf920e51 fix: ensure rollbackTunnelRuntime includes tunnel_%d in cleanup 2026-04-22 09:51:35 +08:00
sagitchu 9aff669c0e fix: ensure tunnel protocol and KCP config are correctly processed and cleaned up 2026-04-22 09:41:41 +08:00
sagitchu 6e60f5cfbd fix: add visually hidden DialogTitle to resolve a11y warning and fix modal background 2026-04-22 09:21:47 +08:00
sagitchu a03c320b89 fix(gost): prevent panic in service parsing with empty md 2026-04-21 22:11:22 +08:00
sagitchu 61dba0ae57 fix: remove trailing brace in tunnel.tsx to resolve CI error 2026-04-21 20:20:19 +08:00
sagitchu f3d6366471 fix: increase kcp tunnel bandwidth limit and fix modal backgrounds 2026-04-21 20:16:58 +08:00
sagitchu c431d79403 fix: correct tunnel protocol handling for KCP cleanup and diagnosis
- Fix KCP tunnel not reclaimed after deletion: service name was
  hardcoded to {id}_tls but services were created as {id}_kcp,
  causing DeleteService to never find the actual KCP service.
  Now reads tunnel.Protocol from DB and derives correct name.

- Fix KCP diagnosis using TCP ping instead of UDP ping:
  tunnel.Protocol was always hardcoded to 'tls' at creation,
  so isUDPBasedProtocol() never matched kcp tunnels. Now
  stores the actual protocol from entry node configuration.

- Fix addTunnelServiceOnNode to extract service name from
  serviceData instead of hardcoding _tls suffix.

- Fix rollbackTunnelRuntime to accept protocol parameter
  so retry cleanup uses correct service name.

- UpdateTunnelTx now persists protocol on tunnel updates.
2026-04-21 18:48:11 +08:00
sagitchu 5107f59d94 fix: tolerate offline nodes when controlling forward services and editing tunnels
- controlForwardServices: skip offline nodes instead of failing entire operation,
  so forward pause/resume/delete works when some entry nodes are offline
- onNodeOnline: always sync forward state on node reconnect (not just post-upgrade),
  so forwards that changed status while a node was offline get synced
- add ListForwardIDsByNode repo method to sync all forwards (including paused)
- tunnel edit UI: allow deselecting already-selected offline nodes in
  entry/chain/exit selectors, matching the backend's existing tolerance
2026-04-21 16:53:47 +08:00
sagitchu 431613cb6a fix(frontend): listen to session changes so logout redirects to login page 2026-04-21 16:24:46 +08:00
sagitchu a6b218f3ee fix(frontend): increase sidebar background opacity to reduce grayness 2026-04-21 16:12:28 +08:00
sagitchu d5d26d9cf9 fix(frontend): hide scrollbars on sidebar, modals, and secondary lists; fix diagnosis modal bg 2026-04-21 16:00:06 +08:00
sagitchu c1bc795674 fix(kcp): enable congestion control, add FEC, and fix remote node UDP diagnosis
- KCP: switch from fast3 to fast2 mode, enable FEC (10/3), enable
  congestion control (nc=0) for automatic rate adaptation
- go-gost/x: support kcp.nc metadata to override mode-initialized
  NoCongestion value in dialer and listener Init()
- Diagnosis: add udpPingViaRemoteNode, fix pingViaRemoteNode to
  dispatch based on protocol, add Protocol field to federation
  diagnose request structs
2026-04-21 15:41:32 +08:00
sagitchu 30e1473f06 fix(traffic): fix flow counter inflation from TOCTOU race in agent traffic reporter
Replace read-then-subtract pattern in collectAndReport with atomic
swap-to-zero to eliminate race where AddTraffic increments counters
between snapshot and clearReportedTraffic, causing residual traffic
to accumulate indefinitely and inflate user flow counters.

Also add defensive check in processFlowItem to skip AddFlow when
forward no longer exists, and send DeleteService to clean up orphaned
agent services.
2026-04-21 14:32:08 +08:00
sagitchu eaf16bf17b fix(frontend): enable horizontal scroll on all table lists for mobile
Change overflow-hidden to overflow-auto in table wrapper classNames
across all list pages (rules, tunnels, nodes, limits, groups, users,
monitor views). Also add table-fixed min-w to forward all-rules table
and fix unescaped entities in dashboard.
2026-04-21 14:18:44 +08:00
sagitchu e995d70be7 feat(diagnosis): add protocol-aware connectivity diagnosis for tunnel chains
- Pass tunnel protocol through diagnosis work items
- Support protocol-specific ping (TCP/UDP/KCP) via remote nodes
- Add KCP probe support in websocket_reporter for chain hop testing
2026-04-21 14:00:02 +08:00
sagitchu a968a10792 fix(frontend): fix dropdown padding, hide scrollbar, login input transparency
- Increase dropdown menu padding from p-1 to p-1.5
- Hide scrollbar in dropdown-menu-content and select listbox
- Change login page input bg from white/50 to white/10 for transparency
2026-04-21 12:41:49 +08:00
sagitchu ea156c33bc style(frontend): sync remaining glass UI theme files and cyberpunk theme 2026-04-21 12:40:00 +08:00
sagitchu 29407c90b6 perf(kcp): optimize tunnel transport defaults for high throughput
- Set KCP mode to fast3 (NoDelay:1, Interval:10ms) for lower latency
- Double SndWnd/RcvWnd from 1024 to 2048 for higher BDP
- Disable FEC (datashard:0, parityshard:0) to eliminate 30% overhead
- Disable compression for tunnel transport
- Increase relay mux MaxStreamBuffer to 2MB for better UDP throughput
- Add kcp.datashard/kcp.parityshard metadata keys support

Before: 210Mbps TCP / 35Mbps UDP (93% loss)
After:  should approach direct-connection speed (~400Mbps+)
2026-04-21 11:33:41 +08:00
76 changed files with 13467 additions and 1783 deletions
+5 -2
View File
@@ -21,11 +21,14 @@ jobs:
with:
node-version: '20.19.0'
- name: Install pnpm
run: npm install -g pnpm
- name: Install dependencies
run: npm install --legacy-peer-deps
run: pnpm install --frozen-lockfile
- name: Build
run: npm run build
run: pnpm run build
backend:
name: Build Go Backend
+4 -4
View File
@@ -22,10 +22,10 @@ FLVX — traffic forwarding panel: Go admin API + Vite/React UI + Go agent.
(cd go-backend && go test ./...)
# Frontend
(cd vite-frontend && npm install --legacy-peer-deps)
(cd vite-frontend && npm run dev) # host 0.0.0.0:3000
(cd vite-frontend && npm run build) # tsc && vite build
(cd vite-frontend && npm run lint) # eslint --fix (no typecheck command)
(cd vite-frontend && pnpm install)
(cd vite-frontend && pnpm run dev) # host 0.0.0.0:3000
(cd vite-frontend && pnpm run build) # tsc && vite build
(cd vite-frontend && pnpm run lint) # eslint --fix (no typecheck command)
# Agent
(cd go-gost && go run .)
@@ -0,0 +1,482 @@
# 最大连接数限制实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`[x]`) syntax for tracking.
**Goal:** 在 FLVX 中实现基于用户的全局最大连接数限制和基于单条规则的独立最大连接数限制功能。前端输入框为 0 或空时表示不限制。
**Architecture:** 采用“覆盖逻辑”(方案二)。
1. 数据库层面:在 `user` 和 `forward` 表中各增加一个整型字段 `max_conn`,默认值为 0(表示不限制)。
2. 后端接口层面:提供 API 更新该字段,在组装下发给 GOST 的配置时,判断规则的 `max_conn` 是否大于 0:
- 如果规则 `max_conn > 0`,则为此规则动态生成一个唯一的连接限制器配置,并在下发服务的 `climiter` 字段中引用该限制器。
- 如果规则 `max_conn == 0`,则检查该规则所属用户的 `max_conn`。
- 如果用户 `max_conn > 0`,则引用以用户维度的连接限制器配置(如 `user_conn_limit_<user_id>`)。
- 否则不下发 `climiter`。
3. 后端服务控制平面:需要在下发服务前,将需要的连接限制器(Rule 或 User 维度)推送到节点上。
- **重要发现:** 当前 `go-gost` 的 WebSocket Reporter (`go-gost/x/socket/websocket_reporter.go`) 仅支持 `TrafficLimiter` 的动态增删(如 `AddLimiters` 等),**不支持** `ConnLimiter`(即 `CLimiters`)。
- **计划修改:** 我们需要先在 `go-gost` 侧(`go-gost/x/socket`)添加针对 `CLimiters` 的 WebSocket 指令(`AddCLimiters`, `UpdateCLimiters`, `DeleteCLimiters`)以及对应的处理函数(参考 `AddLimiters` 等的实现,调用现有的针对 `ConnLimiterRegistry` 的相关接口和配置存储逻辑,具体需要实现类似 `createLimiter` 到 `createConnLimiter` 的逻辑)。
- 完成底层修改后,`go-backend` 再通过这些新增加的 WebSocket 指令,在 `ensureLimiterOnNode` 时下发最大连接数限制规则。
4. 前端层面:在用户管理和规则管理页面增加输入框组件。
**Tech Stack:** Go, GORM, SQLite/PostgreSQL, React, Vite, TypeScript, TailwindCSS.
---
### Task 1: 扩展 go-gost WebSocket 接口以支持 CLimiters
**Files:**
- Modify: `go-gost/x/socket/limiter.go`
- Modify: `go-gost/x/socket/websocket_reporter.go`
[x] **Step 1: 实现 `createConnLimiter` 等功能**
在 `go-gost/x/socket/limiter.go` 中参考现有 `createLimiter` 添加对 `CLimiters` 的支持:
```go
func createConnLimiter(req createLimiterRequest) error {
name := strings.TrimSpace(req.Data.Name)
if name == "" {
return errors.New("limiter name is required")
}
req.Data.Name = name
if registry.ConnLimiterRegistry().IsRegistered(name) {
return errors.New("conn limiter " + name + " already exists")
}
v := parser.ParseConnLimiter(&req.Data)
if err := registry.ConnLimiterRegistry().Register(name, v); err != nil {
return errors.New("conn limiter " + name + " already exists")
}
if c := config.Global(); c != nil {
c.CLimiters = append(c.CLimiters, &req.Data)
}
return nil
}
func updateConnLimiter(req updateLimiterRequest) error {
name := strings.TrimSpace(req.Limiter)
req.Data.Name = name
if registry.ConnLimiterRegistry().IsRegistered(name) {
registry.ConnLimiterRegistry().Unregister(name)
}
v := parser.ParseConnLimiter(&req.Data)
if err := registry.ConnLimiterRegistry().Register(name, v); err != nil {
return errors.New("conn limiter " + name + " already exists")
}
if c := config.Global(); c != nil {
for i := range c.CLimiters {
if c.CLimiters[i].Name == name {
c.CLimiters[i] = &req.Data
return nil
}
}
c.CLimiters = append(c.CLimiters, &req.Data)
}
return nil
}
func deleteConnLimiter(req deleteLimiterRequest) error {
name := strings.TrimSpace(req.Limiter)
if registry.ConnLimiterRegistry().IsRegistered(name) {
registry.ConnLimiterRegistry().Unregister(name)
}
if c := config.Global(); c != nil {
limiteres := c.CLimiters
c.CLimiters = nil
for _, s := range limiteres {
if s.Name == name {
continue
}
c.CLimiters = append(c.CLimiters, s)
}
}
return nil
}
```
[x] **Step 2: 在 `WebSocketReporter` 注册命令**
在 `go-gost/x/socket/websocket_reporter.go` 的 `ProcessCommand` 中添加 case:
```go
case "AddCLimiters":
err = w.handleAddCLimiter(cmd.Data)
response.Type = "AddCLimitersResponse"
needSaveConfig = true
case "UpdateCLimiters":
err = w.handleUpdateCLimiter(cmd.Data)
response.Type = "UpdateCLimitersResponse"
needSaveConfig = true
case "DeleteCLimiters":
err = w.handleDeleteCLimiter(cmd.Data)
response.Type = "DeleteCLimitersResponse"
needSaveConfig = true
```
[x] **Step 3: 实现 Handler 方法**
在 `go-gost/x/socket/websocket_reporter.go` 中添加:
```go
func (w *WebSocketReporter) handleAddCLimiter(data interface{}) error {
jsonData, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("序列化数据失败: %v", err)
}
var limiterConfig config.LimiterConfig
if err := json.Unmarshal(jsonData, &limiterConfig); err != nil {
return fmt.Errorf("解析限流器配置失败: %v", err)
}
req := createLimiterRequest{Data: limiterConfig}
return createConnLimiter(req)
}
func (w *WebSocketReporter) handleUpdateCLimiter(data interface{}) error {
jsonData, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("序列化数据失败: %v", err)
}
var updateReq struct {
Limiter string `json:"limiter"`
Data config.LimiterConfig `json:"data"`
}
if err := json.Unmarshal(jsonData, &updateReq); err != nil {
var limiterConfig config.LimiterConfig
if err := json.Unmarshal(jsonData, &limiterConfig); err != nil {
return fmt.Errorf("解析更新请求失败: %v", err)
}
updateReq.Limiter = limiterConfig.Name
updateReq.Data = limiterConfig
}
req := updateLimiterRequest{
Limiter: updateReq.Limiter,
Data: updateReq.Data,
}
return updateConnLimiter(req)
}
func (w *WebSocketReporter) handleDeleteCLimiter(data interface{}) error {
jsonData, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("序列化数据失败: %v", err)
}
var deleteReq deleteLimiterRequest
if err := json.Unmarshal(jsonData, &deleteReq); err != nil {
var limiterName string
if err := json.Unmarshal(jsonData, &limiterName); err != nil {
return fmt.Errorf("解析删除请求失败: %v", err)
}
deleteReq.Limiter = limiterName
}
return deleteConnLimiter(deleteReq)
}
```
[x] **Step 4: Commit**
```bash
cd go-gost
git add x/socket/limiter.go x/socket/websocket_reporter.go
git commit -m "feat: add CLimiters support for websocket reporter"
cd ..
```
---
### Task 2: 数据库迁移与模型更新
**Files:**
- Modify: `go-backend/internal/store/model/model.go`
- Modify: `go-backend/internal/store/repo/repository.go`
[x] **Step 1: 更新数据库模型**
在 `go-backend/internal/store/model/model.go` 的 `User` 和 `Forward` 结构体中添加 `MaxConn` 字段。
```go
// 在 User 结构体中
type User struct {
// ...
MaxConn int `gorm:"column:max_conn;not null;default:0"`
// ...
}
// 在 Forward 结构体中
type Forward struct {
// ...
MaxConn int `gorm:"column:max_conn;not null;default:0"`
// ...
}
```
[x] **Step 2: 编写数据库迁移**
在 `go-backend/internal/store/repo/repository.go` 的 `AutoMigrate` 逻辑前(如果有自定义迁移)或利用 gorm 自动迁移机制,由于这是 autoMigrate,添加字段只要 `db.AutoMigrate(&model.User{}, &model.Forward{})` 被调用就能自动加上。确认已执行迁移。由于 `FLVX` 通常会自动执行迁移,只需修改模型即可。我们需要处理默认值,由于使用了 `default:0`,GORM 会处理新增字段的默认值,但为了安全起见,如果在旧环境中,可能直接 alter table。
```go
// 无需手动编写 SQL,依赖现有的 gorm AutoMigrate 即可。
```
[x] **Step 3: 运行并验证迁移通过**
Run: `make build` (在 go-backend 中),或者运行一个相关的存储单元测试。
[x] **Step 4: Commit**
```bash
cd go-backend
git add internal/store/model/model.go
git commit -m "feat: add max_conn field to user and forward models"
cd ..
```
---
### Task 3: 后端控制平面 - 连接数限制器的组装与下发
**Files:**
- Modify: `go-backend/internal/http/handler/control_plane.go`
- Modify: `go-backend/internal/store/repo/repository_control.go`
[x] **Step 1: 更新存储层以获取 User 的 MaxConn**
在 `go-backend/internal/store/repo/repository_control.go` 中:
需要一个方法获取 User,或者如果已经有,确保可以拿到 `MaxConn`。
[x] **Step 2: 编写下发 CLimiter 到节点的辅助函数**
在 `go-backend/internal/http/handler/control_plane.go`,参考 `ensureLimiterOnNode` 和 `upsertLimiterOnNode`:
```go
func (h *Handler) ensureConnLimiterOnNode(nodeID int64, limiterName string, maxConn int) error {
limitStr := fmt.Sprintf("$ %d", maxConn)
payload := map[string]interface{}{
"name": limiterName,
"limits": []string{limitStr},
}
if _, err := h.sendNodeCommand(nodeID, "AddCLimiters", payload, false, false); err != nil {
if !isAlreadyExistsMessage(err.Error()) {
return fmt.Errorf("连接限制器下发失败: %w", err)
}
updatePayload := map[string]interface{}{
"limiter": limiterName,
"data": payload,
}
if _, updateErr := h.sendNodeCommand(nodeID, "UpdateCLimiters", updatePayload, false, false); updateErr != nil {
return fmt.Errorf("连接限制器更新失败: %w", updateErr)
}
}
return nil
}
```
[x] **Step 3: 更新组装配置逻辑以绑定 `climiter`**
在 `control_plane.go` 的 `syncForwardServicesWithWarnings` 及其辅助函数 `buildForwardServiceConfigs` 附近:
修改 `buildForwardServiceConfigs` 的签名,传入 `maxConn int` 和对应的 `cLimiterName string`。
```go
func buildForwardServiceConfigs(baseName string, forward *model.Forward, tunnel *model.Tunnel, node *model.Node, port int, bindIP string, limiterID *int64, cLimiterName string) []map[string]interface{} {
// ... 现有逻辑
// 在服务配置生成的部分增加:
if cLimiterName != "" {
service["climiter"] = cLimiterName
}
// ...
}
```
[x] **Step 4: 在转发服务同步主流程中决定并下发 `climiter`**
在 `syncForwardServicesWithWarnings` (可能在多个重载/处理入口处,如 `ensureForwardServices`),查出转发所属 user 的 `MaxConn`,以及转发本身的 `MaxConn`。
```go
// 获取 User
user, err := h.repo.GetUser(forward.UserID)
if err != nil {
return nil, err
}
var cLimiterName string
var maxConnToSet int
if forward.MaxConn > 0 {
maxConnToSet = forward.MaxConn
cLimiterName = fmt.Sprintf("rule_conn_limit_%d", forward.ID)
} else if user != nil && user.MaxConn > 0 {
maxConnToSet = user.MaxConn
cLimiterName = fmt.Sprintf("user_conn_limit_%d", user.ID)
}
if cLimiterName != "" {
for _, fp := range ports {
if err := h.ensureConnLimiterOnNode(fp.NodeID, cLimiterName, maxConnToSet); err != nil {
warnings = append(warnings, fmt.Sprintf("节点 %d 连接限制器下发失败: %v", fp.NodeID, err))
}
}
}
// 传递给 buildForwardServiceConfigs
// ...
```
*(注意:需要确保更新涉及 `buildForwardServiceConfigs` 的所有调用点)*
[x] **Step 5: Commit**
```bash
cd go-backend
git add internal/http/handler/control_plane.go internal/store/repo/repository_control.go
git commit -m "feat: implement max conn limiter dispatching"
cd ..
```
---
### Task 4: 后端接口 - 用户和规则的 CRUD 支持
**Files:**
- Modify: `go-backend/internal/http/handler/admin_user.go`
- Modify: `go-backend/internal/http/handler/forward.go`
[x] **Step 1: 用户接口更新**
在 `go-backend/internal/http/handler/admin_user.go`,修改用户创建和更新请求的结构体(如果有),接收 `MaxConn`,并在保存到数据库时赋值。
```go
type CreateUserReq struct {
// ...
MaxConn *int `json:"maxConn"`
}
// 接收后:
if req.MaxConn != nil {
user.MaxConn = *req.MaxConn
}
```
在获取用户列表时,确保 `MaxConn` 返回给前端。
[x] **Step 2: 规则接口更新**
在 `go-backend/internal/http/handler/forward.go` 中,更新 `CreateForwardReq` 和 `UpdateForwardReq` 结构体,增加 `MaxConn`,并在创建/更新 Forward 时保存到数据库。
如果转发规则的 `MaxConn` 或相关信息改变,触发节点上的规则重载(重新下发服务)。这一步由于更改了数据库,复用现有的 `syncForwardServices` 就会带上最新的配置。
[x] **Step 3: 测试接口**
Run: 可以启动后使用 curl 测试。
[x] **Step 4: Commit**
```bash
cd go-backend
git add internal/http/handler/admin_user.go internal/http/handler/forward.go
git commit -m "feat: add maxConn to user and forward CRUD API"
cd ..
```
---
### Task 5: 前端 - 用户管理页面集成
**Files:**
- Modify: `vite-frontend/src/api/types.ts`
- Modify: `vite-frontend/src/api/index.ts`
- Modify: `vite-frontend/src/pages/users.tsx` (或者对应的用户管理页面文件)
[x] **Step 1: 类型更新**
在 `vite-frontend/src/api/types.ts` 中:
为 `UserApiItem` 和相关的 mutation payload 增加 `maxConn?: number` 属性。
[x] **Step 2: UI 修改**
在用户创建/编辑弹窗中,增加“最大连接数”输入框:
(假设使用 `@nextui-org/react` 的 `Input`)
```tsx
<Input
type="number"
label="最大并发连接数"
placeholder="0 或空表示不限制"
value={formData.maxConn === 0 ? "" : String(formData.maxConn || "")}
onValueChange={(val) => {
const num = parseInt(val, 10);
setFormData({ ...formData, maxConn: isNaN(num) ? 0 : num });
}}
/>
```
并在用户的表格列中展示 `最大连接数`(值为 0 显示“不限制”)。
[x] **Step 3: 运行 Vite 进行验证**
[x] **Step 4: Commit**
```bash
cd vite-frontend
git add src/api/types.ts src/api/index.ts src/pages/users.tsx
git commit -m "feat: add max conn UI to user management"
cd ..
```
---
### Task 6: 前端 - 转发规则页面集成
**Files:**
- Modify: `vite-frontend/src/pages/forward.tsx`
[x] **Step 1: 类型更新**
在 `api/types.ts` 中 `ForwardMutationPayload` 和 `ForwardApiItem` 中增加 `maxConn?: number`。
[x] **Step 2: UI 修改**
在 `vite-frontend/src/pages/forward.tsx` 的创建/编辑规则弹窗(在 "规则限速" 附近)增加“最大连接数”输入框:
```tsx
<Input
type="number"
label="最大并发连接数"
placeholder="0 或空表示不限制"
value={formData.maxConn === 0 ? "" : String(formData.maxConn || "")}
onValueChange={(val) => {
const num = parseInt(val, 10);
setFormData({ ...formData, maxConn: isNaN(num) ? 0 : num });
}}
description="此设置优先于用户的全局连接数限制。0 表示不限制(或使用用户的全局限制)。"
/>
```
如果是在列表/卡片中展示,可以增加一个小标签或者 Tooltip 显示其最大连接数设置。
[x] **Step 3: 验证**
在前端验证该功能能正确读写规则的连接限制字段。
[x] **Step 4: Commit**
```bash
cd vite-frontend
git add src/pages/forward.tsx src/api/types.ts
git commit -m "feat: add max conn UI to forward rules"
cd ..
```
@@ -71,10 +71,11 @@ type RuntimeReleaseRoleRequest struct {
}
type RuntimeDiagnoseRequest struct {
IP string `json:"ip"`
Port int `json:"port"`
Count int `json:"count"`
Timeout int `json:"timeout"`
IP string `json:"ip"`
Port int `json:"port"`
Count int `json:"count"`
Timeout int `json:"timeout"`
Protocol string `json:"protocol"`
}
type RuntimeNodeCommandRequest struct {
+156 -19
View File
@@ -42,6 +42,7 @@ type diagnosisWorkItem struct {
toNode chainNodeRecord
hasChainHop bool
ipPreference string
protocol string
}
type diagnosisExecOptions struct {
@@ -265,6 +266,22 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
serviceBase := buildForwardServiceBaseWithResolvedUserTunnel(forward.ID, forward.UserID, userTunnelID)
user, err := h.repo.GetUserByID(forward.UserID)
if err != nil {
return nil, err
}
var cLimiterName string
var maxConnToSet int
if forward.MaxConn > 0 {
maxConnToSet = forward.MaxConn
cLimiterName = fmt.Sprintf("rule_conn_limit_%d", forward.ID)
} else if user != nil && user.MaxConn > 0 {
maxConnToSet = user.MaxConn
cLimiterName = fmt.Sprintf("user_conn_limit_%d", user.ID)
}
for _, fp := range ports {
if limiterID != nil && speed != nil {
if err := h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed); err != nil {
@@ -282,11 +299,17 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
}
}
if cLimiterName != "" {
if err := h.ensureConnLimiterOnNode(fp.NodeID, cLimiterName, maxConnToSet); err != nil {
warnings = append(warnings, fmt.Sprintf("节点 %d 连接限制器下发失败: %v", fp.NodeID, err))
}
}
node, err := h.getNodeRecord(fp.NodeID)
if err != nil {
return nil, err
}
services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, strings.TrimSpace(fp.InIP), limiterID)
services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, strings.TrimSpace(fp.InIP), limiterID, cLimiterName)
_, err = h.sendNodeCommand(node.ID, method, services, true, false)
if err != nil && allowFallbackAdd && method == "UpdateService" {
if isNotFoundError(err) {
@@ -301,7 +324,7 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
}
if err != nil && strings.EqualFold(strings.TrimSpace(method), "UpdateService") && isCannotAssignRequestedAddressError(err) {
var warning string
warning, err = h.fallbackForwardPortToDefaultBind(forward, tunnel, node, fp, serviceBase, limiterID)
warning, err = h.fallbackForwardPortToDefaultBind(forward, tunnel, node, fp, serviceBase, limiterID, cLimiterName)
if err == nil && warning != "" {
warnings = append(warnings, warning)
}
@@ -327,7 +350,7 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
return warnings, nil
}
func (h *Handler) fallbackForwardPortToDefaultBind(forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, fp forwardPortRecord, serviceBase string, limiterID *int64) (string, error) {
func (h *Handler) fallbackForwardPortToDefaultBind(forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, fp forwardPortRecord, serviceBase string, limiterID *int64, cLimiterName string) (string, error) {
if h == nil || forward == nil || tunnel == nil || node == nil {
return "", errors.New("invalid bind fallback context")
}
@@ -344,7 +367,7 @@ func (h *Handler) fallbackForwardPortToDefaultBind(forward *forwardRecord, tunne
}
time.Sleep(150 * time.Millisecond)
defaultServices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, "", limiterID)
defaultServices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, "", limiterID, cLimiterName)
if _, err := h.sendNodeCommand(node.ID, "AddService", defaultServices, true, false); err != nil {
return "", err
}
@@ -473,6 +496,9 @@ func (h *Handler) controlForwardServices(forward *forwardRecord, commandType str
nodeHandled, lastNotFoundErr, err := h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
if err != nil {
if isNodeOfflineOrTimeoutError(err) {
continue
}
return err
}
@@ -692,6 +718,7 @@ func (h *Handler) prepareForwardDiagnosis(forward *forwardRecord) (string, []dia
}
ipPreference := h.repo.GetTunnelIPPreference(forward.TunnelID)
protocol := strings.ToLower(strings.TrimSpace(tunnel.Protocol))
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
workItems := make([]diagnosisWorkItem, 0, len(chainRows)*2+len(targets))
@@ -706,6 +733,7 @@ func (h *Handler) prepareForwardDiagnosis(forward *forwardRecord) (string, []dia
targetIP: target.IP,
targetPort: target.Port,
description: description,
protocol: "tcp",
metadata: map[string]interface{}{
"fromChainType": 1,
},
@@ -723,6 +751,7 @@ func (h *Handler) prepareForwardDiagnosis(forward *forwardRecord) (string, []dia
hasChainHop: true,
ipPreference: ipPreference,
description: description,
protocol: defaultString(strings.ToLower(strings.TrimSpace(firstNode.Protocol)), protocol),
metadata: map[string]interface{}{
"fromChainType": 1,
"toChainType": 2,
@@ -739,6 +768,7 @@ func (h *Handler) prepareForwardDiagnosis(forward *forwardRecord) (string, []dia
hasChainHop: true,
ipPreference: ipPreference,
description: description,
protocol: defaultString(strings.ToLower(strings.TrimSpace(outNode.Protocol)), protocol),
metadata: map[string]interface{}{
"fromChainType": 1,
"toChainType": 3,
@@ -759,6 +789,7 @@ func (h *Handler) prepareForwardDiagnosis(forward *forwardRecord) (string, []dia
hasChainHop: true,
ipPreference: ipPreference,
description: description,
protocol: defaultString(strings.ToLower(strings.TrimSpace(nextNode.Protocol)), protocol),
metadata: map[string]interface{}{
"fromChainType": 2,
"fromInx": currentNode.Inx,
@@ -776,6 +807,7 @@ func (h *Handler) prepareForwardDiagnosis(forward *forwardRecord) (string, []dia
hasChainHop: true,
ipPreference: ipPreference,
description: description,
protocol: defaultString(strings.ToLower(strings.TrimSpace(outNode.Protocol)), protocol),
metadata: map[string]interface{}{
"fromChainType": 2,
"fromInx": currentNode.Inx,
@@ -795,6 +827,7 @@ func (h *Handler) prepareForwardDiagnosis(forward *forwardRecord) (string, []dia
targetIP: target.IP,
targetPort: target.Port,
description: description,
protocol: "tcp",
metadata: map[string]interface{}{
"fromChainType": 3,
},
@@ -810,6 +843,7 @@ func (h *Handler) prepareForwardDiagnosis(forward *forwardRecord) (string, []dia
targetIP: target.IP,
targetPort: target.Port,
description: description,
protocol: "tcp",
metadata: map[string]interface{}{
"fromChainType": 1,
},
@@ -864,6 +898,7 @@ func (h *Handler) prepareTunnelDiagnosis(tunnelID int64) (string, string, []diag
}
ipPreference := h.repo.GetTunnelIPPreference(tunnelID)
protocol := strings.ToLower(strings.TrimSpace(tunnel.Protocol))
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
workItems := make([]diagnosisWorkItem, 0, len(chainRows)*2)
@@ -876,6 +911,7 @@ func (h *Handler) prepareTunnelDiagnosis(tunnelID int64) (string, string, []diag
targetIP: "www.bing.com",
targetPort: 443,
description: description,
protocol: "tcp",
metadata: map[string]interface{}{
"fromChainType": 1,
},
@@ -892,6 +928,7 @@ func (h *Handler) prepareTunnelDiagnosis(tunnelID int64) (string, string, []diag
hasChainHop: true,
ipPreference: ipPreference,
description: description,
protocol: defaultString(strings.ToLower(strings.TrimSpace(firstNode.Protocol)), protocol),
metadata: map[string]interface{}{
"fromChainType": 1,
"toChainType": 2,
@@ -908,6 +945,7 @@ func (h *Handler) prepareTunnelDiagnosis(tunnelID int64) (string, string, []diag
hasChainHop: true,
ipPreference: ipPreference,
description: description,
protocol: defaultString(strings.ToLower(strings.TrimSpace(outNode.Protocol)), protocol),
metadata: map[string]interface{}{
"fromChainType": 1,
"toChainType": 3,
@@ -928,6 +966,7 @@ func (h *Handler) prepareTunnelDiagnosis(tunnelID int64) (string, string, []diag
hasChainHop: true,
ipPreference: ipPreference,
description: description,
protocol: defaultString(strings.ToLower(strings.TrimSpace(nextNode.Protocol)), protocol),
metadata: map[string]interface{}{
"fromChainType": 2,
"fromInx": currentNode.Inx,
@@ -945,6 +984,7 @@ func (h *Handler) prepareTunnelDiagnosis(tunnelID int64) (string, string, []diag
hasChainHop: true,
ipPreference: ipPreference,
description: description,
protocol: defaultString(strings.ToLower(strings.TrimSpace(outNode.Protocol)), protocol),
metadata: map[string]interface{}{
"fromChainType": 2,
"fromInx": currentNode.Inx,
@@ -963,6 +1003,7 @@ func (h *Handler) prepareTunnelDiagnosis(tunnelID int64) (string, string, []diag
targetIP: "www.bing.com",
targetPort: 443,
description: description,
protocol: "tcp",
metadata: map[string]interface{}{
"fromChainType": 3,
},
@@ -976,6 +1017,7 @@ func (h *Handler) prepareTunnelDiagnosis(tunnelID int64) (string, string, []diag
targetIP: "www.bing.com",
targetPort: 443,
description: description,
protocol: "tcp",
metadata: map[string]interface{}{
"fromChainType": 1,
},
@@ -1095,9 +1137,9 @@ func (h *Handler) executeDiagnosisWorkItem(workItem diagnosisWorkItem, options d
single := make([]map[string]interface{}, 0, 1)
nodeCache := map[int64]*nodeRecord{}
if workItem.hasChainHop {
h.appendChainHopDiagnosis(&single, nodeCache, workItem.fromNodeID, workItem.toNode, workItem.description, workItem.metadata, workItem.ipPreference, options)
h.appendChainHopDiagnosis(&single, nodeCache, workItem.fromNodeID, workItem.toNode, workItem.description, workItem.metadata, workItem.ipPreference, workItem.protocol, options)
} else {
h.appendPathDiagnosis(&single, nodeCache, workItem.fromNodeID, workItem.targetIP, workItem.targetPort, workItem.description, workItem.metadata, options)
h.appendPathDiagnosis(&single, nodeCache, workItem.fromNodeID, workItem.targetIP, workItem.targetPort, workItem.description, workItem.metadata, workItem.protocol, options)
}
if len(single) == 0 {
@@ -1223,14 +1265,14 @@ func (h *Handler) appendFailedDiagnosis(results *[]map[string]interface{}, nodeC
item["nodeName"] = node.Name
}
if strings.TrimSpace(message) == "" {
message = "TCP连接失败"
message = "连接失败"
}
item["success"] = false
item["message"] = message
*results = append(*results, item)
}
func (h *Handler) appendPathDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, targetIP string, targetPort int, description string, metadata map[string]interface{}, options diagnosisExecOptions) {
func (h *Handler) appendPathDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, targetIP string, targetPort int, description string, metadata map[string]interface{}, protocol string, options diagnosisExecOptions) {
item := newDiagnosisResultItem(fromNodeID, targetIP, targetPort, description, metadata)
fromNode, err := h.cachedNode(nodeCache, fromNodeID)
@@ -1247,9 +1289,9 @@ func (h *Handler) appendPathDiagnosis(results *[]map[string]interface{}, nodeCac
pingErr error
)
if fromNode.IsRemote == 1 {
pingData, pingErr = h.tcpPingViaRemoteNode(fromNode, targetIP, targetPort, options)
pingData, pingErr = h.pingViaRemoteNode(fromNode, targetIP, targetPort, protocol, options)
} else {
pingData, pingErr = h.tcpPingViaNode(fromNodeID, targetIP, targetPort, options)
pingData, pingErr = h.pingViaNode(fromNodeID, targetIP, targetPort, protocol, options)
}
if pingErr != nil {
item["success"] = false
@@ -1266,21 +1308,21 @@ func (h *Handler) appendPathDiagnosis(results *[]map[string]interface{}, nodeCac
message := strings.TrimSpace(asString(pingData["message"]))
if success {
if message == "" {
message = "TCP连接成功"
message = "连接成功"
}
} else {
if message == "" {
message = strings.TrimSpace(asString(pingData["errorMessage"]))
}
if message == "" {
message = "TCP连接失败"
message = "连接失败"
}
}
item["message"] = message
*results = append(*results, item)
}
func (h *Handler) appendChainHopDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, toNode chainNodeRecord, description string, metadata map[string]interface{}, ipPreference string, options diagnosisExecOptions) {
func (h *Handler) appendChainHopDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, toNode chainNodeRecord, description string, metadata map[string]interface{}, ipPreference string, protocol string, options diagnosisExecOptions) {
fromNode, _ := h.cachedNode(nodeCache, fromNodeID)
targetNode, err := h.cachedNode(nodeCache, toNode.NodeID)
if err != nil {
@@ -1292,7 +1334,7 @@ func (h *Handler) appendChainHopDiagnosis(results *[]map[string]interface{}, nod
h.appendFailedDiagnosis(results, nodeCache, fromNodeID, strings.Trim(strings.TrimSpace(targetNode.ServerIP), "[]"), toNode.Port, description, metadata, err.Error())
return
}
h.appendPathDiagnosis(results, nodeCache, fromNodeID, targetIP, targetPort, description, metadata, options)
h.appendPathDiagnosis(results, nodeCache, fromNodeID, targetIP, targetPort, description, metadata, protocol, options)
}
func resolveChainProbeTarget(fromNode, targetNode *nodeRecord, preferredPort int, ipPreference string, connectIp string) (string, int, error) {
@@ -1385,13 +1427,81 @@ func (h *Handler) tcpPingViaRemoteNode(node *nodeRecord, ip string, port int, op
fc := client.NewFederationClientWithTimeout(options.commandTimeout)
return fc.Diagnose(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeDiagnoseRequest{
IP: strings.TrimSpace(ip),
Port: port,
Count: 4,
Timeout: options.pingTimeoutMS,
IP: strings.TrimSpace(ip),
Port: port,
Count: 4,
Timeout: options.pingTimeoutMS,
Protocol: "tcp",
})
}
func (h *Handler) udpPingViaRemoteNode(node *nodeRecord, ip string, port int, options diagnosisExecOptions) (map[string]interface{}, error) {
if node == nil {
return nil, errors.New("节点不存在")
}
remoteURL := strings.TrimSpace(node.RemoteURL)
remoteToken := strings.TrimSpace(node.RemoteToken)
if remoteURL == "" || remoteToken == "" {
return nil, errors.New("远程节点缺少共享配置")
}
if options.commandTimeout <= 0 {
options.commandTimeout = diagnosisCommandTimeout
}
if options.pingTimeoutMS <= 0 {
options.pingTimeoutMS = int(diagnosisCommandTimeout / time.Millisecond)
}
fc := client.NewFederationClientWithTimeout(options.commandTimeout)
return fc.Diagnose(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeDiagnoseRequest{
IP: strings.TrimSpace(ip),
Port: port,
Count: 4,
Timeout: options.pingTimeoutMS,
Protocol: "udp",
})
}
func isUDPBasedProtocol(protocol string) bool {
p := strings.ToLower(strings.TrimSpace(protocol))
return p == "kcp" || p == "udp" || p == "quic"
}
func (h *Handler) pingViaNode(nodeID int64, ip string, port int, protocol string, options diagnosisExecOptions) (map[string]interface{}, error) {
if isUDPBasedProtocol(protocol) {
return h.udpPingViaNode(nodeID, ip, port, options)
}
return h.tcpPingViaNode(nodeID, ip, port, options)
}
func (h *Handler) pingViaRemoteNode(node *nodeRecord, ip string, port int, protocol string, options diagnosisExecOptions) (map[string]interface{}, error) {
if isUDPBasedProtocol(protocol) {
return h.udpPingViaRemoteNode(node, ip, port, options)
}
return h.tcpPingViaRemoteNode(node, ip, port, options)
}
func (h *Handler) udpPingViaNode(nodeID int64, ip string, port int, options diagnosisExecOptions) (map[string]interface{}, error) {
if options.commandTimeout <= 0 {
options.commandTimeout = diagnosisCommandTimeout
}
if options.pingTimeoutMS <= 0 {
options.pingTimeoutMS = int(diagnosisCommandTimeout / time.Millisecond)
}
res, err := h.sendNodeCommandWithTimeout(nodeID, "UdpPing", map[string]interface{}{
"ip": ip,
"port": port,
"count": 4,
"timeout": options.pingTimeoutMS,
}, options.commandTimeout, false, false)
if err != nil {
return nil, err
}
if res.Data == nil {
return nil, errors.New("节点未返回诊断数据")
}
return res.Data, nil
}
func splitRemoteTargets(remoteAddr string) []string {
parts := strings.Split(remoteAddr, ",")
out := make([]string, 0, len(parts))
@@ -1549,7 +1659,7 @@ func compactErrorMessage(msg string) string {
return strings.Join(strings.Fields(strings.ToLower(msg)), "")
}
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, bindIP string, limiterID *int64) []map[string]interface{} {
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, bindIP string, limiterID *int64, cLimiterName string) []map[string]interface{} {
protocols := []string{"tcp", "udp"}
services := make([]map[string]interface{}, 0, 2)
targets := splitRemoteTargets(forward.RemoteAddr)
@@ -1592,6 +1702,9 @@ func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel
},
},
}
if cLimiterName != "" {
service["climiter"] = cLimiterName
}
if protocol == "udp" {
listenerMetadata := map[string]interface{}{
"keepAlive": true,
@@ -1707,6 +1820,30 @@ func (h *Handler) ensureLimiterOnNode(nodeID int64, limiterID int64, speed int)
return nil
}
func (h *Handler) ensureConnLimiterOnNode(nodeID int64, limiterName string, maxConn int) error {
limitStr := fmt.Sprintf("$ %d", maxConn)
payload := map[string]interface{}{
"name": limiterName,
"limits": []string{limitStr},
}
if _, err := h.sendNodeCommand(nodeID, "AddCLimiters", payload, false, false); err != nil {
if !isAlreadyExistsMessage(err.Error()) {
return fmt.Errorf("连接限制器下发失败: %w", err)
}
updatePayload := map[string]interface{}{
"limiter": limiterName,
"data": payload,
}
if _, updateErr := h.sendNodeCommand(nodeID, "UpdateCLimiters", updatePayload, false, false); updateErr != nil {
return fmt.Errorf("连接限制器更新失败: %w", updateErr)
}
}
return nil
}
func buildLimiterAddPayload(limiterID int64, speed int) (string, map[string]interface{}) {
rate := float64(speed) / 8.0
limitStr := fmt.Sprintf("$ %.1fMB %.1fMB", rate, rate)
@@ -378,7 +378,7 @@ func TestRetryTunnelServiceAddWithCleanupReturnsCleanupError(t *testing.T) {
func TestBuildForwardServiceConfigs_UsesBindIPForListen(t *testing.T) {
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"}
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 22000, "10.9.8.7", nil)
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 22000, "10.9.8.7", nil, "")
if len(services) != 2 {
t.Fatalf("expected 2 services, got %d", len(services))
}
@@ -393,7 +393,7 @@ func TestBuildForwardServiceConfigs_UsesBindIPForListen(t *testing.T) {
func TestBuildForwardServiceConfigs_DefaultListenAddrWhenBindIPEmpty(t *testing.T) {
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
node := &nodeRecord{TCPListenAddr: "0.0.0.0", UDPListenAddr: "[::]"}
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 22001, "", nil)
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 22001, "", nil, "")
if len(services) != 2 {
t.Fatalf("expected 2 services, got %d", len(services))
}
@@ -409,7 +409,7 @@ func TestBuildForwardServiceConfigs_DefaultListenAddrWhenBindIPEmpty(t *testing.
func TestBuildForwardServiceConfigs_BindIPAlreadyContainsPort(t *testing.T) {
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"}
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 55555, "3.3.3.3:12345", nil)
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 55555, "3.3.3.3:12345", nil, "")
if len(services) != 2 {
t.Fatalf("expected 2 services, got %d", len(services))
}
@@ -464,7 +464,7 @@ func TestBuildForwardServiceConfigs_IPv6BindIP(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"}
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, tt.port, tt.bindIP, nil)
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, tt.port, tt.bindIP, nil, "")
if len(services) != 2 {
t.Fatalf("expected 2 services, got %d", len(services))
}
+12 -6
View File
@@ -86,10 +86,11 @@ type federationRuntimeReleaseRoleRequest struct {
}
type federationRuntimeDiagnoseRequest struct {
IP string `json:"ip"`
Port int `json:"port"`
Count int `json:"count"`
Timeout int `json:"timeout"`
IP string `json:"ip"`
Port int `json:"port"`
Count int `json:"count"`
Timeout int `json:"timeout"`
Protocol string `json:"protocol"`
}
type federationRuntimeCommandRequest struct {
@@ -649,7 +650,7 @@ func (h *Handler) nodeImport(w http.ResponseWriter, r *http.Request) {
return
}
if err := IsSafeRemoteAddr(rURL.Host); err != nil {
response.WriteJSON(w, response.Err(403, "禁止将远程节点地址设置为内部网络或保留地址"))
response.WriteJSON(w, response.Err(403, "禁止将远程节点地址设置为内部网络"))
return
}
@@ -1281,7 +1282,12 @@ func (h *Handler) federationRuntimeDiagnose(w http.ResponseWriter, r *http.Reque
commandTimeout = diagnosisCommandTimeout
}
res, err := h.sendNodeCommandWithTimeout(share.NodeID, "TcpPing", map[string]interface{}{
commandType := "TcpPing"
if isUDPBasedProtocol(req.Protocol) {
commandType = "UdpPing"
}
res, err := h.sendNodeCommandWithTimeout(share.NodeID, commandType, map[string]interface{}{
"ip": req.IP,
"port": req.Port,
"count": req.Count,
@@ -43,16 +43,19 @@ func (h *Handler) processFlowItem(nodeID int64, item flowItem) {
forwardID, userID, userTunnelID, ok := parseFlowServiceIDs(serviceName)
if ok {
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
if quota, quotaErr := h.repo.AddUserQuotaUsage(userID, inFlow+outFlow, time.Now()); quotaErr == nil {
h.enforceUserQuotaIfNeeded(userID, quota)
if h.forwardExists(forwardID) {
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
if quota, quotaErr := h.repo.AddUserQuotaUsage(userID, inFlow+outFlow, time.Now()); quotaErr == nil {
h.enforceUserQuotaIfNeeded(userID, quota)
}
if userTunnelID > 0 {
h.enforceFlowPolicies(userID, userTunnelID)
}
} else if nodeID > 0 {
h.sendDeleteOrphanedForwardService(nodeID, serviceName)
}
h.processPeerShareFlowFromForward(forwardID, nodeID, serviceName, item)
if userTunnelID > 0 {
h.enforceFlowPolicies(userID, userTunnelID)
}
return
}
@@ -553,6 +556,14 @@ func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem
}
parts := strings.Split(name, "_")
if len(parts) == 2 && parts[0] == "tunnel" {
tunnelID, err := strconv.ParseInt(parts[1], 10, 64)
if err == nil && tunnelID > 0 && !h.tunnelExists(tunnelID) {
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{name}}, false, true)
}
continue
}
if len(parts) >= 3 {
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
if err == nil && forwardID > 0 && hasUnboundForwardPeerRuntime {
@@ -566,7 +577,7 @@ func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem
suffix := parts[len(parts)-1]
switch suffix {
case "tls":
case "tls", "kcp", "wss", "mtls", "mwss", "mtcp":
tunnelID, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || tunnelID <= 0 || h.tunnelExists(tunnelID) {
continue
@@ -574,6 +585,10 @@ func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{name}}, false, true)
case "tcp":
if len(parts) < 4 {
tunnelID, err := strconv.ParseInt(parts[0], 10, 64)
if err == nil && tunnelID > 0 && !h.tunnelExists(tunnelID) {
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{name}}, false, true)
}
continue
}
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
@@ -628,6 +643,21 @@ func (h *Handler) forwardExists(forwardID int64) bool {
return ok
}
func (h *Handler) sendDeleteOrphanedForwardService(nodeID int64, serviceName string) {
parts := strings.Split(serviceName, "_")
if len(parts) < 3 {
return
}
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || forwardID <= 0 {
return
}
base := parts[0] + "_" + parts[1] + "_" + parts[2]
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{
"services": []string{base + "_tcp", base + "_udp"},
}, false, true)
}
func (h *Handler) speedLimiterExists(name string) bool {
if name == "" {
return false
+1 -1
View File
@@ -121,7 +121,7 @@ func (h *Handler) runHealthChecks(ctx context.Context) {
func (h *Handler) runTunnelQualityProber(ctx context.Context) {
defer h.jobsWG.Done()
if h == nil || h.qualityProber == nil || !h.isTunnelQualityMonitoringEnabled() {
if h == nil || h.qualityProber == nil {
return
}
+106 -22
View File
@@ -68,8 +68,9 @@ func (h *Handler) userCreate(w http.ResponseWriter, r *http.Request) {
}
roleID := 1
now := time.Now().UnixMilli()
maxConn := asInt(req["maxConn"], 0)
userID, err := h.repo.CreateUser(username, security.MD5(pwd), roleID, expTime, flow, flowResetTime, num, status, now)
userID, err := h.repo.CreateUser(username, security.MD5(pwd), roleID, expTime, flow, flowResetTime, num, status, maxConn, now)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
@@ -156,15 +157,16 @@ func (h *Handler) userUpdate(w http.ResponseWriter, r *http.Request) {
_, hasDailyQuota := req["dailyQuotaGB"]
_, hasMonthlyQuota := req["monthlyQuotaGB"]
now := time.Now().UnixMilli()
maxConn := asInt(req["maxConn"], 0)
pwd := asString(req["pwd"])
if strings.TrimSpace(pwd) == "" {
if err := h.repo.UpdateUserWithoutPassword(id, username, flow, num, expTime, flowResetTime, status, now); err != nil {
if err := h.repo.UpdateUserWithoutPassword(id, username, flow, num, expTime, flowResetTime, status, maxConn, now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
} else {
if err := h.repo.UpdateUserWithPassword(id, username, security.MD5(pwd), flow, num, expTime, flowResetTime, status, now); err != nil {
if err := h.repo.UpdateUserWithPassword(id, username, security.MD5(pwd), flow, num, expTime, flowResetTime, status, maxConn, now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
@@ -657,11 +659,15 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
if trimmed := strings.TrimSpace(inIP); trimmed != "" {
tunnelInIP = sql.NullString{String: trimmed, Valid: true}
}
tunnelProtocol := "tls"
if len(runtimeState.InNodes) > 0 && strings.TrimSpace(runtimeState.InNodes[0].Protocol) != "" {
tunnelProtocol = strings.TrimSpace(runtimeState.InNodes[0].Protocol)
}
tunnel := model.Tunnel{
Name: name,
TrafficRatio: trafficRatio,
Type: typeVal,
Protocol: "tls",
Protocol: tunnelProtocol,
Flow: flow,
CreatedTime: now,
UpdatedTime: now,
@@ -702,7 +708,7 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
if typeVal == 2 {
createdChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState)
if applyErr != nil {
h.rollbackTunnelRuntime(createdChains, createdServices, tunnelID)
h.rollbackTunnelRuntime(createdChains, createdServices, tunnelID, tunnelProtocol)
h.releaseFederationRuntimeRefs(federationReleaseRefs)
_ = h.deleteTunnelByID(tunnelID)
response.WriteJSON(w, response.ErrDefault(applyErr.Error()))
@@ -722,17 +728,30 @@ func (h *Handler) cleanupTunnelRuntime(tunnelID int64) {
return
}
serviceName := fmt.Sprintf("%d_tls", tunnelID)
protocol := strings.TrimSpace(tunnel.Protocol)
if protocol == "" {
protocol = "tls"
}
chainName := fmt.Sprintf("chains_%d", tunnelID)
serviceNames := []string{
fmt.Sprintf("tunnel_%d", tunnelID),
fmt.Sprintf("%d_tls", tunnelID),
fmt.Sprintf("%d_kcp", tunnelID),
fmt.Sprintf("%d_wss", tunnelID),
fmt.Sprintf("%d_mtls", tunnelID),
fmt.Sprintf("%d_mwss", tunnelID),
fmt.Sprintf("%d_tcp", tunnelID),
fmt.Sprintf("%d_mtcp", tunnelID),
}
for _, row := range chainRows {
if row.ChainType == 1 {
_, _ = h.sendNodeCommand(row.NodeID, "DeleteChains", map[string]interface{}{"chain": chainName}, false, true)
} else if row.ChainType == 2 {
_, _ = h.sendNodeCommand(row.NodeID, "DeleteChains", map[string]interface{}{"chain": chainName}, false, true)
_, _ = h.sendNodeCommand(row.NodeID, "DeleteService", map[string]interface{}{"services": []string{serviceName}}, false, true)
_, _ = h.sendNodeCommand(row.NodeID, "DeleteService", map[string]interface{}{"services": serviceNames}, false, true)
} else if row.ChainType == 3 {
_, _ = h.sendNodeCommand(row.NodeID, "DeleteService", map[string]interface{}{"services": []string{serviceName}}, false, true)
_, _ = h.sendNodeCommand(row.NodeID, "DeleteService", map[string]interface{}{"services": serviceNames}, false, true)
}
}
}
@@ -816,6 +835,12 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
}
defer func() { tx.Rollback() }()
updateProtocol := "tls"
if len(runtimeState.OutNodes) > 0 && strings.TrimSpace(runtimeState.OutNodes[0].Protocol) != "" {
updateProtocol = strings.TrimSpace(runtimeState.OutNodes[0].Protocol)
} else if len(runtimeState.InNodes) > 0 && strings.TrimSpace(runtimeState.InNodes[0].Protocol) != "" {
updateProtocol = strings.TrimSpace(runtimeState.InNodes[0].Protocol)
}
if err := h.repo.UpdateTunnelTx(
tx,
id,
@@ -826,6 +851,7 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
asInt(req["status"], 1),
inIp,
ipPreference,
updateProtocol,
now,
); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
@@ -873,7 +899,11 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
if typeVal == 2 {
createdChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState)
if applyErr != nil {
h.rollbackTunnelRuntime(createdChains, createdServices, id)
updateProtocol := "tls"
if len(runtimeState.InNodes) > 0 && strings.TrimSpace(runtimeState.InNodes[0].Protocol) != "" {
updateProtocol = strings.TrimSpace(runtimeState.InNodes[0].Protocol)
}
h.rollbackTunnelRuntime(createdChains, createdServices, id, updateProtocol)
h.releaseFederationRuntimeRefs(federationReleaseRefs)
_ = h.repo.DeleteFederationTunnelBindingsByTunnel(id)
if len(federationReleaseRefs) == 0 && shouldDeferTunnelRuntimeApplyError(applyErr) {
@@ -1698,7 +1728,7 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
}
if roleID != 0 {
if err := IsSafeRemoteAddr(remoteAddr); err != nil {
response.WriteJSON(w, response.Err(403, "禁止将目标地址设置为内部网络或保留地址"))
response.WriteJSON(w, response.Err(403, "禁止将目标地址设置为内部网络"))
return
}
if speedIDVal, ok := req["speedId"]; ok && speedIDVal != nil {
@@ -1749,7 +1779,8 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
if userName == "" {
userName = "user"
}
forwardID, err := h.repo.CreateForwardTx(userID, userName, name, tunnelID, remoteAddr, defaultString(asString(req["strategy"]), "fifo"), now, inx, entryNodes, port, inIp, nullableInt(speedID))
maxConn := asInt(req["maxConn"], 0)
forwardID, err := h.repo.CreateForwardTx(userID, userName, name, tunnelID, remoteAddr, defaultString(asString(req["strategy"]), "fifo"), now, inx, entryNodes, port, inIp, nullableInt(speedID), maxConn)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
@@ -1822,7 +1853,7 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
}
if actorRole != 0 {
if err := IsSafeRemoteAddr(remoteAddr); err != nil {
response.WriteJSON(w, response.Err(403, "禁止将目标地址设置为内部网络或保留地址"))
response.WriteJSON(w, response.Err(403, "禁止将目标地址设置为内部网络"))
return
}
}
@@ -1900,7 +1931,9 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
}
}
now := time.Now().UnixMilli()
if err := h.repo.UpdateForward(id, name, tunnelID, remoteAddr, strategy, now, newSpeedID); err != nil {
maxConn := asInt(req["maxConn"], forward.MaxConn)
if err := h.repo.UpdateForward(id, name, tunnelID, remoteAddr, strategy, now, newSpeedID, maxConn); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
@@ -3362,6 +3395,11 @@ func (h *Handler) addTunnelServiceOnNode(nodeID, tunnelID int64, serviceData []m
return errors.New("invalid tunnel service context")
}
serviceName := fmt.Sprintf("%d_tls", tunnelID)
if len(serviceData) > 0 {
if name, ok := serviceData[0]["name"].(string); ok && strings.TrimSpace(name) != "" {
serviceName = strings.TrimSpace(name)
}
}
return retryTunnelServiceAddWithCleanup(
func() error {
_, err := h.sendNodeCommand(nodeID, "AddService", serviceData, true, false)
@@ -3375,19 +3413,31 @@ func (h *Handler) addTunnelServiceOnNode(nodeID, tunnelID int64, serviceData []m
)
}
func (h *Handler) rollbackTunnelRuntime(chainNodeIDs, serviceNodeIDs []int64, tunnelID int64) {
func (h *Handler) rollbackTunnelRuntime(chainNodeIDs, serviceNodeIDs []int64, tunnelID int64, protocol string) {
if h == nil || tunnelID <= 0 {
return
}
if protocol == "" {
protocol = "tls"
}
seenServices := make(map[int64]struct{})
serviceName := fmt.Sprintf("%d_tls", tunnelID)
serviceNames := []string{
fmt.Sprintf("tunnel_%d", tunnelID),
fmt.Sprintf("%d_tls", tunnelID),
fmt.Sprintf("%d_kcp", tunnelID),
fmt.Sprintf("%d_wss", tunnelID),
fmt.Sprintf("%d_mtls", tunnelID),
fmt.Sprintf("%d_mwss", tunnelID),
fmt.Sprintf("%d_tcp", tunnelID),
fmt.Sprintf("%d_mtcp", tunnelID),
}
for i := len(serviceNodeIDs) - 1; i >= 0; i-- {
nodeID := serviceNodeIDs[i]
if _, ok := seenServices[nodeID]; ok {
continue
}
seenServices[nodeID] = struct{}{}
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{serviceName}}, false, true)
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": serviceNames}, false, true)
}
seenChains := make(map[int64]struct{})
@@ -3457,9 +3507,15 @@ func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRu
connectorMetadata["nodelay"] = true
connectorMetadata["mux.keepaliveInterval"] = "15s"
connectorMetadata["mux.keepaliveTimeout"] = "45s"
connectorMetadata["mux.maxFrameSize"] = 32768
connectorMetadata["mux.maxStreamBuffer"] = 2097152
}
if isKCPTunnelProtocol(protocol) {
connectorMetadata["connectTimeout"] = "30s"
connectorMetadata["mux.keepaliveInterval"] = "15s"
connectorMetadata["mux.keepaliveTimeout"] = "45s"
connectorMetadata["mux.maxFrameSize"] = 32768
connectorMetadata["mux.maxStreamBuffer"] = 2097152
}
if len(connectorMetadata) > 0 {
connector["metadata"] = connectorMetadata
@@ -3505,9 +3561,15 @@ func buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode,
handlerMetadata["nodelay"] = true
handlerMetadata["mux.keepaliveInterval"] = "15s"
handlerMetadata["mux.keepaliveTimeout"] = "45s"
handlerMetadata["mux.maxFrameSize"] = 32768
handlerMetadata["mux.maxStreamBuffer"] = 2097152
}
if isKCPTunnelProtocol(protocol) {
handlerMetadata["connectTimeout"] = "30s"
handlerMetadata["mux.keepaliveInterval"] = "15s"
handlerMetadata["mux.keepaliveTimeout"] = "45s"
handlerMetadata["mux.maxFrameSize"] = 32768
handlerMetadata["mux.maxStreamBuffer"] = 2097152
}
if len(handlerMetadata) > 0 {
handlerCfg["metadata"] = handlerMetadata
@@ -3516,7 +3578,7 @@ func buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode,
handlerCfg["retries"] = nextHopCandidateCount - 1
}
service := map[string]interface{}{
"name": fmt.Sprintf("%d_%s", tunnelID, protocol),
"name": fmt.Sprintf("tunnel_%d", tunnelID),
"addr": processServerAddress(fmt.Sprintf("%s:%d", defaultString(strings.TrimSpace(chainNode.ConnectIP), node.TCPListenAddr), chainNode.Port)),
"handler": handlerCfg,
"listener": buildTunnelListenerConfig(protocol),
@@ -3621,8 +3683,19 @@ func buildTunnelDialerConfig(protocol string) map[string]interface{} {
}
if isKCPTunnelProtocol(protocol) {
dialer["metadata"] = map[string]interface{}{
"kcp.keepalive": 10,
"kcp.tcp": false,
"kcp.keepalive": 10,
"kcp.tcp": false,
"kcp.mode": "fast3",
"kcp.sndwnd": 4096,
"kcp.rcvwnd": 4096,
"kcp.mtu": 1350,
"kcp.sockbuf": 4194304,
"kcp.smuxbuf": 4194304,
"kcp.streambuf": 2097152,
"kcp.datashard": 10,
"kcp.parityshard": 3,
"kcp.nocomp": true,
"kcp.nc": 1,
}
}
return dialer
@@ -3634,8 +3707,19 @@ func buildTunnelListenerConfig(protocol string) map[string]interface{} {
}
if isKCPTunnelProtocol(protocol) {
listener["metadata"] = map[string]interface{}{
"kcp.keepalive": 10,
"kcp.tcp": false,
"kcp.keepalive": 10,
"kcp.tcp": false,
"kcp.mode": "fast3",
"kcp.sndwnd": 4096,
"kcp.rcvwnd": 4096,
"kcp.mtu": 1350,
"kcp.sockbuf": 4194304,
"kcp.smuxbuf": 4194304,
"kcp.streambuf": 2097152,
"kcp.datashard": 10,
"kcp.parityshard": 3,
"kcp.nocomp": true,
"kcp.nc": 1,
}
}
return listener
@@ -4001,7 +4085,7 @@ func (h *Handler) rollbackForwardMutation(oldForward *forwardRecord, oldPorts []
h.repo.RollbackForwardFields(
oldForward.ID, oldForward.UserID, oldForward.UserName, oldForward.Name,
oldForward.TunnelID, oldForward.RemoteAddr, oldForward.Strategy, oldForward.Status,
oldForward.SpeedID,
oldForward.SpeedID, oldForward.MaxConn,
time.Now().UnixMilli(),
)
@@ -31,8 +31,8 @@ func IsSafeRemoteAddr(addr string) error {
}
for _, ip := range ips {
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast() {
return fmt.Errorf("address resolves to internal or reserved IP: %s", ip.String())
if ip.IsLoopback() || ip.IsPrivate() {
return fmt.Errorf("address resolves to internal IP: %s", ip.String())
}
}
+2 -4
View File
@@ -389,9 +389,7 @@ func (h *Handler) consumeNodePendingUpgradeRedeploy(nodeID int64) bool {
}
func (h *Handler) onNodeOnline(nodeID int64) {
if !h.consumeNodePendingUpgradeRedeploy(nodeID) {
return
}
h.consumeNodePendingUpgradeRedeploy(nodeID)
h.redeployNodeRuntimeAfterUpgrade(nodeID)
}
@@ -401,7 +399,7 @@ func (h *Handler) redeployNodeRuntimeAfterUpgrade(nodeID int64) {
fmt.Printf("post-upgrade redeploy: list tunnels for node %d failed: %v\n", nodeID, err)
return
}
forwardIDs, err := h.repo.ListActiveForwardIDsByNode(nodeID)
forwardIDs, err := h.repo.ListForwardIDsByNode(nodeID)
if err != nil {
fmt.Printf("post-upgrade redeploy: list forwards for node %d failed: %v\n", nodeID, err)
return
+4
View File
@@ -23,6 +23,7 @@ type User struct {
CreatedTime int64 `gorm:"column:created_time;not null"`
UpdatedTime sql.NullInt64 `gorm:"column:updated_time"`
Status int `gorm:"not null"`
MaxConn int `gorm:"column:max_conn;not null;default:0"`
}
func (User) TableName() string { return "user" }
@@ -43,6 +44,7 @@ type Forward struct {
Status int `gorm:"not null"`
Inx int `gorm:"not null;default:0"`
SpeedID sql.NullInt64 `gorm:"column:speed_id"`
MaxConn int `gorm:"column:max_conn;not null;default:0"`
}
func (Forward) TableName() string { return "forward" }
@@ -537,6 +539,7 @@ type ForwardRecord struct {
Strategy string
Status int
SpeedID sql.NullInt64
MaxConn int
}
// TunnelRecord is a minimal tunnel view used by control plane.
@@ -546,6 +549,7 @@ type TunnelRecord struct {
Status int
Flow int64
TrafficRatio float64
Protocol string
}
type UserQuotaView struct {
@@ -99,6 +99,22 @@ func (r *Repository) ListActiveForwardIDsByNode(nodeID int64) ([]int64, error) {
return ids, nil
}
func (r *Repository) ListForwardIDsByNode(nodeID int64) ([]int64, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var ids []int64
err := r.db.Model(&model.ForwardPort{}).
Where("forward_port.node_id = ?", nodeID).
Select("DISTINCT forward_port.forward_id").
Order("forward_port.forward_id ASC").
Pluck("forward_port.forward_id", &ids).Error
if err != nil {
return nil, err
}
return ids, nil
}
func (r *Repository) ListForwardPorts(forwardID int64) ([]model.ForwardPortRecord, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
@@ -124,15 +124,16 @@ func (r *Repository) GetForwardRecord(forwardID int64) (*model.ForwardRecord, er
return nil, err
}
fr := model.ForwardRecord{
ID: f.ID,
UserID: f.UserID,
UserName: f.UserName,
Name: f.Name,
TunnelID: f.TunnelID,
RemoteAddr: f.RemoteAddr,
Strategy: f.Strategy,
Status: f.Status,
SpeedID: f.SpeedID,
ID: f.ID,
UserID: f.UserID,
UserName: f.UserName,
Name: f.Name,
TunnelID: f.TunnelID,
RemoteAddr: f.RemoteAddr,
Strategy: f.Strategy,
Status: f.Status,
SpeedID: f.SpeedID,
MaxConn: f.MaxConn,
}
if strings.TrimSpace(fr.Strategy) == "" {
fr.Strategy = "fifo"
@@ -158,6 +159,7 @@ func (r *Repository) GetTunnelRecord(tunnelID int64) (*model.TunnelRecord, error
Status: t.Status,
Flow: t.Flow,
TrafficRatio: t.TrafficRatio,
Protocol: t.Protocol,
}
if tr.Flow <= 0 {
tr.Flow = 1
@@ -37,7 +37,7 @@ func (r *Repository) UserExistsExcluding(username string, excludeID int64) (bool
return cnt > 0, err
}
func (r *Repository) CreateUser(username, pwdHash string, roleID int, expTime, flow, flowResetTime int64, num, status int, now int64) (int64, error) {
func (r *Repository) CreateUser(username, pwdHash string, roleID int, expTime, flow, flowResetTime int64, num, status, maxConn int, now int64) (int64, error) {
if r == nil || r.db == nil {
return 0, errors.New("repository not initialized")
}
@@ -51,6 +51,7 @@ func (r *Repository) CreateUser(username, pwdHash string, roleID int, expTime, f
OutFlow: 0,
FlowResetTime: flowResetTime,
Num: num,
MaxConn: maxConn,
CreatedTime: now,
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
Status: status,
@@ -73,7 +74,7 @@ func (r *Repository) GetUserRoleID(userID int64) (int, error) {
return user.RoleID, nil
}
func (r *Repository) UpdateUserWithPassword(id int64, username, pwdHash string, flow int64, num int, expTime, flowResetTime int64, status int, now int64) error {
func (r *Repository) UpdateUserWithPassword(id int64, username, pwdHash string, flow int64, num int, expTime, flowResetTime int64, status, maxConn int, now int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
@@ -87,11 +88,12 @@ func (r *Repository) UpdateUserWithPassword(id int64, username, pwdHash string,
"exp_time": expTime,
"flow_reset_time": flowResetTime,
"status": status,
"max_conn": maxConn,
"updated_time": sql.NullInt64{Int64: now, Valid: true},
}).Error
}
func (r *Repository) UpdateUserWithoutPassword(id int64, username string, flow int64, num int, expTime, flowResetTime int64, status int, now int64) error {
func (r *Repository) UpdateUserWithoutPassword(id int64, username string, flow int64, num int, expTime, flowResetTime int64, status, maxConn int, now int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
@@ -104,6 +106,7 @@ func (r *Repository) UpdateUserWithoutPassword(id int64, username string, flow i
"exp_time": expTime,
"flow_reset_time": flowResetTime,
"status": status,
"max_conn": maxConn,
"updated_time": sql.NullInt64{Int64: now, Valid: true},
}).Error
}
@@ -394,7 +397,7 @@ func (r *Repository) UpdateTunnelOrder(tunnelID int64, inx int, now int64) {
Updates(map[string]interface{}{"inx": inx, "updated_time": now}).Error
}
func (r *Repository) UpdateTunnelTx(tx *gorm.DB, tunnelID int64, name string, typeVal int, flow int64, trafficRatio float64, status int, inIP, ipPreference string, now int64) error {
func (r *Repository) UpdateTunnelTx(tx *gorm.DB, tunnelID int64, name string, typeVal int, flow int64, trafficRatio float64, status int, inIP, ipPreference string, protocol string, now int64) error {
if tx == nil {
return errors.New("database unavailable")
}
@@ -408,6 +411,7 @@ func (r *Repository) UpdateTunnelTx(tx *gorm.DB, tunnelID int64, name string, ty
"status": status,
"in_ip": nullStringFromInterface(inIP),
"ip_preference": ipPreference,
"protocol": protocol,
"updated_time": now,
}).Error
}
@@ -691,7 +695,7 @@ func (r *Repository) GetMinForwardPort(forwardID int64) sql.NullInt64 {
return p
}
func (r *Repository) UpdateForward(id int64, name string, tunnelID int64, remoteAddr, strategy string, now int64, speedID interface{}) error {
func (r *Repository) UpdateForward(id int64, name string, tunnelID int64, remoteAddr, strategy string, now int64, speedID interface{}, maxConn int) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
@@ -703,6 +707,7 @@ func (r *Repository) UpdateForward(id int64, name string, tunnelID int64, remote
"remote_addr": remoteAddr,
"strategy": strategy,
"speed_id": nullInt64FromInterface(speedID),
"max_conn": maxConn,
"updated_time": now,
}).Error
}
@@ -777,7 +782,7 @@ func (r *Repository) UpdateForwardPortBindIP(forwardID, nodeID int64, port int,
Update("in_ip", sql.NullString{String: inIP, Valid: strings.TrimSpace(inIP) != ""}).Error
}
func (r *Repository) RollbackForwardFields(id, userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, status int, speedID interface{}, now int64) {
func (r *Repository) RollbackForwardFields(id, userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, status int, speedID interface{}, maxConn int, now int64) {
if r == nil || r.db == nil {
return
}
@@ -792,6 +797,7 @@ func (r *Repository) RollbackForwardFields(id, userID int64, userName, name stri
"strategy": strategy,
"status": status,
"speed_id": nullInt64FromInterface(speedID),
"max_conn": maxConn,
"updated_time": now,
}).Error
}
@@ -1252,7 +1258,7 @@ func (r *Repository) EnsureUserTunnelGrant(userID, tunnelID int64) (int64, bool,
return ut.ID, true, nil
}
func (r *Repository) CreateForwardTx(userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, now int64, inx int, entryNodeIDs []int64, port int, inIp string, speedID interface{}) (int64, error) {
func (r *Repository) CreateForwardTx(userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, now int64, inx int, entryNodeIDs []int64, port int, inIp string, speedID interface{}, maxConn int) (int64, error) {
if r == nil || r.db == nil {
return 0, errors.New("repository not initialized")
}
@@ -1271,6 +1277,7 @@ func (r *Repository) CreateForwardTx(userID int64, userName, name string, tunnel
UpdatedTime: now,
Status: 1,
Inx: inx,
MaxConn: maxConn,
SpeedID: nullInt64FromInterface(speedID),
}
if err := tx.Create(&fwd).Error; err != nil {
+163
View File
@@ -0,0 +1,163 @@
import re
with open('internal/http/handler/control_plane.go', 'r') as f:
content = f.read()
# 1. Update ensureLimiterOnNode and add ensureConnLimiterOnNode
ensure_conn_limiter = """
func (h *Handler) ensureConnLimiterOnNode(nodeID int64, limiterName string, maxConn int) error {
\tlimitStr := fmt.Sprintf("$ %d", maxConn)
\t
\tpayload := map[string]interface{}{
\t\t"name": limiterName,
\t\t"limits": []string{limitStr},
\t}
\t
\tif _, err := h.sendNodeCommand(nodeID, "AddCLimiters", payload, false, false); err != nil {
\t\tif !isAlreadyExistsMessage(err.Error()) {
\t\t\treturn fmt.Errorf("连接限制器下发失败: %w", err)
\t\t}
\t\tupdatePayload := map[string]interface{}{
\t\t\t"limiter": limiterName,
\t\t\t"data": payload,
\t\t}
\t\tif _, updateErr := h.sendNodeCommand(nodeID, "UpdateCLimiters", updatePayload, false, false); updateErr != nil {
\t\t\treturn fmt.Errorf("连接限制器更新失败: %w", updateErr)
\t\t}
\t}
\treturn nil
}
"""
content = content.replace('func (h *Handler) ensureLimiterOnNode(nodeID int64, limiterID int64, speed int) error {\n\tif err := h.upsertLimiterOnNode(nodeID, limiterID, speed); err != nil {\n\t\treturn fmt.Errorf("限速规则下发失败: %w", err)\n\t}\n\n\treturn nil\n}',
'func (h *Handler) ensureLimiterOnNode(nodeID int64, limiterID int64, speed int) error {\n\tif err := h.upsertLimiterOnNode(nodeID, limiterID, speed); err != nil {\n\t\treturn fmt.Errorf("限速规则下发失败: %w", err)\n\t}\n\n\treturn nil\n}\n' + ensure_conn_limiter)
# 2. Update buildForwardServiceConfigs declaration
content = content.replace(
'func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, bindIP string, limiterID *int64) []map[string]interface{} {',
'func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, bindIP string, limiterID *int64, cLimiterName string) []map[string]interface{} {'
)
# 3. Inject climiter into generated service
service_map_end = """ }
if protocol == "udp" {"""
service_map_end_new = """ }
if cLimiterName != "" {
service["climiter"] = cLimiterName
}
if protocol == "udp" {"""
content = content.replace(service_map_end, service_map_end_new)
# 4. Update syncForwardServicesWithWarnings
# Find user tunnel resolution
resolution = """ serviceBase := buildForwardServiceBaseWithResolvedUserTunnel(forward.ID, forward.UserID, userTunnelID)
for _, fp := range ports {"""
resolution_new = """ serviceBase := buildForwardServiceBaseWithResolvedUserTunnel(forward.ID, forward.UserID, userTunnelID)
user, err := h.repo.GetUserByID(forward.UserID)
if err != nil {
return nil, err
}
var cLimiterName string
var maxConnToSet int
if forward.MaxConn > 0 {
maxConnToSet = forward.MaxConn
cLimiterName = fmt.Sprintf("rule_conn_limit_%d", forward.ID)
} else if user != nil && user.MaxConn > 0 {
maxConnToSet = user.MaxConn
cLimiterName = fmt.Sprintf("user_conn_limit_%d", user.ID)
}
for _, fp := range ports {"""
content = content.replace(resolution, resolution_new)
# Inject ensureConnLimiterOnNode inside loop
loop_inner = """ if limiterID != nil && speed != nil {
if err := h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed); err != nil {
// If the limiter push fails because the node is offline, skip it with a warning
if isNodeOfflineOrTimeoutError(err) {
node, _ := h.getNodeRecord(fp.NodeID)
nodeName := fmt.Sprintf("%d", fp.NodeID)
if node != nil && strings.TrimSpace(node.Name) != "" {
nodeName = strings.TrimSpace(node.Name)
}
warnings = append(warnings, fmt.Sprintf("节点 %s 不在线,已跳过下发", nodeName))
continue
}
return nil, err
}
}
node, err := h.getNodeRecord(fp.NodeID)"""
loop_inner_new = """ if limiterID != nil && speed != nil {
if err := h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed); err != nil {
// If the limiter push fails because the node is offline, skip it with a warning
if isNodeOfflineOrTimeoutError(err) {
node, _ := h.getNodeRecord(fp.NodeID)
nodeName := fmt.Sprintf("%d", fp.NodeID)
if node != nil && strings.TrimSpace(node.Name) != "" {
nodeName = strings.TrimSpace(node.Name)
}
warnings = append(warnings, fmt.Sprintf("节点 %s 不在线,已跳过下发", nodeName))
continue
}
return nil, err
}
}
if cLimiterName != "" {
if err := h.ensureConnLimiterOnNode(fp.NodeID, cLimiterName, maxConnToSet); err != nil {
warnings = append(warnings, fmt.Sprintf("节点 %d 连接限制器下发失败: %v", fp.NodeID, err))
}
}
node, err := h.getNodeRecord(fp.NodeID)"""
content = content.replace(loop_inner, loop_inner_new)
# Update buildForwardServiceConfigs call in syncForwardServicesWithWarnings
content = content.replace(
'services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, strings.TrimSpace(fp.InIP), limiterID)',
'services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, strings.TrimSpace(fp.InIP), limiterID, cLimiterName)'
)
# Update fallbackForwardPortToDefaultBind call
content = content.replace(
'warning, err = h.fallbackForwardPortToDefaultBind(forward, tunnel, node, fp, serviceBase, limiterID)',
'warning, err = h.fallbackForwardPortToDefaultBind(forward, tunnel, node, fp, serviceBase, limiterID, cLimiterName)'
)
# 5. Update fallbackForwardPortToDefaultBind declaration and logic
content = content.replace(
'func (h *Handler) fallbackForwardPortToDefaultBind(forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, fp forwardPortRecord, serviceBase string, limiterID *int64) (string, error) {',
'func (h *Handler) fallbackForwardPortToDefaultBind(forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, fp forwardPortRecord, serviceBase string, limiterID *int64, cLimiterName string) (string, error) {'
)
content = content.replace(
'defaultServices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, "", limiterID)',
'defaultServices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, "", limiterID, cLimiterName)'
)
with open('internal/http/handler/control_plane.go', 'w') as f:
f.write(content)
# Update control_plane_test.go
with open('internal/http/handler/control_plane_test.go', 'r') as f:
test_content = f.read()
test_content = re.sub(
r'buildForwardServiceConfigs\((.*?),(.*?),(.*?),(.*?),(.*?),(.*?),(.*?)\)',
r'buildForwardServiceConfigs(\1,\2,\3,\4,\5,\6,\7, "")',
test_content
)
with open('internal/http/handler/control_plane_test.go', 'w') as f:
f.write(test_content)
@@ -0,0 +1,284 @@
package contract_test
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
"go-backend/internal/auth"
"go-backend/internal/http/response"
"go-backend/internal/security"
)
func TestMaxConnLimit(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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "max-conn-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
var tunnelID int64
if err := r.DB().Raw("SELECT id FROM tunnel WHERE name = ?", "max-conn-tunnel").Scan(&tunnelID).Error; err != nil {
t.Fatalf("get tunnel ID: %v", err)
}
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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "max-conn-node", "max-conn-secret", "10.20.0.1", "10.20.0.1", "", "32000-32010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node: %v", err)
}
var nodeID int64
if err := r.DB().Raw("SELECT id FROM node WHERE name = ?", "max-conn-node").Scan(&nodeID).Error; err != nil {
t.Fatalf("get node ID: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 32001, 'round', 1, 'tls')
`, tunnelID, nodeID).Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO user_tunnel(user_id, tunnel_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(1, ?, 10, 99999, 0, 0, 1, ?, 1)
`, tunnelID, now + 365*24*3600*1000).Error; err != nil { t.Fatalf("insert user_tunnel: %v", err)
}
var commandMu sync.Mutex
receivedCommands := make([]string, 0)
var addCLimitersData json.RawMessage
var updateCLimitersData json.RawMessage
stopNode := startMockSessionForMaxConn(t, server.URL, "max-conn-secret", func(cmdType string, data json.RawMessage) (bool, string) {
commandMu.Lock()
defer commandMu.Unlock()
receivedCommands = append(receivedCommands, cmdType)
if cmdType == "AddCLimiters" {
addCLimitersData = append([]byte(nil), data...)
return true, "already exists"
}
if cmdType == "UpdateCLimiters" {
updateCLimitersData = append([]byte(nil), data...)
}
return false, ""
})
defer stopNode()
waitNodeStatus(t, r, nodeID, 1)
payload := map[string]interface{}{
"name": "max-conn-forward",
"tunnelId": tunnelID,
"remoteAddr": "1.1.1.1:443",
"strategy": "fifo",
"maxConn": 42,
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(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 create success, got code=%d msg=%s", out.Code, out.Msg)
}
var forwardID int64
if err := r.DB().Raw("SELECT id FROM forward WHERE name = ?", "max-conn-forward").Scan(&forwardID).Error; err != nil {
t.Fatalf("get forward ID: %v", err)
}
commandMu.Lock()
defer commandMu.Unlock()
hasAdd := false
hasUpdate := false
for _, cmd := range receivedCommands {
if cmd == "AddCLimiters" {
hasAdd = true
}
if cmd == "UpdateCLimiters" {
hasUpdate = true
}
}
if !hasAdd {
t.Fatalf("expected AddCLimiters to be sent, but it was not. Received: %v", receivedCommands)
}
if !hasUpdate {
t.Fatalf("expected UpdateCLimiters to be sent after AddCLimiters failed with already exists. Received: %v", receivedCommands)
}
expectedName := fmt.Sprintf("rule_conn_limit_%d", forwardID)
// verify payload for AddCLimiters
var addData map[string]interface{}
if err := json.Unmarshal(addCLimitersData, &addData); err != nil {
t.Fatalf("unmarshal AddCLimiters data: %v", err)
}
if addData["name"] != expectedName {
t.Fatalf("expected limiter name %s, got %v", expectedName, addData["name"])
}
if limits, ok := addData["limits"].([]interface{}); ok {
if len(limits) != 1 || limits[0] != "$ 42" {
t.Fatalf("expected limits to contain '$ 42', got %v", limits)
}
} else {
t.Fatalf("invalid limits type in AddCLimiters data: %v", addData)
}
// verify payload for UpdateCLimiters
var updateData map[string]interface{}
if err := json.Unmarshal(updateCLimitersData, &updateData); err != nil {
t.Fatalf("unmarshal UpdateCLimiters data: %v", err)
}
if updateData["limiter"] != expectedName {
t.Fatalf("expected update limiter name %s, got %v", expectedName, updateData["limiter"])
}
nestedData, ok := updateData["data"].(map[string]interface{})
if !ok {
t.Fatalf("expected nested 'data' in UpdateCLimiters, got %v", updateData)
}
if nestedData["name"] != expectedName {
t.Fatalf("expected nested name %s, got %v", expectedName, nestedData["name"])
}
if nestedLimits, ok := nestedData["limits"].([]interface{}); ok {
if len(nestedLimits) != 1 || nestedLimits[0] != "$ 42" {
t.Fatalf("expected nested limits to contain '$ 42', got %v", nestedLimits)
}
} else {
t.Fatalf("invalid limits type in UpdateCLimiters nested data: %v", nestedData)
}
}
func startMockSessionForMaxConn(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()
})
}
}
+5
View File
@@ -0,0 +1,5 @@
gost
gost_*
gost-*
*.sha256
*.exe
+2 -2
View File
@@ -135,7 +135,7 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
postDown = mdutil.GetStrings(md, parsing.MDKeyPostDown)
ignoreChain = mdutil.GetBool(md, parsing.MDKeyIgnoreChain)
if md.IsExists(parsing.MDKeyEnableStats) {
if md != nil && md.IsExists(parsing.MDKeyEnableStats) {
enableStats = mdutil.GetBool(md, parsing.MDKeyEnableStats)
}
@@ -157,7 +157,7 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
resetTraffic := true
if cfg.Metadata != nil {
md := metadata.NewMetadata(cfg.Metadata)
if md.IsExists(parsing.MDKeyObserverResetTraffic) {
if md != nil && md.IsExists(parsing.MDKeyObserverResetTraffic) {
resetTraffic = mdutil.GetBool(md, parsing.MDKeyObserverResetTraffic)
}
}
+4
View File
@@ -11,6 +11,7 @@ import (
"github.com/go-gost/core/logger"
md "github.com/go-gost/core/metadata"
kcp_util "github.com/go-gost/x/internal/util/kcp"
mdutil "github.com/go-gost/x/metadata/util"
"github.com/go-gost/x/registry"
"github.com/xtaci/kcp-go/v5"
"github.com/xtaci/smux"
@@ -48,6 +49,9 @@ func (d *kcpDialer) Init(md md.Metadata) (err error) {
}
d.md.config.Init()
if md != nil && md.IsExists("kcp.nc") {
d.md.config.NoCongestion = mdutil.GetInt(md, "kcp.nc")
}
return nil
}
+9
View File
@@ -73,6 +73,9 @@ func (d *kcpDialer) parseMetadata(md mdata.Metadata) (err error) {
if md.IsExists("kcp.sndwnd") {
d.md.config.SndWnd = mdutil.GetInt(md, "kcp.sndwnd")
}
if md.IsExists("kcp.sockbuf") {
d.md.config.SockBuf = mdutil.GetInt(md, "kcp.sockbuf")
}
if md.IsExists("kcp.smuxver") {
d.md.config.SmuxVer = mdutil.GetInt(md, "kcp.smuxver")
}
@@ -85,6 +88,12 @@ func (d *kcpDialer) parseMetadata(md mdata.Metadata) (err error) {
if md.IsExists("kcp.nocomp") {
d.md.config.NoComp = mdutil.GetBool(md, "kcp.nocomp")
}
if md.IsExists("kcp.datashard") {
d.md.config.DataShard = mdutil.GetInt(md, "kcp.datashard")
}
if md.IsExists("kcp.parityshard") {
d.md.config.ParityShard = mdutil.GetInt(md, "kcp.parityshard")
}
}
d.md.handshakeTimeout = mdutil.GetDuration(md, handshakeTimeout)
+4
View File
@@ -15,6 +15,7 @@ import (
limiter_wrapper "github.com/go-gost/x/limiter/traffic/wrapper"
metrics "github.com/go-gost/x/metrics/wrapper"
stats "github.com/go-gost/x/observer/stats/wrapper"
mdutil "github.com/go-gost/x/metadata/util"
"github.com/go-gost/x/registry"
"github.com/xtaci/kcp-go/v5"
"github.com/xtaci/smux"
@@ -53,6 +54,9 @@ func (l *kcpListener) Init(md md.Metadata) (err error) {
config := l.md.config
config.Init()
if md != nil && md.IsExists("kcp.nc") {
config.NoCongestion = mdutil.GetInt(md, "kcp.nc")
}
var conn net.PacketConn
if config.TCP {
+9
View File
@@ -76,6 +76,9 @@ func (l *kcpListener) parseMetadata(md mdata.Metadata) (err error) {
if md.IsExists("kcp.sndwnd") {
l.md.config.SndWnd = mdutil.GetInt(md, "kcp.sndwnd")
}
if md.IsExists("kcp.sockbuf") {
l.md.config.SockBuf = mdutil.GetInt(md, "kcp.sockbuf")
}
if md.IsExists("kcp.smuxver") {
l.md.config.SmuxVer = mdutil.GetInt(md, "kcp.smuxver")
}
@@ -88,6 +91,12 @@ func (l *kcpListener) parseMetadata(md mdata.Metadata) (err error) {
if md.IsExists("kcp.nocomp") {
l.md.config.NoComp = mdutil.GetBool(md, "kcp.nocomp")
}
if md.IsExists("kcp.datashard") {
l.md.config.DataShard = mdutil.GetInt(md, "kcp.datashard")
}
if md.IsExists("kcp.parityshard") {
l.md.config.ParityShard = mdutil.GetInt(md, "kcp.parityshard")
}
}
l.md.backlog = mdutil.GetInt(md, backlog)
+4
View File
@@ -60,6 +60,8 @@ func GetInt(md metadata.Metadata, keys ...string) (v int) {
}
case int:
v = vv
case float64:
v = int(vv)
case string:
v, _ = strconv.Atoi(vv)
}
@@ -105,6 +107,8 @@ func GetDuration(md metadata.Metadata, keys ...string) (v time.Duration) {
switch vv := md.Get(key).(type) {
case int:
v = time.Duration(vv) * time.Second
case float64:
v = time.Duration(vv) * time.Second
case string:
v, _ = time.ParseDuration(vv)
if v == 0 {
+20 -61
View File
@@ -88,56 +88,45 @@ func (m *GlobalTrafficManager) startReporting() {
// collectAndReport 收集所有服务流量并合并上报
func (m *GlobalTrafficManager) collectAndReport() {
m.mu.Lock()
// 如果没有流量,直接返回
if len(m.serviceTraffic) == 0 {
m.mu.Unlock()
return
}
// 复制当前所有流量数据(避免长时间持锁)
trafficSnapshot := make(map[string]*ServiceTraffic)
reportData := make(map[string]struct {
up int64
down int64
})
reportItems := make([]TrafficReportItem, 0, len(m.serviceTraffic))
for name, traffic := range m.serviceTraffic {
traffic.mu.Lock()
if traffic.UpBytes > 0 || traffic.DownBytes > 0 {
trafficSnapshot[name] = traffic
reportData[name] = struct {
up int64
down int64
}{
up: traffic.UpBytes,
down: traffic.DownBytes,
}
up := traffic.UpBytes
down := traffic.DownBytes
if up > 0 || down > 0 {
traffic.UpBytes = 0
traffic.DownBytes = 0
}
traffic.mu.Unlock()
if up > 0 || down > 0 {
reportItems = append(reportItems, TrafficReportItem{
N: name,
U: up,
D: down,
})
}
}
m.mu.Unlock()
// 如果没有需要上报的流量,返回
if len(reportData) == 0 {
if len(reportItems) == 0 {
return
}
// 构建上报数据数组(保持每个服务独立)
reportItems := make([]TrafficReportItem, 0, len(reportData))
var totalUp, totalDown int64
for serviceName, data := range reportData {
reportItems = append(reportItems, TrafficReportItem{
N: serviceName, // 保持服务名不变
U: data.up,
D: data.down,
})
totalUp += data.up
totalDown += data.down
for _, item := range reportItems {
totalUp += item.U
totalDown += item.D
}
// 批量发送上报请求(一次HTTP请求包含所有服务)
success, err := sendBatchTrafficReport(m.ctx, reportItems)
if err != nil {
fmt.Printf("❌ 全局流量上报失败: %v (总流量: ↑%d ↓%d, %d个服务)\n", err, totalUp, totalDown, len(reportItems))
@@ -146,36 +135,6 @@ func (m *GlobalTrafficManager) collectAndReport() {
if !success {
fmt.Printf("⚠️ 全局流量上报未成功 (总流量: ↑%d ↓%d, %d个服务)\n", totalUp, totalDown, len(reportItems))
return
}
// 上报成功,清空已上报的流量
m.clearReportedTraffic(reportData)
}
// clearReportedTraffic 清空已成功上报的流量
func (m *GlobalTrafficManager) clearReportedTraffic(reportedData map[string]struct {
up int64
down int64
}) {
m.mu.Lock()
defer m.mu.Unlock()
for serviceName, reported := range reportedData {
if traffic, exists := m.serviceTraffic[serviceName]; exists {
traffic.mu.Lock()
// 减去已上报的流量
traffic.UpBytes -= reported.up
traffic.DownBytes -= reported.down
// 如果流量归零,从map中删除该服务记录(避免内存泄漏)
if traffic.UpBytes <= 0 && traffic.DownBytes <= 0 {
traffic.mu.Unlock()
delete(m.serviceTraffic, serviceName)
} else {
traffic.mu.Unlock()
}
}
}
}
+68
View File
@@ -102,3 +102,71 @@ type updateLimiterRequest struct {
type deleteLimiterRequest struct {
Limiter string `json:"limiter"`
}
func createConnLimiter(req createLimiterRequest) error {
name := strings.TrimSpace(req.Data.Name)
if name == "" {
return errors.New("limiter name is required")
}
req.Data.Name = name
if registry.ConnLimiterRegistry().IsRegistered(name) {
return errors.New("conn limiter " + name + " already exists")
}
v := parser.ParseConnLimiter(&req.Data)
if err := registry.ConnLimiterRegistry().Register(name, v); err != nil {
return errors.New("conn limiter " + name + " already exists")
}
if c := config.Global(); c != nil {
c.CLimiters = append(c.CLimiters, &req.Data)
}
return nil
}
func updateConnLimiter(req updateLimiterRequest) error {
name := strings.TrimSpace(req.Limiter)
req.Data.Name = name
if registry.ConnLimiterRegistry().IsRegistered(name) {
registry.ConnLimiterRegistry().Unregister(name)
}
v := parser.ParseConnLimiter(&req.Data)
if err := registry.ConnLimiterRegistry().Register(name, v); err != nil {
return errors.New("conn limiter " + name + " already exists")
}
if c := config.Global(); c != nil {
for i := range c.CLimiters {
if c.CLimiters[i].Name == name {
c.CLimiters[i] = &req.Data
return nil
}
}
c.CLimiters = append(c.CLimiters, &req.Data)
}
return nil
}
func deleteConnLimiter(req deleteLimiterRequest) error {
name := strings.TrimSpace(req.Limiter)
if registry.ConnLimiterRegistry().IsRegistered(name) {
registry.ConnLimiterRegistry().Unregister(name)
}
if c := config.Global(); c != nil {
limiteres := c.CLimiters
c.CLimiters = nil
for _, s := range limiteres {
if s.Name == name {
continue
}
c.CLimiters = append(c.CLimiters, s)
}
}
return nil
}
+208
View File
@@ -836,6 +836,18 @@ func (w *WebSocketReporter) routeCommand(cmd CommandMessage) {
err = w.handleDeleteLimiter(cmd.Data)
response.Type = "DeleteLimitersResponse"
needSaveConfig = true
case "AddCLimiters":
err = w.handleAddCLimiter(cmd.Data)
response.Type = "AddCLimitersResponse"
needSaveConfig = true
case "UpdateCLimiters":
err = w.handleUpdateCLimiter(cmd.Data)
response.Type = "UpdateCLimitersResponse"
needSaveConfig = true
case "DeleteCLimiters":
err = w.handleDeleteCLimiter(cmd.Data)
response.Type = "DeleteCLimitersResponse"
needSaveConfig = true
// TCP Ping 诊断命令(只读,不需要保存配置)
case "TcpPing":
@@ -845,6 +857,13 @@ func (w *WebSocketReporter) routeCommand(cmd CommandMessage) {
response.Data = tcpPingResult
// needSaveConfig = false (默认值)
// UDP Ping 诊断命令(只读,不需要保存配置)
case "UdpPing":
var udpPingResult TcpPingResponse
udpPingResult, err = w.handleUdpPing(cmd.Data)
response.Type = "UdpPingResponse"
response.Data = udpPingResult
// Service monitor check (read-only)
case "ServiceMonitorCheck":
var checkResult ServiceMonitorCheckResult
@@ -1123,6 +1142,67 @@ func (w *WebSocketReporter) handleDeleteLimiter(data interface{}) error {
return deleteLimiter(deleteReq)
}
func (w *WebSocketReporter) handleAddCLimiter(data interface{}) error {
jsonData, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("序列化数据失败: %v", err)
}
var limiterConfig config.LimiterConfig
if err := json.Unmarshal(jsonData, &limiterConfig); err != nil {
return fmt.Errorf("解析限流器配置失败: %v", err)
}
req := createLimiterRequest{Data: limiterConfig}
return createConnLimiter(req)
}
func (w *WebSocketReporter) handleUpdateCLimiter(data interface{}) error {
jsonData, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("序列化数据失败: %v", err)
}
var updateReq struct {
Limiter string `json:"limiter"`
Data config.LimiterConfig `json:"data"`
}
if err := json.Unmarshal(jsonData, &updateReq); err != nil {
var limiterConfig config.LimiterConfig
if err := json.Unmarshal(jsonData, &limiterConfig); err != nil {
return fmt.Errorf("解析更新请求失败: %v", err)
}
updateReq.Limiter = limiterConfig.Name
updateReq.Data = limiterConfig
}
req := updateLimiterRequest{
Limiter: updateReq.Limiter,
Data: updateReq.Data,
}
return updateConnLimiter(req)
}
func (w *WebSocketReporter) handleDeleteCLimiter(data interface{}) error {
jsonData, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("序列化数据失败: %v", err)
}
var deleteReq deleteLimiterRequest
if err := json.Unmarshal(jsonData, &deleteReq); err != nil {
var limiterName string
if err := json.Unmarshal(jsonData, &limiterName); err != nil {
return fmt.Errorf("解析删除请求失败: %v", err)
}
deleteReq.Limiter = limiterName
}
return deleteConnLimiter(deleteReq)
}
// handleSetProtocol 处理设置屏蔽协议的命令
func (w *WebSocketReporter) handleSetProtocol(data interface{}) error {
jsonData, err := json.Marshal(data)
@@ -1673,6 +1753,64 @@ func (w *WebSocketReporter) handleTcpPing(data interface{}) (TcpPingResponse, er
return response, nil
}
func (w *WebSocketReporter) handleUdpPing(data interface{}) (TcpPingResponse, error) {
jsonData, err := json.Marshal(data)
if err != nil {
return TcpPingResponse{}, fmt.Errorf("序列化UDP ping数据失败: %v", err)
}
var req TcpPingRequest
if err := json.Unmarshal(jsonData, &req); err != nil {
return TcpPingResponse{}, fmt.Errorf("解析UDP ping请求失败: %v", err)
}
if net.ParseIP(req.IP) == nil && !isValidHostname(req.IP) {
return TcpPingResponse{
IP: req.IP,
Port: req.Port,
Success: false,
ErrorMessage: "无效的IP地址或主机名",
RequestId: req.RequestId,
}, nil
}
if req.Port <= 0 || req.Port > 65535 {
return TcpPingResponse{
IP: req.IP,
Port: req.Port,
Success: false,
ErrorMessage: "无效的端口号,范围应为1-65535",
RequestId: req.RequestId,
}, nil
}
if req.Count <= 0 {
req.Count = 4
}
if req.Timeout <= 0 {
req.Timeout = 5000
}
avgTime, packetLoss, err := udpPingHost(req.IP, req.Port, req.Count, req.Timeout)
response := TcpPingResponse{
IP: req.IP,
Port: req.Port,
RequestId: req.RequestId,
}
if err != nil {
response.Success = false
response.ErrorMessage = err.Error()
} else {
response.Success = true
response.AverageTime = avgTime
response.PacketLoss = packetLoss
}
return response, nil
}
// handleServiceMonitorCheck executes a service monitor check on this node.
// It always returns a result (command execution is considered successful even if the check fails).
func (w *WebSocketReporter) handleServiceMonitorCheck(data interface{}) (ServiceMonitorCheckResult, error) {
@@ -1951,6 +2089,76 @@ func tcpPingHost(ip string, port int, count int, timeoutMs int) (float64, float6
return avgTime, packetLoss, nil
}
func udpPingHost(ip string, port int, count int, timeoutMs int) (float64, float64, error) {
var totalTime float64
var successCount int
timeout := time.Duration(timeoutMs) * time.Millisecond
target := net.JoinHostPort(ip, fmt.Sprintf("%d", port))
fmt.Printf("🔍 开始UDP ping测试: %s,次数: %d,超时: %dms\n", target, count, timeoutMs)
if net.ParseIP(ip) == nil {
fmt.Printf("🔍 检测到域名,正在解析DNS...\n")
dnsStart := time.Now()
addrs, err := net.LookupHost(ip)
dnsDuration := time.Since(dnsStart)
if err != nil {
return 0, 100.0, fmt.Errorf("DNS解析失败: %v", err)
}
if len(addrs) == 0 {
return 0, 100.0, fmt.Errorf("DNS解析未返回任何IP地址")
}
fmt.Printf("✅ DNS解析完成 (%.2fms),解析到 %d 个IP: %v\n",
dnsDuration.Seconds()*1000, len(addrs), addrs)
target = net.JoinHostPort(addrs[0], fmt.Sprintf("%d", port))
fmt.Printf("🎯 使用IP地址进行测试: %s\n", target)
} else {
fmt.Printf("🎯 使用IP地址进行测试: %s\n", target)
}
addr, err := net.ResolveUDPAddr("udp", target)
if err != nil {
return 0, 100.0, fmt.Errorf("解析UDP地址失败: %v", err)
}
for i := 0; i < count; i++ {
start := time.Now()
conn, err := net.DialTimeout("udp", addr.String(), timeout)
elapsed := time.Since(start)
if err != nil {
fmt.Printf(" 第%d次UDP连接失败: %v (%.2fms)\n", i+1, err, elapsed.Seconds()*1000)
} else {
fmt.Printf(" 第%d次UDP连接成功: %.2fms\n", i+1, elapsed.Seconds()*1000)
conn.Close()
totalTime += elapsed.Seconds() * 1000
successCount++
}
if i < count-1 {
time.Sleep(100 * time.Millisecond)
}
}
if successCount == 0 {
return 0, 100.0, fmt.Errorf("所有UDP连接尝试都失败")
}
avgTime := totalTime / float64(successCount)
packetLoss := float64(count-successCount) / float64(count) * 100
fmt.Printf("✅ UDP ping完成: 平均连接时间 %.2fms,失败率 %.1f%%\n", avgTime, packetLoss)
return avgTime, packetLoss, nil
}
// isValidHostname 验证主机名格式
func isValidHostname(hostname string) bool {
if len(hostname) == 0 || len(hostname) > 253 {
+1 -1
View File
@@ -82,7 +82,7 @@ python with_server.py --backend-port 8080 --frontend-port 5173 -- pytest -v
# Custom server commands
python with_server.py \
--server "make run" --port 6365 --cwd go-backend \
--server "npm run dev" --port 3000 --cwd vite-frontend \
--server "pnpm run dev" --port 3000 --cwd vite-frontend \
-- pytest -v
```
+2 -2
View File
@@ -145,7 +145,7 @@ Examples:
# Custom server configuration
python with_server.py \\
--server "make run" --port 6365 --cwd go-backend \\
--server "npm run dev" --port 3000 --cwd vite-frontend \\
--server "pnpm run dev" --port 3000 --cwd vite-frontend \\
-- pytest -v
# Use custom backend port
@@ -302,7 +302,7 @@ def build_servers(args) -> list[ServerProcess]:
servers.append(
ServerProcess(
command="npm run dev",
command="pnpm run dev",
port=args.frontend_port,
cwd=root / args.frontend_cwd,
env=frontend_env,
+2 -1
View File
@@ -24,7 +24,8 @@ dist-ssr
*.sw?
pnpm-lock.yaml
yarn.lock
package-lock.json
yarn.lock
package-lock.json
bun.lockb
+4 -4
View File
@@ -33,8 +33,8 @@ React dashboard for FLVX. rolldown-vite + TypeScript + Tailwind v4 + shadcn/radi
## Commands
```bash
npm install --legacy-peer-deps
npm run dev # http://0.0.0.0:3000
npm run build # tsc && vite build
npm run lint # eslint --fix
pnpm install
pnpm run dev # http://0.0.0.0:3000
pnpm run build # tsc && vite build
pnpm run lint # eslint --fix
```
+3 -3
View File
@@ -3,11 +3,11 @@ FROM node:20.19.0 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install --legacy-peer-deps
COPY package.json pnpm-lock.yaml* ./
RUN corepack enable pnpm && pnpm install --frozen-lockfile
COPY . .
RUN npm run build
RUN pnpm run build
# 生产阶段
FROM nginx:stable-alpine AS production-stage
+4 -4
View File
@@ -23,16 +23,16 @@ git clone https://github.com/frontio-ai/vite-template.git
### Install dependencies
You can use one of them `npm`, `yarn`, `pnpm`, `bun`, Example using `npm`:
You can use one of them `npm`, `yarn`, `pnpm`, `bun`, Example using `pnpm`:
```bash
npm install
pnpm install
```
### Run the development server
### Start development server
```bash
npm run dev
pnpm run dev
```
### Setup pnpm (optional)
+2 -1
View File
@@ -52,7 +52,8 @@
"tailwind-merge": "^2.5.5",
"tailwind-variants": "1.0.0",
"tailwindcss": "4.1.11",
"tailwindcss-animate": "^1.0.7"
"tailwindcss-animate": "^1.0.7",
"workbox-window": "^7.4.0"
},
"devDependencies": {
"@eslint/compat": "1.2.8",
+9002
View File
File diff suppressed because it is too large Load Diff
+53 -10
View File
@@ -1,5 +1,11 @@
import { Route, Routes, useLocation, useNavigate, Navigate } from "react-router-dom";
import { useEffect } from "react";
import {
Route,
Routes,
useLocation,
useNavigate,
Navigate,
} from "react-router-dom";
import { useEffect, useState } from "react";
import { AnimatePresence } from "framer-motion";
import IndexPage from "@/pages/index";
@@ -21,8 +27,8 @@ import H5SimpleLayout from "@/layouts/h5-simple";
import { isLoggedIn } from "@/utils/auth";
import { siteConfig, updateSiteConfig } from "@/config/site";
import { useH5Mode } from "@/hooks/useH5Mode";
import { SESSION_UPDATED_EVENT } from "@/utils/session";
// 简化的路由保护组件 - 使用 React Router 导航避免循环
const ProtectedRoute = ({
children,
useSimpleLayout = false,
@@ -32,13 +38,30 @@ const ProtectedRoute = ({
useSimpleLayout?: boolean;
skipLayout?: boolean;
}) => {
const authenticated = isLoggedIn();
const isH5 = useH5Mode();
const navigate = useNavigate();
const [authenticated, setAuthenticated] = useState(() => isLoggedIn());
useEffect(() => {
const handleSessionChange = () => {
const loggedIn = isLoggedIn();
setAuthenticated(loggedIn);
if (!loggedIn) {
navigate("/", { replace: true });
}
};
window.addEventListener(SESSION_UPDATED_EVENT, handleSessionChange);
return () => {
window.removeEventListener(SESSION_UPDATED_EVENT, handleSessionChange);
};
}, [navigate]);
useEffect(() => {
if (!authenticated) {
// 使用 React Router 导航,避免无限跳转
navigate("/", { replace: true });
}
}, [authenticated, navigate]);
@@ -85,21 +108,39 @@ function App() {
useEffect(() => {
const updateBg = () => {
const customBg = siteConfig.app_bg_image;
if (customBg) {
if (customBg === "theme") {
document.documentElement.style.removeProperty("--custom-bg-image");
document.documentElement.style.removeProperty("--custom-bg-color");
document.documentElement.classList.add("has-theme-bg");
document.documentElement.classList.remove("has-custom-bg");
} else if (customBg.startsWith("http") || customBg.startsWith("data:") || customBg.startsWith("/") || customBg.startsWith("blob:")) {
document.documentElement.style.setProperty("--custom-bg-image", `url(${customBg})`);
document.documentElement.style.setProperty("--custom-bg-color", "transparent");
} else if (
customBg.startsWith("http") ||
customBg.startsWith("data:") ||
customBg.startsWith("/") ||
customBg.startsWith("blob:")
) {
document.documentElement.style.setProperty(
"--custom-bg-image",
`url(${customBg})`,
);
document.documentElement.style.setProperty(
"--custom-bg-color",
"transparent",
);
document.documentElement.classList.add("has-custom-bg");
document.documentElement.classList.remove("has-theme-bg");
} else {
// Assume solid color like "#ffffff", "white", etc.
document.documentElement.style.setProperty("--custom-bg-image", "none");
document.documentElement.style.setProperty("--custom-bg-color", customBg);
document.documentElement.style.setProperty(
"--custom-bg-image",
"none",
);
document.documentElement.style.setProperty(
"--custom-bg-color",
customBg,
);
document.documentElement.classList.add("has-custom-bg");
document.documentElement.classList.remove("has-theme-bg");
}
@@ -110,8 +151,10 @@ function App() {
document.documentElement.classList.remove("has-theme-bg");
}
};
updateBg();
window.addEventListener("site-config-updated", updateBg);
return () => {
window.removeEventListener("site-config-updated", updateBg);
};
+8 -3
View File
@@ -149,9 +149,12 @@ export const deleteTunnelWithForwards = (data: {
data,
);
export const previewBatchTunnelDelete = (ids: number[]) =>
Network.post<TunnelBatchDeletePreviewApiData>("/tunnel/batch-delete-preview", {
ids,
});
Network.post<TunnelBatchDeletePreviewApiData>(
"/tunnel/batch-delete-preview",
{
ids,
},
);
export const batchDeleteTunnelsWithForwards = (data: {
ids: number[];
action: "replace" | "delete_forwards";
@@ -496,12 +499,14 @@ export const getServiceMonitorResults = (
options?: { limit?: number; start?: number; end?: number },
) => {
const params: Record<string, string> = {};
if (options?.start != null && options?.end != null) {
params.start = String(options.start);
params.end = String(options.end);
} else if (options?.limit != null) {
params.limit = String(options.limit);
}
return Network.get<ServiceMonitorResultApiItem[]>(
`/monitor/services/${monitorId}/results`,
params,
+4
View File
@@ -20,6 +20,7 @@ export interface UserApiItem {
num: number;
expTime?: number;
flowResetTime?: number;
maxConn?: number;
inFlow?: number;
outFlow?: number;
dailyQuotaGB?: number;
@@ -200,6 +201,7 @@ export interface UserPackageInfoApiData {
num: number;
expTime?: string;
flowResetTime?: number;
maxConn?: number;
[key: string]: unknown;
};
tunnelPermissions: UserTunnelPermissionApiItem[];
@@ -276,6 +278,7 @@ export interface UserMutationPayload {
num?: number;
expTime?: number | string;
flowResetTime?: number;
maxConn?: number;
dailyQuotaGB?: number;
monthlyQuotaGB?: number;
tunnelFlow?: number;
@@ -338,6 +341,7 @@ export interface UserTunnelAssignPayload {
num?: number;
expTime?: number;
flowResetTime?: number;
maxConn?: number;
status?: number;
speedId?: number | null;
tunnels?: Array<{ tunnelId: number; speedId?: number | null }>;
File diff suppressed because one or more lines are too long
@@ -7,13 +7,14 @@
* • "Reset to default" option
*/
import type { ThemeMode } from "@/themes/registry";
import React from "react";
import toast from "react-hot-toast";
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import { useThemeContext } from "@/themes/context";
import type { ThemeMode } from "@/themes/registry";
// ─── Constants ──────────────────────────────────────────────────────────────
@@ -39,12 +40,14 @@ export const ThemeSettings: React.FC = () => {
const handleModeChange = (m: ThemeMode) => {
setMode(m);
const label = m === "light" ? "亮色" : m === "dark" ? "暗色" : "跟随系统";
toast.success(`已切换为${label}模式`);
};
const handleThemeSelect = (id: string) => {
switchTheme(id);
const theme = themes.find((t) => t.id === id);
toast.success(`已切换主题「${theme?.name ?? id}」`);
};
@@ -125,7 +128,13 @@ export const ThemeSettings: React.FC = () => {
<div className="flex-1" style={{ background: secondary }} />
<div className="flex-1" style={{ background: success }} />
<div className="flex-1" style={{ background: danger }} />
<div className="flex-1" style={{ background: bg, borderLeft: "1px solid rgba(0,0,0,0.06)" }} />
<div
className="flex-1"
style={{
background: bg,
borderLeft: "1px solid rgba(0,0,0,0.06)",
}}
/>
</div>
{/* Info */}
+8 -9
View File
@@ -1,4 +1,5 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, style, ...props }: React.ComponentProps<"div">) {
@@ -7,16 +8,18 @@ function Card({ className, style, ...props }: React.ComponentProps<"div">) {
className={cn(
"relative flex flex-col transition-all duration-300",
"rounded-[24px] text-card-foreground shadow-[0_12px_40px_rgba(0,0,0,0.12)] dark:shadow-[0_12px_40px_rgba(0,0,0,0.4)]",
className
className,
)}
data-slot="card"
style={{
...style,
background: "linear-gradient(135deg, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0.1) 40%, rgba(255,255,255,0.05) 60%, rgba(255,255,255,0.2) 100%)",
boxShadow: "inset 0 1px 1px rgba(255,255,255,0.8), inset 0 0 0 1px rgba(255,255,255,0.3), inset 0 -1px 1px rgba(0,0,0,0.1), 0 12px 40px rgba(0,0,0,0.12)",
background:
"linear-gradient(135deg, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0.1) 40%, rgba(255,255,255,0.05) 60%, rgba(255,255,255,0.2) 100%)",
boxShadow:
"inset 0 1px 1px rgba(255,255,255,0.8), inset 0 0 0 1px rgba(255,255,255,0.3), inset 0 -1px 1px rgba(0,0,0,0.1), 0 12px 40px rgba(0,0,0,0.12)",
backdropFilter: "blur(24px) saturate(180%)",
WebkitBackdropFilter: "blur(24px) saturate(180%)",
}}
data-slot="card"
{...props}
/>
);
@@ -63,11 +66,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"p">) {
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("p-6", className)}
data-slot="card-content"
{...props}
/>
<div className={cn("p-6", className)} data-slot="card-content" {...props} />
);
}
+5 -3
View File
@@ -62,13 +62,15 @@ function DialogContent({
"rounded-[24px] text-foreground shadow-[0_20px_60px_rgba(0,0,0,0.2)] dark:shadow-[0_20px_60px_rgba(0,0,0,0.5)] border border-white/80 dark:border-white/10",
className,
)}
data-slot="dialog-content"
style={{
background: "linear-gradient(135deg, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0.1) 40%, rgba(255,255,255,0.05) 60%, rgba(255,255,255,0.2) 100%)",
boxShadow: "inset 0 1px 1px rgba(255,255,255,0.8), inset 0 0 0 1px rgba(255,255,255,0.3), inset 0 -1px 1px rgba(0,0,0,0.1), 0 20px 60px rgba(0,0,0,0.2)",
background:
"linear-gradient(135deg, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0.1) 40%, rgba(255,255,255,0.05) 60%, rgba(255,255,255,0.2) 100%)",
boxShadow:
"inset 0 1px 1px rgba(255,255,255,0.8), inset 0 0 0 1px rgba(255,255,255,0.3), inset 0 -1px 1px rgba(0,0,0,0.1), 0 20px 60px rgba(0,0,0,0.2)",
backdropFilter: "blur(24px) saturate(180%)",
WebkitBackdropFilter: "blur(24px) saturate(180%)",
}}
data-slot="dialog-content"
{...props}
>
<div className="flex flex-col flex-1 min-h-0 gap-4 p-6 rounded-[inherit] overflow-hidden bg-white/40 dark:bg-zinc-900/40 w-full h-full relative z-10 pointer-events-auto">
@@ -85,7 +85,7 @@ function DropdownMenuSubContent({
return (
<DropdownMenuPrimitive.SubContent
className={cn(
"z-50 min-w-32 overflow-hidden rounded-md border border-default-200 bg-white p-1 text-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=top]:slide-in-from-bottom-2 data-[side=bottom]:slide-in-from-top-2 dark:bg-default-50",
"z-50 min-w-32 overflow-y-auto rounded-md border border-default-200 bg-white p-1.5 text-foreground shadow-lg max-h-[--radix-dropdown-menu-content-available-height] [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=top]:slide-in-from-bottom-2 data-[side=bottom]:slide-in-from-top-2 dark:bg-default-50",
className,
)}
data-slot="dropdown-menu-sub-content"
@@ -103,7 +103,7 @@ function DropdownMenuContent({
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
className={cn(
"z-50 min-w-32 overflow-hidden rounded-md border border-default-200 bg-white p-1 text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=top]:slide-in-from-bottom-2 data-[side=bottom]:slide-in-from-top-2 dark:bg-default-50",
"z-50 min-w-32 overflow-y-auto rounded-md border border-default-200 bg-white p-1.5 text-foreground shadow-md max-h-[--radix-dropdown-menu-content-available-height] [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=top]:slide-in-from-bottom-2 data-[side=bottom]:slide-in-from-top-2 dark:bg-default-50",
className,
)}
data-slot="dropdown-menu-content"
+4 -2
View File
@@ -32,8 +32,10 @@ const getInitialConfig = () => {
localStorage.getItem(CACHE_PREFIX + "app_favicon") || "";
const cachedAppBgImage =
localStorage.getItem(CACHE_PREFIX + "app_bg_image") || "";
const isCommercial = localStorage.getItem(CACHE_PREFIX + "is_commercial") === "true";
const hideFooterBrand = localStorage.getItem(CACHE_PREFIX + "hide_footer_brand") === "true";
const isCommercial =
localStorage.getItem(CACHE_PREFIX + "is_commercial") === "true";
const hideFooterBrand =
localStorage.getItem(CACHE_PREFIX + "hide_footer_brand") === "true";
if (cachedAppName) {
return {
+12 -8
View File
@@ -198,19 +198,23 @@ export default function AdminLayout({
if (adminFlag) {
setMonitorAllowed(true);
setMonitorAccessReason(null);
return;
}
let cancelled = false;
(async () => {
try {
const res = await getMonitorAccess();
if (cancelled) return;
if (res.code === 0 && res.data) {
setMonitorAllowed(Boolean(res.data.allowed));
setMonitorAccessReason(
res.data.allowed ? null : (res.data.reason || null),
res.data.allowed ? null : res.data.reason || null,
);
return;
}
// Fail open to preserve legacy navigation behavior.
@@ -376,7 +380,7 @@ export default function AdminLayout({
${isMobile ? "fixed h-screen top-0 left-0 rounded-r-3xl" : "relative h-full rounded-3xl"}
${isMobile && !mobileMenuVisible ? "-translate-x-full" : "translate-x-0"}
${isMobile ? "w-64" : isCollapsed ? "w-20" : "w-[260px]"}
bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl
bg-white/70 dark:bg-zinc-900/70 backdrop-blur-3xl
shadow-[0_10px_30px_rgba(0,0,0,0.1)]
border border-white/80 dark:border-white/10
z-50
@@ -398,7 +402,7 @@ export default function AdminLayout({
</div>
{/* 菜单导航 */}
<nav className="flex-1 px-4 overflow-y-auto overflow-x-hidden [scrollbar-width:none]">
<nav className="flex-1 px-4 overflow-y-auto overflow-x-hidden scrollbar-hide">
<ul className="space-y-2">
{filteredMenuItems.map((item) => {
const isActive = location.pathname === item.path;
@@ -408,6 +412,7 @@ export default function AdminLayout({
return (
<li key={item.path}>
<motion.button
aria-disabled={isMonitorBlocked}
className={`
w-full flex items-center p-3 rounded-2xl text-left
relative min-h-[48px] overflow-hidden transition-colors
@@ -415,12 +420,11 @@ export default function AdminLayout({
${
isActive
? "text-primary dark:text-primary-400 font-semibold"
: isMonitorBlocked
? "text-gray-500 dark:text-gray-400 font-medium"
: "text-gray-600 dark:text-gray-300 font-medium"
: isMonitorBlocked
? "text-gray-500 dark:text-gray-400 font-medium"
: "text-gray-600 dark:text-gray-300 font-medium"
}
`}
aria-disabled={isMonitorBlocked}
title={
isCollapsed
? isMonitorBlocked
@@ -551,7 +555,7 @@ export default function AdminLayout({
)}
{/* 主内容 */}
<main className="flex-1 overflow-y-auto [scrollbar-width:none]">
<main className="flex-1 overflow-y-auto scrollbar-hide">
<AnimatePresence mode="wait">
<motion.div
key={location.pathname}
+4 -1
View File
@@ -114,15 +114,18 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
}
let cancelled = false;
(async () => {
try {
const res = await getMonitorAccess();
if (cancelled) return;
if (res.code === 0 && res.data) {
setMonitorAllowed(Boolean(res.data.allowed));
setMonitorAccessReason(
res.data.allowed ? null : (res.data.reason || null),
res.data.allowed ? null : res.data.reason || null,
);
return;
}
setMonitorAllowed(true);
+4 -2
View File
@@ -13,7 +13,9 @@ const updateSW = registerSW({
toast(
(t) => (
<div className="flex flex-col gap-3">
<span className="text-sm font-medium text-foreground">发现新版本,是否立即刷新以应用更新?</span>
<span className="text-sm font-medium text-foreground">
发现新版本,是否立即刷新以应用更新?
</span>
<div className="flex gap-2 justify-end">
<button
className="px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground hover:bg-primary/90 rounded-md transition-colors"
@@ -33,7 +35,7 @@ const updateSW = registerSW({
</div>
</div>
),
{ duration: Infinity, position: "bottom-right" }
{ duration: Infinity, position: "bottom-right" },
);
},
});
+85 -37
View File
@@ -89,7 +89,8 @@ const CONFIG_ITEMS: ConfigItem[] = [
{
key: "app_bg_image",
label: "自定义背景",
description: "上传自定义背景图片(建议使用深色/浅色均可看清的图片,或使用半透明模糊效果)",
description:
"上传自定义背景图片(建议使用深色/浅色均可看清的图片,或使用半透明模糊效果)",
type: "bg_image",
},
{
@@ -141,7 +142,8 @@ const CONFIG_ITEMS: ConfigItem[] = [
{
key: "monitor_tunnel_quality_enabled",
label: "实时隧道质量检测",
description: "关闭后,前端停止自动刷新,后端停止实时隧道质量探测(全局配置)",
description:
"关闭后,前端停止自动刷新,后端停止实时隧道质量探测(全局配置)",
type: "switch",
},
{
@@ -388,11 +390,13 @@ export default function ConfigPage() {
const handleActivateLicense = async () => {
if (!licenseKeyInput.trim()) {
toast.error("请输入有效的商业授权码");
return;
}
setActivatingLicense(true);
try {
const res = await activateLicense(licenseKeyInput.trim());
if (res.code === 0) {
toast.success("商业版授权激活成功!");
setLicenseKeyInput("");
@@ -479,7 +483,9 @@ export default function ConfigPage() {
if (changedKeys.includes("monitor_tunnel_quality_enabled")) {
window.dispatchEvent(
new CustomEvent("monitorTunnelQualityEnabledChanged", {
detail: { enabled: configs["monitor_tunnel_quality_enabled"] === "true" },
detail: {
enabled: configs["monitor_tunnel_quality_enabled"] === "true",
},
}),
);
}
@@ -496,7 +502,11 @@ export default function ConfigPage() {
// 检查配置项是否应该显示(依赖检查)
const shouldShowItem = (item: ConfigItem): boolean => {
// 隐藏商业版专属的设置项,它们会在专门的卡片中展示
if (["app_name", "app_logo", "app_favicon", "hide_footer_brand"].includes(item.key)) {
if (
["app_name", "app_logo", "app_favicon", "hide_footer_brand"].includes(
item.key,
)
) {
return false;
}
@@ -555,12 +565,16 @@ export default function ConfigPage() {
}
};
const handleBgImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const handleBgImageUpload = async (
e: React.ChangeEvent<HTMLInputElement>,
) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("只能上传图片文件");
return;
}
@@ -568,8 +582,10 @@ export default function ConfigPage() {
try {
const compressedImage = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement("canvas");
let width = img.width;
@@ -592,14 +608,17 @@ export default function ConfigPage() {
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) {
reject(new Error("Canvas context is null"));
return;
}
ctx.drawImage(img, 0, 0, width, height);
// Output as webp for better compression
const dataUrl = canvas.toDataURL("image/webp", 0.8);
resolve(dataUrl);
};
img.onerror = () => reject(new Error("图片加载失败"));
@@ -621,62 +640,66 @@ export default function ConfigPage() {
const renderBgImageUploader = () => {
const bgImage = configs["app_bg_image"] || "";
const isImage = bgImage.startsWith("http") || bgImage.startsWith("data:") || bgImage.startsWith("/") || bgImage.startsWith("blob:");
const isImage =
bgImage.startsWith("http") ||
bgImage.startsWith("data:") ||
bgImage.startsWith("/") ||
bgImage.startsWith("blob:");
const isTheme = bgImage === "theme";
const isSolidColor = bgImage && !isImage && !isTheme;
return (
<div className="flex flex-col gap-4 w-full">
<div className="flex flex-wrap items-center gap-4">
<input
type="file"
accept="image/*"
ref={bgImageFileInputRef}
accept="image/*"
className="hidden"
type="file"
onChange={handleBgImageUpload}
/>
<Button
color="primary"
isLoading={bgImageUploading}
variant="flat"
onPress={() => bgImageFileInputRef.current?.click()}
isLoading={bgImageUploading}
>
上传图片
</Button>
<Button
color="secondary"
isDisabled={bgImageUploading || isTheme}
variant="flat"
onPress={() => handleConfigChange("app_bg_image", "theme")}
isDisabled={bgImageUploading || isTheme}
>
自适应纯色 (跟随深色模式)
</Button>
<Button
color="default"
isDisabled={bgImageUploading || bgImage === "#ffffff"}
variant="flat"
onPress={() => handleConfigChange("app_bg_image", "#ffffff")}
isDisabled={bgImageUploading || bgImage === "#ffffff"}
>
白色纯色
</Button>
{bgImage && (
<Button
color="danger"
isDisabled={bgImageUploading}
variant="flat"
onPress={() => handleConfigChange("app_bg_image", "")}
isDisabled={bgImageUploading}
>
恢复默认
</Button>
)}
</div>
{bgImage && isImage && (
<div className="relative rounded-xl overflow-hidden border border-divider">
<img
src={bgImage}
alt="背景预览"
className="w-full max-h-48 object-cover opacity-80"
src={bgImage}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent flex items-end p-4">
<span className="text-white text-sm font-medium">预览效果</span>
@@ -686,13 +709,15 @@ export default function ConfigPage() {
{bgImage && isTheme && (
<div className="relative rounded-xl border border-divider bg-background h-32 flex items-center justify-center">
<span className="text-foreground text-sm font-medium">当前为自适应纯色背景</span>
<span className="text-foreground text-sm font-medium">
当前为自适应纯色背景
</span>
</div>
)}
{bgImage && isSolidColor && (
<div
className="relative rounded-xl border border-divider h-32 flex items-center justify-center"
<div
className="relative rounded-xl border border-divider h-32 flex items-center justify-center"
style={{ backgroundColor: bgImage }}
>
<span className="text-gray-500 bg-white/80 dark:bg-black/80 px-2 py-1 rounded text-sm font-medium border border-gray-200 dark:border-gray-800 shadow-sm">
@@ -811,8 +836,8 @@ export default function ConfigPage() {
<div className="flex flex-wrap items-center gap-2">
<Button
color="primary"
isLoading={uploading}
isDisabled={isCommercialDisabled}
isLoading={uploading}
size="sm"
variant="flat"
onPress={() => triggerBrandFilePicker(key)}
@@ -853,7 +878,10 @@ export default function ConfigPage() {
const renderConfigItem = (item: ConfigItem) => {
const isChanged =
hasChanges && configs[item.key] !== originalConfigs[item.key];
const isCommercialDisabled = ["app_name", "app_logo", "app_favicon", "hide_footer_brand"].includes(item.key) && configs.is_commercial !== "true";
const isCommercialDisabled =
["app_name", "app_logo", "app_favicon", "hide_footer_brand"].includes(
item.key,
) && configs.is_commercial !== "true";
switch (item.type) {
case "bg_image":
@@ -872,13 +900,15 @@ export default function ConfigPage() {
? "border-warning-300 data-[hover=true]:border-warning-400"
: "",
}}
description={
isCommercialDisabled ? "需商业版授权才能修改此项" : undefined
}
isDisabled={isCommercialDisabled}
placeholder={item.placeholder}
size="md"
value={configs[item.key] || ""}
variant="bordered"
onChange={(e) => handleConfigChange(item.key, e.target.value)}
isDisabled={isCommercialDisabled}
description={isCommercialDisabled ? "需商业版授权才能修改此项" : undefined}
/>
);
@@ -888,12 +918,12 @@ export default function ConfigPage() {
classNames={{
wrapper: isChanged ? "border-warning-300" : "",
}}
isDisabled={isCommercialDisabled}
isSelected={configs[item.key] === "true"}
size="sm"
onValueChange={(checked) =>
handleConfigChange(item.key, checked ? "true" : "false")
}
isDisabled={isCommercialDisabled}
>
<span className="text-sm text-gray-700 dark:text-gray-300">
{configs[item.key] === "true" ? "已启用" : "已禁用"}
@@ -1126,7 +1156,8 @@ export default function ConfigPage() {
<div>
<h2 className="text-xl font-semibold">商业版授权</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
激活商业版授权以解锁自定义品牌功能(替换 Logo、应用名称,移除底部版权信息等)
激活商业版授权以解锁自定义品牌功能(替换
Logo、应用名称,移除底部版权信息等)
</p>
</div>
</div>
@@ -1135,30 +1166,47 @@ export default function ConfigPage() {
<CardBody className="pt-8">
<div className="flex items-end gap-3 max-w-lg mb-6">
<Input
description={
configs.is_commercial === "true"
? configs.license_expiry && configs.license_expiry !== "never"
? `已激活商业版授权 (有效期至: ${new Date(configs.license_expiry).toLocaleDateString()})`
: "已激活商业版授权 (永久有效)"
: "需商业授权才能修改站名、图标并隐藏页脚品牌"
}
isDisabled={configs.is_commercial === "true"}
label="授权激活码"
placeholder="请输入 FLVX- 开头的商业授权码"
value={licenseKeyInput}
variant="bordered"
onChange={(e) => setLicenseKeyInput(e.target.value)}
isDisabled={configs.is_commercial === "true"}
description={configs.is_commercial === "true" ? (configs.license_expiry && configs.license_expiry !== "never" ? `已激活商业版授权 (有效期至: ${new Date(configs.license_expiry).toLocaleDateString()})` : "已激活商业版授权 (永久有效)") : "需商业授权才能修改站名、图标并隐藏页脚品牌"}
/>
<Button
color="primary"
className="mb-6"
isDisabled={configs.is_commercial === "true" || !licenseKeyInput.trim()}
color="primary"
isDisabled={
configs.is_commercial === "true" || !licenseKeyInput.trim()
}
isLoading={activatingLicense}
onPress={handleActivateLicense}
>
{configs.is_commercial === "true" ? "已授权" : "激活授权"}
</Button>
</div>
{configs.is_commercial === "true" && (
<div className="space-y-6">
<Divider className="my-2" />
<h3 className="text-md font-medium text-default-700">白标与品牌设置</h3>
{CONFIG_ITEMS.filter(item => ["app_name", "app_logo", "app_favicon", "hide_footer_brand"].includes(item.key)).map((item, index) => (
<h3 className="text-md font-medium text-default-700">
白标与品牌设置
</h3>
{CONFIG_ITEMS.filter((item) =>
[
"app_name",
"app_logo",
"app_favicon",
"hide_footer_brand",
].includes(item.key),
).map((item, index) => (
<div key={item.key}>
<div className="grid grid-cols-1 gap-6 md:grid-cols-[1fr_2fr]">
<div className="space-y-1">
@@ -1511,18 +1559,18 @@ export default function ConfigPage() {
<AnimatePresence>
{hasChanges && (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
transition={{ type: "spring", damping: 20, stiffness: 300 }}
className="fixed bottom-6 right-6 z-50"
exit={{ y: 100, opacity: 0 }}
initial={{ y: 100, opacity: 0 }}
transition={{ type: "spring", damping: 20, stiffness: 300 }}
>
<Button
isIconOnly
color="primary"
size="lg"
className="w-12 h-12 rounded-full shadow-lg"
color="primary"
isLoading={saving}
size="lg"
onPress={handleSave}
>
{!saving && <SaveIcon className="w-5 h-5" />}
+36 -7
View File
@@ -651,25 +651,44 @@ export default function DashboardPage() {
{/* 顶部个人状态和下拉菜单 */}
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl lg:text-3xl font-bold text-foreground">Good morning, {username}</h1>
<p className="text-sm text-default-500 mt-1">Here's what's happening with your network today.</p>
<h1 className="text-2xl lg:text-3xl font-bold text-foreground">
Good morning, {username}
</h1>
<p className="text-sm text-default-500 mt-1">
Here&apos;s what&apos;s happening with your network today.
</p>
</div>
<div className="flex items-center gap-3">
<Dropdown placement="bottom-end">
<DropdownTrigger>
<Button
className="rounded-full w-10 h-10 min-w-0 bg-primary text-white font-bold text-sm shadow-[0_4px_12px_rgba(0,122,255,0.3)]"
isIconOnly
className="rounded-full w-10 h-10 min-w-0 bg-primary text-white font-bold text-sm shadow-[0_4px_12px_rgba(0,122,255,0.3)]"
variant="solid"
>
{username.slice(0, 2).toUpperCase()}
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="用户菜单" className="bg-white/80 dark:bg-zinc-900/80 backdrop-blur-xl border border-white/50 dark:border-white/10 rounded-2xl shadow-[0_10px_30px_rgba(0,0,0,0.1)]">
<DropdownMenu
aria-label="用户菜单"
className="bg-white/80 dark:bg-zinc-900/80 backdrop-blur-xl border border-white/50 dark:border-white/10 rounded-2xl shadow-[0_10px_30px_rgba(0,0,0,0.1)]"
>
<DropdownItem
key="profile"
startContent={
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
}
onPress={() => navigate("/profile")}
>
@@ -680,7 +699,17 @@ export default function DashboardPage() {
className="text-danger"
color="danger"
startContent={
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z" clipRule="evenodd" /></svg>
<svg
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z"
fillRule="evenodd"
/>
</svg>
}
onPress={handleLogout}
>
@@ -1176,7 +1205,7 @@ export default function DashboardPage() {
</Button>
</div>
<div className="space-y-2 max-h-60 overflow-y-auto">
<div className="space-y-2 max-h-60 overflow-y-auto scrollbar-hide">
{addressList.map((item) => (
<div
key={item.id}
@@ -1,4 +1,8 @@
import type { AnnouncementData } from "@/api";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Button } from "@/shadcn-bridge/heroui/button";
import {
Modal,
@@ -7,8 +11,6 @@ import {
ModalFooter,
ModalHeader,
} from "@/shadcn-bridge/heroui/modal";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
interface AnnouncementModalProps {
announcement: AnnouncementData;
@@ -24,7 +26,11 @@ export const AnnouncementModal = ({
onDontShowAgain,
}: AnnouncementModalProps) => {
return (
<Modal isOpen={isOpen} onOpenChange={(open) => !open && onClose()} size="2xl">
<Modal
isOpen={isOpen}
size="2xl"
onOpenChange={(open) => !open && onClose()}
>
<ModalContent>
<ModalHeader className="flex flex-col gap-1">平台公告</ModalHeader>
<ModalBody>
@@ -50,7 +50,10 @@ export const FlowChartCard = ({
) : (
<div className="h-64 lg:h-80 w-full">
<ResponsiveContainer height="100%" width="100%">
<LineChart data={chartData} margin={{ top: 20, right: 0, left: -20, bottom: 0 }}>
<LineChart
data={chartData}
margin={{ top: 20, right: 0, left: -20, bottom: 0 }}
>
<XAxis
axisLine={false}
dataKey="time"
@@ -106,11 +109,7 @@ export const FlowChartCard = ({
}}
cursor={{ fill: "rgba(0, 122, 255, 0.05)" }}
/>
<Line
dataKey="flow"
stroke="#007aff"
type="monotone"
/>
<Line dataKey="flow" stroke="#007aff" type="monotone" />
</LineChart>
</ResponsiveContainer>
</div>
@@ -25,9 +25,7 @@ export const MetricCard = ({
<p className="text-sm font-medium text-default-600 truncate">
{title}
</p>
<div
className={`p-2 rounded-xl flex-shrink-0 ${iconClassName}`}
>
<div className={`p-2 rounded-xl flex-shrink-0 ${iconClassName}`}>
{icon}
</div>
</div>
@@ -35,9 +33,7 @@ export const MetricCard = ({
{value}
</p>
</div>
<div className="mt-auto pt-4">
{bottomContent}
</div>
<div className="mt-auto pt-4">{bottomContent}</div>
</CardBody>
</Card>
);
@@ -349,17 +349,22 @@ export const useDashboardData = (): DashboardDataState => {
if (res.code === 0 && res.data && res.data.enabled === 1) {
setAnnouncement(res.data);
try {
const storedTimeStr = localStorage.getItem("flvx_announcement_seen_time");
const storedTimeStr = localStorage.getItem(
"flvx_announcement_seen_time",
);
const storedTime = storedTimeStr ? parseInt(storedTimeStr, 10) : 0;
const updateTime = res.data.update_time || 0;
if (updateTime > storedTime) {
setIsAnnouncementModalOpen(true);
}
} catch (err) {
console.warn("Failed to read localStorage for announcement state", err);
console.warn(
"Failed to read localStorage for announcement state",
err,
);
setIsAnnouncementModalOpen(true);
}
} else {
@@ -376,7 +381,10 @@ export const useDashboardData = (): DashboardDataState => {
setIsAnnouncementModalOpen(false);
if (announcement && announcement.update_time) {
try {
localStorage.setItem("flvx_announcement_seen_time", announcement.update_time.toString());
localStorage.setItem(
"flvx_announcement_seen_time",
announcement.update_time.toString(),
);
} catch (err) {
console.warn("Failed to set localStorage for announcement state", err);
}
+79 -46
View File
@@ -54,6 +54,7 @@ import { Switch } from "@/shadcn-bridge/heroui/switch";
import { Alert } from "@/shadcn-bridge/heroui/alert";
import { Progress } from "@/shadcn-bridge/heroui/progress";
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
import { Accordion, AccordionItem } from "@/shadcn-bridge/heroui/accordion";
import {
createForward,
getForwardList,
@@ -118,6 +119,7 @@ interface Forward {
outFlow: number;
serviceRunning: boolean;
federationShareFlow?: number;
maxConn?: number;
createdTime: string;
userName?: string;
userId?: number;
@@ -157,6 +159,7 @@ interface ForwardForm {
interfaceName?: string;
strategy: string;
speedId: number | null;
maxConn?: number;
}
interface ForwardUserGroup {
@@ -1306,6 +1309,7 @@ export default function ForwardPage() {
interfaceName: "",
strategy: "fifo",
speedId: null,
maxConn: 0,
});
const [inIpTouched, setInIpTouched] = useState(false);
@@ -2113,6 +2117,7 @@ export default function ForwardPage() {
interfaceName: forward.interfaceName || "",
strategy: forward.strategy || "fifo",
speedId: normalizeSpeedId(forward.speedId),
maxConn: forward.maxConn || 0,
});
setErrors({});
setModalOpen(true);
@@ -2241,6 +2246,7 @@ export default function ForwardPage() {
remoteAddr: processedRemoteAddr,
strategy: addressCount > 1 ? form.strategy : "fifo",
speedId: normalizedSpeedId,
maxConn: form.maxConn,
};
res = await updateForward(updateData);
@@ -2253,6 +2259,7 @@ export default function ForwardPage() {
remoteAddr: processedRemoteAddr,
strategy: addressCount > 1 ? form.strategy : "fifo",
speedId: normalizedSpeedId,
maxConn: form.maxConn,
};
res = await createForward(createData);
@@ -4176,7 +4183,7 @@ export default function ForwardPage() {
{sortedForwards.length} 条规则
</span>
</div>
<Card className="overflow-hidden rounded-2xl border border-white/80 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_15px_35px_rgba(0,0,0,0.1)]">
<Card className="rounded-2xl border border-white/80 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_15px_35px_rgba(0,0,0,0.1)]">
<DndContext
collisionDetection={pointerWithin}
sensors={sensors}
@@ -4188,8 +4195,10 @@ export default function ForwardPage() {
>
<Table
aria-label="全部规则列表"
className="table-fixed min-w-[1160px]"
classNames={{
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-[24px]",
wrapper:
"bg-transparent p-0 shadow-none border-none overflow-auto rounded-[24px]",
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-white/10 dark:hover:bg-white/5 transition-colors group",
@@ -4327,7 +4336,7 @@ export default function ForwardPage() {
return (
<div
key={`grouped-table-${group.userId}-${group.userName}`}
className="overflow-hidden rounded-2xl border border-white/80 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_15px_35px_rgba(0,0,0,0.1)]"
className="rounded-2xl border border-white/80 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_15px_35px_rgba(0,0,0,0.1)]"
>
<div className="flex items-center justify-between border-b border-white/20 dark:border-white/10 bg-white/20 dark:bg-black/20 backdrop-blur-3xl px-5 py-4">
<div className="flex items-center gap-2">
@@ -4418,7 +4427,7 @@ export default function ForwardPage() {
headerClassName="flex items-center justify-between border-b border-white/20 dark:border-white/10 bg-white/20 dark:bg-black/20 backdrop-blur-3xl px-4 py-2.5"
titleClassName="truncate text-sm font-semibold text-default-700"
tunnel={tunnel}
wrapperClassName="overflow-hidden rounded-2xl border border-white/80 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_15px_35px_rgba(0,0,0,0.1)]"
wrapperClassName="rounded-2xl border border-white/80 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_15px_35px_rgba(0,0,0,0.1)]"
onToggleCollapsed={() =>
toggleTunnelGroupCollapsed(
group.userId,
@@ -4439,7 +4448,8 @@ export default function ForwardPage() {
aria-label={`${group.userName}-${tunnel.tunnelName}规则列表`}
className={`table-fixed ${FORWARD_GROUPED_TABLE_MIN_WIDTH_CLASS}`}
classNames={{
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
wrapper:
"bg-transparent p-0 shadow-none border-none overflow-auto rounded-2xl",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
@@ -4732,37 +4742,60 @@ export default function ForwardPage() {
}
/>
{isAdmin && (
<Select
label="规则限速"
placeholder="不限速"
selectedKeys={
selectedSpeedId !== null
? [selectedSpeedId.toString()]
: []
}
variant="bordered"
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as
| string
| undefined;
setForm((prev) => ({
...prev,
speedId: selectedKey ? Number(selectedKey) : null,
}));
}}
<Accordion variant="bordered">
<AccordionItem
key="advanced"
aria-label="高级设置"
title="高级设置"
>
{availableSpeedLimits.map((speedLimit) => (
<SelectItem
key={speedLimit.id.toString()}
textValue={speedLimit.name}
>
{speedLimit.name}
</SelectItem>
))}
</Select>
)}
<div className="space-y-4 pb-2">
<Input
label="最大连接数"
placeholder="0 或空表示不限制"
type="number"
min="0"
value={form.maxConn === 0 ? "" : String(form.maxConn || "")}
onChange={(e) => {
const value = Math.max(Number(e.target.value) || 0, 0);
setForm((prev) => ({ ...prev, maxConn: value }));
}}
description="此设置优先于用户的全局连接数限制。0 表示不限制。"
variant="bordered"
/>
{isAdmin && (
<Select
label="规则限速"
placeholder="不限速"
selectedKeys={
selectedSpeedId !== null
? [selectedSpeedId.toString()]
: []
}
variant="bordered"
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as
| string
| undefined;
setForm((prev) => ({
...prev,
speedId: selectedKey ? Number(selectedKey) : null,
}));
}}
>
{availableSpeedLimits.map((speedLimit) => (
<SelectItem
key={speedLimit.id.toString()}
textValue={speedLimit.name}
>
{speedLimit.name}
</SelectItem>
))}
</Select>
)}
</div>
</AccordionItem>
</Accordion>
<Select
description={
@@ -4976,7 +5009,7 @@ export default function ForwardPage() {
</Button>
</div>
<div className="space-y-2 max-h-60 overflow-y-auto">
<div className="space-y-2 max-h-60 overflow-y-auto scrollbar-hide">
{addressList.map((item) => (
<div
key={item.id}
@@ -5281,7 +5314,7 @@ export default function ForwardPage() {
</div>
<div
className="max-h-40 overflow-y-auto space-y-1"
className="max-h-40 overflow-y-auto space-y-1 scrollbar-hide"
style={{
scrollbarWidth: "thin",
scrollbarColor: "rgb(156 163 175) transparent",
@@ -5380,7 +5413,7 @@ export default function ForwardPage() {
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden [&>div]:bg-content1 [&>div]:dark:bg-content1",
}}
isOpen={diagnosisModalOpen}
placement="center"
@@ -5398,7 +5431,7 @@ export default function ForwardPage() {
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1 bg-content1 border-b border-divider">
<ModalHeader className="flex flex-col gap-1 border-b border-divider">
<h2 className="text-xl font-bold">规则诊断结果</h2>
{currentDiagnosisForward && (
<div className="flex items-center gap-2 min-w-0">
@@ -5447,7 +5480,7 @@ export default function ForwardPage() {
{/* 统计摘要 */}
<div className="grid grid-cols-3 gap-3">
<div className="text-center p-3 bg-default-100 dark:bg-gray-800 rounded-lg border border-divider">
<div className="text-center p-3 bg-default-100 rounded-lg border border-divider">
<div className="text-2xl font-bold text-foreground">
{diagnosisProgress.total > 0
? diagnosisProgress.total
@@ -5519,7 +5552,7 @@ export default function ForwardPage() {
return (
<div
key={title}
className="border border-divider rounded-lg overflow-hidden bg-white dark:bg-gray-800"
className="border border-divider rounded-lg overflow-hidden"
>
<div className="bg-primary/10 dark:bg-primary/20 px-3 py-2 border-b border-divider">
<h3 className="text-sm font-semibold text-primary">
@@ -5527,7 +5560,7 @@ export default function ForwardPage() {
</h3>
</div>
<table className="w-full text-sm">
<thead className="bg-default-100 dark:bg-gray-700">
<thead className="bg-default-100">
<tr>
<th className="px-3 py-2 text-left font-semibold text-xs">
路径
@@ -5546,7 +5579,7 @@ export default function ForwardPage() {
</th>
</tr>
</thead>
<tbody className="divide-y divide-divider bg-white dark:bg-gray-800">
<tbody className="divide-y divide-divider bg-content1">
{results.map((result, index) => {
const isDiagnosing = Boolean(
result.diagnosing,
@@ -5565,7 +5598,7 @@ export default function ForwardPage() {
isDiagnosing
? "bg-warning-50 dark:bg-warning-900/20"
: isSuccess
? "bg-white dark:bg-gray-800"
? "bg-content1"
: "bg-danger-50 dark:bg-danger-900/30"
}`}
>
@@ -5752,7 +5785,7 @@ export default function ForwardPage() {
isDiagnosing
? "border-warning-200 dark:border-warning-300/30 bg-warning-50 dark:bg-warning-900/20"
: isSuccess
? "border-divider bg-white dark:bg-gray-800"
? "border-divider bg-content1"
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
}`}
>
@@ -5940,7 +5973,7 @@ export default function ForwardPage() {
</div>
)}
</ModalBody>
<ModalFooter className="bg-transparent border-t border-white/20 dark:border-white/10">
<ModalFooter className="border-t border-divider">
<Button variant="light" onPress={onClose}>
关闭
</Button>
+435 -433
View File
@@ -24,7 +24,6 @@ import {
TableRow,
} from "@/shadcn-bridge/heroui/table";
import { Chip } from "@/shadcn-bridge/heroui/chip";
import {
assignGroupPermission,
assignTunnelsToGroup,
@@ -477,451 +476,454 @@ export default function GroupPage() {
) : (
<>
<Card>
<CardHeader className="flex flex-row items-center gap-3 pb-2">
<h3 className="text-lg font-semibold">隧道分组</h3>
<Button
className="h-7 px-3 text-xs font-medium min-w-0 shadow-sm"
color="primary"
size="sm"
onPress={openCreateTunnelGroup}
>
新建
</Button>
</CardHeader>
<CardBody>
<Table
aria-label="隧道分组列表"
<CardHeader className="flex flex-row items-center gap-3 pb-2">
<h3 className="text-lg font-semibold">隧道分组</h3>
<Button
className="h-7 px-3 text-xs font-medium min-w-0 shadow-sm"
color="primary"
size="sm"
onPress={openCreateTunnelGroup}
>
新建
</Button>
</CardHeader>
<CardBody>
<Table
aria-label="隧道分组列表"
classNames={{
wrapper:
"bg-transparent p-0 shadow-none border-none overflow-auto rounded-2xl",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
}}
>
<TableHeader>
<TableColumn>名称</TableColumn>
<TableColumn>隧道</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn>创建时间</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody emptyContent="暂无隧道分组" items={tunnelGroups}>
{(item) => (
<TableRow key={item.id}>
<TableCell>{item.name}</TableCell>
<TableCell>
{item.tunnelNames.length > 0
? item.tunnelNames.join("、")
: "-"}
</TableCell>
<TableCell>
<Chip
color={item.status === 1 ? "success" : "danger"}
size="sm"
>
{item.status === 1 ? "启用" : "停用"}
</Chip>
</TableCell>
<TableCell>{formatDate(item.createdTime)}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
size="sm"
variant="flat"
onPress={() => openAssignTunnels(item)}
>
分配隧道
</Button>
<Button
size="sm"
variant="light"
onPress={() => openEditTunnelGroup(item)}
>
编辑
</Button>
<Button
color="danger"
size="sm"
variant="light"
onPress={() => handleDeleteTunnelGroup(item.id)}
>
删除
</Button>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardBody>
</Card>
<Card>
<CardHeader className="flex flex-row items-center gap-3 pb-2">
<h3 className="text-lg font-semibold">用户分组</h3>
<Button
className="h-7 px-3 text-xs font-medium min-w-0 shadow-sm"
color="primary"
size="sm"
onPress={openCreateUserGroup}
>
新建
</Button>
</CardHeader>
<CardBody>
<Table
aria-label="用户分组列表"
classNames={{
wrapper:
"bg-transparent p-0 shadow-none border-none overflow-auto rounded-2xl",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
}}
>
<TableHeader>
<TableColumn>名称</TableColumn>
<TableColumn>用户</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn>创建时间</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody emptyContent="暂无用户分组" items={userGroups}>
{(item) => (
<TableRow key={item.id}>
<TableCell>{item.name}</TableCell>
<TableCell>
{item.userNames.length > 0
? item.userNames.join("、")
: "-"}
</TableCell>
<TableCell>
<Chip
color={item.status === 1 ? "success" : "danger"}
size="sm"
>
{item.status === 1 ? "启用" : "停用"}
</Chip>
</TableCell>
<TableCell>{formatDate(item.createdTime)}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
size="sm"
variant="flat"
onPress={() => openAssignUsers(item)}
>
分配用户
</Button>
<Button
size="sm"
variant="light"
onPress={() => openEditUserGroup(item)}
>
编辑
</Button>
<Button
color="danger"
size="sm"
variant="light"
onPress={() => handleDeleteUserGroup(item.id)}
>
删除
</Button>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardBody>
</Card>
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">权限分配</h3>
</CardHeader>
<CardBody className="space-y-4">
<div className="grid grid-cols-1 gap-3 md:grid-cols-3 md:items-end">
<Select
items={userGroups}
label="用户分组"
selectedKeys={
selectedUserGroupId ? [String(selectedUserGroupId)] : []
}
onSelectionChange={(keys) => {
const key = Array.from(keys as Set<React.Key>)[0];
setSelectedUserGroupId(key ? Number(key) : null);
}}
>
{(item) => <SelectItem key={item.id}>{item.name}</SelectItem>}
</Select>
<Select
items={tunnelGroups}
label="隧道分组"
selectedKeys={
selectedTunnelGroupId ? [String(selectedTunnelGroupId)] : []
}
onSelectionChange={(keys) => {
const key = Array.from(keys as Set<React.Key>)[0];
setSelectedTunnelGroupId(key ? Number(key) : null);
}}
>
{(item) => <SelectItem key={item.id}>{item.name}</SelectItem>}
</Select>
<Button
className="md:self-end md:justify-self-start whitespace-nowrap px-4"
color="primary"
isLoading={savingPermission}
size="sm"
onPress={handleAssignPermission}
>
分配
</Button>
</div>
<Table
aria-label="分组权限列表"
classNames={{
wrapper:
"bg-transparent p-0 shadow-none border-none overflow-auto rounded-2xl",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
}}
>
<TableHeader>
<TableColumn>ID</TableColumn>
<TableColumn>用户分组</TableColumn>
<TableColumn>隧道分组</TableColumn>
<TableColumn>创建时间</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody emptyContent="暂无权限分配记录" items={permissions}>
{(item) => (
<TableRow key={item.id}>
<TableCell>{item.id}</TableCell>
<TableCell>
{item.userGroupName || item.userGroupId}
</TableCell>
<TableCell>
{item.tunnelGroupName || item.tunnelGroupId}
</TableCell>
<TableCell>{formatDate(item.createdTime)}</TableCell>
<TableCell>
<Button
color="danger"
size="sm"
variant="light"
onPress={() => handleRemovePermission(item.id)}
>
回收
</Button>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardBody>
</Card>
<Modal
backdrop="blur"
classNames={{
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={tunnelGroupModalOpen}
onOpenChange={onTunnelGroupModalChange}
>
<TableHeader>
<TableColumn>名称</TableColumn>
<TableColumn>隧道</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn>创建时间</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody emptyContent="暂无隧道分组" items={tunnelGroups}>
{(item) => (
<TableRow key={item.id}>
<TableCell>{item.name}</TableCell>
<TableCell>
{item.tunnelNames.length > 0
? item.tunnelNames.join("、")
: "-"}
</TableCell>
<TableCell>
<Chip
color={item.status === 1 ? "success" : "danger"}
size="sm"
>
{item.status === 1 ? "启用" : "停用"}
</Chip>
</TableCell>
<TableCell>{formatDate(item.createdTime)}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
size="sm"
variant="flat"
onPress={() => openAssignTunnels(item)}
>
分配隧道
</Button>
<Button
size="sm"
variant="light"
onPress={() => openEditTunnelGroup(item)}
>
编辑
</Button>
<Button
color="danger"
size="sm"
variant="light"
onPress={() => handleDeleteTunnelGroup(item.id)}
>
删除
</Button>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardBody>
</Card>
<ModalContent>
<ModalHeader>
{editingTunnelGroup ? "编辑隧道分组" : "新建隧道分组"}
</ModalHeader>
<ModalBody className="space-y-3">
<Input
label="分组名称"
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
/>
<Select
label="状态"
selectedKeys={[groupStatus]}
onSelectionChange={(keys) => {
const key = Array.from(keys as Set<React.Key>)[0];
<Card>
<CardHeader className="flex flex-row items-center gap-3 pb-2">
<h3 className="text-lg font-semibold">用户分组</h3>
<Button
className="h-7 px-3 text-xs font-medium min-w-0 shadow-sm"
color="primary"
size="sm"
onPress={openCreateUserGroup}
>
新建
</Button>
</CardHeader>
<CardBody>
<Table
aria-label="用户分组列表"
if (key) {
setGroupStatus(String(key));
}
}}
>
<SelectItem key="1">启用</SelectItem>
<SelectItem key="0">停用</SelectItem>
</Select>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onTunnelGroupModalClose}>
取消
</Button>
<Button
color="primary"
isLoading={savingGroup}
onPress={saveTunnelGroup}
>
保存
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal
backdrop="blur"
classNames={{
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={userGroupModalOpen}
onOpenChange={onUserGroupModalChange}
>
<TableHeader>
<TableColumn>名称</TableColumn>
<TableColumn>用户</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn>创建时间</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody emptyContent="暂无用户分组" items={userGroups}>
{(item) => (
<TableRow key={item.id}>
<TableCell>{item.name}</TableCell>
<TableCell>
{item.userNames.length > 0
? item.userNames.join("、")
: "-"}
</TableCell>
<TableCell>
<Chip
color={item.status === 1 ? "success" : "danger"}
size="sm"
>
{item.status === 1 ? "启用" : "停用"}
</Chip>
</TableCell>
<TableCell>{formatDate(item.createdTime)}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
size="sm"
variant="flat"
onPress={() => openAssignUsers(item)}
>
分配用户
</Button>
<Button
size="sm"
variant="light"
onPress={() => openEditUserGroup(item)}
>
编辑
</Button>
<Button
color="danger"
size="sm"
variant="light"
onPress={() => handleDeleteUserGroup(item.id)}
>
删除
</Button>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardBody>
</Card>
<ModalContent>
<ModalHeader>
{editingUserGroup ? "编辑用户分组" : "新建用户分组"}
</ModalHeader>
<ModalBody className="space-y-3">
<Input
label="分组名称"
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
/>
<Select
label="状态"
selectedKeys={[groupStatus]}
onSelectionChange={(keys) => {
const key = Array.from(keys as Set<React.Key>)[0];
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">权限分配</h3>
</CardHeader>
<CardBody className="space-y-4">
<div className="grid grid-cols-1 gap-3 md:grid-cols-3 md:items-end">
<Select
items={userGroups}
label="用户分组"
selectedKeys={
selectedUserGroupId ? [String(selectedUserGroupId)] : []
}
onSelectionChange={(keys) => {
const key = Array.from(keys as Set<React.Key>)[0];
if (key) {
setGroupStatus(String(key));
}
}}
>
<SelectItem key="1">启用</SelectItem>
<SelectItem key="0">停用</SelectItem>
</Select>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onUserGroupModalClose}>
取消
</Button>
<Button
color="primary"
isLoading={savingGroup}
onPress={saveUserGroup}
>
保存
</Button>
</ModalFooter>
</ModalContent>
</Modal>
setSelectedUserGroupId(key ? Number(key) : null);
}}
>
{(item) => <SelectItem key={item.id}>{item.name}</SelectItem>}
</Select>
<Select
items={tunnelGroups}
label="隧道分组"
selectedKeys={
selectedTunnelGroupId ? [String(selectedTunnelGroupId)] : []
}
onSelectionChange={(keys) => {
const key = Array.from(keys as Set<React.Key>)[0];
setSelectedTunnelGroupId(key ? Number(key) : null);
}}
>
{(item) => <SelectItem key={item.id}>{item.name}</SelectItem>}
</Select>
<Button
className="md:self-end md:justify-self-start whitespace-nowrap px-4"
color="primary"
isLoading={savingPermission}
size="sm"
onPress={handleAssignPermission}
>
分配
</Button>
</div>
<Table
aria-label="分组权限列表"
<Modal
backdrop="blur"
classNames={{
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={tunnelAssignModalOpen}
onOpenChange={onTunnelAssignModalChange}
>
<TableHeader>
<TableColumn>ID</TableColumn>
<TableColumn>用户分组</TableColumn>
<TableColumn>隧道分组</TableColumn>
<TableColumn>创建时间</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody emptyContent="暂无权限分配记录" items={permissions}>
{(item) => (
<TableRow key={item.id}>
<TableCell>{item.id}</TableCell>
<TableCell>
{item.userGroupName || item.userGroupId}
</TableCell>
<TableCell>
{item.tunnelGroupName || item.tunnelGroupId}
</TableCell>
<TableCell>{formatDate(item.createdTime)}</TableCell>
<TableCell>
<Button
color="danger"
size="sm"
variant="light"
onPress={() => handleRemovePermission(item.id)}
>
回收
</Button>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardBody>
</Card>
<ModalContent>
<ModalHeader>分配隧道 - {assignTunnelGroup?.name}</ModalHeader>
<ModalBody className="min-w-0">
<Select
className="min-w-0"
classNames={{ trigger: "max-w-full" }}
items={tunnels}
label="选择隧道"
selectedKeys={selectedTunnelKeys}
selectionMode="multiple"
onSelectionChange={(keys) => {
setSelectedTunnelKeys(
new Set(Array.from(keys as Set<React.Key>).map(String)),
);
}}
>
{(item) => <SelectItem key={item.id}>{item.name}</SelectItem>}
</Select>
<p
className="w-full min-w-0 max-w-full text-xs text-default-500 truncate"
title={`当前已选:${selectedTunnelSummary}`}
>
当前已选:{selectedTunnelSummary}
</p>
<p className="text-xs text-default-500">
不选择任何隧道并保存将清空该分组成员。
</p>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onTunnelAssignModalClose}>
取消
</Button>
<Button
color="primary"
isLoading={savingAssign}
onPress={saveAssignTunnels}
>
保存
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={tunnelGroupModalOpen}
onOpenChange={onTunnelGroupModalChange}
>
<ModalContent>
<ModalHeader>
{editingTunnelGroup ? "编辑隧道分组" : "新建隧道分组"}
</ModalHeader>
<ModalBody className="space-y-3">
<Input
label="分组名称"
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
/>
<Select
label="状态"
selectedKeys={[groupStatus]}
onSelectionChange={(keys) => {
const key = Array.from(keys as Set<React.Key>)[0];
if (key) {
setGroupStatus(String(key));
}
}}
>
<SelectItem key="1">启用</SelectItem>
<SelectItem key="0">停用</SelectItem>
</Select>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onTunnelGroupModalClose}>
取消
</Button>
<Button
color="primary"
isLoading={savingGroup}
onPress={saveTunnelGroup}
>
保存
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={userGroupModalOpen}
onOpenChange={onUserGroupModalChange}
>
<ModalContent>
<ModalHeader>
{editingUserGroup ? "编辑用户分组" : "新建用户分组"}
</ModalHeader>
<ModalBody className="space-y-3">
<Input
label="分组名称"
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
/>
<Select
label="状态"
selectedKeys={[groupStatus]}
onSelectionChange={(keys) => {
const key = Array.from(keys as Set<React.Key>)[0];
if (key) {
setGroupStatus(String(key));
}
}}
>
<SelectItem key="1">启用</SelectItem>
<SelectItem key="0">停用</SelectItem>
</Select>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onUserGroupModalClose}>
取消
</Button>
<Button
color="primary"
isLoading={savingGroup}
onPress={saveUserGroup}
>
保存
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={tunnelAssignModalOpen}
onOpenChange={onTunnelAssignModalChange}
>
<ModalContent>
<ModalHeader>分配隧道 - {assignTunnelGroup?.name}</ModalHeader>
<ModalBody className="min-w-0">
<Select
className="min-w-0"
classNames={{ trigger: "max-w-full" }}
items={tunnels}
label="选择隧道"
selectedKeys={selectedTunnelKeys}
selectionMode="multiple"
onSelectionChange={(keys) => {
setSelectedTunnelKeys(
new Set(Array.from(keys as Set<React.Key>).map(String)),
);
}}
>
{(item) => <SelectItem key={item.id}>{item.name}</SelectItem>}
</Select>
<p
className="w-full min-w-0 max-w-full text-xs text-default-500 truncate"
title={`当前已选:${selectedTunnelSummary}`}
>
当前已选:{selectedTunnelSummary}
</p>
<p className="text-xs text-default-500">
不选择任何隧道并保存将清空该分组成员。
</p>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onTunnelAssignModalClose}>
取消
</Button>
<Button
color="primary"
isLoading={savingAssign}
onPress={saveAssignTunnels}
>
保存
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={userAssignModalOpen}
onOpenChange={onUserAssignModalChange}
>
<ModalContent>
<ModalHeader>分配用户 - {assignUserGroup?.name}</ModalHeader>
<ModalBody className="min-w-0">
<Select
className="min-w-0"
classNames={{ trigger: "max-w-full" }}
items={users}
label="选择用户"
selectedKeys={selectedUserKeys}
selectionMode="multiple"
onSelectionChange={(keys) => {
setSelectedUserKeys(
new Set(Array.from(keys as Set<React.Key>).map(String)),
);
}}
>
{(item) => <SelectItem key={item.id}>{item.user}</SelectItem>}
</Select>
<p
className="w-full min-w-0 max-w-full text-xs text-default-500 truncate"
title={`当前已选:${selectedUserSummary}`}
>
当前已选:{selectedUserSummary}
</p>
<p className="text-xs text-default-500">
不选择任何用户并保存将清空该分组成员。
</p>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onUserAssignModalClose}>
取消
</Button>
<Button
color="primary"
isLoading={savingAssign}
onPress={saveAssignUsers}
>
保存
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={userAssignModalOpen}
onOpenChange={onUserAssignModalChange}
>
<ModalContent>
<ModalHeader>分配用户 - {assignUserGroup?.name}</ModalHeader>
<ModalBody className="min-w-0">
<Select
className="min-w-0"
classNames={{ trigger: "max-w-full" }}
items={users}
label="选择用户"
selectedKeys={selectedUserKeys}
selectionMode="multiple"
onSelectionChange={(keys) => {
setSelectedUserKeys(
new Set(Array.from(keys as Set<React.Key>).map(String)),
);
}}
>
{(item) => <SelectItem key={item.id}>{item.user}</SelectItem>}
</Select>
<p
className="w-full min-w-0 max-w-full text-xs text-default-500 truncate"
title={`当前已选:${selectedUserSummary}`}
>
当前已选:{selectedUserSummary}
</p>
<p className="text-xs text-default-500">
不选择任何用户并保存将清空该分组成员。
</p>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onUserAssignModalClose}>
取消
</Button>
<Button
color="primary"
isLoading={savingAssign}
onPress={saveAssignUsers}
>
保存
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)}
</AnimatedPage>
+12 -4
View File
@@ -162,7 +162,9 @@ export default function IndexPage() {
<Card className="w-full bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_20px_40px_rgba(0,0,0,0.15)] border-white/80 dark:border-white/10 rounded-[32px] p-2 sm:p-4">
<CardHeader className="pb-0 pt-6 px-6 flex-col items-center">
<BrandLogo className="w-14 h-14 rounded-2xl mb-4" size={56} />
<h1 className="text-2xl font-bold tracking-tight text-foreground">{siteConfig.name}</h1>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
{siteConfig.name}
</h1>
<p className="text-sm text-default-500 mt-2 font-medium">
Sign in to manage your networks
</p>
@@ -171,7 +173,8 @@ export default function IndexPage() {
<div className="flex flex-col gap-5">
<Input
classNames={{
inputWrapper: "bg-white/50 dark:bg-black/50 backdrop-blur-md border border-white/60 dark:border-white/10 h-12 shadow-sm rounded-xl",
inputWrapper:
"bg-white/10 dark:bg-white/5 backdrop-blur-md border border-white/30 dark:border-white/10 h-12 shadow-sm rounded-xl",
input: "text-base font-medium",
}}
errorMessage={errors.username}
@@ -189,7 +192,8 @@ export default function IndexPage() {
<Input
classNames={{
inputWrapper: "bg-white/50 dark:bg-black/50 backdrop-blur-md border border-white/60 dark:border-white/10 h-12 shadow-sm rounded-xl",
inputWrapper:
"bg-white/10 dark:bg-white/5 backdrop-blur-md border border-white/30 dark:border-white/10 h-12 shadow-sm rounded-xl",
input: "text-base font-medium tracking-wider",
}}
isDisabled={loading}
@@ -211,7 +215,11 @@ export default function IndexPage() {
isLoading={loading}
onPress={handleLogin}
>
{loading ? (showCaptcha ? "Verifying..." : "Signing in...") : "Sign In"}
{loading
? showCaptcha
? "Verifying..."
: "Signing in..."
: "Sign In"}
</Button>
</div>
</CardBody>
+140 -135
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useMemo } from "react";
import toast from "react-hot-toast";
import { LayoutGrid, List } from "lucide-react";
import {
AnimatedPage,
@@ -18,7 +19,6 @@ import {
ModalFooter,
} from "@/shadcn-bridge/heroui/modal";
import { Chip } from "@/shadcn-bridge/heroui/chip";
import { LayoutGrid, List } from "lucide-react";
import {
Table,
TableHeader,
@@ -251,7 +251,11 @@ export default function LimitPage() {
variant="flat"
onPress={() => setViewMode(viewMode === "list" ? "grid" : "list")}
>
{viewMode === "list" ? <LayoutGrid className="w-4 h-4" /> : <List className="w-4 h-4" />}
{viewMode === "list" ? (
<LayoutGrid className="w-4 h-4" />
) : (
<List className="w-4 h-4" />
)}
</Button>
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
新增
@@ -263,153 +267,154 @@ export default function LimitPage() {
{filteredRules.length > 0 ? (
viewMode === "list" ? (
<Card>
<Table
aria-label="限速规则列表"
className="overflow-x-auto min-w-full"
classNames={{
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-[24px]",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
}}
>
<TableHeader>
<TableColumn>规则名称</TableColumn>
<TableColumn>速度限制</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody items={filteredRules}>
{(rule) => (
<TableRow key={rule.id}>
<TableCell>
<div className="flex items-center gap-2">
<div
className={`shrink-0 w-2 h-2 rounded-full ${
rule.status === 1 ? "bg-success" : "bg-danger"
}`}
/>
<span className="font-medium text-foreground text-sm">
{rule.name}
</span>
</div>
</TableCell>
<TableCell>
<span className="text-sm font-mono text-default-600">
{rule.speed} Mbps
</span>
</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
<Button
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
<Table
aria-label="限速规则列表"
className="overflow-x-auto min-w-full"
classNames={{
wrapper:
"bg-transparent p-0 shadow-none border-none overflow-auto rounded-[24px]",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
}}
>
<TableHeader>
<TableColumn>规则名称</TableColumn>
<TableColumn>速度限制</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody items={filteredRules}>
{(rule) => (
<TableRow key={rule.id}>
<TableCell>
<div className="flex items-center gap-2">
<div
className={`shrink-0 w-2 h-2 rounded-full ${
rule.status === 1 ? "bg-success" : "bg-danger"
}`}
/>
<span className="font-medium text-foreground text-sm">
{rule.name}
</span>
</div>
</TableCell>
<TableCell>
<span className="text-sm font-mono text-default-600">
{rule.speed} Mbps
</span>
</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
<Button
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
size="sm"
variant="flat"
onPress={() => handleEdit(rule)}
>
编辑
</Button>
<Button
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
size="sm"
variant="flat"
onPress={() => handleDelete(rule)}
>
删除
</Button>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Card>
) : (
<StaggerList className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{filteredRules.map((rule) => (
<StaggerItem key={rule.id}>
<Card className="shadow-sm overflow-hidden h-full">
<CardHeader className="pb-2 md:pb-2">
<div className="flex justify-between items-start w-full">
<div>
<h3 className="font-semibold text-foreground">
{rule.name}
</h3>
</div>
<Chip
color={rule.status === 1 ? "success" : "danger"}
size="sm"
variant="flat"
>
{rule.status === 1 ? "运行" : "异常"}
</Chip>
</div>
</CardHeader>
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-small text-default-600">
速度限制
</span>
<Chip color="secondary" size="sm" variant="flat">
{rule.speed} Mbps
</Chip>
</div>
</div>
<div className="flex gap-2 mt-4">
<Button
className="flex-1"
color="primary"
size="sm"
startContent={
<svg
aria-hidden="true"
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
}
variant="flat"
onPress={() => handleEdit(rule)}
>
编辑
</Button>
<Button
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
className="flex-1"
color="danger"
size="sm"
startContent={
<svg
aria-hidden="true"
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"
fillRule="evenodd"
/>
<path
clipRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z"
fillRule="evenodd"
/>
</svg>
}
variant="flat"
onPress={() => handleDelete(rule)}
>
删除
</Button>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Card>
) : (
<StaggerList className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{filteredRules.map((rule) => (
<StaggerItem key={rule.id}>
<Card className="shadow-sm overflow-hidden h-full">
<CardHeader className="pb-2 md:pb-2">
<div className="flex justify-between items-start w-full">
<div>
<h3 className="font-semibold text-foreground">
{rule.name}
</h3>
</div>
<Chip
color={rule.status === 1 ? "success" : "danger"}
size="sm"
variant="flat"
>
{rule.status === 1 ? "运行" : "异常"}
</Chip>
</div>
</CardHeader>
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-small text-default-600">
速度限制
</span>
<Chip color="secondary" size="sm" variant="flat">
{rule.speed} Mbps
</Chip>
</div>
</div>
<div className="flex gap-2 mt-4">
<Button
className="flex-1"
color="primary"
size="sm"
startContent={
<svg
aria-hidden="true"
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
}
variant="flat"
onPress={() => handleEdit(rule)}
>
编辑
</Button>
<Button
className="flex-1"
color="danger"
size="sm"
startContent={
<svg
aria-hidden="true"
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"
fillRule="evenodd"
/>
<path
clipRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z"
fillRule="evenodd"
/>
</svg>
}
variant="flat"
onPress={() => handleDelete(rule)}
>
删除
</Button>
</div>
</CardBody>
</Card>
</StaggerItem>
))}
</StaggerList>
</CardBody>
</Card>
</StaggerItem>
))}
</StaggerList>
)
) : (
/* 空状态 */
+47 -12
View File
@@ -2,7 +2,13 @@ import type { MonitorNodeApiItem } from "@/api/types";
import { useCallback, useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { RefreshCw, LayoutGrid, List, Server, ArrowRightLeft } from "lucide-react";
import {
RefreshCw,
LayoutGrid,
List,
Server,
ArrowRightLeft,
} from "lucide-react";
import { AnimatedPage } from "@/components/animated-page";
import { Button } from "@/shadcn-bridge/heroui/button";
@@ -38,11 +44,16 @@ export default function MonitorPage() {
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
const [activeTab, setActiveTab] = useState<MonitorTab>("nodes");
const [realtimeNodeStatus, setRealtimeNodeStatus] = useState<Record<number, "online" | "offline">>({});
const [realtimeNodeMetrics, setRealtimeNodeMetrics] = useState<Record<number, any>>({});
const [realtimeNodeStatus, setRealtimeNodeStatus] = useState<
Record<number, "online" | "offline">
>({});
const [realtimeNodeMetrics, setRealtimeNodeMetrics] = useState<
Record<number, any>
>({});
const handleRealtimeMessage = useCallback((message: any) => {
const nodeId = Number(message?.id ?? 0);
if (!nodeId || Number.isNaN(nodeId)) return;
const type = String(message?.type ?? "");
@@ -50,15 +61,18 @@ export default function MonitorPage() {
if (type === "status") {
const status = Number(payload);
setRealtimeNodeStatus((prev) => ({
...prev,
[nodeId]: status === 1 ? "online" : "offline",
}));
return;
}
if (type === "metric") {
let raw = payload;
if (typeof raw === "string") {
try {
raw = JSON.parse(raw);
@@ -77,7 +91,7 @@ export default function MonitorPage() {
netOutSpeed: Number(metric.netOutSpeed ?? metric.net_out_speed ?? 0),
tcpConns: Number(metric.tcpConns ?? metric.tcp_conns ?? 0),
udpConns: Number(metric.udpConns ?? metric.udp_conns ?? 0),
}
},
}));
}
}, []);
@@ -89,6 +103,7 @@ export default function MonitorPage() {
const loadNodes = useCallback(async (options?: { silent?: boolean }) => {
const silent = options?.silent ?? false;
if (!silent) setNodesLoading(true);
try {
const response = await getMonitorNodes();
@@ -133,7 +148,9 @@ export default function MonitorPage() {
.map((n) => ({
id: Number(n.id),
name: String(n.name ?? ""),
connectionStatus: realtimeNodeStatus[Number(n.id)] || (n.status === 1 ? "online" : "offline"),
connectionStatus:
realtimeNodeStatus[Number(n.id)] ||
(n.status === 1 ? "online" : "offline"),
version: n.version,
}));
@@ -151,6 +168,7 @@ export default function MonitorPage() {
if (node.connectionStatus === "online") {
onlineCount++;
const metric = realtimeNodeMetrics[node.id];
if (metric) {
totalCpu += metric.cpuUsage;
totalConns += metric.tcpConns + metric.udpConns;
@@ -161,6 +179,7 @@ export default function MonitorPage() {
});
const avgCpu = onlineCount > 0 ? totalCpu / onlineCount : 0;
return {
avgCpu,
totalConns,
@@ -174,10 +193,14 @@ export default function MonitorPage() {
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<div className="rounded-3xl border border-white/80 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_10px_30px_rgba(0,0,0,0.1)] p-6 relative overflow-hidden flex flex-col justify-between h-40">
<div className="flex justify-between items-center z-10 relative">
<span className="text-default-600 font-medium text-sm">System Load</span>
<span className="text-default-600 font-medium text-sm">
System Load
</span>
</div>
<div className="z-10 relative">
<span className="text-4xl font-bold text-foreground">{aggregateMetrics.avgCpu.toFixed(1)}%</span>
<span className="text-4xl font-bold text-foreground">
{aggregateMetrics.avgCpu.toFixed(1)}%
</span>
</div>
<div className="absolute bottom-0 left-0 right-0 h-12 flex items-end gap-1 px-6 pb-4 opacity-50 z-0">
<div className="w-full bg-primary/40 h-2 rounded-t-sm" />
@@ -191,10 +214,14 @@ export default function MonitorPage() {
<div className="rounded-3xl border border-white/80 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_10px_30px_rgba(0,0,0,0.1)] p-6 relative overflow-hidden flex flex-col justify-between h-40">
<div className="flex justify-between items-center z-10 relative">
<span className="text-default-600 font-medium text-sm">Active Connections</span>
<span className="text-default-600 font-medium text-sm">
Active Connections
</span>
</div>
<div className="z-10 relative">
<span className="text-4xl font-bold text-foreground">{aggregateMetrics.totalConns}</span>
<span className="text-4xl font-bold text-foreground">
{aggregateMetrics.totalConns}
</span>
</div>
<div className="absolute bottom-0 left-0 right-0 h-12 flex items-end gap-1 px-6 pb-4 opacity-50 z-0">
<div className="w-full bg-success/40 h-3.5 rounded-t-sm" />
@@ -208,10 +235,14 @@ export default function MonitorPage() {
<div className="rounded-3xl border border-white/80 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_10px_30px_rgba(0,0,0,0.1)] p-6 relative overflow-hidden flex flex-col justify-between h-40">
<div className="flex justify-between items-center z-10 relative">
<span className="text-default-600 font-medium text-sm">Bandwidth</span>
<span className="text-default-600 font-medium text-sm">
Bandwidth
</span>
</div>
<div className="z-10 relative">
<span className="text-4xl font-bold text-foreground">{formatBytesPerSecond(aggregateMetrics.totalBandwidth)}</span>
<span className="text-4xl font-bold text-foreground">
{formatBytesPerSecond(aggregateMetrics.totalBandwidth)}
</span>
</div>
<div className="absolute bottom-0 left-0 right-0 h-12 flex items-end gap-1 px-6 pb-4 opacity-50 z-0">
<div className="w-full bg-secondary/40 h-2 rounded-t-sm" />
@@ -236,7 +267,11 @@ export default function MonitorPage() {
variant="flat"
onPress={() => setViewMode(viewMode === "list" ? "grid" : "list")}
>
{viewMode === "list" ? <LayoutGrid className="w-4 h-4" /> : <List className="w-4 h-4" />}
{viewMode === "list" ? (
<LayoutGrid className="w-4 h-4" />
) : (
<List className="w-4 h-4" />
)}
</Button>
{activeTab === "nodes" && (
<Button
+36 -21
View File
@@ -17,10 +17,10 @@ import {
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { LayoutGrid, List } from "lucide-react";
import { SearchBar } from "@/components/search-bar";
import { AnimatedPage } from "@/components/animated-page";
import { LayoutGrid, List } from "lucide-react";
import {
Table,
TableHeader,
@@ -699,7 +699,6 @@ export default function NodePage() {
// 格式化流量
const formatFlow = (bytes: number): string => {
if (!Number.isFinite(bytes) || bytes <= 0) {
return "0 B";
@@ -726,7 +725,6 @@ export default function NodePage() {
return "未知链路";
};
// IPv4/IPv6 格式验证(仅用于判定地址族)
const ipv4Regex =
/^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
@@ -1650,10 +1648,12 @@ export default function NodePage() {
{/* 视图切换按钮 */}
<Button
isIconOnly
className="text-default-600 hidden sm:flex"
size="sm"
variant="flat"
className="text-default-600 hidden sm:flex"
onPress={() => setViewMode(viewMode === "list" ? "grid" : "list")}
onPress={() =>
setViewMode(viewMode === "list" ? "grid" : "list")
}
>
{viewMode === "list" ? (
<LayoutGrid className="w-4 h-4" />
@@ -1767,7 +1767,8 @@ export default function NodePage() {
aria-label="节点列表"
className="overflow-x-auto min-w-full"
classNames={{
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
wrapper:
"bg-transparent p-0 shadow-none border-none overflow-auto rounded-2xl",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-white/10 dark:hover:bg-white/5 transition-colors",
@@ -1776,7 +1777,11 @@ export default function NodePage() {
<TableHeader>
<TableColumn className="w-12 px-4 whitespace-nowrap overflow-hidden">
<Checkbox
isSelected={selectMode && selectedIds.size === displayNodes.length && displayNodes.length > 0}
isSelected={
selectMode &&
selectedIds.size === displayNodes.length &&
displayNodes.length > 0
}
onValueChange={(checked) => {
if (checked) {
selectAll();
@@ -1806,12 +1811,16 @@ export default function NodePage() {
onValueChange={(checked) => {
if (checked) {
setSelectMode(true);
setSelectedIds((prev) => new Set([...prev, node.id]));
setSelectedIds(
(prev) => new Set([...prev, node.id]),
);
} else {
setSelectedIds((prev) => {
const next = new Set(prev);
next.delete(node.id);
if (next.size === 0) setSelectMode(false);
return next;
});
}
@@ -1833,7 +1842,11 @@ export default function NodePage() {
{node.name}
</span>
{hasRemark && (
<Chip size="sm" variant="flat" className="h-4 px-1 text-[10px]">
<Chip
className="h-4 px-1 text-[10px]"
size="sm"
variant="flat"
>
{node.remark}
</Chip>
)}
@@ -1841,7 +1854,9 @@ export default function NodePage() {
</TableCell>
<TableCell>
<span className="text-sm font-mono text-default-600">
{isRemoteNode ? new URL(node.remoteUrl || "").hostname : node.serverIp}
{isRemoteNode
? new URL(node.remoteUrl || "").hostname
: node.serverIp}
</span>
</TableCell>
<TableCell>
@@ -1853,10 +1868,10 @@ export default function NodePage() {
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
{!isRemoteNode && (
<Button
size="sm"
variant="flat"
className="h-6 px-2 min-w-0 text-xs bg-emerald-50 text-emerald-600 hover:bg-emerald-100 dark:bg-emerald-950/30 dark:text-emerald-400"
isLoading={node.copyLoading}
size="sm"
variant="flat"
onPress={() => openInstallSelector(node)}
>
安装
@@ -1864,11 +1879,11 @@ export default function NodePage() {
)}
{!isRemoteNode && (
<Button
size="sm"
variant="flat"
className="h-6 px-2 min-w-0 text-xs bg-amber-50 text-amber-600 hover:bg-amber-100 dark:bg-amber-950/30 dark:text-amber-400"
isDisabled={node.connectionStatus !== "online"}
isLoading={node.upgradeLoading}
size="sm"
variant="flat"
onPress={() => {
setUpgradeTargetNodeId(node.id);
openUpgradeModal("single");
@@ -1879,11 +1894,11 @@ export default function NodePage() {
)}
{!isRemoteNode && (
<Button
size="sm"
variant="flat"
className="h-6 px-2 min-w-0 text-xs bg-blue-50 text-blue-600 hover:bg-blue-100 dark:bg-blue-950/30 dark:text-blue-400"
isDisabled={node.connectionStatus !== "online"}
isLoading={node.rollbackLoading}
size="sm"
variant="flat"
onPress={() => handleRollbackNode(node)}
>
回退
@@ -1891,18 +1906,18 @@ export default function NodePage() {
)}
{!isRemoteNode && (
<Button
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
size="sm"
variant="flat"
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
onPress={() => handleEdit(node)}
>
编辑
</Button>
)}
<Button
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
size="sm"
variant="flat"
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
onPress={() => handleDelete(node)}
>
删除
@@ -2054,7 +2069,9 @@ export default function NodePage() {
type="button"
onClick={(e) => {
e.stopPropagation();
handleDismissExpiryReminder(node.id);
handleDismissExpiryReminder(
node.id,
);
}}
>
关闭提醒
@@ -2327,8 +2344,6 @@ export default function NodePage() {
</div>
)}
<div className="mt-auto space-y-3">
{/* 操作按钮 */}
<div className="space-y-1.5">
File diff suppressed because it is too large Load Diff
@@ -5,7 +5,13 @@ import type {
TunnelQualityHopApiItem,
} from "@/api/types";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
LineChart,
Line,
@@ -35,7 +41,6 @@ import {
getMonitorTunnelQualityHistory,
getConfigByName,
} from "@/api";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Chip } from "@/shadcn-bridge/heroui/chip";
@@ -54,7 +59,8 @@ interface TunnelMonitorViewProps {
}
const QUALITY_POLL_INTERVAL = 1_000; // 1 second
const MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY = "monitor_tunnel_quality_enabled";
const MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY =
"monitor_tunnel_quality_enabled";
const MONITOR_TUNNEL_QUALITY_ENABLED_EVENT =
"monitorTunnelQualityEnabledChanged";
@@ -78,7 +84,13 @@ const formatTimestamp = (ts: number, rangeMs?: number): string => {
});
};
function LatencyDisplay({ value, loading }: { value?: number; loading?: boolean }) {
function LatencyDisplay({
value,
loading,
}: {
value?: number;
loading?: boolean;
}) {
if (loading) {
return <RefreshCw className="w-3 h-3 animate-spin inline text-primary" />;
}
@@ -87,11 +99,16 @@ function LatencyDisplay({ value, loading }: { value?: number; loading?: boolean
}
const ms = value.toFixed(0);
let colorClass = "text-success";
if (value > 200) colorClass = "text-danger";
else if (value > 100) colorClass = "text-warning";
else if (value > 50) colorClass = "text-primary";
return <span className={`font-mono text-xs font-semibold ${colorClass}`}>{ms}ms</span>;
return (
<span className={`font-mono text-xs font-semibold ${colorClass}`}>
{ms}ms
</span>
);
}
function LiveDot() {
@@ -116,9 +133,11 @@ function UptimeHistoryBar({
}) {
const bars = [...Array(UPTIME_KUMA_BARS_COUNT)].map((_, i) => {
const historyIndex = history.length - UPTIME_KUMA_BARS_COUNT + i;
if (historyIndex >= 0 && historyIndex < history.length) {
return history[historyIndex];
}
return null;
});
@@ -183,13 +202,20 @@ const TIME_RANGE_OPTIONS = [
{ key: String(24 * 60 * 60 * 1000), label: "24小时" },
];
function TimeRangeSelect({ value, onChange }: { value: number; onChange: (v: number) => void }) {
function TimeRangeSelect({
value,
onChange,
}: {
value: number;
onChange: (v: number) => void;
}) {
return (
<Select
className="w-36"
selectedKeys={[String(value)]}
onSelectionChange={(keys) => {
const v = Number(Array.from(keys)[0]);
if (v > 0) onChange(v);
}}
>
@@ -205,13 +231,23 @@ interface QualityChartCardProps {
onRangeChange: (v: number) => void;
loading: boolean;
error: string | null;
data: Array<{ time: string; entryToExit: number | null; exitToBing: number | null }>;
data: Array<{
time: string;
entryToExit: number | null;
exitToBing: number | null;
}>;
tunnelId: number;
onRefresh: (id: number) => void;
}
const QualityChartCard = React.memo(function QualityChartCard({
rangeMs, onRangeChange, loading, error, data, tunnelId, onRefresh,
rangeMs,
onRangeChange,
loading,
error,
data,
tunnelId,
onRefresh,
}: QualityChartCardProps) {
return (
<Card>
@@ -219,7 +255,12 @@ const QualityChartCard = React.memo(function QualityChartCard({
<h3 className="text-lg font-semibold">质量趋势</h3>
<div className="flex items-center gap-2">
<TimeRangeSelect value={rangeMs} onChange={onRangeChange} />
<Button isLoading={loading} size="sm" variant="flat" onPress={() => onRefresh(tunnelId)}>
<Button
isLoading={loading}
size="sm"
variant="flat"
onPress={() => onRefresh(tunnelId)}
>
<RefreshCw className="w-4 h-4 mr-1" />
刷新
</Button>
@@ -227,38 +268,75 @@ const QualityChartCard = React.memo(function QualityChartCard({
</CardHeader>
<CardBody>
{loading ? (
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
<div className="flex justify-center py-8">
<RefreshCw className="w-6 h-6 animate-spin" />
</div>
) : error ? (
<div className="text-center py-8 text-danger text-sm">{error}</div>
) : data.length > 0 ? (
<div className="h-64">
<ResponsiveContainer height="100%" width="100%">
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<CartesianGrid opacity={0.3} strokeDasharray="3 3" />
<XAxis dataKey="time" fontSize={11} tick={{ fill: "#888" }} />
<YAxis
fontSize={11}
label={{
value: "延迟 (ms)",
angle: -90,
position: "insideLeft",
style: { fontSize: 11, fill: "#888" },
}}
tick={{ fill: "#888" }}
tickFormatter={(v: any) => `${Number(v).toFixed(0)}ms`}
label={{ value: "延迟 (ms)", angle: -90, position: "insideLeft", style: { fontSize: 11, fill: "#888" } }}
/>
<Tooltip
contentStyle={{ backgroundColor: "rgba(0,0,0,0.85)", border: "none", borderRadius: "8px", fontSize: 12 }}
labelStyle={{ color: "#fff" }}
contentStyle={{
backgroundColor: "rgba(0,0,0,0.85)",
border: "none",
borderRadius: "8px",
fontSize: 12,
}}
formatter={(value: unknown, name: string) => {
const n = Number(value);
if (!Number.isFinite(n)) return "-";
const label = name === "entryToExit" ? "入口→出口" : name === "exitToBing" ? "出口→Bing" : name;
const label =
name === "entryToExit"
? "入口→出口"
: name === "exitToBing"
? "出口→Bing"
: name;
return [`${n.toFixed(1)}ms`, label];
}}
labelStyle={{ color: "#fff" }}
/>
<Line
connectNulls
dataKey="entryToExit"
dot={false}
name="entryToExit"
stroke="#10b981"
strokeWidth={2}
type="monotone"
/>
<Line
connectNulls
dataKey="exitToBing"
dot={false}
name="exitToBing"
stroke="#3b82f6"
strokeWidth={2}
type="monotone"
/>
<Line connectNulls dataKey="entryToExit" dot={false} name="entryToExit" stroke="#10b981" strokeWidth={2} type="monotone" />
<Line connectNulls dataKey="exitToBing" dot={false} name="exitToBing" stroke="#3b82f6" strokeWidth={2} type="monotone" />
</LineChart>
</ResponsiveContainer>
</div>
) : (
<div className="text-center py-8 text-default-500">暂无质量历史数据</div>
<div className="text-center py-8 text-default-500">
暂无质量历史数据
</div>
)}
</CardBody>
</Card>
@@ -270,20 +348,33 @@ interface TrafficChartCardProps {
onRangeChange: (v: number) => void;
loading: boolean;
error: string | null;
data: Array<{ time: string; bytesIn: number; bytesOut: number; connections: number }>;
data: Array<{
time: string;
bytesIn: number;
bytesOut: number;
connections: number;
}>;
tunnelId: number;
onRefresh: (id: number) => void;
}
const TrafficChartCard = React.memo(function TrafficChartCard({
rangeMs, onRangeChange, loading, error, data, tunnelId, onRefresh,
rangeMs,
onRangeChange,
loading,
error,
data,
tunnelId,
onRefresh,
}: TrafficChartCardProps) {
const yFormatter = (value: unknown) => {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(n) / Math.log(k));
return `${parseFloat((n / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
};
@@ -293,7 +384,12 @@ const TrafficChartCard = React.memo(function TrafficChartCard({
<h3 className="text-lg font-semibold">流量趋势</h3>
<div className="flex items-center gap-2">
<TimeRangeSelect value={rangeMs} onChange={onRangeChange} />
<Button isLoading={loading} size="sm" variant="flat" onPress={() => onRefresh(tunnelId)}>
<Button
isLoading={loading}
size="sm"
variant="flat"
onPress={() => onRefresh(tunnelId)}
>
<RefreshCw className="w-4 h-4 mr-1" />
刷新
</Button>
@@ -301,23 +397,48 @@ const TrafficChartCard = React.memo(function TrafficChartCard({
</CardHeader>
<CardBody>
{loading ? (
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
<div className="flex justify-center py-8">
<RefreshCw className="w-6 h-6 animate-spin" />
</div>
) : error ? (
<div className="text-center py-8 text-danger text-sm">{error}</div>
) : data.length > 0 ? (
<div className="h-64">
<ResponsiveContainer height="100%" width="100%">
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<CartesianGrid opacity={0.3} strokeDasharray="3 3" />
<XAxis dataKey="time" fontSize={11} tick={{ fill: "#888" }} />
<YAxis fontSize={11} tick={{ fill: "#888" }} tickFormatter={yFormatter} />
<Tooltip
contentStyle={{ backgroundColor: "rgba(0,0,0,0.85)", border: "none", borderRadius: "8px", fontSize: 12 }}
labelStyle={{ color: "#fff" }}
formatter={yFormatter}
<YAxis
fontSize={11}
tick={{ fill: "#888" }}
tickFormatter={yFormatter}
/>
<Tooltip
contentStyle={{
backgroundColor: "rgba(0,0,0,0.85)",
border: "none",
borderRadius: "8px",
fontSize: 12,
}}
formatter={yFormatter}
labelStyle={{ color: "#fff" }}
/>
<Line
dataKey="bytesIn"
dot={false}
name="入站流量"
stroke="#10b981"
strokeWidth={2}
type="monotone"
/>
<Line
dataKey="bytesOut"
dot={false}
name="出站流量"
stroke="#ef4444"
strokeWidth={2}
type="monotone"
/>
<Line dataKey="bytesIn" dot={false} name="入站流量" stroke="#10b981" strokeWidth={2} type="monotone" />
<Line dataKey="bytesOut" dot={false} name="出站流量" stroke="#ef4444" strokeWidth={2} type="monotone" />
</LineChart>
</ResponsiveContainer>
</div>
@@ -333,6 +454,7 @@ function ForwardingChainTopology({ hopsStr }: { hopsStr?: string }) {
if (!hopsStr) return null;
let hops: TunnelQualityHopApiItem[] = [];
try {
hops = JSON.parse(hopsStr);
} catch {
@@ -353,28 +475,49 @@ function ForwardingChainTopology({ hopsStr }: { hopsStr?: string }) {
<div className="flex items-center overflow-x-auto pb-2 py-2">
{hops.map((hop, index) => {
const hasError = hop.latency < 0 || hop.loss > 0;
const colorClass = hop.latency < 0 ? "text-danger" : (hop.loss > 0 ? "text-warning" : "text-success");
const colorClass =
hop.latency < 0
? "text-danger"
: hop.loss > 0
? "text-warning"
: "text-success";
const borderColor = hasError ? "border-danger" : "";
return (
<React.Fragment key={index}>
{index === 0 && (
<Chip size="sm" variant="flat" className="shrink-0 font-mono shadow-sm">
<Chip
className="shrink-0 font-mono shadow-sm"
size="sm"
variant="flat"
>
{hop.fromNodeName}
</Chip>
)}
<div className="flex flex-col items-center justify-center min-w-[70px] mx-1 shrink-0 relative">
<span className={`text-[10px] font-mono leading-none mb-1 ${colorClass}`}>
<span
className={`text-[10px] font-mono leading-none mb-1 ${colorClass}`}
>
{hop.latency >= 0 ? `${hop.latency.toFixed(0)}ms` : "超时"}
</span>
<div className={`h-[2px] w-full relative flex items-center justify-end bg-default-200 ${hop.latency < 0 ? "!bg-danger" : ""}`}>
<ArrowRight className={`w-3.5 h-3.5 absolute -right-2 ${colorClass} bg-background rounded-full p-[1px] z-10`} />
<div
className={`h-[2px] w-full relative flex items-center justify-end bg-default-200 ${hop.latency < 0 ? "!bg-danger" : ""}`}
>
<ArrowRight
className={`w-3.5 h-3.5 absolute -right-2 ${colorClass} bg-background rounded-full p-[1px] z-10`}
/>
</div>
<span className={`text-[10px] font-mono leading-none mt-1.5 ${hop.loss > 0 ? "text-warning" : "text-default-400"}`}>
<span
className={`text-[10px] font-mono leading-none mt-1.5 ${hop.loss > 0 ? "text-warning" : "text-default-400"}`}
>
{hop.loss.toFixed(0)}% 丢包
</span>
</div>
<Chip size="sm" variant="flat" className={`shrink-0 font-mono shadow-sm ${borderColor}`}>
<Chip
className={`shrink-0 font-mono shadow-sm ${borderColor}`}
size="sm"
variant="flat"
>
{hop.toNodeName}
</Chip>
</React.Fragment>
@@ -386,15 +529,21 @@ function ForwardingChainTopology({ hopsStr }: { hopsStr?: string }) {
);
}
export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) {
export function TunnelMonitorView({
viewMode = "grid",
}: TunnelMonitorViewProps) {
const [tunnels, setTunnels] = useState<MonitorTunnelApiItem[]>([]);
const [tunnelsLoading, setTunnelsLoading] = useState(false);
const [tunnelsError, setTunnelsError] = useState<string | null>(null);
const [accessDenied, setAccessDenied] = useState<string | null>(null);
// Quality data from backend periodic probing (latest per tunnel)
const [qualityMap, setQualityMap] = useState<Record<number, TunnelQualityApiItem>>({});
const [qualityHistoryMap, setQualityHistoryMap] = useState<Record<number, TunnelQualityApiItem[]>>({});
const [qualityMap, setQualityMap] = useState<
Record<number, TunnelQualityApiItem>
>({});
const [qualityHistoryMap, setQualityHistoryMap] = useState<
Record<number, TunnelQualityApiItem[]>
>({});
const initialHistoryFetched = useRef(false);
const [qualityLoading, setQualityLoading] = useState(false);
const qualityTimerRef = useRef<number | null>(null);
@@ -405,20 +554,27 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
const [detailTunnelId, setDetailTunnelId] = useState<number | null>(null);
// Quality history for chart (mirrors service monitor results)
const [qualityHistory, setQualityHistory] = useState<TunnelQualityApiItem[]>([]);
const [qualityHistory, setQualityHistory] = useState<TunnelQualityApiItem[]>(
[],
);
const [qualityHistoryLoading, setQualityHistoryLoading] = useState(false);
const [qualityHistoryError, setQualityHistoryError] = useState<string | null>(null);
const [qualityHistoryError, setQualityHistoryError] = useState<string | null>(
null,
);
const [qualityRangeMs, setQualityRangeMs] = useState(60 * 60 * 1000);
// Tunnel traffic metrics for chart
const [tunnelMetrics, setTunnelMetrics] = useState<TunnelMetricApiItem[]>([]);
const [tunnelMetricsLoading, setTunnelMetricsLoading] = useState(false);
const [tunnelMetricsError, setTunnelMetricsError] = useState<string | null>(null);
const [tunnelMetricsError, setTunnelMetricsError] = useState<string | null>(
null,
);
const [tunnelRangeMs, setTunnelRangeMs] = useState(60 * 60 * 1000);
// --- Load tunnel list ---
const loadTunnels = useCallback(async (options?: { silent?: boolean }) => {
const silent = options?.silent ?? false;
if (!silent) setTunnelsLoading(true);
try {
const response = await getMonitorTunnels();
@@ -427,12 +583,14 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
setAccessDenied(null);
setTunnelsError(null);
setTunnels(response.data);
return;
}
if (response.code === 403) {
setAccessDenied(response.msg || "暂无监控权限,请联系管理员授权");
setTunnelsError(null);
setTunnels([]);
return;
}
setTunnelsError(response.msg || "加载隧道列表失败");
@@ -452,6 +610,7 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
const response = await getConfigByName(
MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY,
);
setMonitorTunnelQualityEnabled(
typeof response.data?.value === "string"
? response.data.value === "true"
@@ -477,7 +636,9 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
useEffect(() => {
const handleMonitorTunnelQualityEnabledChanged = (event: Event) => {
const enabled = (event as CustomEvent<{ enabled?: boolean }>).detail?.enabled;
const enabled = (event as CustomEvent<{ enabled?: boolean }>).detail
?.enabled;
if (typeof enabled === "boolean") {
setMonitorTunnelQualityEnabled(enabled);
if (!enabled) {
@@ -510,31 +671,44 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
const start = end - 5 * 60 * 1000;
const promises = tunnels.map((t) =>
getMonitorTunnelQualityHistory(t.id, start, end).then(res => {
if (res.code === 0 && res.data) {
return { id: t.id, data: res.data };
}
return { id: t.id, data: [] };
}).catch(() => ({ id: t.id, data: [] }))
getMonitorTunnelQualityHistory(t.id, start, end)
.then((res) => {
if (res.code === 0 && res.data) {
return { id: t.id, data: res.data };
}
return { id: t.id, data: [] };
})
.catch(() => ({ id: t.id, data: [] })),
);
const results = await Promise.all(promises);
setQualityHistoryMap(prev => {
setQualityHistoryMap((prev) => {
const nextMap = { ...prev };
results.forEach(r => {
results.forEach((r) => {
const existing = nextMap[r.id] || [];
const merged = [...r.data, ...existing].sort((a,b) => a.timestamp - b.timestamp);
const merged = [...r.data, ...existing].sort(
(a, b) => a.timestamp - b.timestamp,
);
const unique: TunnelQualityApiItem[] = [];
for (const item of merged) {
if (unique.length === 0 || unique[unique.length-1].timestamp !== item.timestamp) {
unique.push(item);
}
if (
unique.length === 0 ||
unique[unique.length - 1].timestamp !== item.timestamp
) {
unique.push(item);
}
}
nextMap[r.id] = unique.slice(-UPTIME_KUMA_BARS_COUNT);
});
return nextMap;
});
};
void fetchHistory();
}
}, [tunnels]);
@@ -542,27 +716,36 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
// --- Load quality snapshots (auto-polling every 10s) ---
const loadQuality = useCallback(async (options?: { silent?: boolean }) => {
const silent = options?.silent ?? false;
if (!silent) setQualityLoading(true);
try {
const response = await getMonitorTunnelQuality();
if (response.code === 0 && Array.isArray(response.data)) {
const map: Record<number, TunnelQualityApiItem> = {};
for (const q of response.data) {
map[q.tunnelId] = q;
}
setQualityMap(map);
// Accumulate locally
setQualityHistoryMap(prev => {
const nextMap = { ...prev };
for (const q of response.data) {
const currentArr = nextMap[q.tunnelId] || [];
const lastItem = currentArr.length > 0 ? currentArr[currentArr.length - 1] : null;
if (!lastItem || lastItem.timestamp !== q.timestamp) {
nextMap[q.tunnelId] = [...currentArr, q].slice(-UPTIME_KUMA_BARS_COUNT);
}
}
return nextMap;
setQualityHistoryMap((prev) => {
const nextMap = { ...prev };
for (const q of response.data) {
const currentArr = nextMap[q.tunnelId] || [];
const lastItem =
currentArr.length > 0 ? currentArr[currentArr.length - 1] : null;
if (!lastItem || lastItem.timestamp !== q.timestamp) {
nextMap[q.tunnelId] = [...currentArr, q].slice(
-UPTIME_KUMA_BARS_COUNT,
);
}
}
return nextMap;
});
}
} catch {
@@ -586,6 +769,7 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
window.clearInterval(qualityTimerRef.current);
qualityTimerRef.current = null;
}
return;
}
@@ -605,19 +789,26 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
const loadQualityHistory = useCallback(
async (tunnelId: number, options?: { silent?: boolean }) => {
const silent = options?.silent ?? false;
if (!silent) setQualityHistoryLoading(true);
try {
const end = Date.now();
const start = end - qualityRangeMs;
const response = await getMonitorTunnelQualityHistory(tunnelId, start, end);
const response = await getMonitorTunnelQualityHistory(
tunnelId,
start,
end,
);
if (response.code === 0 && Array.isArray(response.data)) {
setQualityHistoryError(null);
setQualityHistory(response.data);
return;
}
if (response.code === 403) {
setAccessDenied(response.msg || "暂无监控权限");
return;
}
setQualityHistoryError(response.msg || "加载质量历史失败");
@@ -637,6 +828,7 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
const loadTunnelMetrics = useCallback(
async (tunnelId: number, options?: { silent?: boolean }) => {
const silent = options?.silent ?? false;
if (!silent) setTunnelMetricsLoading(true);
try {
const end = Date.now();
@@ -648,7 +840,9 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
const ordered = [...response.data].sort(
(a, b) => a.timestamp - b.timestamp,
);
setTunnelMetrics(ordered);
return;
}
setTunnelMetricsError(response.msg || "加载流量数据失败");
@@ -695,29 +889,32 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
// Memoize chart data so React.memo sub-components see stable references
const qualityChartData = useMemo(
() => qualityHistory.map((q) => ({
time: formatTimestamp(q.timestamp, qualityRangeMs),
entryToExit: q.entryToExitLatency >= 0 ? q.entryToExitLatency : null,
exitToBing: q.exitToBingLatency >= 0 ? q.exitToBingLatency : null,
entryToExitLoss: q.entryToExitLoss,
exitToBingLoss: q.exitToBingLoss,
})),
() =>
qualityHistory.map((q) => ({
time: formatTimestamp(q.timestamp, qualityRangeMs),
entryToExit: q.entryToExitLatency >= 0 ? q.entryToExitLatency : null,
exitToBing: q.exitToBingLatency >= 0 ? q.exitToBingLatency : null,
entryToExitLoss: q.entryToExitLoss,
exitToBingLoss: q.exitToBingLoss,
})),
[qualityHistory, qualityRangeMs],
);
const tunnelChartData = useMemo(
() => tunnelMetrics.map((m) => ({
time: formatTimestamp(m.timestamp, tunnelRangeMs),
bytesIn: m.bytesIn,
bytesOut: m.bytesOut,
connections: m.connections,
})),
() =>
tunnelMetrics.map((m) => ({
time: formatTimestamp(m.timestamp, tunnelRangeMs),
bytesIn: m.bytesIn,
bytesOut: m.bytesOut,
connections: m.connections,
})),
[tunnelMetrics, tunnelRangeMs],
);
const detailTunnel = detailTunnelId != null
? tunnels.find((t) => t.id === detailTunnelId)
: null;
const detailTunnel =
detailTunnelId != null
? tunnels.find((t) => t.id === detailTunnelId)
: null;
// Aggregate stats
const tunnelStats = useMemo(() => {
@@ -729,9 +926,11 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
// Last quality update timestamp
const lastQualityUpdate = useMemo(() => {
let latest = 0;
for (const q of Object.values(qualityMap)) {
if (q.timestamp > latest) latest = q.timestamp;
}
return latest > 0 ? new Date(latest).toLocaleTimeString("zh-CN") : null;
}, [qualityMap]);
@@ -759,18 +958,28 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3 flex-wrap">
<Button size="sm" variant="flat" onPress={() => {
setDetailTunnelId(null);
setQualityHistory([]);
setTunnelMetrics([]);
}}>
<Button
size="sm"
variant="flat"
onPress={() => {
setDetailTunnelId(null);
setQualityHistory([]);
setTunnelMetrics([]);
}}
>
<ArrowLeft className="w-4 h-4 mr-1" />
返回隧道列表
</Button>
<div className="flex items-center gap-2">
<ArrowRightLeft className={`w-5 h-5 ${detailTunnel.status === 1 ? "text-success" : "text-default-400"}`} />
<ArrowRightLeft
className={`w-5 h-5 ${detailTunnel.status === 1 ? "text-success" : "text-default-400"}`}
/>
<h3 className="text-lg font-semibold">{detailTunnel.name}</h3>
<Chip size="sm" color={detailTunnel.status === 1 ? "success" : "danger"} variant="flat">
<Chip
color={detailTunnel.status === 1 ? "success" : "danger"}
size="sm"
variant="flat"
>
{detailTunnel.status === 1 ? "启用" : "禁用"}
</Chip>
</div>
@@ -784,7 +993,10 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
<Zap className="w-3 h-3" />
入口 → 出口 延迟
</span>
<LatencyDisplay value={quality?.entryToExitLatency} loading={qualityLoading} />
<LatencyDisplay
loading={qualityLoading}
value={quality?.entryToExitLatency}
/>
</CardBody>
</Card>
<Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50">
@@ -793,22 +1005,37 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
<Globe className="w-3 h-3" />
出口 → Bing 延迟
</span>
<LatencyDisplay value={quality?.exitToBingLatency} loading={qualityLoading} />
<LatencyDisplay
loading={qualityLoading}
value={quality?.exitToBingLatency}
/>
</CardBody>
</Card>
<Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50">
<CardBody className="py-3 px-4 flex flex-col items-center justify-center min-h-[5rem]">
<span className="text-[11px] text-default-500 mb-1.5">入口 → 出口 丢包</span>
<span className={`text-sm font-semibold font-mono ${(quality?.entryToExitLoss ?? 0) > 0 ? "text-warning" : ""}`}>
{quality?.entryToExitLoss !== undefined ? `${quality.entryToExitLoss.toFixed(1)}%` : "-"}
<span className="text-[11px] text-default-500 mb-1.5">
入口 → 出口 丢包
</span>
<span
className={`text-sm font-semibold font-mono ${(quality?.entryToExitLoss ?? 0) > 0 ? "text-warning" : ""}`}
>
{quality?.entryToExitLoss !== undefined
? `${quality.entryToExitLoss.toFixed(1)}%`
: "-"}
</span>
</CardBody>
</Card>
<Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50">
<CardBody className="py-3 px-4 flex flex-col items-center justify-center min-h-[5rem]">
<span className="text-[11px] text-default-500 mb-1.5">出口 → Bing 丢包</span>
<span className={`text-sm font-semibold font-mono ${(quality?.exitToBingLoss ?? 0) > 0 ? "text-warning" : ""}`}>
{quality?.exitToBingLoss !== undefined ? `${quality.exitToBingLoss.toFixed(1)}%` : "-"}
<span className="text-[11px] text-default-500 mb-1.5">
出口 → Bing 丢包
</span>
<span
className={`text-sm font-semibold font-mono ${(quality?.exitToBingLoss ?? 0) > 0 ? "text-warning" : ""}`}
>
{quality?.exitToBingLoss !== undefined
? `${quality.exitToBingLoss.toFixed(1)}%`
: "-"}
</span>
</CardBody>
</Card>
@@ -829,7 +1056,8 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
)}
{quality?.timestamp && (
<span className="text-default-400">
· 最近更新: {new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
· 最近更新:{" "}
{new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
</span>
)}
{quality?.errorMessage && (
@@ -844,23 +1072,23 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
{/* ====== Quality History Chart — isolated with React.memo ====== */}
<QualityChartCard
rangeMs={qualityRangeMs}
onRangeChange={setQualityRangeMs}
loading={qualityHistoryLoading}
error={qualityHistoryError}
data={qualityChartData}
error={qualityHistoryError}
loading={qualityHistoryLoading}
rangeMs={qualityRangeMs}
tunnelId={detailTunnelId}
onRangeChange={setQualityRangeMs}
onRefresh={loadQualityHistory}
/>
{/* ====== Traffic Chart — isolated with React.memo ====== */}
<TrafficChartCard
rangeMs={tunnelRangeMs}
onRangeChange={setTunnelRangeMs}
loading={tunnelMetricsLoading}
error={tunnelMetricsError}
data={tunnelChartData}
error={tunnelMetricsError}
loading={tunnelMetricsLoading}
rangeMs={tunnelRangeMs}
tunnelId={detailTunnelId}
onRangeChange={setTunnelRangeMs}
onRefresh={loadTunnelMetrics}
/>
</div>
@@ -871,7 +1099,9 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center gap-3 mb-1">
<Chip color="primary" size="sm" variant="flat">隧道 {tunnelStats.enabled}/{tunnelStats.total}</Chip>
<Chip color="primary" size="sm" variant="flat">
隧道 {tunnelStats.enabled}/{tunnelStats.total}
</Chip>
{lastQualityUpdate ? (
<div className="flex items-center gap-1.5 text-xs text-default-500">
{monitorTunnelQualityEnabled ? (
@@ -893,7 +1123,12 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
</div>
) : null}
<div className="ml-auto">
<Button isLoading={tunnelsLoading} size="sm" variant="flat" onPress={() => loadTunnels()}>
<Button
isLoading={tunnelsLoading}
size="sm"
variant="flat"
onPress={() => loadTunnels()}
>
<RefreshCw className="w-4 h-4 mr-1" />
刷新
</Button>
@@ -920,21 +1155,33 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
className="group relative overflow-hidden shadow-sm border border-divider dark:border-default-100 hover:-translate-y-1 hover:shadow-lg transition-all duration-300 h-full flex flex-col cursor-pointer bg-background"
onClick={() => setDetailTunnelId(tunnel.id)}
>
<div className={`absolute top-0 left-0 right-0 h-1 ${isEnabled ? "bg-success" : "bg-danger"}`} />
<div className={`absolute -right-8 -top-8 w-24 h-24 rounded-full blur-2xl opacity-10 transition-opacity group-hover:opacity-20 ${isEnabled ? "bg-success" : "bg-danger"}`} />
<div
className={`absolute top-0 left-0 right-0 h-1 ${isEnabled ? "bg-success" : "bg-danger"}`}
/>
<div
className={`absolute -right-8 -top-8 w-24 h-24 rounded-full blur-2xl opacity-10 transition-opacity group-hover:opacity-20 ${isEnabled ? "bg-success" : "bg-danger"}`}
/>
<CardHeader className="pb-2 pt-5 px-5 flex flex-row justify-between items-start gap-4">
<div className="flex items-center gap-3 min-w-0">
<div className="relative flex-shrink-0">
<div className="w-10 h-10 rounded-xl bg-default-100 dark:bg-default-50/10 flex items-center justify-center border border-divider">
<ArrowRightLeft className={`w-5 h-5 ${isEnabled ? "text-success" : "text-danger"}`} />
<ArrowRightLeft
className={`w-5 h-5 ${isEnabled ? "text-success" : "text-danger"}`}
/>
</div>
<span className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-background ${isEnabled ? "bg-success" : "bg-danger"}`} />
<span
className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-background ${isEnabled ? "bg-success" : "bg-danger"}`}
/>
</div>
<div className="flex flex-col min-w-0">
<h3 className="font-semibold text-foreground text-sm truncate">{tunnel.name}</h3>
<h3 className="font-semibold text-foreground text-sm truncate">
{tunnel.name}
</h3>
<div className="flex items-center gap-1.5 text-[11px] text-default-500 mt-0.5">
<span className="font-mono">{isEnabled ? "启用" : "禁用"}</span>
<span className="font-mono">
{isEnabled ? "启用" : "禁用"}
</span>
</div>
</div>
</div>
@@ -947,20 +1194,30 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
<Zap className="w-3 h-3" />
入口→出口
</div>
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="entryToExit" latestValue={quality?.entryToExitLatency} />
<UptimeHistoryBar
history={qualityHistoryMap[tunnel.id]}
latestValue={quality?.entryToExitLatency}
type="entryToExit"
/>
</div>
<div className="space-y-1">
<div className="text-[10px] text-default-500 flex items-center gap-1">
<Globe className="w-3 h-3" />
出口→Bing
</div>
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="exitToBing" latestValue={quality?.exitToBingLatency} />
<UptimeHistoryBar
history={qualityHistoryMap[tunnel.id]}
latestValue={quality?.exitToBingLatency}
type="exitToBing"
/>
</div>
</div>
<div className="flex justify-between items-center pt-2 border-t border-divider/50">
{quality?.errorMessage ? (
<span className="text-[11px] text-danger truncate">{quality.errorMessage}</span>
<span className="text-[11px] text-danger truncate">
{quality.errorMessage}
</span>
) : quality?.timestamp ? (
<span className="text-[11px] text-default-500 flex items-center gap-1">
{monitorTunnelQualityEnabled ? (
@@ -968,11 +1225,15 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
) : (
<WifiOff className="w-3 h-3 text-warning" />
)}
{new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
{new Date(quality.timestamp).toLocaleTimeString(
"zh-CN",
)}
</span>
) : (
<span className="text-[11px] text-default-400">
{monitorTunnelQualityEnabled ? "等待探测..." : "实时检测已关闭"}
{monitorTunnelQualityEnabled
? "等待探测..."
: "实时检测已关闭"}
</span>
)}
</div>
@@ -987,7 +1248,8 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
aria-label="隧道列表"
className="overflow-x-auto min-w-full"
classNames={{
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
wrapper:
"bg-transparent p-0 shadow-none border-none overflow-auto rounded-2xl",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
@@ -1006,7 +1268,11 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
const isEnabled = tunnel.status === 1;
return (
<TableRow key={tunnel.id} className="cursor-pointer" onClick={() => setDetailTunnelId(tunnel.id)}>
<TableRow
key={tunnel.id}
className="cursor-pointer"
onClick={() => setDetailTunnelId(tunnel.id)}
>
<TableCell>
<div className="flex items-center gap-1.5">
{isEnabled ? (
@@ -1017,13 +1283,23 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
</div>
</TableCell>
<TableCell>
<span className="font-semibold text-sm whitespace-nowrap">{tunnel.name}</span>
<span className="font-semibold text-sm whitespace-nowrap">
{tunnel.name}
</span>
</TableCell>
<TableCell>
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="entryToExit" latestValue={quality?.entryToExitLatency} />
<UptimeHistoryBar
history={qualityHistoryMap[tunnel.id]}
latestValue={quality?.entryToExitLatency}
type="entryToExit"
/>
</TableCell>
<TableCell>
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="exitToBing" latestValue={quality?.exitToBingLatency} />
<UptimeHistoryBar
history={qualityHistoryMap[tunnel.id]}
latestValue={quality?.exitToBingLatency}
type="exitToBing"
/>
</TableCell>
<TableCell>
{quality?.timestamp ? (
@@ -1033,7 +1309,9 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
) : (
<WifiOff className="w-3.5 h-3.5 text-warning" />
)}
{new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
{new Date(quality.timestamp).toLocaleTimeString(
"zh-CN",
)}
</span>
) : (
<span className="text-xs text-default-400">
+2 -8
View File
@@ -457,10 +457,7 @@ export default function PanelSharingPage() {
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{shares.map((share) => (
<Card
key={share.id}
className="overflow-hidden"
>
<Card key={share.id} className="overflow-hidden">
<CardHeader className="flex justify-between pb-2 md:pb-2">
<h3 className="font-bold">{share.name}</h3>
<div className="flex gap-2">
@@ -604,10 +601,7 @@ export default function PanelSharingPage() {
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{remoteUsageNodes.map((node) => (
<Card
key={node.nodeId}
className="overflow-hidden"
>
<Card key={node.nodeId} className="overflow-hidden">
<CardHeader className="flex justify-between pb-2 md:pb-2">
<h3 className="font-bold">{node.nodeName}</h3>
<span className="text-xs text-default-500">
File diff suppressed because it is too large Load Diff
+67 -23
View File
@@ -219,6 +219,7 @@ export default function UserPage() {
num: 10,
expTime: null,
flowResetTime: 0,
maxConn: 0,
});
const [userFormLoading, setUserFormLoading] = useState(false);
const [quotaResetLoading, setQuotaResetLoading] = useState(false);
@@ -514,6 +515,7 @@ export default function UserPage() {
num: 10,
expTime: null,
flowResetTime: 0,
maxConn: 0,
groupIds: [],
});
onUserModalOpen();
@@ -1080,7 +1082,8 @@ export default function UserPage() {
aria-label="用户列表"
className="overflow-x-auto min-w-full"
classNames={{
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
wrapper:
"bg-transparent p-0 shadow-none border-none overflow-auto rounded-2xl",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
@@ -1108,7 +1111,9 @@ export default function UserPage() {
<div className="flex items-center gap-3">
<div className="flex items-center justify-center shrink-0 w-10 h-10 rounded-full bg-primary text-white font-bold text-sm relative">
{(user.name || user.user).slice(0, 2).toUpperCase()}
<span className={`absolute bottom-0 right-0 w-3 h-3 border-2 border-white dark:border-zinc-900 rounded-full ${userStatus.color === "success" ? "bg-success" : "bg-danger"}`} />
<span
className={`absolute bottom-0 right-0 w-3 h-3 border-2 border-white dark:border-zinc-900 rounded-full ${userStatus.color === "success" ? "bg-success" : "bg-danger"}`}
/>
</div>
<div className="flex flex-col">
<span className="font-medium text-foreground text-sm">
@@ -1119,7 +1124,12 @@ export default function UserPage() {
</span>
</div>
{user.disabledByQuota && (
<Chip className="text-[10px] h-4 px-1 ml-1" color="danger" size="sm" variant="flat">
<Chip
className="text-[10px] h-4 px-1 ml-1"
color="danger"
size="sm"
variant="flat"
>
超额
</Chip>
)}
@@ -1143,17 +1153,24 @@ export default function UserPage() {
</TableCell>
<TableCell>
<div className="flex flex-col gap-0.5 whitespace-nowrap text-[11px]">
{((user.dailyQuotaGB ?? 0) > 0 || (user.monthlyQuotaGB ?? 0) > 0) ? (
<>
<div className="flex gap-1 justify-between">
<span className="text-default-500">日配额:</span>
<span>{formatFlow(user.dailyUsedBytes ?? 0)} / {formatQuotaLimit(user.dailyQuotaGB)}</span>
</div>
<div className="flex gap-1 justify-between">
<span className="text-default-500">月配额:</span>
<span>{formatFlow(user.monthlyUsedBytes ?? 0)} / {formatQuotaLimit(user.monthlyQuotaGB)}</span>
</div>
</>
{(user.dailyQuotaGB ?? 0) > 0 ||
(user.monthlyQuotaGB ?? 0) > 0 ? (
<>
<div className="flex gap-1 justify-between">
<span className="text-default-500">日配额:</span>
<span>
{formatFlow(user.dailyUsedBytes ?? 0)} /{" "}
{formatQuotaLimit(user.dailyQuotaGB)}
</span>
</div>
<div className="flex gap-1 justify-between">
<span className="text-default-500">月配额:</span>
<span>
{formatFlow(user.monthlyUsedBytes ?? 0)} /{" "}
{formatQuotaLimit(user.monthlyQuotaGB)}
</span>
</div>
</>
) : (
<span className="text-default-400">无配额</span>
)}
@@ -1171,51 +1188,53 @@ export default function UserPage() {
</span>
{expStatus && (
<Chip
className="h-5 px-1 text-[10px]"
color={expStatus.color}
size="sm"
variant="flat"
className="h-5 px-1 text-[10px]"
>
{expStatus.text}
</Chip>
)}
</>
) : (
<span className="text-default-400 text-sm">无限期</span>
<span className="text-default-400 text-sm">
无限期
</span>
)}
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
<Button
className="h-6 px-2 min-w-0 text-xs bg-emerald-50 text-emerald-600 hover:bg-emerald-100 dark:bg-emerald-950/30 dark:text-emerald-400"
size="sm"
variant="flat"
className="h-6 px-2 min-w-0 text-xs bg-emerald-50 text-emerald-600 hover:bg-emerald-100 dark:bg-emerald-950/30 dark:text-emerald-400"
onPress={() => handleManageTunnels(user)}
>
权限
</Button>
<Button
size="sm"
variant="flat"
className="h-6 px-2 min-w-0 text-xs bg-amber-50 text-amber-600 hover:bg-amber-100 dark:bg-amber-950/30 dark:text-amber-400"
size="sm"
title="重置流量"
variant="flat"
onPress={() => handleResetFlow(user)}
>
重置
</Button>
<Button
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
size="sm"
variant="flat"
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
onPress={() => handleEdit(user)}
>
编辑
</Button>
<Button
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
size="sm"
variant="flat"
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
onPress={() => handleDelete(user)}
>
删除
@@ -1252,7 +1271,9 @@ export default function UserPage() {
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex items-center justify-center shrink-0 w-10 h-10 rounded-full bg-primary text-white font-bold text-sm relative">
{(user.name || user.user).slice(0, 2).toUpperCase()}
<span className={`absolute bottom-0 right-0 w-3 h-3 border-2 border-white dark:border-zinc-900 rounded-full ${userStatus.color === "success" ? "bg-success" : "bg-danger"}`} />
<span
className={`absolute bottom-0 right-0 w-3 h-3 border-2 border-white dark:border-zinc-900 rounded-full ${userStatus.color === "success" ? "bg-success" : "bg-danger"}`}
/>
</div>
<div className="flex flex-col min-w-0">
<h3 className="font-semibold text-foreground truncate text-sm">
@@ -1497,6 +1518,28 @@ export default function UserPage() {
setUserForm((prev) => ({ ...prev, flow: value }));
}}
/>
<Input
label="最大连接数"
placeholder="0 或空表示不限制"
type="number"
min="0"
value={userForm.maxConn === 0 ? "" : String(userForm.maxConn || "")}
onChange={(e) => {
const value = Math.max(Number(e.target.value) || 0, 0);
setUserForm((prev) => ({ ...prev, maxConn: value }));
}}
/>
<Input
label="最大连接数"
placeholder="0 或空表示不限制"
type="number"
min="0"
value={userForm.maxConn === 0 ? "" : String(userForm.maxConn || "")}
onChange={(e) => {
const value = Math.max(Number(e.target.value) || 0, 0);
setUserForm((prev) => ({ ...prev, maxConn: value }));
}}
/>
<Input
isRequired
label="规则数量"
@@ -1748,7 +1791,7 @@ export default function UserPage() {
流量限制、规则数量、到期时间、流量重置时间将自动继承用户设置
</div>
<div className="grid gap-2 max-h-72 overflow-y-auto pr-1">
<div className="grid gap-2 max-h-72 overflow-y-auto pr-1 scrollbar-hide">
{tunnels.map((tunnel) => {
const isAssigned = isTunnelAssigned(tunnel.id);
const isSelected = batchTunnelSelections.has(tunnel.id);
@@ -1907,6 +1950,7 @@ export default function UserPage() {
<TableColumn>规则数量</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn>限速规则</TableColumn>
<TableColumn>最大连接</TableColumn>
<TableColumn>重置时间</TableColumn>
<TableColumn>到期时间</TableColumn>
<TableColumn>操作</TableColumn>
@@ -19,10 +19,5 @@ export function CardHeader({
}
export function CardBody({ className, ...props }: React.ComponentProps<"div">) {
return (
<CardContent
className={cn("p-4 md:p-6", className)}
{...props}
/>
);
return <CardContent className={cn("p-4 md:p-6", className)} {...props} />;
}
@@ -51,7 +51,11 @@ export interface DropdownMenuProps {
export function DropdownMenu({ children, className }: DropdownMenuProps) {
const { align } = React.useContext(DropdownContext);
return <DropdownMenuContent align={align} className={className}>{children}</DropdownMenuContent>;
return (
<DropdownMenuContent align={align} className={className}>
{children}
</DropdownMenuContent>
);
}
export interface DropdownItemProps {
@@ -3,6 +3,7 @@ import * as React from "react";
import {
Dialog,
DialogContent as BaseDialogContent,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
@@ -159,6 +160,7 @@ export function ModalContent({
showCloseButton={false}
{...props}
>
<DialogTitle className="sr-only">Modal Dialog</DialogTitle>
{renderedChildren}
</BaseDialogContent>
);
@@ -296,7 +296,7 @@ export function Select<T>({
<div
ref={listboxRef}
className={cn(
"absolute left-0 z-50 w-full space-y-1 overflow-y-auto rounded-md border border-divider bg-background p-2 shadow-md max-h-56",
"absolute left-0 z-50 w-full space-y-1 overflow-y-auto rounded-md border border-divider bg-background p-2 shadow-md max-h-56 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
placementClasses,
)}
id={`${generatedId}-listbox`}
@@ -414,8 +414,8 @@ export function Select<T>({
<option value="">{placeholder ?? "请选择"}</option>
{options.map((option) => (
<option
className="bg-background text-foreground"
key={option.key}
className="bg-background text-foreground"
disabled={disabled.has(option.key)}
value={option.key}
>
+44 -24
View File
@@ -5,6 +5,8 @@
* re-renders when the active theme changes.
*/
import type { ThemePackage, ComponentKey, LayoutKey, PageKey } from "./types";
import React, {
createContext,
useContext,
@@ -14,7 +16,6 @@ import React, {
useMemo,
} from "react";
import type { ThemePackage, ComponentKey, LayoutKey, PageKey } from "./types";
import {
subscribe,
getActiveTheme,
@@ -59,11 +60,20 @@ interface ThemeContextValue {
unregister: (id: string) => void;
/** Resolve a component (returns themed override or fallback). */
component: <P = any>(key: ComponentKey, fallback: React.ComponentType<P>) => React.ComponentType<P>;
component: <P = any>(
key: ComponentKey,
fallback: React.ComponentType<P>,
) => React.ComponentType<P>;
/** Resolve a layout. */
layout: (key: LayoutKey, fallback: React.ComponentType<{ children: React.ReactNode }>) => React.ComponentType<{ children: React.ReactNode }>;
layout: (
key: LayoutKey,
fallback: React.ComponentType<{ children: React.ReactNode }>,
) => React.ComponentType<{ children: React.ReactNode }>;
/** Resolve a page. */
page: <P = any>(key: PageKey, fallback: React.ComponentType<P>) => React.ComponentType<P>;
page: <P = any>(
key: PageKey,
fallback: React.ComponentType<P>,
) => React.ComponentType<P>;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
@@ -73,6 +83,7 @@ const ThemeContext = createContext<ThemeContextValue | null>(null);
// We use a monotonic counter to create new snapshot references when the
// registry notifies.
let snapshotCounter = 0;
function getSnapshot() {
return snapshotCounter;
}
@@ -81,6 +92,7 @@ const originalSubscribe = (onStoreChange: () => void) => {
snapshotCounter++;
onStoreChange();
});
return unsub;
};
@@ -98,7 +110,9 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
useEffect(() => {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const handler = () => reapplyActiveTheme();
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
@@ -115,27 +129,27 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const register = useCallback((pkg: ThemePackage) => registerTheme(pkg), []);
const unregister = useCallback((id: string) => unregisterTheme(id), []);
const value = useMemo<ThemeContextValue>(() => ({
activeTheme: getActiveTheme(),
activeThemeId: getActiveThemeId(),
themes: getRegisteredThemes(),
mode: getSavedMode(),
effectiveMode: getEffectiveMode(),
switchTheme,
resetTheme,
setMode,
register,
unregister,
component: resolveComponent,
layout: resolveLayout,
page: resolvePage,
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [snapshotCounter, switchTheme, resetTheme, setMode, register, unregister]);
const value = useMemo<ThemeContextValue>(
() => ({
activeTheme: getActiveTheme(),
activeThemeId: getActiveThemeId(),
themes: getRegisteredThemes(),
mode: getSavedMode(),
effectiveMode: getEffectiveMode(),
switchTheme,
resetTheme,
setMode,
register,
unregister,
component: resolveComponent,
layout: resolveLayout,
page: resolvePage,
}),
[snapshotCounter, switchTheme, resetTheme, setMode, register, unregister],
);
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
);
};
@@ -144,7 +158,10 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
/** Access the full theme context. */
export function useThemeContext(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useThemeContext must be used within <ThemeProvider>");
if (!ctx)
throw new Error("useThemeContext must be used within <ThemeProvider>");
return ctx;
}
@@ -166,6 +183,7 @@ export function useThemedComponent<P = any>(
fallback: React.ComponentType<P>,
): React.ComponentType<P> {
const ctx = useThemeContext();
return ctx.component(key, fallback);
}
@@ -175,6 +193,7 @@ export function useThemedLayout(
fallback: React.ComponentType<{ children: React.ReactNode }>,
): React.ComponentType<{ children: React.ReactNode }> {
const ctx = useThemeContext();
return ctx.layout(key, fallback);
}
@@ -184,5 +203,6 @@ export function useThemedPage<P = any>(
fallback: React.ComponentType<P>,
): React.ComponentType<P> {
const ctx = useThemeContext();
return ctx.page(key, fallback);
}
@@ -10,10 +10,11 @@
* or you can build a completely new component from scratch.
*/
import type { ButtonProps } from "@/shadcn-bridge/heroui/button";
import React from "react";
// Import the original Button's props interface for compatibility
import type { ButtonProps } from "@/shadcn-bridge/heroui/button";
// Optionally import the original to wrap it
import { Button as OriginalButton } from "@/shadcn-bridge/heroui/button";
@@ -12,6 +12,7 @@
*/
import type { ThemePackage } from "../types";
import { CyberpunkButton } from "./components/button";
const cyberpunkTheme: ThemePackage = {
+31 -3
View File
@@ -9,7 +9,13 @@
* integration lives in `./context.tsx`.
*/
import type { ThemePackage, ThemeTokens, ComponentKey, LayoutKey, PageKey } from "./types";
import type {
ThemePackage,
ThemeTokens,
ComponentKey,
LayoutKey,
PageKey,
} from "./types";
// ─── internal state ──────────────────────────────────────────────────────────
@@ -62,7 +68,7 @@ export function getActiveThemeId(): string | null {
/** Get the currently active ThemePackage (or null). */
export function getActiveTheme(): ThemePackage | null {
return activeId ? installed.get(activeId) ?? null : null;
return activeId ? (installed.get(activeId) ?? null) : null;
}
// ─── theme mode ──────────────────────────────────────────────────────────────
@@ -71,13 +77,18 @@ export type ThemeMode = "light" | "dark" | "system";
export function resolveSystemMode(): "light" | "dark" {
if (typeof window === "undefined") return "light";
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
export function getSavedMode(): ThemeMode {
if (typeof window === "undefined") return "system";
const raw = localStorage.getItem(MODE_KEY);
if (raw === "light" || raw === "dark" || raw === "system") return raw;
return "system";
}
@@ -88,6 +99,7 @@ export function saveMode(mode: ThemeMode): void {
export function getEffectiveMode(): "light" | "dark" {
const mode = getSavedMode();
return mode === "system" ? resolveSystemMode() : mode;
}
@@ -103,8 +115,10 @@ export function getEffectiveMode(): "light" | "dark" {
*/
export function activateTheme(id: string): void {
const pkg = installed.get(id);
if (!pkg) {
console.warn(`[FLVX themes] Theme "${id}" is not registered.`);
return;
}
@@ -116,6 +130,7 @@ export function activateTheme(id: string): void {
// Inject tokens
const mode = getEffectiveMode();
const tokens = mode === "dark" ? pkg.tokens?.dark : pkg.tokens?.light;
if (tokens) injectTokens(tokens);
// Inject custom CSS
@@ -128,6 +143,7 @@ export function activateTheme(id: string): void {
// Update dark class
const root = document.documentElement;
root.classList.toggle("dark", mode === "dark");
root.style.colorScheme = mode;
@@ -143,6 +159,7 @@ export function activateTheme(id: string): void {
/** Deactivate the current theme, reverting all overrides. */
export function deactivateTheme(): void {
const prev = activeId ? installed.get(activeId) : null;
prev?.onDeactivate?.();
// Remove injected tokens
@@ -163,20 +180,24 @@ export function reapplyActiveTheme(): void {
const id = activeId;
// quick re-inject without full lifecycle
const pkg = installed.get(id);
if (!pkg) return;
clearInjectedTokens();
const mode = getEffectiveMode();
const tokens = mode === "dark" ? pkg.tokens?.dark : pkg.tokens?.light;
if (tokens) injectTokens(tokens);
const root = document.documentElement;
root.classList.toggle("dark", mode === "dark");
root.style.colorScheme = mode;
} else {
// No theme active — just set dark class based on mode
const mode = getEffectiveMode();
const root = document.documentElement;
root.classList.toggle("dark", mode === "dark");
root.style.colorScheme = mode;
}
@@ -195,6 +216,7 @@ export function resolveComponent<P = any>(
): React.ComponentType<P> {
const pkg = activeId ? installed.get(activeId) : null;
const override = pkg?.components?.[key];
return (override as React.ComponentType<P>) ?? fallback;
}
@@ -204,6 +226,7 @@ export function resolveLayout(
fallback: React.ComponentType<{ children: React.ReactNode }>,
): React.ComponentType<{ children: React.ReactNode }> {
const pkg = activeId ? installed.get(activeId) : null;
return pkg?.layouts?.[key] ?? fallback;
}
@@ -214,6 +237,7 @@ export function resolvePage<P = any>(
): React.ComponentType<P> {
const pkg = activeId ? installed.get(activeId) : null;
const override = pkg?.pages?.[key];
return (override as React.ComponentType<P>) ?? fallback;
}
@@ -221,6 +245,7 @@ export function resolvePage<P = any>(
export function subscribe(listener: ChangeListener): () => void {
changeListeners.add(listener);
return () => changeListeners.delete(listener);
}
@@ -232,6 +257,7 @@ export function subscribe(listener: ChangeListener): () => void {
*/
export function initThemeSystem(): void {
const savedId = localStorage.getItem(STORAGE_KEY);
if (savedId && installed.has(savedId)) {
activateTheme(savedId);
} else {
@@ -246,6 +272,7 @@ const injectedVars: string[] = [];
function injectTokens(tokens: ThemeTokens): void {
const root = document.documentElement;
for (const [varName, value] of Object.entries(tokens)) {
if (value !== undefined) {
root.style.setProperty(varName, value);
@@ -256,6 +283,7 @@ function injectTokens(tokens: ThemeTokens): void {
function clearInjectedTokens(): void {
const root = document.documentElement;
for (const varName of injectedVars) {
root.style.removeProperty(varName);
}
+8 -2
View File
@@ -182,7 +182,11 @@ export type ComponentKey =
* All overridable layout keys.
* Layouts receive `{ children: React.ReactNode }` as props.
*/
export type LayoutKey = "AdminLayout" | "H5Layout" | "H5SimpleLayout" | "DefaultLayout";
export type LayoutKey =
| "AdminLayout"
| "H5Layout"
| "H5SimpleLayout"
| "DefaultLayout";
/**
* All overridable page keys.
@@ -283,7 +287,9 @@ export interface ThemePackage {
/**
* Map of layout overrides. Each layout receives `{ children }`.
*/
layouts?: Partial<Record<LayoutKey, ComponentType<{ children: React.ReactNode }>>>;
layouts?: Partial<
Record<LayoutKey, ComponentType<{ children: React.ReactNode }>>
>;
/**
* Map of page overrides. Each page is a full route-level component.
+4 -1
View File
@@ -44,6 +44,7 @@ export interface UserForm {
num: number;
expTime: Date | null;
flowResetTime: number;
maxConn?: number;
groupIds?: number[];
}
@@ -56,7 +57,8 @@ export interface UserTunnel {
flow: number; // 流量限制(GB)
num: number; // 转发数量
expTime: number; // 过期时间戳
flowResetTime: number; // 流量重置日期
flowResetTime: number;
maxConn?: number; // 流量重置日期
speedId?: number | null; // 限速规则ID
speedLimitName?: string; // 限速规则名称
inFlow?: number; // 下载流量(字节)
@@ -70,6 +72,7 @@ export interface UserTunnelForm {
num: number;
expTime: Date | null;
flowResetTime: number;
maxConn?: number;
speedId: number | null;
}
+2 -1
View File
@@ -2,8 +2,9 @@ import { clearSession } from "@/utils/session";
/**
* 安全退出登录函数
* 清除登录相关数据,但保留用户偏好设置(如主题)
* 清除登录相关数据,并强制刷新跳转到首页
*/
export const safeLogout = () => {
clearSession();
window.location.href = "/";
};