Compare commits

...

51 Commits

Author SHA1 Message Date
sagit 18445ec063 feat(ui): add list view for node and user pages (#350)
add list view feature
2026-03-20 10:02:11 +08:00
sagitchu 08bc91e5c9 feat(ui): add list view for node and user pages 2026-03-20 10:00:56 +08:00
sagit 2f424bea31 feat: monitor tunnel top level (#347)
Merge chore/force-update sync
2026-03-19 15:46:02 +08:00
sagitchu bc75ed745d feat: monitor tunnel top level 2026-03-19 15:44:25 +08:00
sagit 78fb9a31d6 chore: Delete openspec documentation, AI agent configurations, and development plans. (#346) 2026-03-19 06:22:51 +00:00
sagitchu d3ed2e8856 chore: Delete openspec documentation, AI agent configurations, and development plans. 2026-03-19 14:16:07 +08:00
sagit db21ce6bb4 feat: implement monitor page list view (#345) 2026-03-19 13:54:12 +08:00
sagit 76ad841231 chore: release 2.1.9-alpha5 (#344)
Release 2.1.9-alpha5
2026-03-19 11:49:43 +08:00
sagitchu d0535707dc chore: release 2.1.9-alpha5 and update project knowledge base 2026-03-19 11:48:03 +08:00
sagit 6458b5af00 feat: beautify monitor tab and improve user page (#343) 2026-03-18 22:46:02 +08:00
sagit 555039e028 feat: monitor page redesign and node card cleanup (#340) 2026-03-18 18:57:23 +08:00
sagit 7134253b2c feat: redesign monitor view (#339)
Redesign monitor view
2026-03-18 17:40:19 +08:00
sagitchu 58d29b440a fix(ci): remove unused variables to fix TS build 2026-03-18 17:39:09 +08:00
sagitchu 6a3a9add08 feat: redesign monitor view 2026-03-18 17:21:39 +08:00
sagitchu 6d986524f1 fix: remaining changes in forward 2026-03-18 15:51:27 +08:00
sagitchu 92a8fed796 feat: redesign monitor page to nezha-style server grid 2026-03-18 15:48:48 +08:00
sagit b314192621 feat(monitoring): add node/tunnel metrics, service monitors, and health checks (#331)
## Summary
- Add comprehensive monitoring system with
NodeMetric/TunnelMetric/ServiceMonitor models
- Implement metrics ingestion service with per-minute bucket aggregation
and upsert support
- Add health checker for node connectivity monitoring with configurable
intervals
- Wire node metrics from WebSocket SystemInfo messages to metrics
service
- Add tunnel metrics ingestion from flow upload endpoint with
transaction support
- Create monitoring REST API endpoints for nodes, tunnels, and services
- Implement service monitor CRUD and execution (TCP/ICMP health checks)
- Add MonitorPermission model for non-admin access control to monitoring
features
- Create frontend monitor page with node/tunnel/service views
- Include schema migration (v6) for tunnel_metric unique index and
deduplication
- Fix tunnel entry port conflict validation to use transaction (Tx
variants)
2026-03-18 15:12:22 +08:00
sagit 1e5f9bfb04 Merge branch 'main' into opencode/shiny-falcon 2026-03-18 14:17:06 +08:00
sagitchu 5972378897 fix: resolve merge conflicts and fix monitoring bugs
- Add missing 'uptime' field to NodeMetricApiItem type definition
- Fix WS message handling: non-UpgradeProgress typed messages now
  broadcast via broadcastInfo instead of being silently dropped
- Strengthen looksLikeSystemInfoMessage heuristic to require ≥3
  matching keys to avoid false positives
- Fix tab/space indentation inconsistency in admin.tsx useEffect
- Remove duplicate method declarations from merge (repository_control,
  mutations)
- Update tunnel_entry_sqlite_test to use renamed Tx suffix function
2026-03-18 14:12:47 +08:00
sagitchu 455900ba41 Merge branch 'main' into opencode/shiny-falcon
# Conflicts:
#	go-backend/internal/http/handler/mutations.go
#	go-backend/tests/contract/issue313_entry_port_conflict_contract_test.go
2026-03-18 14:09:00 +08:00
sagit 85e57213ee chore: update knowledge base metadata for release 2.1.8 (#337)
Updating AGENTS.md with new release version and current commit hash.
2026-03-18 13:52:28 +08:00
sagitchu 1377061234 chore: update knowledge base metadata for release 2.1.8 2026-03-18 13:49:41 +08:00
sagit ea21a7deef fix(user): improve tunnel selector contrast in dark mode (#336) 2026-03-18 04:10:32 +00:00
sagit 9b98194a0a feat(tunnel): add delete rule resolution settings (#335)
- Add backend API for tunnel delete rule resolution (allow, deny, confirm)
- Add contract tests for delete resolution endpoint
- Add frontend API types and endpoints for delete resolution
- Add tunnel delete resolution settings UI with resolution mode selector
- Support per-tunnel and global delete resolution configuration
2026-03-18 10:05:03 +08:00
sagit 2df061a19f fix(backend): resolve SQLite deadlock in tunnel entry updates (#334)
- Fix deadlock when updating tunnel entries with offline nodes
- Add test file for tunnel entry SQLite operations
- Update contract tests for entry port conflict and limiter sync
- Add plan documents for SQLite deadlock fix and contract semantics
2026-03-18 09:00:20 +08:00
sagitchu 46a60376c4 Merge remote-tracking branch 'origin/main' into opencode/shiny-falcon
# Conflicts:
#	vite-frontend/src/pages/node.tsx
#	vite-frontend/src/pages/user.tsx
2026-03-17 15:18:25 +08:00
sagitchu 9de240f034 feat(monitoring): add node/tunnel metrics, service monitors, and health checks
- Add NodeMetric/TunnelMetric/ServiceMonitor models and repository methods
- Implement metrics ingestion service with per-minute bucket aggregation
- Add health checker for node connectivity monitoring
- Wire node metrics from WebSocket SystemInfo messages
- Add tunnel metrics ingestion from flow upload endpoint
- Create monitoring REST API endpoints for nodes, tunnels, services
- Implement service monitor CRUD and execution (TCP/ICMP checks)
- Add MonitorPermission for non-admin access control
- Create frontend monitor page with node/tunnel/service views
- Add tunnel metrics ingestion from agent flow reports
- Include schema migration for tunnel_metric unique index
- Fix tunnel entry port conflict validation to use transaction

Entire-Checkpoint: 030821a7c8e3
2026-03-17 14:59:09 +08:00
sagit 41ef814643 fix(forward): make force-delete work with offline nodes
Bypass DeleteService in force-delete and remove forward records directly, allowing deletion when nodes are offline.
2026-03-16 14:15:28 +00:00
sagit 6c7b4817f9 fix(forward): stabilize selection in non-compact grouped view
Prevent cascading checkbox toggles and scope select-all per tunnel group; update grouped styling to neutral gray.
2026-03-16 12:04:29 +00:00
sagit 7507507fd9 fix(forward): show all users by default in non-compact mode
Restore 2.1.8-beta9 admin default filtering when compact mode is off; keep compact mode focused on self.
2026-03-16 10:06:41 +00:00
sagit ff94406945 feat(node): restore info button popover style for remark/renewal info (#327)
Restore the 2.1.8-beta9 style of displaying node remark and renewal
info via an info button (ℹ️) in the CardHeader with a hover popover,
instead of inline display in the CardBody.

- Add infoPopoverPlacement state and updateInfoPopoverPlacement callback
- Add info button with hover popover showing expiry reminder and remark
- Restore drag handle with touch support and responsive visibility
- Remove inline info display from CardBody
2026-03-16 11:42:23 +08:00
sagit 9aa13c4dfb fix: remove tunnel name from card view (#326)
## Summary
- Remove tunnel name display from card view since it's already shown in
the group header
2026-03-16 10:59:18 +08:00
sagitchu 4a8c400944 fix: remove tunnel name and ratio from card view (shown in group header) 2026-03-16 10:57:56 +08:00
sagit 18e7ec94a8 fix: restore copy functionality for entry/target fields in non-compact table view and hide redundant tunnel name in grouped card view (#325) 2026-03-16 02:36:06 +00:00
sagit 02f2a1c8b3 fix: add missing renderCard prop to SortableForwardCard in non-compact card view (#324)
## Summary
- Fix TypeError "renderCard is not a function" when using non-compact
card view mode on the rules page
- The `SortableForwardCard` component was missing the required
`renderCard` prop in the non-compact grouped view
2026-03-16 09:28:11 +08:00
sagitchu 681a0bef48 fix: add missing renderCard prop to SortableForwardCard in non-compact card view 2026-03-16 09:26:42 +08:00
sagit 67bf5be0f2 Restore removed features and keep UI improvements (#322) 2026-03-15 23:35:25 +08:00
sagitchu efb613b0b5 Fix TypeScript compilation errors
- Add isIndeterminate support to Checkbox component
- Use showAddressModal in SortableTableRow for multi-address display
- Remove unused onClose parameter in search modal
- Remove unused infoPopoverPlacement and related code in node.tsx
2026-03-15 23:31:39 +08:00
sagitchu 8124e59de5 Roll back port column separation in forward table
- Merge entry address:port into single '入口' column
- Merge target address:port into single '目标' column
- Remove separate port columns from compact table
- Keep UI improvements: always show checkbox, selection highlight
2026-03-15 23:12:48 +08:00
sagitchu ac8c293ff3 Document forward.tsx refactoring review results 2026-03-15 23:01:29 +08:00
sagitchu 4e38b73cac Keep UI improvements: Modal styles, expiryReminderDismissed, BatchActionResultModal 2026-03-15 22:51:09 +08:00
sagitchu 8f336377f6 Revert user page search bar to inline implementation for consistency 2026-03-15 22:28:44 +08:00
sagitchu 5f78dd66fc Update plan: all restoration tasks completed 2026-03-15 22:12:51 +08:00
sagitchu f2ee939006 Restore BatchActionResultModal usage in tunnel.tsx 2026-03-15 22:12:33 +08:00
sagitchu 23d2060742 Restore BatchActionResultModal usage in forward.tsx
- Add BatchOperationFailure type import
- Add BatchActionResultModal component import
- Add BatchResultModalState interface and empty state constant
- Add batchResultModal state
- Add presentBatchOutcome callback for unified batch operation result handling
- Update handleBatchDelete to use presentBatchOutcome
- Add BatchActionResultModal rendering at end of component
2026-03-15 21:59:26 +08:00
sagitchu bb0da0b769 Restore version badge and FLVX branding 2026-03-15 21:33:35 +08:00
sagitchu e51af4be1f Revert backend address description to main version 2026-03-15 21:18:11 +08:00
sagitchu a82f3a75b0 Update plan document for PR #322 restoration 2026-03-15 21:08:11 +08:00
sagitchu 7d07fe08b7 Restore removed features from PR #322
- Add back BatchOperationFailure type and batch failure handling functions
- Add back dismissNodeExpiryReminder API endpoint
- Add back update channel selection UI in config page
- Keep simplified version display in version-footer.tsx
2026-03-15 21:07:08 +08:00
abai569ok 375877b223 2.1.8-beta1.7 2026-03-15 20:33:53 +08:00
sagit 004daeadb6 fix: add retry logic for tunnel chain and federation middle-hop failover (#321) 2026-03-15 11:13:46 +08:00
99 changed files with 13575 additions and 1982 deletions
-84
View File
@@ -1,84 +0,0 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Task",
"hooks": [
{
"type": "command",
"command": "entire hooks claude-code post-task"
}
]
},
{
"matcher": "TodoWrite",
"hooks": [
{
"type": "command",
"command": "entire hooks claude-code post-todo"
}
]
}
],
"PreToolUse": [
{
"matcher": "Task",
"hooks": [
{
"type": "command",
"command": "entire hooks claude-code pre-task"
}
]
}
],
"SessionEnd": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "entire hooks claude-code session-end"
}
]
}
],
"SessionStart": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "entire hooks claude-code session-start"
}
]
}
],
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "entire hooks claude-code stop"
}
]
}
],
"UserPromptSubmit": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "entire hooks claude-code user-prompt-submit"
}
]
}
]
},
"permissions": {
"deny": [
"Read(./.entire/metadata/**)"
]
}
}
@@ -0,0 +1,62 @@
# 功能请求:在规则页面显示隧道倍率
## 问题描述
当前规则(Forward)页面在列表中显示隧道名称,但**不显示隧道的流量倍率(trafficRatio)**。管理员在管理规则时无法快速查看该规则所使用的隧道倍率信息,需要跳转到隧道页面才能查看。
## 期望行为
在规则列表页面中,在隧道名称旁边或单独列显示该隧道的流量倍率(例如:`1x`, `0.5x`, `2x`)。
## 建议实现位置
### 前端修改
1. **`vite-frontend/src/pages/forward.tsx`**
- 在 `Forward` interface 中添加 `tunnelTrafficRatio?: number` 字段
- 在表格列中添加倍率显示(可以在隧道名称 Chip 旁边或单独一列)
- 从 `userTunnel` 或 `getTunnelList` API 获取隧道倍率信息
2. **显示格式建议**
```tsx
<Chip className="...">
{forward.tunnelName} ({forward.tunnelTrafficRatio}x)
</Chip>
```
或者单独一列:
```tsx
<TableCell>
{forward.tunnelTrafficRatio}x
</TableCell>
```
### 后端修改
1. **`go-backend/internal/http/handler/handler.go`**
- 在 `forwardList` 接口返回中添加隧道的 `trafficRatio` 字段
- 需要在查询 Forward 时 JOIN Tunnel 表获取倍率信息
2. **或者在前端加载规则后,批量获取隧道信息**
- 调用 `getTunnelList` 获取所有隧道信息
- 根据 `tunnelId` 匹配倍率
## 相关文件
- 前端:`vite-frontend/src/pages/forward.tsx`
- 前端类型:`vite-frontend/src/api/types.ts`
- 后端:`go-backend/internal/http/handler/handler.go`
- 隧道类型定义:`vite-frontend/src/api/types.ts` (TunnelApiItem)
## 优先级
中等 - 不影响核心功能,但能提升管理效率
## 截图参考
隧道页面已显示倍率:
- 位置:隧道卡片统计信息区域
- 显示格式:`流量倍率 {trafficRatio}x`
---
**Labels**: `enhancement`, `frontend`, `backend`, `ui/ux`
+3
View File
@@ -62,6 +62,9 @@ go-gost/ss/
.classpath
.project
.settings/
# OpenCode session metadata
.entire/
bin/
tmp/
*.swp
@@ -1,71 +0,0 @@
# Plan: 搭建开发环境
## 目标
为 Flux Panel 项目安装所有缺失的开发依赖,使 3 个子项目都能本地开发和构建。
## 当前状态
### ✅ 已安装
| 工具 | 版本 | 用途 |
|------|------|------|
| Node.js | v20.19.2 | vite-frontend |
| npm | 9.2.0 | vite-frontend |
| Go | 1.24.4 | go-gost |
| Docker | 29.1.4 | 容器化部署 |
### ❌ 缺失
| 工具 | 需求版本 | 用途 |
|------|----------|------|
| Java | 21 | springboot-backend |
| Maven | 3.x | 构建后端 |
| Docker Compose | v2 | 容器编排 |
---
## 执行任务
### Task 1: 安装 Java 21
```bash
apt-get update && apt-get install -y openjdk-21-jdk
```
**验证**: `java -version` 应显示 openjdk 21
### Task 2: 安装 Maven
```bash
apt-get install -y maven
```
**验证**: `mvn -v` 应显示 Maven 3.x
### Task 3: 安装 Docker Compose Plugin
```bash
apt-get install -y docker-compose-plugin
```
**验证**: `docker compose version` 应显示版本号
### Task 4: 安装前端依赖
```bash
cd /root/flux-panel/vite-frontend && npm install
```
**验证**: `node_modules/` 目录存在
### Task 5: 验证后端可构建
```bash
cd /root/flux-panel/springboot-backend && mvn clean compile -q
```
**验证**: 编译成功无错误
### Task 6: 验证 Go 模块
```bash
cd /root/flux-panel/go-gost && go mod download
```
**验证**: 依赖下载成功
---
## 完成标准
- [ ] `java -version` → openjdk 21
- [ ] `mvn -v` → Maven 3.x
- [ ] `docker compose version` → v2.x
- [ ] 前端: `npm run dev` 可启动
- [ ] 后端: `mvn compile` 成功
- [ ] Go: `go build .` 成功
-33
View File
@@ -1,33 +0,0 @@
# Issue #211: 转发自定义监听IP / 隧道指定连接IP
## 需求总结
1. **节点**: 高级配置增加"额外IP地址"字段(逗号分隔)
2. **转发**: 创建/编辑时可指定入口监听IP
3. **隧道**: 配置出口节点时可指定连接IP
---
## 任务清单
### 后端
- [x] 1. 数据模型扩展 - Node/ForwardPort/ChainTunnel 增加字段
- [x] 2. Repository - CreateNode/UpdateNode 处理 extraIPs
- [x] 3. Repository - resolveForwardIngress 使用 forward_port.in_ip
- [x] 4. Repository - GetNodeAllIPs 辅助函数(返回节点所有可用IP)
- [x] 5. Handler - 转发创建/更新处理 inIp 参数
- [x] 6. Handler - 隧道出口节点处理 connectIp 参数
- [x] 7. Handler - 节点API返回 extraIPs 字段
### 前端
- [x] 8. 节点编辑页 - 高级配置增加"额外IP"输入
- [x] 9. 转发编辑弹窗 - 增加"监听IP"下拉选择
- [x] 10. 隧道配置页 - 出口节点增加"连接IP"输入
---
## 完成进度
- 开始时间: 2026-03-02
- 完成时间: 2026-03-02
- 完成任务: 10/10
- 后端完成: ✅
- 前端完成: ✅
+3 -3
View File
@@ -1,9 +1,9 @@
# PROJECT KNOWLEDGE BASE
**Generated:** Thu Feb 26 2026
**Commit:** 21008cc
**Generated:** Thu Mar 19 2026
**Commit:** 6458b5a
**Branch:** main
**Tag:** 2.1.5-rc15
**Tag:** 2.1.9-alpha5
## OVERVIEW
FLVX (formerly Flux Panel) is a traffic forwarding management system built on a forked GOST v3 stack. It ships as a Go-based admin API (SQLite/PostgreSQL) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
-148
View File
@@ -1,148 +0,0 @@
# 限速功能重构实施计划
## 一、需求概述
**原始需求**: 限速功能当前绑定到具体隧道,需要改为不绑定隧道,创建限速后可以自由在隧道上限速,也可以在转发上限速。
**核心变更**:
1. 限速规则(SpeedLimit)与隧道的绑定关系改为可选
2. 转发(Forward)支持独立的限速规则
---
## 二、实施计划清单
### 2.0 计划状态(审计更新:2026-02-26)
- 总体状态:**进行中(未验收通过)**
- 已完成:模型、仓储查询、限速 CRUD、控制面优先级、限速页与类型改造、编译与测试通过
- 未完成:**Forward 独立限速写入链路**(前端表单 -> API handler -> repository 落库 `forward.speed_id`)
### 2.1 后端模型层 (Model)
| 序号 | 任务 | 文件 | 状态 |
|------|------|------|------|
| M1 | SpeedLimit.TunnelID 改为 sql.NullInt64 (可空) | `go-backend/internal/store/model/model.go` | ✅ 完成 |
| M2 | SpeedLimit.TunnelName 改为 sql.NullString (可空) | `go-backend/internal/store/model/model.go` | ✅ 完成 |
| M3 | Forward 添加 SpeedID sql.NullInt64 字段 | `go-backend/internal/store/model/model.go` | ✅ 完成 |
| M4 | ForwardRecord 添加 SpeedID sql.NullInt64 字段 | `go-backend/internal/store/model/model.go` | ✅ 完成 |
| M5 | SpeedLimitBackup.TunnelID 改为指针类型 | `go-backend/internal/store/model/model.go` | ✅ 完成 |
| M6 | ForwardBackup 添加 SpeedID *int64 字段 | `go-backend/internal/store/model/model.go` | ✅ 完成 |
### 2.2 后端仓储层 (Repository)
| 序号 | 任务 | 文件 | 状态 |
|------|------|------|------|
| R1 | ListSpeedLimits() 返回可空 tunnelId/tunnelName | `go-backend/internal/store/repo/repository.go` | ✅ 完成 |
| R2 | ListForwards() 返回 speedId 字段 | `go-backend/internal/store/repo/repository.go` | ✅ 完成 |
| R3 | CreateSpeedLimit() 参数 tunnelID 改为 *int64 | `go-backend/internal/store/repo/repository_mutations.go` | ✅ 完成 |
| R4 | UpdateSpeedLimit() 参数 tunnelID 改为 *int64 | `go-backend/internal/store/repo/repository_mutations.go` | ✅ 完成 |
| R5 | GetSpeedLimitTunnelID() 返回 sql.NullInt64 | `go-backend/internal/store/repo/repository_mutations.go` | ✅ 完成 |
| R6 | exportSpeedLimits() 处理可空字段 | `go-backend/internal/store/repo/repository.go` | ✅ 完成 |
| R7 | importSpeedLimits() 处理可空字段 | `go-backend/internal/store/repo/repository.go` | ✅ 完成 |
| R8 | GetSpeedLimitSpeed() 新增方法 | `go-backend/internal/store/repo/repository_flow.go` | ✅ 完成 |
| R9 | ListForwardsByTunnel() 返回 SpeedID | `go-backend/internal/store/repo/repository_control.go` | ✅ 完成 |
| R10 | ListActiveForwardsByUser() 返回 SpeedID | `go-backend/internal/store/repo/repository_flow.go` | ✅ 完成 |
| R11 | ListActiveForwardsByUserTunnel() 返回 SpeedID | `go-backend/internal/store/repo/repository_flow.go` | ✅ 完成 |
| R12 | GetForwardRecord() 返回 SpeedID | `go-backend/internal/store/repo/repository_flow.go` | ✅ 完成 |
### 2.3 后端处理器层 (Handler)
| 序号 | 任务 | 文件 | 状态 |
|------|------|------|------|
| H1 | speedLimitCreate 处理可选 tunnelId | `go-backend/internal/http/handler/mutations.go` | ✅ 完成 |
| H2 | speedLimitUpdate 处理可选 tunnelId | `go-backend/internal/http/handler/mutations.go` | ✅ 完成 |
| H3 | speedLimitDelete 处理可空 tunnelID | `go-backend/internal/http/handler/mutations.go` | ✅ 完成 |
### 2.4 后端控制平面 (Control Plane)
| 序号 | 任务 | 文件 | 状态 |
|------|------|------|------|
| C1 | syncForwardServices 优先使用 Forward.SpeedID | `go-backend/internal/http/handler/control_plane.go` | ✅ 完成 |
| C2 | 回退到 UserTunnel 的 speed limit | `go-backend/internal/http/handler/control_plane.go` | ✅ 完成 |
### 2.5 前端类型定义 (TypeScript Types)
| 序号 | 任务 | 文件 | 状态 |
|------|------|------|------|
| T1 | SpeedLimitApiItem.tunnelId 改为可选 | `vite-frontend/src/api/types.ts` | ✅ 完成 |
| T2 | ForwardApiItem 添加 speedId 字段 | `vite-frontend/src/api/types.ts` | ✅ 完成 |
| T3 | ForwardMutationPayload 添加 speedId 字段 | `vite-frontend/src/api/types.ts` | ✅ 完成 |
| T4 | SpeedLimitMutationPayload.tunnelId 改为可选 | `vite-frontend/src/api/types.ts` | ✅ 完成 |
### 2.6 前端页面组件
| 序号 | 任务 | 文件 | 状态 |
|------|------|------|------|
| F1 | SpeedLimitRule 接口更新 | `vite-frontend/src/pages/limit.tsx` | ✅ 完成 |
| F2 | SpeedLimitForm 接口更新 | `vite-frontend/src/pages/limit.tsx` | ✅ 完成 |
| F3 | validateForm 移除 tunnelId 必填校验 | `vite-frontend/src/pages/limit.tsx` | ✅ 完成 |
| F4 | Select 组件改为可选 | `vite-frontend/src/pages/limit.tsx` | ✅ 完成 |
| F5 | 显示"未绑定"状态 | `vite-frontend/src/pages/limit.tsx` | ✅ 完成 |
### 2.7 编译验证
| 序号 | 任务 | 状态 |
|------|------|------|
| B1 | Go 后端编译通过 | ✅ 完成 |
| B2 | TypeScript 类型检查通过 | ✅ 完成 |
| B3 | `go test ./...` 全量通过 | ✅ 完成 |
| B4 | `go test ./tests/contract/... -run SpeedLimit` 通过 | ✅ 完成 |
### 2.8 Forward 独立限速写入链路补全(新增)
| 序号 | 任务 | 文件 | 状态 |
|------|------|------|------|
| N1 | forwardCreate 支持接收并校验可选 speedId,写入 Forward.SpeedID | `go-backend/internal/http/handler/mutations.go` | ✅ 完成 |
| N2 | forwardUpdate 支持更新/清空 speedId,并触发服务重下发 | `go-backend/internal/http/handler/mutations.go` | ✅ 完成 |
| N3 | CreateForwardTx 支持落库 speed_id | `go-backend/internal/store/repo/repository_mutations.go` | ✅ 完成 |
| N4 | UpdateForward 支持更新 speed_id | `go-backend/internal/store/repo/repository_mutations.go` | ✅ 完成 |
| N5 | Forward 页面新增限速选择并透传 speedId | `vite-frontend/src/pages/forward.tsx` | ✅ 完成 |
| N6 | Forward 相关契约测试补充 speedId 写入/清空断言 | `go-backend/tests/contract/forward_contract_test.go` | ✅ 完成 |
---
## 三、优先级说明
限速规则应用优先级:
1. **Forward.SpeedID** - 转发级别的限速 (最高优先)
2. **UserTunnel.SpeedID** - 用户隧道权限级别的限速 (回退)
---
## 四、数据库兼容性
- SpeedLimit 表: `tunnel_id` 和 `tunnel_name` 字段改为可空 (GORM AutoMigrate 自动处理)
- Forward 表: 新增 `speed_id` 可空字段 (GORM AutoMigrate 自动处理)
---
## 五、验证检查项
### 5.1 功能验证(审计后)
- [x] 创建不限速规则的限速 (不绑定隧道)
- [x] 创建绑定隧道的限速 (兼容旧逻辑)
- [x] 编辑限速规则,切换隧道绑定状态
- [ ] 删除限速规则
- [ ] 转发列表正确显示 speedId
### 5.2 API 验证(审计后)
- [x] GET /api/speed-limit/list 返回可选 tunnelId
- [x] POST /api/speed-limit/create 接受可选 tunnelId
- [x] POST /api/speed-limit/update 接受可选 tunnelId
- [ ] GET /api/forward/list 返回 speedId
### 5.3 兼容性验证(审计后)
- [x] 现有绑定隧道的限速规则继续正常工作
- [ ] 现有 UserTunnel 的限速继续正常工作
- [ ] 备份/恢复功能正常
### 5.4 Forward 独立限速闭环验证(新增)
- [x] POST /api/forward/create 接受 speedId 并写入 `forward.speed_id`
- [x] POST /api/forward/update 可更新/清空 speedId
- [x] Forward 表单可选择限速并提交 speedId
- [ ] `syncForwardServices` 实际使用 Forward.SpeedID 而非仅回退 UserTunnel.SpeedID
+360
View File
@@ -0,0 +1,360 @@
package health
import (
"context"
"errors"
"fmt"
"log"
"net"
"strings"
"sync"
"time"
"go-backend/internal/monitoring"
"go-backend/internal/store/model"
"go-backend/internal/store/repo"
"go-backend/internal/ws"
)
type nodeCommander interface {
SendCommand(nodeID int64, cmdType string, data interface{}, timeout time.Duration) (ws.CommandResult, error)
}
type Checker struct {
repo *repo.Repository
commander nodeCommander
lastRun map[int64]int64
inFlight map[int64]struct{}
mu sync.RWMutex
cancel context.CancelFunc
wg sync.WaitGroup
}
func NewChecker(repo *repo.Repository, commander nodeCommander) *Checker {
return &Checker{
repo: repo,
commander: commander,
lastRun: make(map[int64]int64),
inFlight: make(map[int64]struct{}),
}
}
func (c *Checker) Start(ctx context.Context) {
c.mu.Lock()
ctx, cancel := context.WithCancel(ctx)
c.cancel = cancel
c.mu.Unlock()
c.runChecks(ctx)
for {
limits := c.loadServiceMonitorLimits()
scanInterval := time.Duration(limits.CheckerScanIntervalSec) * time.Second
if scanInterval <= 0 {
scanInterval = 30 * time.Second
}
timer := time.NewTimer(scanInterval)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
c.runChecks(ctx)
}
}
}
func (c *Checker) Stop() {
c.mu.Lock()
if c.cancel != nil {
c.cancel()
}
c.mu.Unlock()
c.wg.Wait()
}
func (c *Checker) RunOnce(m *model.ServiceMonitor) (*model.ServiceMonitorResult, error) {
if c == nil {
return nil, errors.New("checker not initialized")
}
if m == nil {
return nil, errors.New("monitor is nil")
}
limits := c.loadServiceMonitorLimits()
return c.executeCheck(m, time.Now().UnixMilli(), limits), nil
}
func (c *Checker) runChecks(ctx context.Context) {
if c == nil || c.repo == nil {
return
}
limits := c.loadServiceMonitorLimits()
monitors, err := c.repo.ListEnabledServiceMonitors()
if err != nil {
log.Printf("service monitor scheduler failed op=list_enabled err=%v", err)
return
}
if len(monitors) == 0 {
return
}
// Use persisted result timestamps to avoid restart bursts.
latest, err := c.repo.GetLatestServiceMonitorResults()
if err != nil {
log.Printf("service monitor scheduler failed op=get_latest_results err=%v", err)
latest = nil
}
persistedLast := make(map[int64]int64, len(latest))
for _, r := range latest {
if r.MonitorID <= 0 || r.Timestamp <= 0 {
continue
}
persistedLast[r.MonitorID] = r.Timestamp
}
now := time.Now().UnixMilli()
due := make([]model.ServiceMonitor, 0, len(monitors))
for _, m := range monitors {
select {
case <-ctx.Done():
return
default:
}
intervalSec := m.IntervalSec
if intervalSec <= 0 {
intervalSec = limits.DefaultIntervalSec
}
if intervalSec < limits.MinIntervalSec {
intervalSec = limits.MinIntervalSec
}
intervalMs := int64(intervalSec) * 1000
c.mu.Lock()
if _, ok := c.inFlight[m.ID]; ok {
c.mu.Unlock()
continue
}
lastSeen := persistedLast[m.ID]
if v := c.lastRun[m.ID]; v > lastSeen {
lastSeen = v
}
if lastSeen > 0 && intervalMs > 0 && now-lastSeen < intervalMs {
c.mu.Unlock()
continue
}
c.inFlight[m.ID] = struct{}{}
// Use now as a best-effort guard against overlapping scans; the final
// timestamp is updated again when the result is persisted.
c.lastRun[m.ID] = now
c.mu.Unlock()
due = append(due, m)
}
if len(due) == 0 {
return
}
workerLimit := limits.WorkerLimit
if workerLimit <= 0 {
workerLimit = 1
}
if workerLimit > len(due) {
workerLimit = len(due)
}
jobs := make(chan model.ServiceMonitor, len(due))
for _, m := range due {
jobs <- m
}
close(jobs)
for i := 0; i < workerLimit; i++ {
c.wg.Add(1)
go func() {
defer c.wg.Done()
for {
select {
case <-ctx.Done():
return
case m, ok := <-jobs:
if !ok {
return
}
ts := time.Now().UnixMilli()
result := c.executeCheck(&m, ts, limits)
if err := c.repo.InsertServiceMonitorResult(result); err != nil {
log.Printf("monitoring write failed op=service_monitor_result.insert monitor_id=%d err=%v", result.MonitorID, err)
}
c.mu.Lock()
c.lastRun[m.ID] = result.Timestamp
delete(c.inFlight, m.ID)
c.mu.Unlock()
}
}
}()
}
}
func (c *Checker) executeCheck(m *model.ServiceMonitor, timestamp int64, limits monitoring.ServiceMonitorLimits) *model.ServiceMonitorResult {
result := &model.ServiceMonitorResult{
MonitorID: m.ID,
NodeID: m.NodeID,
Timestamp: timestamp,
}
timeoutSec := m.TimeoutSec
if timeoutSec <= 0 {
timeoutSec = limits.DefaultTimeoutSec
}
if timeoutSec < limits.MinTimeoutSec {
timeoutSec = limits.MinTimeoutSec
}
if timeoutSec > limits.MaxTimeoutSec {
timeoutSec = limits.MaxTimeoutSec
}
timeout := time.Duration(timeoutSec) * time.Second
// When nodeId is set, run checks on the specified node.
if m.NodeID > 0 {
c.checkOnNode(m, timeoutSec, timeout, result)
return result
}
switch strings.ToLower(strings.TrimSpace(m.Type)) {
case "tcp":
c.checkTCP(m.Target, timeout, result)
case "icmp":
result.Success = 0
result.ErrorMessage = "ICMP 监控必须指定执行节点"
default:
result.Success = 0
result.ErrorMessage = fmt.Sprintf("不支持的检查类型: %s", m.Type)
}
return result
}
func (c *Checker) loadServiceMonitorLimits() monitoring.ServiceMonitorLimits {
defaults := monitoring.DefaultServiceMonitorLimits()
if c == nil || c.repo == nil {
return defaults
}
cfg, err := c.repo.GetConfigsByNames([]string{
monitoring.ConfigServiceMonitorCheckerScanIntervalSec,
monitoring.ConfigServiceMonitorWorkerLimit,
monitoring.ConfigServiceMonitorMinIntervalSec,
monitoring.ConfigServiceMonitorDefaultIntervalSec,
monitoring.ConfigServiceMonitorMinTimeoutSec,
monitoring.ConfigServiceMonitorDefaultTimeoutSec,
monitoring.ConfigServiceMonitorMaxTimeoutSec,
})
if err != nil {
return defaults
}
return monitoring.ServiceMonitorLimitsFromConfigMap(cfg)
}
type serviceMonitorCheckRequest struct {
MonitorID int64 `json:"monitorId"`
Type string `json:"type"`
Target string `json:"target"`
TimeoutSec int `json:"timeoutSec"`
}
func (c *Checker) checkOnNode(m *model.ServiceMonitor, timeoutSec int, timeout time.Duration, result *model.ServiceMonitorResult) {
if c == nil || m == nil || result == nil {
return
}
if c.commander == nil {
result.Success = 0
result.ErrorMessage = "节点检查不可用"
return
}
checkType := strings.ToLower(strings.TrimSpace(m.Type))
if checkType != "tcp" && checkType != "icmp" {
result.Success = 0
result.ErrorMessage = fmt.Sprintf("不支持的检查类型: %s", m.Type)
return
}
if strings.TrimSpace(m.Target) == "" {
result.Success = 0
result.ErrorMessage = "检查目标为空"
return
}
req := serviceMonitorCheckRequest{
MonitorID: m.ID,
Type: checkType,
Target: m.Target,
TimeoutSec: timeoutSec,
}
cmdTimeout := timeout
if cmdTimeout < 2*time.Second {
cmdTimeout = 2 * time.Second
}
cmdTimeout = cmdTimeout + 2*time.Second
cmdRes, err := c.commander.SendCommand(m.NodeID, "ServiceMonitorCheck", req, cmdTimeout)
if err != nil {
result.Success = 0
result.ErrorMessage = err.Error()
return
}
if cmdRes.Data == nil {
result.Success = 0
result.ErrorMessage = "节点返回为空"
return
}
if v, ok := cmdRes.Data["success"]; ok {
if b, ok := v.(bool); ok {
if b {
result.Success = 1
} else {
result.Success = 0
}
}
}
if v, ok := cmdRes.Data["latencyMs"]; ok {
if f, ok := v.(float64); ok {
result.LatencyMs = f
}
}
if v, ok := cmdRes.Data["statusCode"]; ok {
if f, ok := v.(float64); ok {
result.StatusCode = int(f)
}
}
if v, ok := cmdRes.Data["errorMessage"]; ok {
if s, ok := v.(string); ok {
result.ErrorMessage = s
}
}
}
func (c *Checker) checkTCP(target string, timeout time.Duration, result *model.ServiceMonitorResult) {
start := time.Now()
conn, err := net.DialTimeout("tcp", target, timeout)
latency := time.Since(start)
result.LatencyMs = float64(latency.Milliseconds())
if err != nil {
result.Success = 0
result.ErrorMessage = err.Error()
return
}
_ = conn.Close()
result.Success = 1
}
+477
View File
@@ -0,0 +1,477 @@
package health
import (
"context"
"net"
"testing"
"time"
"go-backend/internal/monitoring"
"go-backend/internal/store/model"
"go-backend/internal/store/repo"
"go-backend/internal/ws"
)
type fakeCommander struct {
lastNodeID int64
lastType string
lastData interface{}
res ws.CommandResult
err error
}
type delayedCommander struct {
delayByMonitorID map[int64]time.Duration
}
func (d *delayedCommander) SendCommand(nodeID int64, cmdType string, data interface{}, _ time.Duration) (ws.CommandResult, error) {
_ = nodeID
_ = cmdType
if req, ok := data.(serviceMonitorCheckRequest); ok {
if delay := d.delayByMonitorID[req.MonitorID]; delay > 0 {
time.Sleep(delay)
}
}
return ws.CommandResult{
Success: true,
Data: map[string]interface{}{
"success": true,
"latencyMs": float64(1),
},
}, nil
}
func (f *fakeCommander) SendCommand(nodeID int64, cmdType string, data interface{}, _ time.Duration) (ws.CommandResult, error) {
f.lastNodeID = nodeID
f.lastType = cmdType
f.lastData = data
return f.res, f.err
}
func TestTCPHealthCheckViaMonitor(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer listener.Close()
addr := listener.Addr().String()
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
conn.Close()
}
}()
t.Run("successful tcp check", func(t *testing.T) {
checker := NewChecker(nil, nil)
limits := checker.loadServiceMonitorLimits()
now := time.Now().UnixMilli()
monitor := &model.ServiceMonitor{
Type: "tcp",
Target: addr,
TimeoutSec: 5,
}
result := checker.executeCheck(monitor, now, limits)
if result.Success != 1 {
t.Fatalf("expected success, got error: %s", result.ErrorMessage)
}
if result.LatencyMs < 0 {
t.Fatalf("expected non-negative latency, got %f", result.LatencyMs)
}
})
t.Run("failed tcp check - connection refused", func(t *testing.T) {
checker := NewChecker(nil, nil)
limits := checker.loadServiceMonitorLimits()
now := time.Now().UnixMilli()
monitor := &model.ServiceMonitor{
Type: "tcp",
Target: "127.0.0.1:1",
TimeoutSec: 1,
}
result := checker.executeCheck(monitor, now, limits)
if result.Success == 1 {
t.Fatalf("expected failure for connection refused")
}
if result.ErrorMessage == "" {
t.Fatalf("expected error message")
}
})
}
func TestCheckerRunChecks(t *testing.T) {
r, err := repo.Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer listener.Close()
tcpAddr := listener.Addr().String()
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
conn.Close()
}
}()
now := time.Now().UnixMilli()
monitors := []*model.ServiceMonitor{
{
Name: "TCP Monitor",
Type: "tcp",
Target: tcpAddr,
IntervalSec: 60,
TimeoutSec: 5,
NodeID: 0,
Enabled: 1,
CreatedTime: now,
UpdatedTime: now,
},
{
Name: "TCP Monitor 2",
Type: "tcp",
Target: tcpAddr,
IntervalSec: 60,
TimeoutSec: 5,
NodeID: 0,
Enabled: 1,
CreatedTime: now,
UpdatedTime: now,
},
{
Name: "Disabled Monitor",
Type: "tcp",
Target: "127.0.0.1:1",
IntervalSec: 60,
TimeoutSec: 5,
NodeID: 0,
Enabled: 0,
CreatedTime: now,
UpdatedTime: now,
},
}
for _, m := range monitors {
if err := r.CreateServiceMonitor(m); err != nil {
t.Fatalf("create monitor: %v", err)
}
}
monitors[2].Enabled = 0
if err := r.UpdateServiceMonitor(monitors[2]); err != nil {
t.Fatalf("update disabled monitor: %v", err)
}
enabledMonitors, err := r.ListEnabledServiceMonitors()
if err != nil {
t.Fatalf("list enabled monitors: %v", err)
}
if len(enabledMonitors) != 2 {
t.Fatalf("expected 2 enabled monitors, got %d", len(enabledMonitors))
}
checker := NewChecker(r, nil)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
go checker.Start(ctx)
time.Sleep(500 * time.Millisecond)
results, err := r.GetServiceMonitorResults(monitors[0].ID, 10)
if err != nil {
t.Fatalf("get tcp results: %v", err)
}
if len(results) == 0 {
t.Fatalf("expected at least one result for tcp monitor")
}
for _, res := range results {
if res.Success != 1 {
t.Fatalf("expected success for tcp monitor, got failure: %s", res.ErrorMessage)
}
}
results2, err := r.GetServiceMonitorResults(monitors[1].ID, 10)
if err != nil {
t.Fatalf("get tcp results 2: %v", err)
}
if len(results2) == 0 {
t.Fatalf("expected at least one result for tcp monitor 2")
}
for _, res := range results2 {
if res.Success != 1 {
t.Fatalf("expected success for tcp monitor 2, got failure: %s", res.ErrorMessage)
}
}
disabledResults, err := r.GetServiceMonitorResults(monitors[2].ID, 10)
if err != nil {
t.Fatalf("get disabled results: %v", err)
}
if len(disabledResults) != 0 {
t.Fatalf("expected no results for disabled monitor, got %d", len(disabledResults))
}
}
func TestCheckerUnsupportedType(t *testing.T) {
checker := NewChecker(nil, nil)
limits := checker.loadServiceMonitorLimits()
now := time.Now().UnixMilli()
monitor := &model.ServiceMonitor{
Type: "http",
Target: "https://example.com",
TimeoutSec: 5,
}
result := checker.executeCheck(monitor, now, limits)
if result.Success == 1 {
t.Fatalf("expected failure for unsupported type")
}
if result.ErrorMessage == "" {
t.Fatalf("expected error message for unsupported type")
}
}
func TestCheckerDefaultTimeout(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer listener.Close()
addr := listener.Addr().String()
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
conn.Close()
}
}()
checker := NewChecker(nil, nil)
limits := checker.loadServiceMonitorLimits()
now := time.Now().UnixMilli()
monitor := &model.ServiceMonitor{
Type: "tcp",
Target: addr,
TimeoutSec: 0,
}
result := checker.executeCheck(monitor, now, limits)
if result.Success != 1 {
t.Fatalf("expected success with default timeout, got error: %s", result.ErrorMessage)
}
}
func TestCheckerStop(t *testing.T) {
r, err := repo.Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer listener.Close()
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
conn.Close()
}
}()
now := time.Now().UnixMilli()
monitor := &model.ServiceMonitor{
Name: "Test Monitor",
Type: "tcp",
Target: listener.Addr().String(),
IntervalSec: 60,
TimeoutSec: 5,
NodeID: 0,
Enabled: 1,
CreatedTime: now,
UpdatedTime: now,
}
if err := r.CreateServiceMonitor(monitor); err != nil {
t.Fatalf("create monitor: %v", err)
}
checker := NewChecker(r, nil)
ctx := context.Background()
go checker.Start(ctx)
time.Sleep(100 * time.Millisecond)
checker.Stop()
results, err := r.GetServiceMonitorResults(monitor.ID, 10)
if err != nil {
t.Fatalf("get results: %v", err)
}
if len(results) == 0 {
t.Fatalf("expected at least one result before stop")
}
}
func TestCheckerRunsOnNodeWhenNodeIDSet(t *testing.T) {
fake := &fakeCommander{
res: ws.CommandResult{
Success: true,
Data: map[string]interface{}{
"success": false,
"latencyMs": float64(12),
"errorMessage": "unreachable",
},
},
}
checker := NewChecker(nil, fake)
limits := checker.loadServiceMonitorLimits()
now := time.Now().UnixMilli()
monitor := &model.ServiceMonitor{
ID: 99,
Type: "icmp",
Target: "8.8.8.8",
TimeoutSec: 2,
NodeID: 123,
}
res := checker.executeCheck(monitor, now, limits)
if fake.lastNodeID != 123 {
t.Fatalf("expected command to be sent to node 123, got %d", fake.lastNodeID)
}
if fake.lastType != "ServiceMonitorCheck" {
t.Fatalf("expected ServiceMonitorCheck command, got %s", fake.lastType)
}
if res.Success != 0 {
t.Fatalf("expected failed result from node check")
}
if res.ErrorMessage != "unreachable" {
t.Fatalf("expected errorMessage unreachable, got %q", res.ErrorMessage)
}
}
func TestCheckerDoesNotBurstOnRestartWhenRecentResultsExist(t *testing.T) {
r, err := repo.Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
monitor := &model.ServiceMonitor{
Name: "recent-monitor",
Type: "tcp",
Target: "127.0.0.1:1",
IntervalSec: 60,
TimeoutSec: 1,
NodeID: 0,
Enabled: 1,
CreatedTime: now,
UpdatedTime: now,
}
if err := r.CreateServiceMonitor(monitor); err != nil {
t.Fatalf("create monitor: %v", err)
}
if err := r.InsertServiceMonitorResult(&model.ServiceMonitorResult{
MonitorID: monitor.ID,
NodeID: 0,
Timestamp: now - 10_000,
Success: 1,
}); err != nil {
t.Fatalf("seed recent result: %v", err)
}
checker := NewChecker(r, nil)
ctx, cancel := context.WithCancel(context.Background())
go checker.Start(ctx)
// Give the initial scan a chance to run.
time.Sleep(200 * time.Millisecond)
cancel()
checker.Stop()
results, err := r.GetServiceMonitorResults(monitor.ID, 10)
if err != nil {
t.Fatalf("get results: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected no immediate rerun (1 result), got %d", len(results))
}
}
func TestCheckerConcurrencyPreventsSlowMonitorBlockingOthers(t *testing.T) {
r, err := repo.Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
// Force worker limit to at least 2 for this test.
_ = r.UpsertConfig(monitoring.ConfigServiceMonitorWorkerLimit, "2", now)
slow := &model.ServiceMonitor{
Name: "slow",
Type: "icmp",
Target: "8.8.8.8",
IntervalSec: 60,
TimeoutSec: 1,
NodeID: 123,
Enabled: 1,
CreatedTime: now,
UpdatedTime: now,
}
if err := r.CreateServiceMonitor(slow); err != nil {
t.Fatalf("create slow monitor: %v", err)
}
fast := &model.ServiceMonitor{
Name: "fast",
Type: "icmp",
Target: "1.1.1.1",
IntervalSec: 60,
TimeoutSec: 1,
NodeID: 123,
Enabled: 1,
CreatedTime: now,
UpdatedTime: now,
}
if err := r.CreateServiceMonitor(fast); err != nil {
t.Fatalf("create fast monitor: %v", err)
}
cmd := &delayedCommander{delayByMonitorID: map[int64]time.Duration{slow.ID: 800 * time.Millisecond}}
checker := NewChecker(r, cmd)
ctx, cancel := context.WithCancel(context.Background())
go checker.Start(ctx)
// Fast monitor should complete even while slow one is still running.
time.Sleep(250 * time.Millisecond)
results, err := r.GetServiceMonitorResults(fast.ID, 10)
if err != nil {
t.Fatalf("get fast results: %v", err)
}
if len(results) == 0 {
t.Fatalf("expected fast monitor to have results without waiting for slow")
}
cancel()
checker.Stop()
}
@@ -33,7 +33,7 @@ func TestSelectTunnelDialHost_ConnectIpPriority(t *testing.T) {
func TestBuildTunnelChainServiceConfig_UsesConnectIPForListen(t *testing.T) {
node := &nodeRecord{TCPListenAddr: "[::]"}
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21000, ConnectIP: "2001:db8::88"}
services := buildTunnelChainServiceConfig(99, chain, node)
services := buildTunnelChainServiceConfig(99, chain, node, 1)
if len(services) != 1 {
t.Fatalf("expected 1 service, got %d", len(services))
}
@@ -46,7 +46,7 @@ func TestBuildTunnelChainServiceConfig_UsesConnectIPForListen(t *testing.T) {
func TestBuildTunnelChainServiceConfig_FallsBackToNodeListenAddr(t *testing.T) {
node := &nodeRecord{TCPListenAddr: "10.8.0.5"}
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21002}
services := buildTunnelChainServiceConfig(99, chain, node)
services := buildTunnelChainServiceConfig(99, chain, node, 1)
if len(services) != 1 {
t.Fatalf("expected 1 service, got %d", len(services))
}
@@ -59,7 +59,7 @@ func TestBuildTunnelChainServiceConfig_FallsBackToNodeListenAddr(t *testing.T) {
func TestBuildTunnelChainServiceConfig_DefaultListenAddrWhenConnectIPEmpty(t *testing.T) {
node := &nodeRecord{TCPListenAddr: "[::]"}
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21001}
services := buildTunnelChainServiceConfig(99, chain, node)
services := buildTunnelChainServiceConfig(99, chain, node, 1)
if len(services) != 1 {
t.Fatalf("expected 1 service, got %d", len(services))
}
@@ -69,6 +69,42 @@ func TestBuildTunnelChainServiceConfig_DefaultListenAddrWhenConnectIPEmpty(t *te
}
}
func TestBuildTunnelChainServiceConfig_SetsRetriesWhenMultipleCandidates(t *testing.T) {
node := &nodeRecord{TCPListenAddr: "[::]"}
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21001}
services := buildTunnelChainServiceConfig(99, chain, node, 3)
if len(services) != 1 {
t.Fatalf("expected 1 service, got %d", len(services))
}
handler, _ := services[0]["handler"].(map[string]interface{})
if handler == nil {
t.Fatal("expected handler config")
}
retries, ok := handler["retries"].(int)
if !ok {
t.Fatal("expected retries to be set when nextHopCandidateCount > 1")
}
if retries != 2 {
t.Fatalf("expected retries=2 (candidates-1), got %d", retries)
}
}
func TestBuildTunnelChainServiceConfig_NoRetriesWhenSingleCandidate(t *testing.T) {
node := &nodeRecord{TCPListenAddr: "[::]"}
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21001}
services := buildTunnelChainServiceConfig(99, chain, node, 1)
if len(services) != 1 {
t.Fatalf("expected 1 service, got %d", len(services))
}
handler, _ := services[0]["handler"].(map[string]interface{})
if handler == nil {
t.Fatal("expected handler config")
}
if _, hasRetries := handler["retries"]; hasRetries {
t.Fatal("expected no retries when nextHopCandidateCount is 1")
}
}
func TestNodeSupportsV6_Nil(t *testing.T) {
if nodeSupportsV6(nil) {
t.Fatal("nil node must not support v6")
+36 -19
View File
@@ -141,6 +141,32 @@ type remoteUsageNodeItem struct {
SyncError string `json:"syncError,omitempty"`
}
func buildFederationServiceConfig(serviceName, addr, protocol, role, chainName string, targetCount int, interfaceName string) map[string]interface{} {
service := map[string]interface{}{
"name": serviceName,
"addr": addr,
"handler": map[string]interface{}{
"type": "relay",
},
"listener": map[string]interface{}{
"type": protocol,
},
}
if isTLSTunnelProtocol(protocol) {
service["handler"].(map[string]interface{})["metadata"] = map[string]interface{}{"nodelay": true}
}
if role == "middle" {
service["handler"].(map[string]interface{})["chain"] = chainName
if targetCount > 1 {
service["handler"].(map[string]interface{})["retries"] = targetCount - 1
}
}
if role == "exit" && strings.TrimSpace(interfaceName) != "" {
service["metadata"] = map[string]interface{}{"interface": interfaceName}
}
return service
}
func (h *Handler) federationShareList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("Invalid method"))
@@ -1096,25 +1122,16 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
}
}
service := map[string]interface{}{
"name": serviceName,
"addr": fmt.Sprintf("%s:%d", node.TCPListenAddr, runtime.Port),
"handler": map[string]interface{}{
"type": "relay",
},
"listener": map[string]interface{}{
"type": protocol,
},
}
if isTLSTunnelProtocol(protocol) {
service["handler"].(map[string]interface{})["metadata"] = map[string]interface{}{"nodelay": true}
}
if req.Role == "middle" {
service["handler"].(map[string]interface{})["chain"] = chainName
}
if req.Role == "exit" && strings.TrimSpace(node.InterfaceName) != "" {
service["metadata"] = map[string]interface{}{"interface": node.InterfaceName}
}
targetCount := len(req.Targets)
service := buildFederationServiceConfig(
serviceName,
fmt.Sprintf("%s:%d", node.TCPListenAddr, runtime.Port),
protocol,
req.Role,
chainName,
targetCount,
node.InterfaceName,
)
if _, err := h.sendNodeCommand(share.NodeID, "AddService", []map[string]interface{}{service}, true, false); err != nil {
if req.Role == "middle" {
_, _ = h.sendNodeCommand(share.NodeID, "DeleteChains", map[string]interface{}{"chain": chainName}, false, true)
@@ -227,6 +227,60 @@ func TestPrepareTunnelCreateStateAllowsOfflineRemoteMiddleNode(t *testing.T) {
}
}
func TestBuildFederationServiceConfig_MiddleRoleWithMultipleTargets_SetsRetries(t *testing.T) {
service := buildFederationServiceConfig("svc-middle", ":40000", "tls", "middle", "chain-next", 3, "")
handler := service["handler"].(map[string]interface{})
if handler["chain"] != "chain-next" {
t.Fatalf("expected chain 'chain-next', got %v", handler["chain"])
}
if handler["retries"] != 2 {
t.Fatalf("expected retries 2 for 3 targets, got %v", handler["retries"])
}
}
func TestBuildFederationServiceConfig_MiddleRoleWithSingleTarget_NoRetries(t *testing.T) {
service := buildFederationServiceConfig("svc-middle", ":40000", "tls", "middle", "chain-next", 1, "")
handler := service["handler"].(map[string]interface{})
if handler["chain"] != "chain-next" {
t.Fatalf("expected chain 'chain-next', got %v", handler["chain"])
}
if _, hasRetries := handler["retries"]; hasRetries {
t.Fatalf("expected no retries for single target, got %v", handler["retries"])
}
}
func TestBuildFederationServiceConfig_ExitRole_NoRetriesRegardlessOfTargets(t *testing.T) {
service := buildFederationServiceConfig("svc-exit", ":40000", "tls", "exit", "", 3, "eth0")
handler := service["handler"].(map[string]interface{})
if _, hasChain := handler["chain"]; hasChain {
t.Fatalf("expected no chain for exit role, got %v", handler["chain"])
}
if _, hasRetries := handler["retries"]; hasRetries {
t.Fatalf("expected no retries for exit role, got %v", handler["retries"])
}
metadata := service["metadata"].(map[string]interface{})
if metadata["interface"] != "eth0" {
t.Fatalf("expected interface 'eth0', got %v", metadata["interface"])
}
}
func TestBuildFederationServiceConfig_TLSTunnelProtocol_SetsNodelay(t *testing.T) {
service := buildFederationServiceConfig("svc-tls", ":40000", "tls", "middle", "chain-next", 2, "")
handler := service["handler"].(map[string]interface{})
meta := handler["metadata"].(map[string]interface{})
if meta["nodelay"] != true {
t.Fatalf("expected nodelay=true for TLS protocol, got %v", meta["nodelay"])
}
}
func TestBuildFederationServiceConfig_NonTLSProtocol_NoNodelay(t *testing.T) {
service := buildFederationServiceConfig("svc-tcp", ":40000", "tcp", "middle", "chain-next", 2, "")
handler := service["handler"].(map[string]interface{})
if _, hasMeta := handler["metadata"]; hasMeta {
t.Fatalf("expected no metadata for non-TLS protocol, got %v", handler["metadata"])
}
}
func TestFederationRuntimeReservePortRejectsWhenShareFlowExceeded(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
+51 -3
View File
@@ -16,17 +16,21 @@ import (
"time"
"go-backend/internal/auth"
"go-backend/internal/health"
"go-backend/internal/http/middleware"
"go-backend/internal/http/response"
"go-backend/internal/metrics"
"go-backend/internal/security"
"go-backend/internal/store/repo"
"go-backend/internal/ws"
)
type Handler struct {
repo *repo.Repository
jwtSecret string
wsServer *ws.Server
repo *repo.Repository
jwtSecret string
wsServer *ws.Server
metrics *metrics.IngestionService
healthCheck *health.Checker
captchaMu sync.Mutex
captchaTokens map[string]int64
@@ -83,10 +87,31 @@ func New(repo *repo.Repository, jwtSecret string) *Handler {
repo: repo,
jwtSecret: jwtSecret,
wsServer: ws.NewServer(repo, jwtSecret),
metrics: metrics.NewIngestionService(repo),
healthCheck: nil,
captchaTokens: make(map[string]int64),
pendingUpgradeRedeploy: make(map[int64]struct{}),
}
h.healthCheck = health.NewChecker(repo, h.wsServer)
h.wsServer.SetNodeOnlineHook(h.onNodeOnline)
h.wsServer.SetNodeMetricHook(func(nodeID int64, info ws.SystemInfo) {
metricInfo := metrics.SystemInfo{
Uptime: info.Uptime,
BytesReceived: info.BytesReceived,
BytesTransmitted: info.BytesTransmitted,
CPUUsage: info.CPUUsage,
MemoryUsage: info.MemoryUsage,
DiskUsage: info.DiskUsage,
Load1: info.Load1,
Load5: info.Load5,
Load15: info.Load15,
TCPConns: info.TCPConns,
UDPConns: info.UDPConns,
NetInSpeed: info.NetInSpeed,
NetOutSpeed: info.NetOutSpeed,
}
h.metrics.RecordNodeMetric(nodeID, metricInfo)
})
return h
}
@@ -135,6 +160,10 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/tunnel/get", h.tunnelGet)
mux.HandleFunc("/api/v1/tunnel/update", h.tunnelUpdate)
mux.HandleFunc("/api/v1/tunnel/delete", h.tunnelDelete)
mux.HandleFunc("/api/v1/tunnel/delete-preview", h.tunnelDeletePreview)
mux.HandleFunc("/api/v1/tunnel/delete-with-forwards", h.tunnelDeleteWithForwards)
mux.HandleFunc("/api/v1/tunnel/batch-delete-preview", h.tunnelBatchDeletePreview)
mux.HandleFunc("/api/v1/tunnel/batch-delete-with-forwards", h.tunnelBatchDeleteWithForwards)
mux.HandleFunc("/api/v1/tunnel/diagnose", h.tunnelDiagnose)
mux.HandleFunc("/api/v1/tunnel/diagnose/stream", h.tunnelDiagnoseStream)
mux.HandleFunc("/api/v1/tunnel/update-order", h.tunnelUpdateOrder)
@@ -196,6 +225,23 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/announcement/get", h.getAnnouncement)
mux.HandleFunc("/api/v1/announcement/update", h.updateAnnouncement)
mux.HandleFunc("/api/v1/monitor/access", h.monitorAccessHandler)
mux.HandleFunc("/api/v1/monitor/nodes/", h.monitorNodeMetricsHandler)
mux.HandleFunc("/api/v1/monitor/nodes", h.monitorNodeListHandler)
mux.HandleFunc("/api/v1/monitor/tunnels", h.monitorTunnelListHandler)
mux.HandleFunc("/api/v1/monitor/tunnels/", h.monitorTunnelMetrics)
mux.HandleFunc("/api/v1/monitor/services", h.monitorServiceListHandler)
mux.HandleFunc("/api/v1/monitor/services/create", h.monitorServiceCreate)
mux.HandleFunc("/api/v1/monitor/services/update", h.monitorServiceUpdate)
mux.HandleFunc("/api/v1/monitor/services/delete", h.monitorServiceDelete)
mux.HandleFunc("/api/v1/monitor/services/run", h.monitorServiceRun)
mux.HandleFunc("/api/v1/monitor/services/latest-results", h.monitorServiceLatestResultsHandler)
mux.HandleFunc("/api/v1/monitor/services/limits", h.monitorServiceLimitsHandler)
mux.HandleFunc("/api/v1/monitor/services/", h.monitorServiceResultsHandler)
mux.HandleFunc("/api/v1/monitor/permission/list", h.monitorPermissionList)
mux.HandleFunc("/api/v1/monitor/permission/assign", h.monitorPermissionAssign)
mux.HandleFunc("/api/v1/monitor/permission/remove", h.monitorPermissionRemove)
mux.HandleFunc("/flow/test", h.flowTest)
mux.HandleFunc("/flow/config", h.flowConfig)
mux.HandleFunc("/flow/upload", h.flowUpload)
@@ -724,6 +770,8 @@ func (h *Handler) flowUpload(w http.ResponseWriter, r *http.Request) {
if err == nil && strings.TrimSpace(raw) != "" {
var items []flowItem
if json.Unmarshal([]byte(raw), &items) == nil {
nowMs := time.Now().UnixMilli()
h.recordTunnelMetricsFromFlowItems(node.ID, items, nowMs)
for _, item := range items {
h.processFlowItem(node.ID, item)
}
+17 -1
View File
@@ -18,12 +18,14 @@ func (h *Handler) StartBackgroundJobs() {
ctx, cancel := context.WithCancel(context.Background())
h.jobsCancel = cancel
h.jobsStarted = true
h.jobsWG.Add(3)
h.jobsWG.Add(5)
h.jobsMu.Unlock()
go h.runHourlyStatsLoop(ctx)
go h.runDailyMaintenanceLoop(ctx)
go h.runNodeRenewalCycleLoop(ctx)
go h.runMetricsIngestion(ctx)
go h.runHealthChecks(ctx)
}
func (h *Handler) StopBackgroundJobs() {
@@ -47,6 +49,20 @@ func (h *Handler) StopBackgroundJobs() {
h.jobsWG.Wait()
}
func (h *Handler) runMetricsIngestion(ctx context.Context) {
defer h.jobsWG.Done()
if h.metrics != nil {
h.metrics.Start(ctx)
}
}
func (h *Handler) runHealthChecks(ctx context.Context) {
defer h.jobsWG.Done()
if h.healthCheck != nil {
h.healthCheck.Start(ctx)
}
}
func (h *Handler) runHourlyStatsLoop(ctx context.Context) {
defer h.jobsWG.Done()
@@ -0,0 +1,795 @@
package handler
import (
"log"
"net/http"
"strconv"
"strings"
"time"
"go-backend/internal/http/response"
"go-backend/internal/monitoring"
"go-backend/internal/store/model"
)
const (
defaultMetricsRangeMs = int64(60 * 60 * 1000) // 1h
maxMetricsRangeMs = int64(24 * 60 * 60 * 1000) // 24h
)
func (h *Handler) resolveServiceMonitorLimits() monitoring.ServiceMonitorLimits {
defaults := monitoring.DefaultServiceMonitorLimits()
if h == nil || h.repo == nil {
return defaults
}
cfg, err := h.repo.GetConfigsByNames([]string{
monitoring.ConfigServiceMonitorCheckerScanIntervalSec,
monitoring.ConfigServiceMonitorWorkerLimit,
monitoring.ConfigServiceMonitorMinIntervalSec,
monitoring.ConfigServiceMonitorDefaultIntervalSec,
monitoring.ConfigServiceMonitorMinTimeoutSec,
monitoring.ConfigServiceMonitorDefaultTimeoutSec,
monitoring.ConfigServiceMonitorMaxTimeoutSec,
})
if err != nil {
return defaults
}
return monitoring.ServiceMonitorLimitsFromConfigMap(cfg)
}
func (h *Handler) monitorNodeMetricsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureMonitoringAccess(w, r) {
return
}
path := r.URL.Path
prefix := "/api/v1/monitor/nodes/"
if !strings.HasPrefix(path, prefix) {
response.WriteJSON(w, response.ErrDefault("无效的路径"))
return
}
rest := strings.TrimPrefix(path, prefix)
if strings.HasSuffix(rest, "/metrics/latest") {
h.handleNodeMetricsLatest(w, r, strings.TrimSuffix(rest, "/metrics/latest"))
return
}
if strings.HasSuffix(rest, "/metrics") {
h.handleNodeMetrics(w, r, strings.TrimSuffix(rest, "/metrics"))
return
}
response.WriteJSON(w, response.ErrDefault("无效的路径"))
}
type monitorNodeListItem struct {
ID int64 `json:"id"`
Inx int `json:"inx"`
Name string `json:"name"`
Status int `json:"status"`
UpdatedTime int64 `json:"updatedTime"`
}
func (h *Handler) monitorNodeListHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureMonitoringAccess(w, r) {
return
}
nodes, err := h.repo.ListMonitorNodes()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
items := make([]monitorNodeListItem, 0, len(nodes))
for _, n := range nodes {
updated := int64(0)
if n.UpdatedTime.Valid {
updated = n.UpdatedTime.Int64
}
items = append(items, monitorNodeListItem{
ID: n.ID,
Inx: n.Inx,
Name: n.Name,
Status: n.Status,
UpdatedTime: updated,
})
}
response.WriteJSON(w, response.OK(items))
}
type monitorTunnelListItem struct {
ID int64 `json:"id"`
Inx int `json:"inx"`
Name string `json:"name"`
Status int `json:"status"`
UpdatedTime int64 `json:"updatedTime"`
}
func (h *Handler) monitorTunnelListHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureMonitoringAccess(w, r) {
return
}
tunnels, err := h.repo.ListMonitorTunnels()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
items := make([]monitorTunnelListItem, 0, len(tunnels))
for _, t := range tunnels {
items = append(items, monitorTunnelListItem{
ID: t.ID,
Inx: t.Inx,
Name: t.Name,
Status: t.Status,
UpdatedTime: t.UpdatedTime,
})
}
response.WriteJSON(w, response.OK(items))
}
func (h *Handler) handleNodeMetrics(w http.ResponseWriter, r *http.Request, nodeIDStr string) {
nodeID, err := strconv.ParseInt(nodeIDStr, 10, 64)
if err != nil || nodeID <= 0 {
response.WriteJSON(w, response.ErrDefault("无效的节点ID"))
return
}
now := time.Now().UnixMilli()
startMs := now - defaultMetricsRangeMs
endMs := now
if s := r.URL.Query().Get("start"); s != "" {
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
startMs = v
}
}
if e := r.URL.Query().Get("end"); e != "" {
if v, err := strconv.ParseInt(e, 10, 64); err == nil {
endMs = v
}
}
if startMs <= 0 || endMs <= 0 {
response.WriteJSON(w, response.ErrDefault("无效的时间范围"))
return
}
if endMs < startMs {
response.WriteJSON(w, response.ErrDefault("无效的时间范围"))
return
}
if endMs-startMs > maxMetricsRangeMs {
response.WriteJSON(w, response.ErrDefault("时间范围过大"))
return
}
metrics, err := h.repo.GetNodeMetrics(nodeID, startMs, endMs)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(metrics))
}
func (h *Handler) handleNodeMetricsLatest(w http.ResponseWriter, _ *http.Request, nodeIDStr string) {
nodeID, err := strconv.ParseInt(nodeIDStr, 10, 64)
if err != nil || nodeID <= 0 {
response.WriteJSON(w, response.ErrDefault("无效的节点ID"))
return
}
metric, err := h.repo.GetLatestNodeMetric(nodeID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if metric == nil {
response.WriteJSON(w, response.OK(nil))
return
}
response.WriteJSON(w, response.OK(metric))
}
func (h *Handler) monitorTunnelMetrics(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureMonitoringAccess(w, r) {
return
}
tunnelIDStr := extractPathParam(r.URL.Path, "/api/v1/monitor/tunnels/", "/metrics")
tunnelID, err := strconv.ParseInt(tunnelIDStr, 10, 64)
if err != nil || tunnelID <= 0 {
response.WriteJSON(w, response.ErrDefault("无效的隧道ID"))
return
}
now := time.Now().UnixMilli()
startMs := now - defaultMetricsRangeMs
endMs := now
if s := r.URL.Query().Get("start"); s != "" {
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
startMs = v
}
}
if e := r.URL.Query().Get("end"); e != "" {
if v, err := strconv.ParseInt(e, 10, 64); err == nil {
endMs = v
}
}
if startMs <= 0 || endMs <= 0 {
response.WriteJSON(w, response.ErrDefault("无效的时间范围"))
return
}
if endMs < startMs {
response.WriteJSON(w, response.ErrDefault("无效的时间范围"))
return
}
if endMs-startMs > maxMetricsRangeMs {
response.WriteJSON(w, response.ErrDefault("时间范围过大"))
return
}
metrics, err := h.repo.GetTunnelMetricsAggregated(tunnelID, startMs, endMs)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(metrics))
}
func (h *Handler) monitorServiceListHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureMonitoringAccess(w, r) {
return
}
monitors, err := h.repo.ListServiceMonitors()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(monitors))
}
type createServiceMonitorRequest struct {
Name string `json:"name"`
Type string `json:"type"`
Target string `json:"target"`
IntervalSec int `json:"intervalSec"`
TimeoutSec int `json:"timeoutSec"`
NodeID int64 `json:"nodeId"`
Enabled *int `json:"enabled"`
}
func (h *Handler) monitorServiceCreate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureMonitoringAccess(w, r) {
return
}
var req createServiceMonitorRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
response.WriteJSON(w, response.ErrDefault("名称不能为空"))
return
}
monitorType := strings.ToLower(strings.TrimSpace(req.Type))
if monitorType != "tcp" && monitorType != "icmp" {
response.WriteJSON(w, response.ErrDefault("类型必须是 tcp 或 icmp"))
return
}
target := strings.TrimSpace(req.Target)
if target == "" {
response.WriteJSON(w, response.ErrDefault("目标地址不能为空"))
return
}
limits := h.resolveServiceMonitorLimits()
intervalSec := req.IntervalSec
if intervalSec <= 0 {
intervalSec = limits.DefaultIntervalSec
}
if intervalSec < limits.MinIntervalSec {
intervalSec = limits.MinIntervalSec
}
timeoutSec := req.TimeoutSec
if timeoutSec <= 0 {
timeoutSec = limits.DefaultTimeoutSec
}
if timeoutSec < limits.MinTimeoutSec {
timeoutSec = limits.MinTimeoutSec
}
if timeoutSec > limits.MaxTimeoutSec {
timeoutSec = limits.MaxTimeoutSec
}
enabled := 1
if req.Enabled != nil {
if *req.Enabled == 0 || *req.Enabled == 1 {
enabled = *req.Enabled
}
}
now := time.Now().UnixMilli()
if req.NodeID > 0 {
n, err := h.repo.GetNodeByID(req.NodeID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if n == nil {
response.WriteJSON(w, response.ErrDefault("节点不存在"))
return
}
}
m := &model.ServiceMonitor{
Name: name,
Type: monitorType,
Target: target,
IntervalSec: intervalSec,
TimeoutSec: timeoutSec,
NodeID: req.NodeID,
Enabled: enabled,
CreatedTime: now,
UpdatedTime: now,
}
if m.Type == "icmp" && m.NodeID <= 0 {
response.WriteJSON(w, response.ErrDefault("ICMP 监控必须选择执行节点"))
return
}
// enabled is already normalized above.
if err := h.repo.CreateServiceMonitor(m); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(m))
}
type updateServiceMonitorRequest struct {
ID int64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Target string `json:"target"`
IntervalSec int `json:"intervalSec"`
TimeoutSec int `json:"timeoutSec"`
NodeID *int64 `json:"nodeId"`
Enabled *int `json:"enabled"`
}
func (h *Handler) monitorServiceUpdate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureMonitoringAccess(w, r) {
return
}
var req updateServiceMonitorRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
if req.ID <= 0 {
response.WriteJSON(w, response.ErrDefault("无效的监控ID"))
return
}
existing, err := h.repo.GetServiceMonitor(req.ID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if existing == nil {
response.WriteJSON(w, response.ErrDefault("监控不存在"))
return
}
name := strings.TrimSpace(req.Name)
if name != "" {
existing.Name = name
}
monitorType := strings.ToLower(strings.TrimSpace(req.Type))
if monitorType == "tcp" || monitorType == "icmp" {
existing.Type = monitorType
}
target := strings.TrimSpace(req.Target)
if target != "" {
existing.Target = target
}
limits := h.resolveServiceMonitorLimits()
if req.IntervalSec > 0 {
intervalSec := req.IntervalSec
if intervalSec < limits.MinIntervalSec {
intervalSec = limits.MinIntervalSec
}
existing.IntervalSec = intervalSec
}
if req.TimeoutSec > 0 {
timeoutSec := req.TimeoutSec
if timeoutSec < limits.MinTimeoutSec {
timeoutSec = limits.MinTimeoutSec
}
if timeoutSec > limits.MaxTimeoutSec {
timeoutSec = limits.MaxTimeoutSec
}
existing.TimeoutSec = timeoutSec
}
if req.NodeID != nil {
existing.NodeID = *req.NodeID
}
if req.Enabled != nil {
if *req.Enabled == 0 || *req.Enabled == 1 {
existing.Enabled = *req.Enabled
}
}
existing.UpdatedTime = time.Now().UnixMilli()
if existing.Type == "icmp" && existing.NodeID <= 0 {
response.WriteJSON(w, response.ErrDefault("ICMP 监控必须选择执行节点"))
return
}
if existing.NodeID > 0 {
n, err := h.repo.GetNodeByID(existing.NodeID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if n == nil {
response.WriteJSON(w, response.ErrDefault("节点不存在"))
return
}
}
if err := h.repo.UpdateServiceMonitor(existing); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(existing))
}
type deleteServiceMonitorRequest struct {
ID int64 `json:"id"`
}
func (h *Handler) monitorServiceDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureMonitoringAccess(w, r) {
return
}
var req deleteServiceMonitorRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
if req.ID <= 0 {
response.WriteJSON(w, response.ErrDefault("无效的监控ID"))
return
}
if err := h.repo.DeleteServiceMonitor(req.ID); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OKEmpty())
}
func (h *Handler) monitorServiceRun(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureMonitoringAccess(w, r) {
return
}
if h.healthCheck == nil {
response.WriteJSON(w, response.ErrDefault("监控服务不可用"))
return
}
var req deleteServiceMonitorRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
if req.ID <= 0 {
response.WriteJSON(w, response.ErrDefault("无效的监控ID"))
return
}
m, err := h.repo.GetServiceMonitor(req.ID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if m == nil {
response.WriteJSON(w, response.ErrDefault("监控不存在"))
return
}
res, err := h.healthCheck.RunOnce(m)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := h.repo.InsertServiceMonitorResult(res); err != nil {
log.Printf("monitoring write failed op=service_monitor_result.manual_insert monitor_id=%d err=%v", res.MonitorID, err)
}
response.WriteJSON(w, response.OK(res))
}
func (h *Handler) monitorServiceResultsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureMonitoringAccess(w, r) {
return
}
monitorIDStr := extractPathParam(r.URL.Path, "/api/v1/monitor/services/", "/results")
monitorID, err := strconv.ParseInt(monitorIDStr, 10, 64)
if err != nil || monitorID <= 0 {
response.WriteJSON(w, response.ErrDefault("无效的监控ID"))
return
}
limit := 100
if l := r.URL.Query().Get("limit"); l != "" {
if v, err := strconv.Atoi(l); err == nil && v > 0 && v <= 1000 {
limit = v
}
}
results, err := h.repo.GetServiceMonitorResults(monitorID, limit)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(results))
}
func (h *Handler) monitorServiceLatestResultsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureMonitoringAccess(w, r) {
return
}
results, err := h.repo.GetLatestServiceMonitorResults()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(results))
}
func (h *Handler) monitorServiceLimitsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureMonitoringAccess(w, r) {
return
}
response.WriteJSON(w, response.OK(h.resolveServiceMonitorLimits()))
}
func extractPathParam(path, prefix, suffix string) string {
if !strings.HasPrefix(path, prefix) {
return ""
}
rest := strings.TrimPrefix(path, prefix)
if suffix != "" {
rest = strings.TrimSuffix(rest, suffix)
}
return rest
}
type monitorAccessData struct {
Allowed bool `json:"allowed"`
Reason string `json:"reason,omitempty"`
}
// monitorAccessHandler is a lightweight capability check for frontend navigation.
// It does NOT replace authorization on the actual monitoring endpoints.
func (h *Handler) monitorAccessHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
userID, roleID, err := userRoleFromRequest(r)
if err != nil {
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
return
}
if roleID == 0 {
response.WriteJSON(w, response.OK(monitorAccessData{Allowed: true}))
return
}
allowed, err := h.repo.HasMonitorPermission(userID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
data := monitorAccessData{Allowed: allowed}
if !allowed {
data.Reason = "need_admin_grant"
}
response.WriteJSON(w, response.OK(data))
}
func (h *Handler) ensureAdminAccess(w http.ResponseWriter, r *http.Request) bool {
_, roleID, err := userRoleFromRequest(r)
if err != nil {
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
return false
}
if roleID != 0 {
response.WriteJSON(w, response.Err(403, "权限不足,仅管理员可操作"))
return false
}
return true
}
func (h *Handler) ensureMonitoringAccess(w http.ResponseWriter, r *http.Request) bool {
userID, roleID, err := userRoleFromRequest(r)
if err != nil {
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
return false
}
if roleID == 0 {
return true
}
allowed, err := h.repo.HasMonitorPermission(userID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return false
}
if !allowed {
response.WriteJSON(w, response.Err(403, "权限不足:当前账户非管理员,且未被授予监控权限。请联系管理员在用户管理中授权监控权限。"))
return false
}
return true
}
func (h *Handler) monitorPermissionList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureAdminAccess(w, r) {
return
}
items, err := h.repo.ListMonitorPermissions()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(items))
}
type monitorPermissionMutationRequest struct {
UserID int64 `json:"userId"`
}
func (h *Handler) monitorPermissionAssign(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureAdminAccess(w, r) {
return
}
var req monitorPermissionMutationRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
if req.UserID <= 0 {
response.WriteJSON(w, response.ErrDefault("无效的用户ID"))
return
}
u, err := h.repo.GetUserByID(req.UserID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if u == nil {
response.WriteJSON(w, response.ErrDefault("用户不存在"))
return
}
if err := h.repo.InsertMonitorPermission(req.UserID, time.Now().UnixMilli()); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OKEmpty())
}
func (h *Handler) monitorPermissionRemove(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureAdminAccess(w, r) {
return
}
var req monitorPermissionMutationRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
if req.UserID <= 0 {
response.WriteJSON(w, response.ErrDefault("无效的用户ID"))
return
}
if err := h.repo.DeleteMonitorPermission(req.UserID); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OKEmpty())
}
+49 -14
View File
@@ -832,7 +832,7 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
newEntryNodeIDs = append(newEntryNodeIDs, in.NodeID)
}
}
if err := h.validateTunnelEntryPortConflictsForNewEntries(id, oldEntryNodeIDs, newEntryNodeIDs); err != nil {
if err := h.validateTunnelEntryPortConflictsForNewEntriesTx(tx, id, oldEntryNodeIDs, newEntryNodeIDs); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
@@ -1020,8 +1020,22 @@ func (h *Handler) cleanupTunnelForwardRuntimesOnRemovedEntryNodes(tunnelID int64
}
}
func (h *Handler) validateTunnelEntryPortConflictsForNewEntries(tunnelID int64, oldEntryNodeIDs, newEntryNodeIDs []int64) error {
if h == nil || h.repo == nil || tunnelID <= 0 {
func (h *Handler) validateForwardPortAvailabilityTx(tx *gorm.DB, node *nodeRecord, port int, currentForwardID int64) error {
if h == nil || h.repo == nil || tx == nil || node == nil || port <= 0 {
return nil
}
occupied, err := h.repo.HasOtherForwardOnNodePortTx(tx, node.ID, port, currentForwardID)
if err != nil {
return err
}
if occupied {
return fmt.Errorf("节点 %s 端口 %d 已被其他转发占用", node.Name, port)
}
return nil
}
func (h *Handler) validateTunnelEntryPortConflictsForNewEntriesTx(tx *gorm.DB, tunnelID int64, oldEntryNodeIDs, newEntryNodeIDs []int64) error {
if h == nil || h.repo == nil || tx == nil || tunnelID <= 0 {
return nil
}
@@ -1030,7 +1044,7 @@ func (h *Handler) validateTunnelEntryPortConflictsForNewEntries(tunnelID int64,
return nil
}
forwards, err := h.listForwardsByTunnel(tunnelID)
forwards, err := h.repo.ListForwardsByTunnelTx(tx, tunnelID)
if err != nil || len(forwards) == 0 {
return nil
}
@@ -1040,7 +1054,7 @@ func (h *Handler) validateTunnelEntryPortConflictsForNewEntries(tunnelID int64,
if f == nil {
continue
}
oldPorts, portsErr := h.listForwardPorts(f.ID)
oldPorts, portsErr := h.repo.ListForwardPortsTx(tx, f.ID)
if portsErr != nil {
continue
}
@@ -1050,14 +1064,12 @@ func (h *Handler) validateTunnelEntryPortConflictsForNewEntries(tunnelID int64,
}
for _, nodeID := range addedNodeIDs {
node, nodeErr := h.getNodeRecord(nodeID)
node, nodeErr := h.repo.GetNodeRecordTx(tx, nodeID)
if nodeErr != nil {
continue
}
if err := validateLocalNodePort(node, port); err != nil {
return fmt.Errorf("转发 %s 入口端口冲突: %w", f.Name, err)
}
if err := h.validateForwardPortAvailability(node, port, f.ID); err != nil {
if err := h.validateForwardPortAvailabilityTx(tx, node, port, f.ID); err != nil {
return fmt.Errorf("转发 %s 入口端口冲突: %w", f.Name, err)
}
}
@@ -1852,7 +1864,27 @@ func (h *Handler) forwardDelete(w http.ResponseWriter, r *http.Request) {
}
func (h *Handler) forwardForceDelete(w http.ResponseWriter, r *http.Request) {
h.forwardDelete(w, r)
id := idFromBody(r, w)
if id <= 0 {
return
}
_, _, _, err := h.resolveForwardAccess(r, id)
if err != nil {
if errors.Is(err, errForwardNotFound) {
response.WriteJSON(w, response.ErrDefault("转发不存在"))
return
}
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
// Force delete: remove DB record without touching node services.
// This is used when nodes are offline or service deletion fails.
if err := h.deleteForwardByID(id); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OKEmpty())
}
func (h *Handler) forwardPause(w http.ResponseWriter, r *http.Request) {
@@ -3097,7 +3129,7 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
}
createdChains = append(createdChains, chainNode.NodeID)
serviceData := buildTunnelChainServiceConfig(state.TunnelID, chainNode, state.Nodes[chainNode.NodeID])
serviceData := buildTunnelChainServiceConfig(state.TunnelID, chainNode, state.Nodes[chainNode.NodeID], len(nextTargets))
if err := h.addTunnelServiceOnNode(chainNode.NodeID, state.TunnelID, serviceData); err != nil {
return createdChains, createdServices, fmt.Errorf("转发链节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)
}
@@ -3109,7 +3141,7 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
if node := state.Nodes[outNode.NodeID]; node != nil && node.IsRemote == 1 {
continue
}
serviceData := buildTunnelChainServiceConfig(state.TunnelID, outNode, state.Nodes[outNode.NodeID])
serviceData := buildTunnelChainServiceConfig(state.TunnelID, outNode, state.Nodes[outNode.NodeID], 1)
if err := h.addTunnelServiceOnNode(outNode.NodeID, state.TunnelID, serviceData); err != nil {
return createdChains, createdServices, fmt.Errorf("出口节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[outNode.NodeID]), err)
}
@@ -3260,7 +3292,7 @@ func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRu
}, nil
}
func buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode, node *nodeRecord) []map[string]interface{} {
func buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode, node *nodeRecord, nextHopCandidateCount int) []map[string]interface{} {
if node == nil {
return nil
}
@@ -3271,6 +3303,9 @@ func buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode,
if isTLSTunnelProtocol(protocol) {
handlerCfg["metadata"] = map[string]interface{}{"nodelay": true}
}
if nextHopCandidateCount > 1 {
handlerCfg["retries"] = nextHopCandidateCount - 1
}
service := map[string]interface{}{
"name": fmt.Sprintf("%d_tls", tunnelID),
"addr": processServerAddress(fmt.Sprintf("%s:%d", defaultString(strings.TrimSpace(chainNode.ConnectIP), node.TCPListenAddr), chainNode.Port)),
@@ -0,0 +1,655 @@
package handler
import (
"errors"
"fmt"
"net/http"
"strings"
"time"
"go-backend/internal/http/response"
)
const tunnelDeletePreviewSampleLimit = 5
const (
tunnelDeleteActionReplace = "replace"
tunnelDeleteActionDeleteForwards = "delete_forwards"
)
var (
errInvalidTunnelDeleteTarget = errors.New("invalid tunnel delete target")
)
type tunnelDeleteForwardPreviewItem struct {
ID int64 `json:"id"`
Name string `json:"name"`
UserID int64 `json:"userId"`
UserName string `json:"userName"`
InPort int `json:"inPort"`
}
type tunnelDeletePreviewData struct {
TunnelID int64 `json:"tunnelId"`
TunnelName string `json:"tunnelName"`
ForwardCount int `json:"forwardCount"`
SampleForwards []tunnelDeleteForwardPreviewItem `json:"sampleForwards"`
}
type tunnelBatchDeletePreviewData struct {
TunnelCount int `json:"tunnelCount"`
TotalForwardCount int `json:"totalForwardCount"`
Items []tunnelDeletePreviewData `json:"items"`
}
type tunnelDeleteWithForwardsRequest struct {
ID int64 `json:"id"`
Action string `json:"action"`
TargetTunnelID int64 `json:"targetTunnelId"`
}
type tunnelBatchDeleteWithForwardsRequest struct {
IDs []int64 `json:"ids"`
Action string `json:"action"`
TargetTunnelID int64 `json:"targetTunnelId"`
}
type tunnelDeleteWithForwardsResult struct {
ForwardCount int `json:"forwardCount"`
MigratedCount int `json:"migratedCount"`
DeletedForwardCount int `json:"deletedForwardCount"`
PortAdjustedCount int `json:"portAdjustedCount"`
Warnings []string `json:"warnings,omitempty"`
}
type tunnelBatchDeleteWithForwardsResult struct {
SuccessCount int `json:"successCount"`
FailCount int `json:"failCount"`
Failures []batchFailureDetail `json:"failures,omitempty"`
DeletedForwardCount int `json:"deletedForwardCount"`
MigratedCount int `json:"migratedCount"`
PortAdjustedCount int `json:"portAdjustedCount"`
Warnings []string `json:"warnings,omitempty"`
}
type tunnelForwardMigrationPlan struct {
forward *forwardRecord
oldPorts []forwardPortRecord
targetTunnelID int64
targetPort int
keptNodeIDs []int64
removedNodeIDs []int64
portAdjusted bool
}
func (h *Handler) tunnelDeletePreview(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
id := idFromBody(r, w)
if id <= 0 {
return
}
preview, err := h.buildTunnelDeletePreview(id)
if err != nil {
if strings.Contains(err.Error(), "不存在") {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(preview))
}
func (h *Handler) tunnelBatchDeletePreview(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req struct {
IDs []int64 `json:"ids"`
}
if err := decodeJSON(r.Body, &req); err != nil || len(req.IDs) == 0 {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
preview, err := h.buildTunnelBatchDeletePreview(req.IDs)
if err != nil {
if strings.Contains(err.Error(), "不存在") {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(preview))
}
func (h *Handler) tunnelDeleteWithForwards(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req tunnelDeleteWithForwardsRequest
if err := decodeJSON(r.Body, &req); err != nil || req.ID <= 0 {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
action, err := normalizeTunnelDeleteAction(req.Action)
if err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
if action == tunnelDeleteActionReplace {
if _, _, authErr := userRoleFromRequest(r); authErr != nil {
response.WriteJSON(w, response.Err(401, "无效的token或token已过期"))
return
}
}
result, failures, err := h.processTunnelDeleteWithForwards(req.ID, action, req.TargetTunnelID)
if err != nil {
if err == errInvalidTunnelDeleteTarget {
response.WriteJSON(w, response.ErrDefault("目标隧道不能为空"))
return
}
if strings.Contains(err.Error(), "目标隧道不能与当前隧道相同") || strings.Contains(err.Error(), "目标隧道不存在") || strings.Contains(err.Error(), "目标隧道已禁用") || strings.Contains(err.Error(), "隧道不存在") {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if len(failures) > 0 {
response.WriteJSON(w, response.R{
Code: -2,
Msg: "部分规则迁移失败",
TS: time.Now().UnixMilli(),
Data: batchOperationResult{SuccessCount: 0, FailCount: len(failures), Failures: failures},
})
return
}
response.WriteJSON(w, response.OK(result))
}
func (h *Handler) tunnelBatchDeleteWithForwards(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req tunnelBatchDeleteWithForwardsRequest
if err := decodeJSON(r.Body, &req); err != nil || len(req.IDs) == 0 {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
action, err := normalizeTunnelDeleteAction(req.Action)
if err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
if action == tunnelDeleteActionReplace {
if _, _, authErr := userRoleFromRequest(r); authErr != nil {
response.WriteJSON(w, response.Err(401, "无效的token或token已过期"))
return
}
}
normalizedIDs := normalizeTunnelIDs(req.IDs)
if len(normalizedIDs) == 0 {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
if action == tunnelDeleteActionReplace {
if req.TargetTunnelID <= 0 {
response.WriteJSON(w, response.ErrDefault("目标隧道不能为空"))
return
}
for _, id := range normalizedIDs {
if id == req.TargetTunnelID {
response.WriteJSON(w, response.ErrDefault("目标隧道不能包含在删除列表中"))
return
}
}
}
result := tunnelBatchDeleteWithForwardsResult{}
for _, tunnelID := range normalizedIDs {
tunnelName, _ := h.repo.GetTunnelName(tunnelID)
singleResult, failures, processErr := h.processTunnelDeleteWithForwards(tunnelID, action, req.TargetTunnelID)
if processErr != nil {
result.FailCount++
result.Failures = appendBatchFailure(result.Failures, tunnelID, tunnelName, processErr)
continue
}
if len(failures) > 0 {
result.FailCount++
result.Failures = appendBatchFailureReason(
result.Failures,
tunnelID,
tunnelName,
summarizeTunnelDeleteRuleFailures(failures),
)
continue
}
result.SuccessCount++
result.DeletedForwardCount += singleResult.DeletedForwardCount
result.MigratedCount += singleResult.MigratedCount
result.PortAdjustedCount += singleResult.PortAdjustedCount
if len(singleResult.Warnings) > 0 {
result.Warnings = append(result.Warnings, singleResult.Warnings...)
}
}
response.WriteJSON(w, response.OK(result))
}
func (h *Handler) buildTunnelDeletePreview(tunnelID int64) (*tunnelDeletePreviewData, error) {
if _, err := h.getTunnelRecord(tunnelID); err != nil {
return nil, err
}
tunnelName, err := h.repo.GetTunnelName(tunnelID)
if err != nil {
return nil, err
}
forwards, err := h.listForwardsByTunnel(tunnelID)
if err != nil {
return nil, err
}
samples := make([]tunnelDeleteForwardPreviewItem, 0, minInt(len(forwards), tunnelDeletePreviewSampleLimit))
for i, forward := range forwards {
if i >= tunnelDeletePreviewSampleLimit {
break
}
ports, portsErr := h.listForwardPorts(forward.ID)
if portsErr != nil {
return nil, portsErr
}
inPort := 0
if len(ports) > 0 {
inPort = ports[0].Port
}
samples = append(samples, tunnelDeleteForwardPreviewItem{
ID: forward.ID,
Name: forward.Name,
UserID: forward.UserID,
UserName: forward.UserName,
InPort: inPort,
})
}
return &tunnelDeletePreviewData{
TunnelID: tunnelID,
TunnelName: tunnelName,
ForwardCount: len(forwards),
SampleForwards: samples,
}, nil
}
func (h *Handler) buildTunnelBatchDeletePreview(ids []int64) (*tunnelBatchDeletePreviewData, error) {
normalizedIDs := normalizeTunnelIDs(ids)
items := make([]tunnelDeletePreviewData, 0, len(normalizedIDs))
totalForwardCount := 0
for _, id := range normalizedIDs {
preview, err := h.buildTunnelDeletePreview(id)
if err != nil {
return nil, err
}
items = append(items, *preview)
totalForwardCount += preview.ForwardCount
}
return &tunnelBatchDeletePreviewData{
TunnelCount: len(items),
TotalForwardCount: totalForwardCount,
Items: items,
}, nil
}
func normalizeTunnelDeleteAction(action string) (string, error) {
normalized := strings.TrimSpace(action)
if normalized == "" {
return tunnelDeleteActionDeleteForwards, nil
}
if normalized != tunnelDeleteActionReplace && normalized != tunnelDeleteActionDeleteForwards {
return "", errors.New("invalid tunnel delete action")
}
return normalized, nil
}
func normalizeTunnelIDs(ids []int64) []int64 {
seen := make(map[int64]struct{}, len(ids))
out := make([]int64, 0, len(ids))
for _, id := range ids {
if id <= 0 {
continue
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
func summarizeTunnelDeleteRuleFailures(failures []batchFailureDetail) string {
if len(failures) == 0 {
return "未知错误"
}
parts := make([]string, 0, minInt(len(failures), 3))
for i, failure := range failures {
if i >= 3 {
break
}
name := strings.TrimSpace(failure.Name)
if name == "" {
name = fmt.Sprintf("规则 #%d", failure.ID)
}
parts = append(parts, fmt.Sprintf("%s: %s", name, strings.TrimSpace(failure.Reason)))
}
if len(failures) > 3 {
parts = append(parts, fmt.Sprintf("另有 %d 条规则失败", len(failures)-3))
}
return strings.Join(parts, ";")
}
func (h *Handler) processTunnelDeleteWithForwards(tunnelID int64, action string, targetTunnelID int64) (tunnelDeleteWithForwardsResult, []batchFailureDetail, error) {
preview, err := h.buildTunnelDeletePreview(tunnelID)
if err != nil {
return tunnelDeleteWithForwardsResult{}, nil, err
}
result := tunnelDeleteWithForwardsResult{ForwardCount: preview.ForwardCount}
if preview.ForwardCount == 0 {
if err := h.deleteTunnelAndCleanup(tunnelID); err != nil {
return tunnelDeleteWithForwardsResult{}, nil, err
}
return result, nil, nil
}
if action == tunnelDeleteActionDeleteForwards {
result.DeletedForwardCount = preview.ForwardCount
if err := h.deleteTunnelAndCleanup(tunnelID); err != nil {
return tunnelDeleteWithForwardsResult{}, nil, err
}
return result, nil, nil
}
if targetTunnelID <= 0 {
return tunnelDeleteWithForwardsResult{}, nil, errInvalidTunnelDeleteTarget
}
if targetTunnelID == tunnelID {
return tunnelDeleteWithForwardsResult{}, nil, errors.New("目标隧道不能与当前隧道相同")
}
return h.processTunnelDeleteReplaceAction(tunnelID, targetTunnelID, result)
}
func (h *Handler) processTunnelDeleteReplaceAction(tunnelID, targetTunnelID int64, result tunnelDeleteWithForwardsResult) (tunnelDeleteWithForwardsResult, []batchFailureDetail, error) {
targetTunnel, err := h.getTunnelRecord(targetTunnelID)
if err != nil {
return tunnelDeleteWithForwardsResult{}, nil, errors.New("目标隧道不存在")
}
if targetTunnel.Status != 1 {
return tunnelDeleteWithForwardsResult{}, nil, errors.New("目标隧道已禁用")
}
plans, failures, err := h.planTunnelDeleteForwardMigrations(tunnelID, targetTunnelID)
if err != nil {
return tunnelDeleteWithForwardsResult{}, nil, err
}
if len(failures) > 0 {
return tunnelDeleteWithForwardsResult{}, failures, nil
}
portAdjustedCount := 0
warnings, execErr, execFailure := h.executeTunnelDeleteForwardMigrations(plans)
for _, plan := range plans {
if plan.portAdjusted {
portAdjustedCount++
}
}
if execErr != nil {
failures = append(failures, execFailure)
return tunnelDeleteWithForwardsResult{}, failures, nil
}
if err := h.deleteTunnelAndCleanup(tunnelID); err != nil {
h.rollbackTunnelForwardMigrationPlans(plans)
_ = h.redeployTunnelAndForwards(tunnelID)
return tunnelDeleteWithForwardsResult{}, nil, err
}
result.MigratedCount = len(plans)
result.PortAdjustedCount = portAdjustedCount
if len(warnings) > 0 {
result.Warnings = warnings
}
return result, nil, nil
}
func (h *Handler) planTunnelDeleteForwardMigrations(sourceTunnelID, targetTunnelID int64) ([]tunnelForwardMigrationPlan, []batchFailureDetail, error) {
forwards, err := h.listForwardsByTunnel(sourceTunnelID)
if err != nil {
return nil, nil, err
}
entryNodes, err := h.tunnelEntryNodeIDs(targetTunnelID)
if err != nil {
return nil, nil, err
}
if len(entryNodes) == 0 {
return nil, nil, errors.New("目标隧道缺少入口节点")
}
plans := make([]tunnelForwardMigrationPlan, 0, len(forwards))
failures := make([]batchFailureDetail, 0)
reservedPorts := make(map[int64]map[int]bool)
for _, forward := range forwards {
plan, planErr := h.planSingleTunnelDeleteForwardMigration(&forward, targetTunnelID, entryNodes, reservedPorts)
if planErr != nil {
failures = appendBatchFailure(failures, forward.ID, forward.Name, planErr)
continue
}
plans = append(plans, plan)
}
return plans, failures, nil
}
func (h *Handler) planSingleTunnelDeleteForwardMigration(forward *forwardRecord, targetTunnelID int64, targetEntryNodes []int64, reservedPorts map[int64]map[int]bool) (tunnelForwardMigrationPlan, error) {
if forward == nil {
return tunnelForwardMigrationPlan{}, errors.New("转发不存在")
}
oldPorts, err := h.listForwardPorts(forward.ID)
if err != nil {
return tunnelForwardMigrationPlan{}, err
}
if len(oldPorts) == 0 {
return tunnelForwardMigrationPlan{}, errors.New("转发入口端口不存在")
}
minPort := h.repo.GetMinForwardPort(forward.ID)
targetPort := 0
if minPort.Valid {
targetPort = int(minPort.Int64)
}
if targetPort <= 0 {
targetPort = h.pickTunnelPort(targetTunnelID)
}
if targetPort <= 0 {
targetPort = 10000
}
hasCustomInIP := false
for _, oldPort := range oldPorts {
if strings.TrimSpace(oldPort.InIP) != "" {
hasCustomInIP = true
break
}
}
if hasCustomInIP && len(targetEntryNodes) > 1 {
return tunnelForwardMigrationPlan{}, errors.New("多入口隧道的转发不支持保留自定义监听IP,请先手动调整该规则")
}
for _, nodeID := range targetEntryNodes {
node, nodeErr := h.getNodeRecord(nodeID)
if nodeErr != nil {
return tunnelForwardMigrationPlan{}, nodeErr
}
if err := validateRemoteNodePort(node, targetPort); err != nil {
return tunnelForwardMigrationPlan{}, err
}
if err := validateLocalNodePort(node, targetPort); err != nil {
return tunnelForwardMigrationPlan{}, err
}
if err := h.validateForwardPortAvailability(node, targetPort, forward.ID); err != nil {
return tunnelForwardMigrationPlan{}, err
}
if reservedOnNode, ok := reservedPorts[nodeID]; ok && reservedOnNode[targetPort] {
return tunnelForwardMigrationPlan{}, fmt.Errorf("目标隧道入口节点端口 %d 已被本次迁移中的其他规则占用", targetPort)
}
}
for _, nodeID := range targetEntryNodes {
reservedOnNode := reservedPorts[nodeID]
if reservedOnNode == nil {
reservedOnNode = make(map[int]bool)
reservedPorts[nodeID] = reservedOnNode
}
reservedOnNode[targetPort] = true
}
oldNodeIDs := forwardPortNodeIDs(oldPorts)
newNodeIDs := uniqueInt64s(targetEntryNodes)
removedNodeIDs := diffInt64s(oldNodeIDs, newNodeIDs)
keptNodeIDs := diffInt64s(oldNodeIDs, removedNodeIDs)
previousPort := 0
if len(oldPorts) > 0 {
previousPort = oldPorts[0].Port
}
return tunnelForwardMigrationPlan{
forward: forward,
oldPorts: oldPorts,
targetTunnelID: targetTunnelID,
targetPort: targetPort,
keptNodeIDs: keptNodeIDs,
removedNodeIDs: removedNodeIDs,
portAdjusted: previousPort > 0 && previousPort != targetPort,
}, nil
}
func (h *Handler) executeTunnelDeleteForwardMigrations(plans []tunnelForwardMigrationPlan) ([]string, error, batchFailureDetail) {
warnings := make([]string, 0)
completed := make([]tunnelForwardMigrationPlan, 0, len(plans))
for _, plan := range plans {
migrationWarnings, err := h.applyTunnelDeleteForwardMigration(plan)
if err != nil {
h.rollbackTunnelForwardMigrationPlans(completed)
return warnings, err, batchFailureDetail{ID: plan.forward.ID, Name: plan.forward.Name, Reason: normalizeBatchFailureReason(errString(err))}
}
warnings = append(warnings, migrationWarnings...)
completed = append(completed, plan)
}
return warnings, nil, batchFailureDetail{}
}
func (h *Handler) applyTunnelDeleteForwardMigration(plan tunnelForwardMigrationPlan) ([]string, error) {
if plan.forward == nil {
return nil, errors.New("转发不存在")
}
if err := h.repo.UpdateForwardTunnel(plan.forward.ID, plan.targetTunnelID, time.Now().UnixMilli()); err != nil {
return nil, err
}
if err := h.replaceForwardPorts(plan.forward.ID, plan.targetTunnelID, plan.targetPort, ""); err != nil {
h.rollbackForwardMutation(plan.forward, plan.oldPorts)
return nil, err
}
updatedForward, err := h.getForwardRecord(plan.forward.ID)
if err != nil {
h.rollbackForwardMutation(plan.forward, plan.oldPorts)
return nil, err
}
warnings := make([]string, 0)
if len(plan.keptNodeIDs) > 0 {
for _, nodeID := range plan.keptNodeIDs {
if delErr := h.deleteForwardServicesOnNodeBatch(plan.forward, nodeID); delErr != nil {
nodeLabel := fmt.Sprintf("%d", nodeID)
if n, nErr := h.getNodeRecord(nodeID); nErr == nil && n != nil && strings.TrimSpace(n.Name) != "" {
nodeLabel = strings.TrimSpace(n.Name)
}
warnings = append(warnings, fmt.Sprintf("节点 %s 清理旧转发监听失败: %v", nodeLabel, delErr))
}
}
time.Sleep(tunnelServiceBindRetryDelay)
}
syncWarnings, err := h.syncForwardServicesWithWarnings(updatedForward, "UpdateService", true)
if err != nil {
h.rollbackForwardMutation(plan.forward, plan.oldPorts)
return nil, err
}
warnings = append(warnings, syncWarnings...)
if len(plan.removedNodeIDs) > 0 {
for _, nodeID := range plan.removedNodeIDs {
if delErr := h.deleteForwardServicesOnNodeBatch(plan.forward, nodeID); delErr != nil {
nodeLabel := fmt.Sprintf("%d", nodeID)
if n, nErr := h.getNodeRecord(nodeID); nErr == nil && n != nil && strings.TrimSpace(n.Name) != "" {
nodeLabel = strings.TrimSpace(n.Name)
}
warnings = append(warnings, fmt.Sprintf("节点 %s 清理旧隧道残留服务失败: %v", nodeLabel, delErr))
}
}
}
return warnings, nil
}
func (h *Handler) rollbackTunnelForwardMigrationPlans(plans []tunnelForwardMigrationPlan) {
for i := len(plans) - 1; i >= 0; i-- {
plan := plans[i]
h.rollbackForwardMutation(plan.forward, plan.oldPorts)
}
}
func (h *Handler) deleteTunnelAndCleanup(tunnelID int64) error {
h.cleanupTunnelRuntime(tunnelID)
h.cleanupFederationRuntime(tunnelID)
if err := h.deleteTunnelByID(tunnelID); err != nil {
return err
}
return nil
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
@@ -0,0 +1,104 @@
package handler
import (
"path/filepath"
"testing"
"time"
"go-backend/internal/store/repo"
)
func TestValidateTunnelEntryPortConflictsForNewEntriesDoesNotBlockOnSQLiteTx(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() {
_ = r.Close()
})
h := &Handler{repo: r}
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, port, created_time, status, tcp_listen_addr, udp_listen_addr, is_remote)
VALUES
('entry-old', 'secret-old', '10.0.0.1', '12000-12010', ?, 1, '[::]', '[::]', 0),
('entry-new', 'secret-new', '10.0.0.2', '12000-12010', ?, 1, '[::]', '[::]', 0)
`, now, now).Error; err != nil {
t.Fatalf("insert nodes: %v", err)
}
var oldEntryID, newEntryID int64
if err := r.DB().Raw(`SELECT id FROM node WHERE name = 'entry-old'`).Scan(&oldEntryID).Error; err != nil {
t.Fatalf("load old entry id: %v", err)
}
if err := r.DB().Raw(`SELECT id FROM node WHERE name = 'entry-new'`).Scan(&newEntryID).Error; err != nil {
t.Fatalf("load new entry id: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, inx, ip_preference)
VALUES('sqlite-tunnel', 1, 1, 'tls', 1, ?, ?, 1, 1, '')
`, now, now).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
var tunnelID int64
if err := r.DB().Raw(`SELECT id FROM tunnel WHERE name = 'sqlite-tunnel'`).Scan(&tunnelID).Error; err != nil {
t.Fatalf("load tunnel id: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, inx, protocol)
VALUES(?, '1', ?, 1, 'tls')
`, tunnelID, oldEntryID).Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, created_time, updated_time, status, inx)
VALUES(1, 'tester', 'forward-a', ?, '127.0.0.1:8080', 'fifo', ?, ?, 1, 1)
`, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
var forwardID int64
if err := r.DB().Raw(`SELECT id FROM forward WHERE name = 'forward-a'`).Scan(&forwardID).Error; err != nil {
t.Fatalf("load forward id: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward_port(forward_id, node_id, port)
VALUES(?, ?, 12001)
`, forwardID, oldEntryID).Error; err != nil {
t.Fatalf("insert forward_port: %v", err)
}
tx := r.BeginTx()
if tx == nil {
t.Fatal("begin tx: nil transaction")
}
if tx.Error != nil {
t.Fatalf("begin tx: %v", tx.Error)
}
errCh := make(chan error, 1)
doneCh := make(chan struct{})
go func() {
defer close(doneCh)
errCh <- h.validateTunnelEntryPortConflictsForNewEntriesTx(tx, tunnelID, []int64{oldEntryID}, []int64{oldEntryID, newEntryID})
}()
select {
case err := <-errCh:
if err != nil {
_ = tx.Rollback().Error
t.Fatalf("unexpected validation error: %v", err)
}
case <-time.After(500 * time.Millisecond):
_ = tx.Rollback().Error
<-doneCh
t.Fatal("validation blocked while transaction was open on sqlite")
}
if err := tx.Rollback().Error; err != nil {
t.Fatalf("rollback tx: %v", err)
}
}
@@ -0,0 +1,111 @@
package handler
import (
"log"
"strings"
"time"
"go-backend/internal/store/model"
)
type tunnelTrafficDelta struct {
bytesIn int64
bytesOut int64
}
func unixMilliBucketMinute(nowMs int64) int64 {
if nowMs <= 0 {
return 0
}
const minuteMs = int64(time.Minute / time.Millisecond)
return nowMs - (nowMs % minuteMs)
}
func (h *Handler) recordTunnelMetricsFromFlowItems(nodeID int64, items []flowItem, nowMs int64) {
if h == nil || h.repo == nil {
return
}
if nodeID <= 0 || len(items) == 0 {
return
}
bucketTs := unixMilliBucketMinute(nowMs)
if bucketTs <= 0 {
return
}
forwardDeltas := make(map[int64]tunnelTrafficDelta)
for _, item := range items {
name := strings.TrimSpace(item.N)
if name == "" || name == "web_api" {
continue
}
forwardID, _, _, ok := parseFlowServiceIDs(name)
if !ok {
continue
}
if item.D == 0 && item.U == 0 {
continue
}
d := forwardDeltas[forwardID]
d.bytesIn += item.D
d.bytesOut += item.U
forwardDeltas[forwardID] = d
}
if len(forwardDeltas) == 0 {
return
}
forwardIDs := make([]int64, 0, len(forwardDeltas))
for id := range forwardDeltas {
forwardIDs = append(forwardIDs, id)
}
forwardTunnelMap, err := h.repo.MapForwardIDsToTunnelIDs(forwardIDs)
if err != nil {
log.Printf("monitoring write skipped op=tunnel_metric.map_forward_to_tunnel node_id=%d err=%v", nodeID, err)
return
}
if len(forwardTunnelMap) == 0 {
return
}
tunnelAgg := make(map[int64]tunnelTrafficDelta)
for forwardID, delta := range forwardDeltas {
tunnelID := forwardTunnelMap[forwardID]
if tunnelID <= 0 {
continue
}
a := tunnelAgg[tunnelID]
a.bytesIn += delta.bytesIn
a.bytesOut += delta.bytesOut
tunnelAgg[tunnelID] = a
}
if len(tunnelAgg) == 0 {
return
}
metrics := make([]*model.TunnelMetric, 0, len(tunnelAgg))
for tunnelID, delta := range tunnelAgg {
if delta.bytesIn == 0 && delta.bytesOut == 0 {
continue
}
metrics = append(metrics, &model.TunnelMetric{
TunnelID: tunnelID,
NodeID: nodeID,
Timestamp: bucketTs,
BytesIn: delta.bytesIn,
BytesOut: delta.bytesOut,
Connections: 0,
Errors: 0,
AvgLatencyMs: 0,
})
}
if len(metrics) == 0 {
return
}
if err := h.repo.UpsertTunnelMetricBuckets(metrics); err != nil {
log.Printf("monitoring write failed op=tunnel_metric.upsert_buckets node_id=%d bucket_ts=%d count=%d err=%v", nodeID, bucketTs, len(metrics), err)
}
}
@@ -101,6 +101,10 @@ func shouldSkip(path string) bool {
}
func requiresAdmin(path string) bool {
if strings.HasPrefix(path, "/api/v1/monitor/permission/") {
return true
}
if strings.HasPrefix(path, "/api/v1/group/") {
return true
}
+135
View File
@@ -0,0 +1,135 @@
package metrics
import (
"context"
"log"
"sync"
"time"
"go-backend/internal/store/model"
"go-backend/internal/store/repo"
)
type SystemInfo struct {
Uptime uint64 `json:"uptime"`
BytesReceived uint64 `json:"bytes_received"`
BytesTransmitted uint64 `json:"bytes_transmitted"`
CPUUsage float64 `json:"cpu_usage"`
MemoryUsage float64 `json:"memory_usage"`
DiskUsage float64 `json:"disk_usage"`
Load1 float64 `json:"load1"`
Load5 float64 `json:"load5"`
Load15 float64 `json:"load15"`
TCPConns int64 `json:"tcp_conns"`
UDPConns int64 `json:"udp_conns"`
NetInSpeed int64 `json:"net_in_speed"`
NetOutSpeed int64 `json:"net_out_speed"`
}
type IngestionService struct {
repo *repo.Repository
nodeBuffer []*model.NodeMetric
nodeBufferMu sync.Mutex
flushInterval time.Duration
retentionDays int
}
func NewIngestionService(repo *repo.Repository) *IngestionService {
return &IngestionService{
repo: repo,
nodeBuffer: make([]*model.NodeMetric, 0, 500),
flushInterval: 30 * time.Second,
retentionDays: 7,
}
}
func (s *IngestionService) Start(ctx context.Context) {
flushTicker := time.NewTicker(s.flushInterval)
defer flushTicker.Stop()
pruneTicker := time.NewTicker(1 * time.Hour)
defer pruneTicker.Stop()
for {
select {
case <-ctx.Done():
s.flushNodeMetrics()
return
case <-flushTicker.C:
s.flushNodeMetrics()
case <-pruneTicker.C:
s.pruneMetrics()
}
}
}
func (s *IngestionService) RecordNodeMetric(nodeID int64, info SystemInfo) {
m := &model.NodeMetric{
NodeID: nodeID,
Timestamp: time.Now().UnixMilli(),
CPUUsage: info.CPUUsage,
MemUsage: info.MemoryUsage,
DiskUsage: info.DiskUsage,
NetInBytes: int64(info.BytesReceived),
NetOutBytes: int64(info.BytesTransmitted),
NetInSpeed: info.NetInSpeed,
NetOutSpeed: info.NetOutSpeed,
Load1: info.Load1,
Load5: info.Load5,
Load15: info.Load15,
TCPConns: info.TCPConns,
UDPConns: info.UDPConns,
Uptime: int64(info.Uptime),
}
s.nodeBufferMu.Lock()
s.nodeBuffer = append(s.nodeBuffer, m)
shouldFlush := len(s.nodeBuffer) >= 200
s.nodeBufferMu.Unlock()
if shouldFlush {
go s.flushNodeMetrics()
}
}
func (s *IngestionService) flushNodeMetrics() {
s.nodeBufferMu.Lock()
if len(s.nodeBuffer) == 0 {
s.nodeBufferMu.Unlock()
return
}
buffer := s.nodeBuffer
s.nodeBuffer = make([]*model.NodeMetric, 0, 500)
s.nodeBufferMu.Unlock()
if s.repo == nil {
return
}
if err := s.repo.InsertNodeMetricBatch(buffer); err != nil {
log.Printf("monitoring write failed op=node_metric.flush count=%d err=%v", len(buffer), err)
}
}
func (s *IngestionService) pruneMetrics() {
cutoff := time.Now().Add(-time.Duration(s.retentionDays) * 24 * time.Hour).UnixMilli()
if s.repo == nil {
return
}
if err := s.repo.PruneNodeMetrics(cutoff); err != nil {
log.Printf("monitoring prune failed op=node_metric cutoff=%d err=%v", cutoff, err)
}
if err := s.repo.PruneTunnelMetrics(cutoff); err != nil {
log.Printf("monitoring prune failed op=tunnel_metric cutoff=%d err=%v", cutoff, err)
}
if err := s.repo.PruneServiceMonitorResults(cutoff); err != nil {
log.Printf("monitoring prune failed op=service_monitor_result cutoff=%d err=%v", cutoff, err)
}
}
func (s *IngestionService) GetLatestMetric(nodeID int64) (*model.NodeMetric, error) {
return s.repo.GetLatestNodeMetric(nodeID)
}
func (s *IngestionService) GetMetrics(nodeID int64, startMs, endMs int64) ([]model.NodeMetric, error) {
return s.repo.GetNodeMetrics(nodeID, startMs, endMs)
}
@@ -0,0 +1,294 @@
package metrics
import (
"context"
"testing"
"time"
"go-backend/internal/store/repo"
)
func TestRecordNodeMetric(t *testing.T) {
r, err := repo.Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
svc := NewIngestionService(r)
info := SystemInfo{
Uptime: 86400,
BytesReceived: 1024000,
BytesTransmitted: 2048000,
CPUUsage: 45.5,
MemoryUsage: 60.2,
DiskUsage: 30.1,
Load1: 1.5,
Load5: 1.2,
Load15: 0.9,
TCPConns: 100,
UDPConns: 50,
NetInSpeed: 51200,
NetOutSpeed: 102400,
}
svc.RecordNodeMetric(1, info)
svc.flushNodeMetrics()
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
if len(metrics) != 1 {
t.Fatalf("expected 1 metric, got %d", len(metrics))
}
m := metrics[0]
if m.CPUUsage != 45.5 {
t.Fatalf("expected CPUUsage 45.5, got %f", m.CPUUsage)
}
if m.MemUsage != 60.2 {
t.Fatalf("expected MemUsage 60.2, got %f", m.MemUsage)
}
if m.DiskUsage != 30.1 {
t.Fatalf("expected DiskUsage 30.1, got %f", m.DiskUsage)
}
if m.Load1 != 1.5 {
t.Fatalf("expected Load1 1.5, got %f", m.Load1)
}
if m.TCPConns != 100 {
t.Fatalf("expected TCPConns 100, got %d", m.TCPConns)
}
if m.UDPConns != 50 {
t.Fatalf("expected UDPConns 50, got %d", m.UDPConns)
}
}
func TestRecordNodeMetricAutoFlush(t *testing.T) {
r, err := repo.Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
svc := NewIngestionService(r)
info := SystemInfo{
CPUUsage: 50.0,
MemoryUsage: 60.0,
DiskUsage: 30.0,
}
for i := 0; i < 250; i++ {
svc.RecordNodeMetric(1, info)
}
time.Sleep(100 * time.Millisecond)
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
if len(metrics) < 200 {
t.Fatalf("expected at least 200 metrics after auto-flush, got %d", len(metrics))
}
}
func TestIngestionServiceStart(t *testing.T) {
r, err := repo.Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
svc := NewIngestionService(r)
svc.flushInterval = 100 * time.Millisecond
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
info := SystemInfo{
CPUUsage: 45.0,
MemoryUsage: 55.0,
DiskUsage: 35.0,
}
go svc.Start(ctx)
for i := 0; i < 10; i++ {
svc.RecordNodeMetric(1, info)
time.Sleep(50 * time.Millisecond)
}
<-ctx.Done()
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
if len(metrics) == 0 {
t.Fatalf("expected metrics after service run")
}
}
func TestGetLatestMetric(t *testing.T) {
r, err := repo.Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
svc := NewIngestionService(r)
now := time.Now().UnixMilli()
info1 := SystemInfo{CPUUsage: 40.0, MemoryUsage: 50.0, DiskUsage: 30.0}
svc.RecordNodeMetric(1, info1)
time.Sleep(5 * time.Millisecond)
info2 := SystemInfo{CPUUsage: 60.0, MemoryUsage: 70.0, DiskUsage: 40.0}
svc.RecordNodeMetric(1, info2)
svc.flushNodeMetrics()
latest, err := svc.GetLatestMetric(1)
if err != nil {
t.Fatalf("get latest: %v", err)
}
if latest == nil {
t.Fatalf("expected latest metric")
}
if latest.CPUUsage != 60.0 {
t.Fatalf("expected latest CPUUsage 60.0, got %f", latest.CPUUsage)
}
_ = now
latestNone, err := svc.GetLatestMetric(999)
if err != nil {
t.Fatalf("get latest for non-existent: %v", err)
}
if latestNone != nil {
t.Fatalf("expected nil for non-existent node")
}
}
func TestGetMetricsWithTimeRange(t *testing.T) {
r, err := repo.Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
svc := NewIngestionService(r)
now := time.Now().UnixMilli()
for i := 0; i < 5; i++ {
info := SystemInfo{
CPUUsage: float64(40 + i*5),
MemoryUsage: 50.0,
DiskUsage: 30.0,
}
svc.RecordNodeMetric(1, info)
time.Sleep(10 * time.Millisecond)
}
svc.flushNodeMetrics()
metrics, err := svc.GetMetrics(1, 0, now+1000)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
if len(metrics) != 5 {
t.Fatalf("expected 5 metrics, got %d", len(metrics))
}
}
func TestPruneMetrics(t *testing.T) {
r, err := repo.Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
svc := NewIngestionService(r)
svc.retentionDays = 1
info := SystemInfo{CPUUsage: 50.0, MemoryUsage: 60.0, DiskUsage: 30.0}
svc.RecordNodeMetric(1, info)
svc.flushNodeMetrics()
svc.pruneMetrics()
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
if len(metrics) != 1 {
t.Fatalf("expected 1 metric (not pruned), got %d", len(metrics))
}
}
func TestMultipleNodes(t *testing.T) {
r, err := repo.Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
svc := NewIngestionService(r)
info := SystemInfo{
CPUUsage: 50.0,
MemoryUsage: 60.0,
DiskUsage: 30.0,
}
svc.RecordNodeMetric(1, info)
svc.RecordNodeMetric(2, info)
svc.RecordNodeMetric(3, info)
svc.flushNodeMetrics()
for nodeID := int64(1); nodeID <= 3; nodeID++ {
metrics, err := r.GetNodeMetrics(nodeID, 0, time.Now().UnixMilli()+1000)
if err != nil {
t.Fatalf("get metrics for node %d: %v", nodeID, err)
}
if len(metrics) != 1 {
t.Fatalf("expected 1 metric for node %d, got %d", nodeID, len(metrics))
}
}
}
func TestZeroValues(t *testing.T) {
r, err := repo.Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
svc := NewIngestionService(r)
info := SystemInfo{}
svc.RecordNodeMetric(1, info)
svc.flushNodeMetrics()
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
if len(metrics) != 1 {
t.Fatalf("expected 1 metric, got %d", len(metrics))
}
m := metrics[0]
if m.CPUUsage != 0 || m.MemUsage != 0 || m.DiskUsage != 0 {
t.Fatalf("expected zero values, got CPU=%f Mem=%f Disk=%f", m.CPUUsage, m.MemUsage, m.DiskUsage)
}
}
+114
View File
@@ -0,0 +1,114 @@
package monitoring
import (
"strconv"
"strings"
)
type ServiceMonitorLimits struct {
CheckerScanIntervalSec int `json:"checkerScanIntervalSec"`
WorkerLimit int `json:"workerLimit"`
MinIntervalSec int `json:"minIntervalSec"`
DefaultIntervalSec int `json:"defaultIntervalSec"`
MinTimeoutSec int `json:"minTimeoutSec"`
DefaultTimeoutSec int `json:"defaultTimeoutSec"`
MaxTimeoutSec int `json:"maxTimeoutSec"`
}
const (
ConfigServiceMonitorCheckerScanIntervalSec = "service_monitor_checker_scan_interval_sec"
ConfigServiceMonitorWorkerLimit = "service_monitor_worker_limit"
ConfigServiceMonitorMinIntervalSec = "service_monitor_min_interval_sec"
ConfigServiceMonitorDefaultIntervalSec = "service_monitor_default_interval_sec"
ConfigServiceMonitorMinTimeoutSec = "service_monitor_min_timeout_sec"
ConfigServiceMonitorDefaultTimeoutSec = "service_monitor_default_timeout_sec"
ConfigServiceMonitorMaxTimeoutSec = "service_monitor_max_timeout_sec"
)
func DefaultServiceMonitorLimits() ServiceMonitorLimits {
return ServiceMonitorLimits{
CheckerScanIntervalSec: 30,
WorkerLimit: 5,
MinIntervalSec: 30,
DefaultIntervalSec: 60,
MinTimeoutSec: 1,
DefaultTimeoutSec: 5,
MaxTimeoutSec: 60,
}
}
// ServiceMonitorLimitsFromConfigMap parses limits from vite_config values.
// Missing/invalid values fall back to defaults.
func ServiceMonitorLimitsFromConfigMap(cfg map[string]string) ServiceMonitorLimits {
limits := DefaultServiceMonitorLimits()
if cfg == nil {
return limits
}
limits.CheckerScanIntervalSec = parseConfigInt(cfg, ConfigServiceMonitorCheckerScanIntervalSec, limits.CheckerScanIntervalSec)
limits.WorkerLimit = parseConfigInt(cfg, ConfigServiceMonitorWorkerLimit, limits.WorkerLimit)
limits.MinIntervalSec = parseConfigInt(cfg, ConfigServiceMonitorMinIntervalSec, limits.MinIntervalSec)
limits.DefaultIntervalSec = parseConfigInt(cfg, ConfigServiceMonitorDefaultIntervalSec, limits.DefaultIntervalSec)
limits.MinTimeoutSec = parseConfigInt(cfg, ConfigServiceMonitorMinTimeoutSec, limits.MinTimeoutSec)
limits.DefaultTimeoutSec = parseConfigInt(cfg, ConfigServiceMonitorDefaultTimeoutSec, limits.DefaultTimeoutSec)
limits.MaxTimeoutSec = parseConfigInt(cfg, ConfigServiceMonitorMaxTimeoutSec, limits.MaxTimeoutSec)
return normalizeServiceMonitorLimits(limits)
}
func normalizeServiceMonitorLimits(limits ServiceMonitorLimits) ServiceMonitorLimits {
if limits.CheckerScanIntervalSec <= 0 {
limits.CheckerScanIntervalSec = 30
}
if limits.WorkerLimit <= 0 {
limits.WorkerLimit = 5
}
if limits.WorkerLimit > 50 {
limits.WorkerLimit = 50
}
if limits.MinIntervalSec <= 0 {
limits.MinIntervalSec = limits.CheckerScanIntervalSec
}
if limits.MinIntervalSec < limits.CheckerScanIntervalSec {
limits.MinIntervalSec = limits.CheckerScanIntervalSec
}
if limits.DefaultIntervalSec <= 0 {
limits.DefaultIntervalSec = 60
}
if limits.DefaultIntervalSec < limits.MinIntervalSec {
limits.DefaultIntervalSec = limits.MinIntervalSec
}
if limits.MinTimeoutSec <= 0 {
limits.MinTimeoutSec = 1
}
if limits.DefaultTimeoutSec <= 0 {
limits.DefaultTimeoutSec = 5
}
if limits.DefaultTimeoutSec < limits.MinTimeoutSec {
limits.DefaultTimeoutSec = limits.MinTimeoutSec
}
if limits.MaxTimeoutSec <= 0 {
limits.MaxTimeoutSec = 60
}
if limits.MaxTimeoutSec < limits.DefaultTimeoutSec {
limits.MaxTimeoutSec = limits.DefaultTimeoutSec
}
return limits
}
func parseConfigInt(cfg map[string]string, key string, fallback int) int {
v := strings.TrimSpace(cfg[key])
if v == "" {
return fallback
}
n, err := strconv.Atoi(v)
if err != nil {
return fallback
}
return n
}
+73
View File
@@ -235,6 +235,16 @@ type GroupPermissionGrant struct {
func (GroupPermissionGrant) TableName() string { return "group_permission_grant" }
// MonitorPermission grants a non-admin user access to monitoring endpoints.
// One row per user_id.
type MonitorPermission struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
UserID int64 `gorm:"column:user_id;not null;uniqueIndex:idx_monitor_permission_user" json:"userId"`
CreatedTime int64 `gorm:"column:created_time;not null" json:"createdTime"`
}
func (MonitorPermission) TableName() string { return "monitor_permission" }
type ViteConfig struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
Name string `gorm:"type:varchar(200);not null;uniqueIndex" json:"name"`
@@ -641,3 +651,66 @@ type UserForwardDetail struct {
Status int
CreatedAt int64
}
type NodeMetric struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
NodeID int64 `gorm:"column:node_id;not null;index:idx_node_metric_node_time,priority:1" json:"nodeId"`
Timestamp int64 `gorm:"not null;index:idx_node_metric_node_time,priority:2;index:idx_node_metric_time" json:"timestamp"`
CPUUsage float64 `gorm:"column:cpu_usage" json:"cpuUsage"`
MemUsage float64 `gorm:"column:mem_usage" json:"memoryUsage"`
DiskUsage float64 `gorm:"column:disk_usage" json:"diskUsage"`
NetInBytes int64 `gorm:"column:net_in_bytes" json:"netInBytes"`
NetOutBytes int64 `gorm:"column:net_out_bytes" json:"netOutBytes"`
NetInSpeed int64 `gorm:"column:net_in_speed" json:"netInSpeed"`
NetOutSpeed int64 `gorm:"column:net_out_speed" json:"netOutSpeed"`
Load1 float64 `gorm:"column:load1" json:"load1"`
Load5 float64 `gorm:"column:load5" json:"load5"`
Load15 float64 `gorm:"column:load15" json:"load15"`
TCPConns int64 `gorm:"column:tcp_conns" json:"tcpConns"`
UDPConns int64 `gorm:"column:udp_conns" json:"udpConns"`
Uptime int64 `gorm:"column:uptime" json:"uptime"`
}
func (NodeMetric) TableName() string { return "node_metric" }
type TunnelMetric struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
TunnelID int64 `gorm:"column:tunnel_id;not null;index:idx_tunnel_metric_tunnel_time,priority:1" json:"tunnelId"`
NodeID int64 `gorm:"column:node_id;not null;index:idx_tunnel_metric_tunnel_time,priority:2" json:"nodeId"`
Timestamp int64 `gorm:"not null;index:idx_tunnel_metric_tunnel_time,priority:3;index:idx_tunnel_metric_time" json:"timestamp"`
BytesIn int64 `gorm:"column:bytes_in" json:"bytesIn"`
BytesOut int64 `gorm:"column:bytes_out" json:"bytesOut"`
Connections int64 `gorm:"column:connections" json:"connections"`
Errors int64 `gorm:"column:errors" json:"errors"`
AvgLatencyMs float64 `gorm:"column:avg_latency_ms" json:"avgLatencyMs"`
}
func (TunnelMetric) TableName() string { return "tunnel_metric" }
type ServiceMonitor struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
Name string `gorm:"type:varchar(100);not null" json:"name"`
Type string `gorm:"type:varchar(20);not null" json:"type"`
Target string `gorm:"type:text;not null" json:"target"`
IntervalSec int `gorm:"column:interval_sec;not null;default:60" json:"intervalSec"`
TimeoutSec int `gorm:"column:timeout_sec;not null;default:5" json:"timeoutSec"`
NodeID int64 `gorm:"column:node_id;index" json:"nodeId"`
Enabled int `gorm:"not null;default:1" json:"enabled"`
CreatedTime int64 `gorm:"column:created_time;not null" json:"createdTime"`
UpdatedTime int64 `gorm:"column:updated_time;not null" json:"updatedTime"`
}
func (ServiceMonitor) TableName() string { return "service_monitor" }
type ServiceMonitorResult struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
MonitorID int64 `gorm:"column:monitor_id;not null;index:idx_monitor_result_monitor_time,priority:1" json:"monitorId"`
NodeID int64 `gorm:"column:node_id;not null;index" json:"nodeId"`
Timestamp int64 `gorm:"not null;index:idx_monitor_result_monitor_time,priority:2" json:"timestamp"`
Success int `gorm:"not null" json:"success"`
LatencyMs float64 `gorm:"column:latency_ms" json:"latencyMs"`
StatusCode int `gorm:"column:status_code" json:"statusCode"`
ErrorMessage string `gorm:"column:error_message;type:text" json:"errorMessage"`
}
func (ServiceMonitorResult) TableName() string { return "service_monitor_result" }
+487 -1
View File
@@ -47,6 +47,10 @@ type UserGroupBackup = model.UserGroupBackup
type PermissionBackup = model.PermissionBackup
type PermissionGrantBackup = model.PermissionGrantBackup
type ImportResult = model.ImportResult
type NodeMetric = model.NodeMetric
type TunnelMetric = model.TunnelMetric
type ServiceMonitor = model.ServiceMonitor
type ServiceMonitorResult = model.ServiceMonitorResult
// ─── Repository ──────────────────────────────────────────────────────
@@ -176,12 +180,17 @@ func autoMigrateAll(db *gorm.DB) error {
&model.UserGroupUser{},
&model.GroupPermission{},
&model.GroupPermissionGrant{},
&model.MonitorPermission{},
&model.ViteConfig{},
&model.PeerShare{},
&model.PeerShareRuntime{},
&model.FederationTunnelBinding{},
&model.Announcement{},
&model.SchemaVersion{},
&model.NodeMetric{},
&model.TunnelMetric{},
&model.ServiceMonitor{},
&model.ServiceMonitorResult{},
}
if db.Dialector.Name() != "sqlite" {
@@ -394,6 +403,24 @@ func (r *Repository) ListConfigs() (map[string]string, error) {
return result, nil
}
func (r *Repository) GetConfigsByNames(names []string) (map[string]string, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
if len(names) == 0 {
return map[string]string{}, nil
}
var configs []model.ViteConfig
if err := r.db.Select("name", "value").Where("name IN ?", names).Find(&configs).Error; err != nil {
return nil, err
}
result := make(map[string]string, len(configs))
for _, c := range configs {
result[c.Name] = c.Value
}
return result, nil
}
func (r *Repository) UpsertConfig(name, value string, now int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
@@ -2689,12 +2716,13 @@ func (r *Repository) GetUserTunnelByID(id int64) (*model.UserTunnel, error) {
// ─── Migration ───────────────────────────────────────────────────────
const currentSchemaVersion = 5
const currentSchemaVersion = 6
var ensurePostgresIDDefaultsFn = ensurePostgresIDDefaults
var migrateViteConfigValueColumnTypeFn = migrateViteConfigValueColumnType
var migrateSpeedLimitTunnelBindingFn = migrateSpeedLimitTunnelBinding
var migratePostgresTrafficInt64ColumnsFn = migratePostgresTrafficInt64Columns
var migrateTunnelMetricBucketUniqueIndexFn = migrateTunnelMetricBucketUniqueIndex
func getSchemaVersion(db *gorm.DB) int {
var v model.SchemaVersion
@@ -2764,6 +2792,12 @@ func migrateSchema(db *gorm.DB) error {
}
}
if ver < 6 {
if err := migrateTunnelMetricBucketUniqueIndexFn(db); err != nil {
return err
}
}
setSchemaVersion(db, currentSchemaVersion)
return nil
}
@@ -2867,6 +2901,130 @@ func migratePostgresTrafficInt64Columns(db *gorm.DB) error {
return nil
}
func migrateTunnelMetricBucketUniqueIndex(db *gorm.DB) error {
if db == nil {
return errors.New("nil db")
}
if !db.Migrator().HasTable(&model.TunnelMetric{}) {
return nil
}
return db.Transaction(func(tx *gorm.DB) error {
// Only do the heavier dedupe work when needed.
var dupGroups int64
q := `
SELECT COUNT(1) AS cnt
FROM (
SELECT 1
FROM tunnel_metric
GROUP BY tunnel_id, node_id, timestamp
HAVING COUNT(*) > 1
) t
`
if err := tx.Raw(q).Scan(&dupGroups).Error; err != nil {
return fmt.Errorf("inspect tunnel_metric duplicates: %w", err)
}
if dupGroups > 0 {
switch tx.Dialector.Name() {
case "postgres":
sql := `
WITH agg AS (
SELECT MIN(id) AS keep_id,
tunnel_id,
node_id,
timestamp,
SUM(bytes_in) AS bytes_in,
SUM(bytes_out) AS bytes_out,
SUM(connections) AS connections,
SUM(errors) AS errors,
AVG(avg_latency_ms) AS avg_latency_ms
FROM tunnel_metric
GROUP BY tunnel_id, node_id, timestamp
HAVING COUNT(*) > 1
), updated AS (
UPDATE tunnel_metric tm
SET bytes_in = agg.bytes_in,
bytes_out = agg.bytes_out,
connections = agg.connections,
errors = agg.errors,
avg_latency_ms = agg.avg_latency_ms
FROM agg
WHERE tm.id = agg.keep_id
RETURNING tm.id
)
DELETE FROM tunnel_metric tm
USING agg
WHERE tm.tunnel_id = agg.tunnel_id
AND tm.node_id = agg.node_id
AND tm.timestamp = agg.timestamp
AND tm.id <> agg.keep_id
`
if err := tx.Exec(sql).Error; err != nil {
return fmt.Errorf("dedupe tunnel_metric buckets: %w", err)
}
default:
// SQLite (and other) path.
if err := tx.Exec(`DROP TABLE IF EXISTS tunnel_metric_dedupe`).Error; err != nil {
return fmt.Errorf("prepare tunnel_metric dedupe table: %w", err)
}
if err := tx.Exec(`
CREATE TEMP TABLE tunnel_metric_dedupe AS
SELECT MIN(id) AS keep_id,
tunnel_id,
node_id,
timestamp,
SUM(bytes_in) AS bytes_in,
SUM(bytes_out) AS bytes_out,
SUM(connections) AS connections,
SUM(errors) AS errors,
AVG(avg_latency_ms) AS avg_latency_ms
FROM tunnel_metric
GROUP BY tunnel_id, node_id, timestamp
HAVING COUNT(*) > 1
`).Error; err != nil {
return fmt.Errorf("build tunnel_metric dedupe table: %w", err)
}
if err := tx.Exec(`
UPDATE tunnel_metric
SET bytes_in = (SELECT bytes_in FROM tunnel_metric_dedupe d WHERE d.keep_id = tunnel_metric.id),
bytes_out = (SELECT bytes_out FROM tunnel_metric_dedupe d WHERE d.keep_id = tunnel_metric.id),
connections = (SELECT connections FROM tunnel_metric_dedupe d WHERE d.keep_id = tunnel_metric.id),
errors = (SELECT errors FROM tunnel_metric_dedupe d WHERE d.keep_id = tunnel_metric.id),
avg_latency_ms = (SELECT avg_latency_ms FROM tunnel_metric_dedupe d WHERE d.keep_id = tunnel_metric.id)
WHERE id IN (SELECT keep_id FROM tunnel_metric_dedupe)
`).Error; err != nil {
return fmt.Errorf("update tunnel_metric deduped rows: %w", err)
}
if err := tx.Exec(`
DELETE FROM tunnel_metric
WHERE id IN (
SELECT tm.id
FROM tunnel_metric tm
JOIN tunnel_metric_dedupe d
ON tm.tunnel_id = d.tunnel_id
AND tm.node_id = d.node_id
AND tm.timestamp = d.timestamp
WHERE tm.id <> d.keep_id
)
`).Error; err != nil {
return fmt.Errorf("delete tunnel_metric duplicates: %w", err)
}
_ = tx.Exec(`DROP TABLE IF EXISTS tunnel_metric_dedupe`).Error
}
}
// Uniqueness is required for safe upsert on (tunnel_id, node_id, timestamp).
if err := tx.Exec(
`CREATE UNIQUE INDEX IF NOT EXISTS uidx_tunnel_metric_bucket ON tunnel_metric(tunnel_id, node_id, timestamp)`,
).Error; err != nil {
return fmt.Errorf("create tunnel_metric unique index: %w", err)
}
return nil
})
}
func alterPostgresColumnToBigIntIfNeeded(db *gorm.DB, tableName, columnName string) error {
if db == nil {
return errors.New("nil db")
@@ -3140,3 +3298,331 @@ var osMkdirAll = func(path string) error {
// Suppress unused import warning for log
var _ = log.Printf
func (r *Repository) InsertNodeMetric(m *model.NodeMetric) error {
if r == nil || r.db == nil {
return nil
}
return r.db.Create(m).Error
}
func (r *Repository) InsertNodeMetricBatch(metrics []*model.NodeMetric) error {
if r == nil || r.db == nil || len(metrics) == 0 {
return nil
}
return r.db.CreateInBatches(metrics, 100).Error
}
func (r *Repository) GetNodeMetrics(nodeID int64, startMs, endMs int64) ([]model.NodeMetric, error) {
if r == nil || r.db == nil {
return nil, nil
}
var metrics []model.NodeMetric
err := r.db.Where("node_id = ? AND timestamp >= ? AND timestamp <= ?", nodeID, startMs, endMs).
Order("timestamp DESC").
Limit(5000).
Find(&metrics).Error
if len(metrics) > 1 {
for i, j := 0, len(metrics)-1; i < j; i, j = i+1, j-1 {
metrics[i], metrics[j] = metrics[j], metrics[i]
}
}
return metrics, err
}
func (r *Repository) GetLatestNodeMetric(nodeID int64) (*model.NodeMetric, error) {
if r == nil || r.db == nil {
return nil, nil
}
var m model.NodeMetric
err := r.db.Where("node_id = ?", nodeID).Order("timestamp DESC").First(&m).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &m, nil
}
func (r *Repository) PruneNodeMetrics(olderThanMs int64) error {
if r == nil || r.db == nil {
return nil
}
return r.db.Where("timestamp < ?", olderThanMs).Delete(&model.NodeMetric{}).Error
}
func (r *Repository) InsertTunnelMetric(m *model.TunnelMetric) error {
if r == nil || r.db == nil {
return nil
}
return r.db.Create(m).Error
}
func (r *Repository) InsertTunnelMetricBatch(metrics []*model.TunnelMetric) error {
if r == nil || r.db == nil || len(metrics) == 0 {
return nil
}
return r.db.CreateInBatches(metrics, 100).Error
}
// UpsertTunnelMetricBuckets adds the provided metric deltas into per-minute buckets.
// Requires a unique index on (tunnel_id, node_id, timestamp) for safe upserts.
func (r *Repository) UpsertTunnelMetricBuckets(metrics []*model.TunnelMetric) error {
if r == nil || r.db == nil || len(metrics) == 0 {
return nil
}
// Postgres rejects a single INSERT ... ON CONFLICT when the input contains
// duplicate conflict keys. Pre-aggregate within this batch to keep inserts safe.
type bucketKey struct {
tunnelID int64
nodeID int64
timestamp int64
}
agg := make(map[bucketKey]*model.TunnelMetric, len(metrics))
for _, m := range metrics {
if m == nil {
continue
}
if m.TunnelID <= 0 || m.NodeID <= 0 || m.Timestamp <= 0 {
continue
}
if m.BytesIn == 0 && m.BytesOut == 0 && m.Connections == 0 && m.Errors == 0 {
continue
}
k := bucketKey{tunnelID: m.TunnelID, nodeID: m.NodeID, timestamp: m.Timestamp}
if existing, ok := agg[k]; ok {
existing.BytesIn += m.BytesIn
existing.BytesOut += m.BytesOut
existing.Connections += m.Connections
existing.Errors += m.Errors
if existing.AvgLatencyMs == 0 && m.AvgLatencyMs != 0 {
existing.AvgLatencyMs = m.AvgLatencyMs
}
continue
}
cp := *m
agg[k] = &cp
}
if len(agg) == 0 {
return nil
}
rows := make([]*model.TunnelMetric, 0, len(agg))
for _, v := range agg {
rows = append(rows, v)
}
return r.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "tunnel_id"}, {Name: "node_id"}, {Name: "timestamp"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"bytes_in": gorm.Expr("bytes_in + excluded.bytes_in"),
"bytes_out": gorm.Expr("bytes_out + excluded.bytes_out"),
"connections": gorm.Expr("connections + excluded.connections"),
"errors": gorm.Expr("errors + excluded.errors"),
// avg_latency_ms is not additive; keep the existing bucket value.
}),
}).CreateInBatches(rows, 100).Error
}
func (r *Repository) GetTunnelMetrics(tunnelID int64, startMs, endMs int64) ([]model.TunnelMetric, error) {
if r == nil || r.db == nil {
return nil, nil
}
var metrics []model.TunnelMetric
err := r.db.Where("tunnel_id = ? AND timestamp >= ? AND timestamp <= ?", tunnelID, startMs, endMs).
Order("timestamp DESC").
Limit(5000).
Find(&metrics).Error
if len(metrics) > 1 {
for i, j := 0, len(metrics)-1; i < j; i, j = i+1, j-1 {
metrics[i], metrics[j] = metrics[j], metrics[i]
}
}
return metrics, err
}
// GetTunnelMetricsAggregated returns tunnel-level aggregated series (one point per timestamp).
// Storage remains per (tunnel_id, node_id, timestamp) for future drill-down.
func (r *Repository) GetTunnelMetricsAggregated(tunnelID int64, startMs, endMs int64) ([]model.TunnelMetric, error) {
if r == nil || r.db == nil {
return nil, nil
}
var metrics []model.TunnelMetric
err := r.db.Model(&model.TunnelMetric{}).
Select(
"tunnel_id, 0 AS node_id, timestamp, "+
"SUM(bytes_in) AS bytes_in, "+
"SUM(bytes_out) AS bytes_out, "+
"SUM(connections) AS connections, "+
"SUM(errors) AS errors, "+
"AVG(avg_latency_ms) AS avg_latency_ms",
).
Where("tunnel_id = ? AND timestamp >= ? AND timestamp <= ?", tunnelID, startMs, endMs).
Group("tunnel_id, timestamp").
Order("timestamp ASC").
Limit(5000).
Scan(&metrics).Error
if metrics == nil {
metrics = make([]model.TunnelMetric, 0)
}
return metrics, err
}
func (r *Repository) PruneTunnelMetrics(olderThanMs int64) error {
if r == nil || r.db == nil {
return nil
}
return r.db.Where("timestamp < ?", olderThanMs).Delete(&model.TunnelMetric{}).Error
}
func (r *Repository) ListServiceMonitors() ([]model.ServiceMonitor, error) {
if r == nil || r.db == nil {
return nil, nil
}
var monitors []model.ServiceMonitor
err := r.db.Order("id ASC").Find(&monitors).Error
return monitors, err
}
func (r *Repository) ListEnabledServiceMonitors() ([]model.ServiceMonitor, error) {
if r == nil || r.db == nil {
return nil, nil
}
var monitors []model.ServiceMonitor
err := r.db.Where("enabled = 1 AND type IN (?)", []string{"tcp", "icmp"}).Order("id ASC").Find(&monitors).Error
return monitors, err
}
func (r *Repository) GetServiceMonitor(id int64) (*model.ServiceMonitor, error) {
if r == nil || r.db == nil {
return nil, nil
}
var m model.ServiceMonitor
err := r.db.First(&m, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &m, nil
}
func (r *Repository) CreateServiceMonitor(m *model.ServiceMonitor) error {
if r == nil || r.db == nil {
return nil
}
return r.db.Create(m).Error
}
func (r *Repository) UpdateServiceMonitor(m *model.ServiceMonitor) error {
if r == nil || r.db == nil {
return nil
}
return r.db.Save(m).Error
}
func (r *Repository) DeleteServiceMonitor(id int64) error {
if r == nil || r.db == nil {
return nil
}
if id <= 0 {
return nil
}
// Keep API/UI semantics simple: deleting a monitor also deletes its history.
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("monitor_id = ?", id).Delete(&model.ServiceMonitorResult{}).Error; err != nil {
return err
}
return tx.Delete(&model.ServiceMonitor{}, id).Error
})
}
func (r *Repository) InsertServiceMonitorResult(result *model.ServiceMonitorResult) error {
if r == nil || r.db == nil {
return nil
}
return r.db.Create(result).Error
}
func (r *Repository) GetServiceMonitorResults(monitorID int64, limit int) ([]model.ServiceMonitorResult, error) {
if r == nil || r.db == nil {
return nil, nil
}
if limit <= 0 {
limit = 100
}
var results []model.ServiceMonitorResult
err := r.db.Where("monitor_id = ?", monitorID).
Order("timestamp DESC").
Limit(limit).
Find(&results).Error
return results, err
}
// GetLatestServiceMonitorResults returns the newest result per monitor_id.
// This is intended for list rendering (avoid N+1 queries).
func (r *Repository) GetLatestServiceMonitorResults() ([]model.ServiceMonitorResult, error) {
if r == nil || r.db == nil {
return nil, nil
}
var results []model.ServiceMonitorResult
// Prefer a window-function query (works on modern SQLite + Postgres).
q1 := `
SELECT id, monitor_id, node_id, timestamp, success, latency_ms, status_code, error_message
FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY monitor_id ORDER BY timestamp DESC, id DESC) AS rn
FROM service_monitor_result
) t
WHERE rn = 1
ORDER BY monitor_id ASC
`
if err := r.db.Raw(q1).Scan(&results).Error; err == nil {
return results, nil
}
// Fallback: just return newest rows (best-effort). This avoids hard failure on older SQLite builds.
// Note: This may not include all monitors if the table is extremely large and skewed.
results = nil
q2 := `
SELECT id, monitor_id, node_id, timestamp, success, latency_ms, status_code, error_message
FROM service_monitor_result
ORDER BY timestamp DESC, id DESC
LIMIT 5000
`
err := r.db.Raw(q2).Scan(&results).Error
if err != nil {
return nil, err
}
seen := make(map[int64]struct{}, len(results))
out := make([]model.ServiceMonitorResult, 0, len(results))
for _, row := range results {
if row.MonitorID <= 0 {
continue
}
if _, ok := seen[row.MonitorID]; ok {
continue
}
seen[row.MonitorID] = struct{}{}
out = append(out, row)
}
// Keep response stable for the frontend.
sort.Slice(out, func(i, j int) bool { return out[i].MonitorID < out[j].MonitorID })
return out, nil
}
func (r *Repository) PruneServiceMonitorResults(olderThanMs int64) error {
if r == nil || r.db == nil {
return nil
}
return r.db.Where("timestamp < ?", olderThanMs).Delete(&model.ServiceMonitorResult{}).Error
}
@@ -30,8 +30,15 @@ func (r *Repository) ListForwardsByTunnel(tunnelID int64) ([]model.ForwardRecord
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
return r.ListForwardsByTunnelTx(r.db, tunnelID)
}
func (r *Repository) ListForwardsByTunnelTx(tx *gorm.DB, tunnelID int64) ([]model.ForwardRecord, error) {
if tx == nil {
return nil, errors.New("database unavailable")
}
var forwards []model.Forward
err := r.db.Where("tunnel_id = ?", tunnelID).Order("id ASC").Find(&forwards).Error
err := tx.Where("tunnel_id = ?", tunnelID).Order("id ASC").Find(&forwards).Error
if err != nil {
return nil, err
}
@@ -57,6 +64,7 @@ func (r *Repository) ListForwardsByTunnel(tunnelID int64) ([]model.ForwardRecord
return rows, nil
}
func (r *Repository) ListActiveTunnelIDsByNode(nodeID int64) ([]int64, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
@@ -95,8 +103,15 @@ func (r *Repository) ListForwardPorts(forwardID int64) ([]model.ForwardPortRecor
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
return r.ListForwardPortsTx(r.db, forwardID)
}
func (r *Repository) ListForwardPortsTx(tx *gorm.DB, forwardID int64) ([]model.ForwardPortRecord, error) {
if tx == nil {
return nil, errors.New("database unavailable")
}
var ports []model.ForwardPort
err := r.db.Where("forward_id = ?", forwardID).Order("id ASC").Find(&ports).Error
err := tx.Where("forward_id = ?", forwardID).Order("id ASC").Find(&ports).Error
if err != nil {
return nil, err
}
@@ -111,16 +126,24 @@ func (r *Repository) ListForwardPorts(forwardID int64) ([]model.ForwardPortRecor
return rows, nil
}
func (r *Repository) HasOtherForwardOnNodePort(nodeID int64, port int, currentForwardID int64) (bool, error) {
if r == nil || r.db == nil {
return false, errors.New("repository not initialized")
}
return r.HasOtherForwardOnNodePortTx(r.db, nodeID, port, currentForwardID)
}
func (r *Repository) HasOtherForwardOnNodePortTx(tx *gorm.DB, nodeID int64, port int, currentForwardID int64) (bool, error) {
if tx == nil {
return false, errors.New("database unavailable")
}
if nodeID <= 0 || port <= 0 {
return false, nil
}
var count int64
err := r.db.Model(&model.ForwardPort{}).
err := tx.Model(&model.ForwardPort{}).
Where("node_id = ? AND port = ? AND forward_id <> ?", nodeID, port, currentForwardID).
Count(&count).Error
if err != nil {
@@ -130,6 +153,7 @@ func (r *Repository) HasOtherForwardOnNodePort(nodeID int64, port int, currentFo
return count > 0, nil
}
func (r *Repository) GetTunnelOutProtocol(tunnelID int64) (string, error) {
if r == nil || r.db == nil {
return "", errors.New("repository not initialized")
@@ -161,6 +161,64 @@ func (r *Repository) ForwardExists(forwardID int64) (bool, error) {
return count > 0, nil
}
// MapForwardIDsToTunnelIDs returns a mapping from forward.id to forward.tunnel_id.
// Missing forward IDs are omitted from the returned map.
func (r *Repository) MapForwardIDsToTunnelIDs(forwardIDs []int64) (map[int64]int64, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
if len(forwardIDs) == 0 {
return map[int64]int64{}, nil
}
// Deduplicate and filter invalid IDs.
ids := make([]int64, 0, len(forwardIDs))
seen := make(map[int64]struct{}, len(forwardIDs))
for _, id := range forwardIDs {
if id <= 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
if len(ids) == 0 {
return map[int64]int64{}, nil
}
type row struct {
ID int64 `gorm:"column:id"`
TunnelID int64 `gorm:"column:tunnel_id"`
}
out := make(map[int64]int64, len(ids))
const chunkSize = 500
for start := 0; start < len(ids); start += chunkSize {
end := start + chunkSize
if end > len(ids) {
end = len(ids)
}
var rows []row
if err := r.db.Model(&model.Forward{}).
Select("id", "tunnel_id").
Where("id IN ?", ids[start:end]).
Find(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
if r.ID <= 0 || r.TunnelID <= 0 {
continue
}
out[r.ID] = r.TunnelID
}
}
return out, nil
}
func (r *Repository) SpeedLimitExists(id int64) (bool, error) {
if r == nil || r.db == nil {
return false, errors.New("repository not initialized")
@@ -0,0 +1,18 @@
package repo
import (
"errors"
"go-backend/internal/store/model"
)
func (r *Repository) ListMonitorNodes() ([]model.Node, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var nodes []model.Node
err := r.db.Select("id", "inx", "name", "status", "updated_time").
Order("inx ASC, id ASC").
Find(&nodes).Error
return nodes, err
}
@@ -0,0 +1,54 @@
package repo
import (
"errors"
"go-backend/internal/store/model"
"gorm.io/gorm/clause"
)
func (r *Repository) InsertMonitorPermission(userID int64, now int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
if userID <= 0 {
return nil
}
row := model.MonitorPermission{UserID: userID, CreatedTime: now}
return r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error
}
func (r *Repository) DeleteMonitorPermission(userID int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
if userID <= 0 {
return nil
}
return r.db.Where("user_id = ?", userID).Delete(&model.MonitorPermission{}).Error
}
func (r *Repository) HasMonitorPermission(userID int64) (bool, error) {
if r == nil || r.db == nil {
return false, errors.New("repository not initialized")
}
if userID <= 0 {
return false, nil
}
var count int64
err := r.db.Model(&model.MonitorPermission{}).Where("user_id = ?", userID).Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func (r *Repository) ListMonitorPermissions() ([]model.MonitorPermission, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var items []model.MonitorPermission
err := r.db.Order("id ASC").Find(&items).Error
return items, err
}
@@ -0,0 +1,18 @@
package repo
import (
"errors"
"go-backend/internal/store/model"
)
func (r *Repository) ListMonitorTunnels() ([]model.Tunnel, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var tunnels []model.Tunnel
err := r.db.Select("id", "inx", "name", "status", "updated_time").
Order("inx ASC, id ASC").
Find(&tunnels).Error
return tunnels, err
}
@@ -0,0 +1,134 @@
package repo
import (
"sync"
"testing"
"time"
"go-backend/internal/store/model"
)
func TestGetTunnelMetricsAggregatedSumsAcrossNodes(t *testing.T) {
r, err := Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
ts := time.Now().UnixMilli()
if err := r.InsertTunnelMetric(&model.TunnelMetric{
TunnelID: 1,
NodeID: 1,
Timestamp: ts,
BytesIn: 100,
BytesOut: 200,
}); err != nil {
t.Fatalf("insert tunnel metric n1: %v", err)
}
if err := r.InsertTunnelMetric(&model.TunnelMetric{
TunnelID: 1,
NodeID: 2,
Timestamp: ts,
BytesIn: 300,
BytesOut: 400,
}); err != nil {
t.Fatalf("insert tunnel metric n2: %v", err)
}
metrics, err := r.GetTunnelMetricsAggregated(1, ts-1000, ts+1000)
if err != nil {
t.Fatalf("get aggregated tunnel metrics: %v", err)
}
if len(metrics) != 1 {
t.Fatalf("expected 1 aggregated point, got %d", len(metrics))
}
if metrics[0].Timestamp != ts {
t.Fatalf("expected timestamp %d, got %d", ts, metrics[0].Timestamp)
}
if metrics[0].BytesIn != 400 {
t.Fatalf("expected bytesIn 400, got %d", metrics[0].BytesIn)
}
if metrics[0].BytesOut != 600 {
t.Fatalf("expected bytesOut 600, got %d", metrics[0].BytesOut)
}
}
func TestUpsertTunnelMetricBucketsAggregatesDuplicateKeysInBatch(t *testing.T) {
r, err := Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
ts := time.Now().UnixMilli()
items := []*model.TunnelMetric{
{TunnelID: 1, NodeID: 1, Timestamp: ts, BytesIn: 10, BytesOut: 20},
{TunnelID: 1, NodeID: 1, Timestamp: ts, BytesIn: 30, BytesOut: 40},
}
if err := r.UpsertTunnelMetricBuckets(items); err != nil {
t.Fatalf("upsert buckets: %v", err)
}
rows, err := r.GetTunnelMetrics(1, ts-1000, ts+1000)
if err != nil {
t.Fatalf("get tunnel metrics: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 stored row, got %d", len(rows))
}
if rows[0].BytesIn != 40 {
t.Fatalf("expected bytesIn 40, got %d", rows[0].BytesIn)
}
if rows[0].BytesOut != 60 {
t.Fatalf("expected bytesOut 60, got %d", rows[0].BytesOut)
}
}
func TestUpsertTunnelMetricBucketsIsSafeUnderConcurrency(t *testing.T) {
r, err := Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
ts := time.Now().UnixMilli()
const workers = 20
const perWorkerIn = int64(5)
const perWorkerOut = int64(7)
var wg sync.WaitGroup
wg.Add(workers)
for i := 0; i < workers; i++ {
go func() {
defer wg.Done()
_ = r.UpsertTunnelMetricBuckets([]*model.TunnelMetric{{
TunnelID: 1,
NodeID: 1,
Timestamp: ts,
BytesIn: perWorkerIn,
BytesOut: perWorkerOut,
}})
}()
}
wg.Wait()
rows, err := r.GetTunnelMetrics(1, ts-1000, ts+1000)
if err != nil {
t.Fatalf("get tunnel metrics: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 stored row, got %d", len(rows))
}
wantIn := int64(workers) * perWorkerIn
wantOut := int64(workers) * perWorkerOut
if rows[0].BytesIn != wantIn {
t.Fatalf("expected bytesIn %d, got %d", wantIn, rows[0].BytesIn)
}
if rows[0].BytesOut != wantOut {
t.Fatalf("expected bytesOut %d, got %d", wantOut, rows[0].BytesOut)
}
}
+91 -4
View File
@@ -72,6 +72,7 @@ type Server struct {
jwtSecret string
upgrader websocket.Upgrader
onNodeOnline func(nodeID int64)
onNodeMetric func(nodeID int64, info SystemInfo)
mu sync.RWMutex
admins map[*connWrap]struct{}
@@ -80,6 +81,22 @@ type Server struct {
pending map[string]pendingRequest
}
type SystemInfo struct {
Uptime uint64 `json:"uptime"`
BytesReceived uint64 `json:"bytes_received"`
BytesTransmitted uint64 `json:"bytes_transmitted"`
CPUUsage float64 `json:"cpu_usage"`
MemoryUsage float64 `json:"memory_usage"`
DiskUsage float64 `json:"disk_usage"`
Load1 float64 `json:"load1"`
Load5 float64 `json:"load5"`
Load15 float64 `json:"load15"`
TCPConns int64 `json:"tcp_conns"`
UDPConns int64 `json:"udp_conns"`
NetInSpeed int64 `json:"net_in_speed"`
NetOutSpeed int64 `json:"net_out_speed"`
}
func (s *Server) SetNodeOnlineHook(fn func(nodeID int64)) {
if s == nil {
return
@@ -89,6 +106,15 @@ func (s *Server) SetNodeOnlineHook(fn func(nodeID int64)) {
s.mu.Unlock()
}
func (s *Server) SetNodeMetricHook(fn func(nodeID int64, info SystemInfo)) {
if s == nil {
return
}
s.mu.Lock()
s.onNodeMetric = fn
s.mu.Unlock()
}
func NewServer(repo *repo.Repository, jwtSecret string) *Server {
return &Server{
repo: repo,
@@ -231,12 +257,73 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
var parsed struct {
Type string `json:"type"`
}
if json.Unmarshal([]byte(msg), &parsed) == nil && parsed.Type == "UpgradeProgress" {
s.broadcastTyped(nodeID, "upgrade_progress", msg)
} else {
s.broadcastInfo(nodeID, msg)
if json.Unmarshal([]byte(msg), &parsed) == nil && parsed.Type != "" {
switch parsed.Type {
case "UpgradeProgress":
s.broadcastTyped(nodeID, "upgrade_progress", msg)
continue
default:
// Unknown typed messages still get broadcast so future
// agent message types are not silently lost.
s.broadcastInfo(nodeID, msg)
continue
}
}
if looksLikeSystemInfoMessage(msg) {
var sysInfo SystemInfo
if err := json.Unmarshal([]byte(msg), &sysInfo); err == nil {
s.mu.RLock()
onMetric := s.onNodeMetric
s.mu.RUnlock()
if onMetric != nil {
go onMetric(nodeID, sysInfo)
}
s.broadcastTyped(nodeID, "metric", msg)
continue
}
}
s.broadcastInfo(nodeID, msg)
}
}
func looksLikeSystemInfoMessage(msg string) bool {
// Keep this as a cheap heuristic so that arbitrary JSON objects don't get
// misclassified as metrics (SystemInfo unmarshal would otherwise succeed with
// all-zero values).
if strings.TrimSpace(msg) == "" {
return false
}
if !strings.Contains(msg, "{") {
return false
}
keys := []string{
"\"uptime\"",
"\"cpu_usage\"",
"\"memory_usage\"",
"\"disk_usage\"",
"\"bytes_received\"",
"\"bytes_transmitted\"",
"\"net_in_speed\"",
"\"net_out_speed\"",
"\"tcp_conns\"",
"\"udp_conns\"",
"\"load1\"",
"\"load5\"",
"\"load15\"",
}
matched := 0
for _, k := range keys {
if strings.Contains(msg, k) {
matched++
if matched >= 3 {
return true
}
}
}
return false
}
func (s *Server) SendCommand(nodeID int64, cmdType string, data interface{}, timeout time.Duration) (CommandResult, error) {
@@ -32,7 +32,6 @@ func TestIssue313_EntryPortCrossTunnelConflictContract(t *testing.T) {
return mustLastInsertID(t, repo, name)
}
entryA := insertNode("issue313-entry-a", "10.100.0.1", "2000-2010")
entryB1 := insertNode("issue313-entry-b1", "10.100.0.2", "2000-2010")
entryB2 := insertNode("issue313-entry-b2", "10.100.0.3", "2000-2010")
chainA := insertNode("issue313-chain-a", "10.100.0.4", "3000-3010")
@@ -51,7 +50,7 @@ func TestIssue313_EntryPortCrossTunnelConflictContract(t *testing.T) {
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 2000, 'round', 1, 'tls')
`, tunnelAID, entryA).Error; err != nil {
`, tunnelAID, entryB2).Error; err != nil {
t.Fatalf("insert chain_tunnel entry a: %v", err)
}
if err := repo.DB().Exec(`
@@ -109,10 +108,16 @@ func TestIssue313_EntryPortCrossTunnelConflictContract(t *testing.T) {
}
forwardAID := mustLastInsertID(t, repo, "issue313-forward-a")
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardAID, entryA, 2000).Error; err != nil {
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardAID, entryB2, 2000).Error; err != nil {
t.Fatalf("insert forward_port a: %v", err)
}
// Simulate legacy dirty data: tunnel A already occupies port 2000 on entryB2.
// When tunnel B adds entryB2, the inherited forward port should conflict cross-tunnel.
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardAID, entryB2, 2000).Error; err != nil {
t.Fatalf("insert forward_port a on entryB2: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(3132, 1, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
@@ -171,7 +176,8 @@ func TestIssue313_EntryPortCrossTunnelConflictContract(t *testing.T) {
t.Fatalf("expected update failure due to cross-tunnel port conflict, got success with code 0")
}
if !bytes.Contains(res.Body.Bytes(), []byte("端口")) && !bytes.Contains(res.Body.Bytes(), []byte("占用")) {
msgBytes := []byte(out.Msg)
if !bytes.Contains(msgBytes, []byte("端口")) && !bytes.Contains(msgBytes, []byte("占用")) {
t.Fatalf("expected port conflict error message, got %q", out.Msg)
}
@@ -705,7 +705,7 @@ func TestTunnelUpdateChangesEntryNodeButLeavesOldForwardRuntimeContract(t *testi
}
oldEntryNodeID := insertNode("issue281-old-entry", "issue281-old-entry-secret", "10.51.0.1", "51000-51010", 0)
newEntryNodeID := insertNode("issue281-new-entry", "issue281-new-entry-secret", "10.51.0.2", "52000-52010", 1)
newEntryNodeID := insertNode("issue281-new-entry", "issue281-new-entry-secret", "10.51.0.2", "51000-51010", 1)
exitNodeID := insertNode("issue281-exit", "issue281-exit-secret", "10.51.0.3", "53000-53010", 2)
if err := r.DB().Exec(`
@@ -881,8 +881,8 @@ func TestTunnelUpdateEntryTransitionsCleanupForwardRuntimeContract(t *testing.T)
}
entryA := insertNode("issue281-transition-entry-a", "issue281-transition-entry-a-secret", "10.52.0.1", "54000-54010", 0)
entryB := insertNode("issue281-transition-entry-b", "issue281-transition-entry-b-secret", "10.52.0.2", "55000-55010", 1)
entryC := insertNode("issue281-transition-entry-c", "issue281-transition-entry-c-secret", "10.52.0.3", "56000-56010", 2)
entryB := insertNode("issue281-transition-entry-b", "issue281-transition-entry-b-secret", "10.52.0.2", "54000-54010", 1)
entryC := insertNode("issue281-transition-entry-c", "issue281-transition-entry-c-secret", "10.52.0.3", "54000-54010", 2)
exitNodeID := insertNode("issue281-transition-exit", "issue281-transition-exit-secret", "10.52.0.4", "57000-57010", 3)
if err := r.DB().Exec(`
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,235 @@
package contract_test
import (
"testing"
"time"
"go-backend/internal/http/response"
storeRepo "go-backend/internal/store/repo"
)
func TestTunnelDeletePreviewIncludesDependentRulesContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
adminToken := mustAdminToken(t, secret)
now := time.Now().UnixMilli()
sourceTunnelID, sourceNodeID := seedTunnelDeleteTunnelWithNode(t, repo, now, "preview-source-tunnel", "preview-source-node", "21000-21010")
seedTunnelDeleteForward(t, repo, now, sourceTunnelID, sourceNodeID, "preview-forward", 21001)
out := requestContractEnvelope(t, router, adminToken, "/api/v1/tunnel/delete-preview", map[string]interface{}{"id": sourceTunnelID})
if out.Code != 0 {
t.Fatalf("expected success, got code=%d msg=%q", out.Code, out.Msg)
}
data, ok := out.Data.(map[string]interface{})
if !ok {
t.Fatalf("expected preview data object, got %T", out.Data)
}
if contractValueAsInt64(data["tunnelId"]) != sourceTunnelID {
t.Fatalf("unexpected tunnelId: %#v", data["tunnelId"])
}
if contractValueAsInt64(data["forwardCount"]) != 1 {
t.Fatalf("expected forwardCount=1, got %#v", data["forwardCount"])
}
samples, ok := data["sampleForwards"].([]interface{})
if !ok || len(samples) != 1 {
t.Fatalf("expected one sample forward, got %#v", data["sampleForwards"])
}
first, ok := samples[0].(map[string]interface{})
if !ok {
t.Fatalf("expected sample object, got %T", samples[0])
}
if first["name"] != "preview-forward" {
t.Fatalf("unexpected sample name: %#v", first["name"])
}
if contractValueAsInt64(first["inPort"]) != 21001 {
t.Fatalf("unexpected sample inPort: %#v", first["inPort"])
}
}
func TestTunnelDeleteWithForwardsDeleteActionRemovesTunnelAndRulesContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
adminToken := mustAdminToken(t, secret)
now := time.Now().UnixMilli()
sourceTunnelID, sourceNodeID := seedTunnelDeleteTunnelWithNode(t, repo, now, "delete-source-tunnel", "delete-source-node", "22000-22010")
forwardID := seedTunnelDeleteForward(t, repo, now, sourceTunnelID, sourceNodeID, "delete-forward", 22001)
out := requestContractEnvelope(t, router, adminToken, "/api/v1/tunnel/delete-with-forwards", map[string]interface{}{
"id": sourceTunnelID,
"action": "delete_forwards",
})
if out.Code != 0 {
t.Fatalf("expected success, got code=%d msg=%q", out.Code, out.Msg)
}
if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM tunnel WHERE id = ?`, sourceTunnelID); count != 0 {
t.Fatalf("expected tunnel deleted, got count=%d", count)
}
if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM forward WHERE id = ?`, forwardID); count != 0 {
t.Fatalf("expected forward deleted, got count=%d", count)
}
if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM forward_port WHERE forward_id = ?`, forwardID); count != 0 {
t.Fatalf("expected forward ports deleted, got count=%d", count)
}
}
func TestTunnelDeleteWithForwardsReplaceReturnsFailureDetailsContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
adminToken := mustAdminToken(t, secret)
now := time.Now().UnixMilli()
sourceTunnelID, sourceNodeID := seedTunnelDeleteTunnelWithNode(t, repo, now, "replace-source-tunnel", "replace-source-node", "23000-23010")
forwardID := seedTunnelDeleteForward(t, repo, now, sourceTunnelID, sourceNodeID, "replace-forward", 23001)
targetTunnelID, targetNodeID := seedTunnelDeleteTunnelWithNode(t, repo, now, "replace-target-tunnel", "replace-target-node", "23000-23010")
seedTunnelDeleteForward(t, repo, now, targetTunnelID, targetNodeID, "occupied-forward", 23001)
out := requestContractEnvelope(t, router, adminToken, "/api/v1/tunnel/delete-with-forwards", map[string]interface{}{
"id": sourceTunnelID,
"action": "replace",
"targetTunnelId": targetTunnelID,
})
if out.Code != -2 {
t.Fatalf("expected failure code -2, got code=%d msg=%q", out.Code, out.Msg)
}
result := mustTunnelDeleteFailureResult(t, out)
if contractValueAsInt64(result["failCount"]) != 1 {
t.Fatalf("expected failCount=1, got %#v", result["failCount"])
}
assertBatchFailureNameAndReason(t, result, "replace-forward", "节点 replace-target-node 端口 23001 已被其他转发占用")
if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM tunnel WHERE id = ?`, sourceTunnelID); count != 1 {
t.Fatalf("expected source tunnel kept, got count=%d", count)
}
if tunnelAfter := mustQueryInt64(t, repo, `SELECT tunnel_id FROM forward WHERE id = ?`, forwardID); tunnelAfter != sourceTunnelID {
t.Fatalf("expected forward tunnel unchanged, got %d", tunnelAfter)
}
}
func TestTunnelBatchDeletePreviewIncludesTotalsContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
adminToken := mustAdminToken(t, secret)
now := time.Now().UnixMilli()
tunnelA, nodeA := seedTunnelDeleteTunnelWithNode(t, repo, now, "batch-preview-a", "batch-preview-node-a", "24000-24010")
tunnelB, _ := seedTunnelDeleteTunnelWithNode(t, repo, now, "batch-preview-b", "batch-preview-node-b", "24100-24110")
seedTunnelDeleteForward(t, repo, now, tunnelA, nodeA, "batch-preview-forward", 24001)
out := requestContractEnvelope(t, router, adminToken, "/api/v1/tunnel/batch-delete-preview", map[string]interface{}{
"ids": []int64{tunnelA, tunnelB},
})
if out.Code != 0 {
t.Fatalf("expected success, got code=%d msg=%q", out.Code, out.Msg)
}
data, ok := out.Data.(map[string]interface{})
if !ok {
t.Fatalf("expected preview object, got %T", out.Data)
}
if contractValueAsInt64(data["tunnelCount"]) != 2 {
t.Fatalf("expected tunnelCount=2, got %#v", data["tunnelCount"])
}
if contractValueAsInt64(data["totalForwardCount"]) != 1 {
t.Fatalf("expected totalForwardCount=1, got %#v", data["totalForwardCount"])
}
}
func TestTunnelBatchDeleteWithForwardsReturnsTunnelLevelFailuresContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
adminToken := mustAdminToken(t, secret)
now := time.Now().UnixMilli()
sourceTunnelA, _ := seedTunnelDeleteTunnelWithNode(t, repo, now, "batch-replace-source-a", "batch-replace-source-node-a", "25000-25010")
sourceTunnelB, sourceNodeB := seedTunnelDeleteTunnelWithNode(t, repo, now, "batch-replace-source-b", "batch-replace-source-node-b", "25100-25110")
targetTunnelID, targetNodeID := seedTunnelDeleteTunnelWithNode(t, repo, now, "batch-replace-target", "batch-replace-target-node", "25000-25010")
seedTunnelDeleteForward(t, repo, now, sourceTunnelB, sourceNodeB, "batch-replace-forward-b", 25002)
seedTunnelDeleteForward(t, repo, now, targetTunnelID, targetNodeID, "batch-replace-occupied", 25002)
out := requestContractEnvelope(t, router, adminToken, "/api/v1/tunnel/batch-delete-with-forwards", map[string]interface{}{
"ids": []int64{sourceTunnelA, sourceTunnelB},
"action": "replace",
"targetTunnelId": targetTunnelID,
})
if out.Code != 0 {
t.Fatalf("expected success envelope, got code=%d msg=%q", out.Code, out.Msg)
}
result := mustTunnelDeleteFailureResult(t, out)
if contractValueAsInt64(result["successCount"]) != 1 {
t.Fatalf("expected successCount=1, got %#v", result["successCount"])
}
if contractValueAsInt64(result["failCount"]) != 1 {
t.Fatalf("expected failCount=1, got %#v", result["failCount"])
}
assertBatchFailureNameAndReason(t, result, "batch-replace-source-b", "batch-replace-forward-b: 节点 batch-replace-target-node 端口 25002 已被其他转发占用")
if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM tunnel WHERE id = ?`, sourceTunnelA); count != 0 {
t.Fatalf("expected source tunnel A deleted, got count=%d", count)
}
if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM tunnel WHERE id = ?`, sourceTunnelB); count != 1 {
t.Fatalf("expected source tunnel B kept, got count=%d", count)
}
}
func seedTunnelDeleteTunnelWithNode(t *testing.T, repo *storeRepo.Repository, now int64, tunnelName, nodeName, portRange string) (int64, int64) {
t.Helper()
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, status, created_time, updated_time, in_ip, inx, ip_preference)
VALUES(?, 1.0, 1, 'tls', 1, 1, ?, ?, NULL, 0, '')
`, tunnelName, now, now).Error; err != nil {
t.Fatalf("insert tunnel %s: %v", tunnelName, err)
}
tunnelID := mustLastInsertID(t, repo, tunnelName)
if err := repo.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, '10.0.0.1', '10.0.0.1', '', ?, '', 'v1', 1, 1, 1, ?, ?, 1, '[::]', '[::]', 0)
`, nodeName, nodeName+"-secret", portRange, now, now).Error; err != nil {
t.Fatalf("insert node %s: %v", nodeName, err)
}
nodeID := mustLastInsertID(t, repo, nodeName)
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 0, 'round', 1, 'tls')
`, tunnelID, nodeID).Error; err != nil {
t.Fatalf("insert chain_tunnel for %s: %v", tunnelName, err)
}
return tunnelID, nodeID
}
func seedTunnelDeleteForward(t *testing.T, repo *storeRepo.Repository, now int64, tunnelID, nodeID int64, forwardName string, port int) int64 {
t.Helper()
if err := repo.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(2, 'contract-user', ?, ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
`, forwardName, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert forward %s: %v", forwardName, err)
}
forwardID := mustLastInsertID(t, repo, forwardName)
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, port).Error; err != nil {
t.Fatalf("insert forward_port for %s: %v", forwardName, err)
}
return forwardID
}
func mustTunnelDeleteFailureResult(t *testing.T, out response.R) map[string]interface{} {
t.Helper()
result, ok := out.Data.(map[string]interface{})
if !ok {
t.Fatalf("expected result object, got %T", out.Data)
}
return result
}
@@ -0,0 +1,97 @@
package contract_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"go-backend/internal/store/model"
)
func TestFlowUploadInsertsTunnelMetrics(t *testing.T) {
secret := "monitoring-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
node := &model.Node{
Name: "node-1",
Secret: "node-secret",
ServerIP: "127.0.0.1",
Port: "10000-10010",
TCPListenAddr: "[::]",
UDPListenAddr: "[::]",
CreatedTime: now,
Status: 1,
}
if err := repo.DB().Create(node).Error; err != nil {
t.Fatalf("seed node: %v", err)
}
tunnel := &model.Tunnel{
Name: "tunnel-1",
TrafficRatio: 1.0,
Type: 1,
Protocol: "tls",
Flow: 1,
CreatedTime: now,
UpdatedTime: now,
Status: 1,
}
if err := repo.DB().Create(tunnel).Error; err != nil {
t.Fatalf("seed tunnel: %v", err)
}
forward := &model.Forward{
UserID: 123,
UserName: "user-123",
Name: "forward-1",
TunnelID: tunnel.ID,
RemoteAddr: "1.1.1.1:80",
CreatedTime: now,
UpdatedTime: now,
Status: 1,
}
if err := repo.DB().Create(forward).Error; err != nil {
t.Fatalf("seed forward: %v", err)
}
serviceName := jsonNumber(forward.ID) + "_123_0"
body, _ := json.Marshal([]map[string]interface{}{{
"n": serviceName,
"u": 200,
"d": 100,
}})
req := httptest.NewRequest(http.MethodPost, "/flow/upload?secret="+node.Secret, bytes.NewReader(body))
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", res.Code)
}
metrics, err := repo.GetTunnelMetrics(tunnel.ID, 0, now+60_000)
if err != nil {
t.Fatalf("get tunnel metrics: %v", err)
}
if len(metrics) != 1 {
t.Fatalf("expected 1 tunnel metric row, got %d", len(metrics))
}
if metrics[0].TunnelID != tunnel.ID {
t.Fatalf("expected tunnelId %d, got %d", tunnel.ID, metrics[0].TunnelID)
}
if metrics[0].NodeID != node.ID {
t.Fatalf("expected nodeId %d, got %d", node.ID, metrics[0].NodeID)
}
if metrics[0].BytesIn != 100 {
t.Fatalf("expected bytesIn 100, got %d", metrics[0].BytesIn)
}
if metrics[0].BytesOut != 200 {
t.Fatalf("expected bytesOut 200, got %d", metrics[0].BytesOut)
}
}
+3 -6
View File
@@ -7,7 +7,6 @@ import (
"errors"
"fmt"
"io"
"log"
"net"
"os"
"os/exec"
@@ -63,11 +62,9 @@ func SetProtocolBlock(httpOn int, tlsOn int, socksOn int) {
type Option func(opts *options)
func init() {
_, err := LoadConfig("config.json")
fmt.Println("config.json loaded")
if err != nil {
log.Fatal(err)
}
// NOTE: This package can be imported by tests/tools that don't have a local
// config.json. Missing config should not crash the process.
_, _ = LoadConfig("config.json")
needWrap = isTls+isSocks+isHttp > 0
}
+380 -18
View File
@@ -17,7 +17,7 @@ import (
"runtime"
"strconv"
"strings"
"sync" // 新增:用于管理连接状态的互斥锁
"sync"
"time"
"github.com/go-gost/x/config"
@@ -25,34 +25,67 @@ import (
"github.com/go-gost/x/service"
"github.com/gorilla/websocket"
"github.com/shirou/gopsutil/v3/cpu"
"github.com/shirou/gopsutil/v3/disk"
"github.com/shirou/gopsutil/v3/host"
"github.com/shirou/gopsutil/v3/load"
"github.com/shirou/gopsutil/v3/mem"
psnet "github.com/shirou/gopsutil/v3/net"
"golang.org/x/net/icmp"
"golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
)
// SystemInfo 系统信息结构体
type SystemInfo struct {
Uptime uint64 `json:"uptime"` // 开机时间 (秒)
BytesReceived uint64 `json:"bytes_received"` // 接收字节数
BytesTransmitted uint64 `json:"bytes_transmitted"` // 发送字节数
CPUUsage float64 `json:"cpu_usage"` // CPU使用率(百分比)
MemoryUsage float64 `json:"memory_usage"` // 内存使用率(百分比)
Uptime uint64 `json:"uptime"`
BytesReceived uint64 `json:"bytes_received"`
BytesTransmitted uint64 `json:"bytes_transmitted"`
CPUUsage float64 `json:"cpu_usage"`
MemoryUsage float64 `json:"memory_usage"`
DiskUsage float64 `json:"disk_usage"`
Load1 float64 `json:"load1"`
Load5 float64 `json:"load5"`
Load15 float64 `json:"load15"`
TCPConns int64 `json:"tcp_conns"`
UDPConns int64 `json:"udp_conns"`
NetInSpeed int64 `json:"net_in_speed"`
NetOutSpeed int64 `json:"net_out_speed"`
}
// NetworkStats 网络统计信息
type NetworkStats struct {
BytesReceived uint64 `json:"bytes_received"` // 接收字节数
BytesTransmitted uint64 `json:"bytes_transmitted"` // 发送字节数
BytesReceived uint64 `json:"bytes_received"`
BytesTransmitted uint64 `json:"bytes_transmitted"`
BytesRecvDelta uint64 `json:"bytes_recv_delta"`
BytesSentDelta uint64 `json:"bytes_sent_delta"`
}
// CPUInfo CPU信息
type CPUInfo struct {
Usage float64 `json:"usage"` // CPU使用率(百分比)
Usage float64 `json:"usage"`
}
// MemoryInfo 内存信息
type MemoryInfo struct {
Usage float64 `json:"usage"` // 内存使用率(百分比)
Usage float64 `json:"usage"`
}
// DiskInfo 磁盘信息
type DiskInfo struct {
Usage float64 `json:"usage"`
}
// LoadInfo 负载信息
type LoadInfo struct {
Load1 float64 `json:"load1"`
Load5 float64 `json:"load5"`
Load15 float64 `json:"load15"`
}
// ConnectionInfo 连接信息
type ConnectionInfo struct {
TCPConns int64 `json:"tcp_conns"`
UDPConns int64 `json:"udp_conns"`
}
// CommandMessage 命令消息结构体
@@ -91,6 +124,25 @@ type TcpPingResponse struct {
RequestId string `json:"requestId,omitempty"`
}
// ServiceMonitorCheckRequest service monitor check request.
type ServiceMonitorCheckRequest struct {
MonitorID int64 `json:"monitorId"`
Type string `json:"type"` // tcp|icmp
Target string `json:"target"`
TimeoutSec int `json:"timeoutSec"`
}
// ServiceMonitorCheckResult node-executed check output.
// CommandResponse.Success indicates command execution status.
// Actual check success is represented by this struct.
type ServiceMonitorCheckResult struct {
MonitorID int64 `json:"monitorId"`
Success bool `json:"success"`
LatencyMs float64 `json:"latencyMs"`
StatusCode int `json:"statusCode,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
}
const (
reporterReadWait = 60 * time.Second
reporterWriteWait = 5 * time.Second
@@ -134,7 +186,7 @@ func NewWebSocketReporter(serverURL string, secret string) *WebSocketReporter {
return &WebSocketReporter{
url: serverURL,
reconnectTime: 5 * time.Second, // 重连间隔
pingInterval: 2 * time.Second, // 发送间隔改为2秒
pingInterval: 5 * time.Second, // 指标上报间隔
configInterval: 10 * time.Minute, // 配置上报间隔
ctx: ctx,
cancel: cancel,
@@ -449,11 +501,35 @@ func (w *WebSocketReporter) handleConnection() {
}
}
var lastNetBytesReceived uint64
var lastNetBytesTransmitted uint64
var lastNetTime int64
var connInfoCached ConnectionInfo
var connInfoCachedAt int64
var connInfoCachedMu sync.Mutex
// collectSystemInfo 收集系统信息
func (w *WebSocketReporter) collectSystemInfo() SystemInfo {
networkStats := getNetworkStats()
cpuInfo := getCPUInfo()
memoryInfo := getMemoryInfo()
diskInfo := getDiskInfo()
loadInfo := getLoadInfo()
connInfo := getConnectionInfo()
now := time.Now().UnixMilli()
var netInSpeed, netOutSpeed int64
if lastNetTime > 0 {
deltaMs := now - lastNetTime
if deltaMs > 0 {
netInSpeed = int64(float64(networkStats.BytesRecvDelta) * 1000 / float64(deltaMs))
netOutSpeed = int64(float64(networkStats.BytesSentDelta) * 1000 / float64(deltaMs))
}
}
lastNetBytesReceived = networkStats.BytesReceived
lastNetBytesTransmitted = networkStats.BytesTransmitted
lastNetTime = now
return SystemInfo{
Uptime: getUptime(),
@@ -461,6 +537,14 @@ func (w *WebSocketReporter) collectSystemInfo() SystemInfo {
BytesTransmitted: networkStats.BytesTransmitted,
CPUUsage: cpuInfo.Usage,
MemoryUsage: memoryInfo.Usage,
DiskUsage: diskInfo.Usage,
Load1: loadInfo.Load1,
Load5: loadInfo.Load5,
Load15: loadInfo.Load15,
TCPConns: connInfo.TCPConns,
UDPConns: connInfo.UDPConns,
NetInSpeed: netInSpeed,
NetOutSpeed: netOutSpeed,
}
}
@@ -622,7 +706,7 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
if cmdMsg.Type != "call" {
// 其他状态变更命令保持同步,确保顺序执行
if cmdMsg.Type == "TcpPing" || cmdMsg.Type == "UpgradeAgent" || cmdMsg.Type == "RollbackAgent" {
if cmdMsg.Type == "TcpPing" || cmdMsg.Type == "ServiceMonitorCheck" || cmdMsg.Type == "UpgradeAgent" || cmdMsg.Type == "RollbackAgent" {
go w.routeCommand(cmdMsg)
} else {
w.routeCommand(cmdMsg)
@@ -638,7 +722,7 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
}
if cmdMsg.Type != "call" {
// 其他状态变更命令保持同步,确保顺序执行
if cmdMsg.Type == "TcpPing" || cmdMsg.Type == "UpgradeAgent" || cmdMsg.Type == "RollbackAgent" {
if cmdMsg.Type == "TcpPing" || cmdMsg.Type == "ServiceMonitorCheck" || cmdMsg.Type == "UpgradeAgent" || cmdMsg.Type == "RollbackAgent" {
go w.routeCommand(cmdMsg)
} else {
w.routeCommand(cmdMsg)
@@ -726,6 +810,13 @@ func (w *WebSocketReporter) routeCommand(cmd CommandMessage) {
response.Data = tcpPingResult
// needSaveConfig = false (默认值)
// Service monitor check (read-only)
case "ServiceMonitorCheck":
var checkResult ServiceMonitorCheckResult
checkResult, err = w.handleServiceMonitorCheck(cmd.Data)
response.Type = "ServiceMonitorCheckResponse"
response.Data = checkResult
// Protocol blocking switches
case "SetProtocol":
err = w.handleSetProtocol(cmd.Data)
@@ -1381,17 +1472,21 @@ func getNetworkStats() NetworkStats {
return stats
}
// 汇总所有非回环接口的流量
for _, io := range ioCounters {
// 跳过回环接口
if io.Name == "lo" || strings.HasPrefix(io.Name, "lo") {
continue
}
stats.BytesReceived += io.BytesRecv
stats.BytesTransmitted += io.BytesSent
}
if lastNetBytesReceived > 0 && stats.BytesReceived >= lastNetBytesReceived {
stats.BytesRecvDelta = stats.BytesReceived - lastNetBytesReceived
}
if lastNetBytesTransmitted > 0 && stats.BytesTransmitted >= lastNetBytesTransmitted {
stats.BytesSentDelta = stats.BytesTransmitted - lastNetBytesTransmitted
}
return stats
}
@@ -1399,8 +1494,8 @@ func getNetworkStats() NetworkStats {
func getCPUInfo() CPUInfo {
var cpuInfo CPUInfo
// 获取CPU使用率
percentages, err := cpu.Percent(time.Second, false)
// 获取CPU使用率 (non-blocking)
percentages, err := cpu.Percent(0, false)
if err == nil && len(percentages) > 0 {
cpuInfo.Usage = percentages[0]
}
@@ -1422,6 +1517,69 @@ func getMemoryInfo() MemoryInfo {
return memInfo
}
// getDiskInfo 获取磁盘信息
func getDiskInfo() DiskInfo {
var diskInfo DiskInfo
usage, err := disk.Usage("/")
if err != nil {
return diskInfo
}
diskInfo.Usage = usage.UsedPercent
return diskInfo
}
// getLoadInfo 获取负载信息
func getLoadInfo() LoadInfo {
var loadInfo LoadInfo
avg, err := load.Avg()
if err != nil {
return loadInfo
}
loadInfo.Load1 = avg.Load1
loadInfo.Load5 = avg.Load5
loadInfo.Load15 = avg.Load15
return loadInfo
}
// getConnectionInfo 获取连接信息
func getConnectionInfo() ConnectionInfo {
now := time.Now().UnixMilli()
const refreshEveryMs = int64((15 * time.Second) / time.Millisecond)
connInfoCachedMu.Lock()
if connInfoCachedAt > 0 && now-connInfoCachedAt < refreshEveryMs {
v := connInfoCached
connInfoCachedMu.Unlock()
return v
}
connInfoCachedMu.Unlock()
var connInfo ConnectionInfo
connStats, err := psnet.Connections("tcp")
if err == nil {
connInfo.TCPConns = int64(len(connStats))
}
udpStats, err := psnet.Connections("udp")
if err == nil {
connInfo.UDPConns = int64(len(udpStats))
}
connInfoCachedMu.Lock()
connInfoCached = connInfo
connInfoCachedAt = now
connInfoCachedMu.Unlock()
return connInfo
}
// StartWebSocketReporterWithConfig 使用配置字段启动WebSocket报告器
func StartWebSocketReporterWithConfig(addr string, secret string, http int, tls int, socks int, version string) *WebSocketReporter {
@@ -1503,6 +1661,210 @@ func (w *WebSocketReporter) handleTcpPing(data interface{}) (TcpPingResponse, er
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) {
jsonData, err := json.Marshal(data)
if err != nil {
return ServiceMonitorCheckResult{}, fmt.Errorf("序列化检查数据失败: %v", err)
}
var req ServiceMonitorCheckRequest
if err := json.Unmarshal(jsonData, &req); err != nil {
return ServiceMonitorCheckResult{}, fmt.Errorf("解析检查请求失败: %v", err)
}
checkType := strings.ToLower(strings.TrimSpace(req.Type))
target := strings.TrimSpace(req.Target)
res := ServiceMonitorCheckResult{MonitorID: req.MonitorID}
if checkType != "tcp" && checkType != "icmp" {
res.Success = false
res.ErrorMessage = "不支持的检查类型"
return res, nil
}
if target == "" {
res.Success = false
res.ErrorMessage = "检查目标为空"
return res, nil
}
timeoutSec := req.TimeoutSec
if timeoutSec <= 0 {
timeoutSec = 5
}
timeout := time.Duration(timeoutSec) * time.Second
start := time.Now()
switch checkType {
case "tcp":
// Validate and normalize host:port.
_, _, splitErr := net.SplitHostPort(target)
if splitErr != nil {
res.Success = false
res.ErrorMessage = "无效的TCP目标"
res.LatencyMs = float64(time.Since(start).Milliseconds())
return res, nil
}
conn, dialErr := net.DialTimeout("tcp", target, timeout)
res.LatencyMs = float64(time.Since(start).Milliseconds())
if dialErr != nil {
res.Success = false
res.ErrorMessage = dialErr.Error()
return res, nil
}
_ = conn.Close()
res.Success = true
return res, nil
case "icmp":
rtt, pingErr := icmpPing(target, timeout)
res.LatencyMs = float64(rtt.Milliseconds())
if pingErr != nil {
res.Success = false
res.ErrorMessage = pingErr.Error()
return res, nil
}
res.Success = true
return res, nil
}
res.Success = false
res.ErrorMessage = "未知错误"
res.LatencyMs = float64(time.Since(start).Milliseconds())
return res, nil
}
func icmpPing(target string, timeout time.Duration) (time.Duration, error) {
start := time.Now()
target = strings.TrimSpace(target)
if target == "" {
return time.Since(start), fmt.Errorf("无效的ICMP目标")
}
// Avoid accepting URL-like targets.
if strings.Contains(target, "://") {
return time.Since(start), fmt.Errorf("无效的ICMP目标")
}
if strings.HasPrefix(target, "[") && strings.HasSuffix(target, "]") {
target = strings.TrimSuffix(strings.TrimPrefix(target, "["), "]")
}
ipAddr, err := net.ResolveIPAddr("ip", target)
if err != nil || ipAddr == nil || ipAddr.IP == nil {
if err == nil {
err = fmt.Errorf("unknown address")
}
return time.Since(start), fmt.Errorf("解析目标失败: %v", err)
}
isV4 := ipAddr.IP.To4() != nil
listenAddr := "0.0.0.0"
proto := 1
var echoType icmp.Type = ipv4.ICMPTypeEcho
var echoReplyType icmp.Type = ipv4.ICMPTypeEchoReply
networks := []string{"udp4", "ip4:icmp"}
if !isV4 {
listenAddr = "::"
proto = 58
echoType = ipv6.ICMPTypeEchoRequest
echoReplyType = ipv6.ICMPTypeEchoReply
networks = []string{"udp6", "ip6:ipv6-icmp"}
}
var conn *icmp.PacketConn
selectedNetwork := ""
var lastErr error
for _, nw := range networks {
c, err := icmp.ListenPacket(nw, listenAddr)
if err == nil {
conn = c
selectedNetwork = nw
break
}
lastErr = err
}
if conn == nil {
if lastErr != nil {
return time.Since(start), fmt.Errorf("创建ICMP连接失败: %v", lastErr)
}
return time.Since(start), fmt.Errorf("创建ICMP连接失败")
}
defer conn.Close()
id := os.Getpid() & 0xffff
seq := 1
wm := icmp.Message{
Type: echoType,
Code: 0,
Body: &icmp.Echo{
ID: id,
Seq: seq,
Data: []byte("FLVX-PING"),
},
}
wb, err := wm.Marshal(nil)
if err != nil {
return time.Since(start), err
}
_ = conn.SetDeadline(time.Now().Add(timeout))
var dst net.Addr
if strings.HasPrefix(selectedNetwork, "udp") {
dst = &net.UDPAddr{IP: ipAddr.IP, Zone: ipAddr.Zone}
} else {
dst = &net.IPAddr{IP: ipAddr.IP, Zone: ipAddr.Zone}
}
if _, err := conn.WriteTo(wb, dst); err != nil {
return time.Since(start), err
}
addrIP := func(a net.Addr) net.IP {
switch v := a.(type) {
case *net.IPAddr:
return v.IP
case *net.UDPAddr:
return v.IP
default:
return nil
}
}
rb := make([]byte, 1500)
for {
n, peer, err := conn.ReadFrom(rb)
if err != nil {
return time.Since(start), err
}
if p := addrIP(peer); p != nil && !p.Equal(ipAddr.IP) {
continue
}
rm, err := icmp.ParseMessage(proto, rb[:n])
if err != nil {
continue
}
if rm.Type != echoReplyType {
continue
}
echo, ok := rm.Body.(*icmp.Echo)
if !ok {
continue
}
if echo.Seq != seq {
continue
}
// For non-privileged endpoints, the kernel may choose the ID.
if !strings.HasPrefix(selectedNetwork, "udp") && echo.ID != id {
continue
}
return time.Since(start), nil
}
}
// tcpPingHost 执行TCP连接测试,返回平均连接时间和失败率
func tcpPingHost(ip string, port int, count int, timeoutMs int) (float64, float64, error) {
var totalTime float64
@@ -1,2 +0,0 @@
schema: spec-driven
created: 2026-02-17
@@ -1,29 +0,0 @@
## Context
FLVX is a distributed system consisting of a central management panel (Backend + Frontend) and multiple forwarding agents (Nodes). The backend manages configuration, users, and billing, while agents handle the actual traffic forwarding using a modified GOST v3 stack. Communication between the panel and agents is secured and synchronized.
## Goals / Non-Goals
**Goals:**
- Document the high-level architecture of the system.
- Describe the data model for users, tunnels, and nodes.
- Explain the communication protocol between Panel and Agent.
- Detail the authentication and authorization mechanisms.
**Non-Goals:**
- Refactoring the existing architecture.
- Detailed code-level documentation of every function.
- Changing the database schema.
## Decisions
- **Architecture**: The system follows a client-server model where the Panel acts as the server and Agents act as clients that pull configuration and push status.
- **Data Model**: Core entities are Users, Nodes (Agents), Tunnels (Groups of rules), and Forwarding Rules.
- **Communication**: Agents use a heartbeat mechanism to report status and fetch configuration updates. The protocol uses AES encryption with a pre-shared key (Node Secret).
- **Authentication**: JWT for Frontend-Backend communication; API Key (Node Secret) for Agent-Backend communication.
## Risks / Trade-offs
- **Security**: The security of the agent communication relies heavily on the secrecy of the Node Secret.
- **Scalability**: Centralized management might become a bottleneck with a very large number of agents.
- **Complexity**: Synchronizing state across distributed agents introduces complexity in handling failures and inconsistencies.
@@ -1,28 +0,0 @@
## Why
The current system lacks formal specification documents describing its capabilities. This makes it difficult for new developers to understand the intended behavior and for existing developers to ensure consistency when adding new features. Documenting the existing functionality will serve as a baseline for future changes and help in identifying gaps or inconsistencies.
## What Changes
- Create formal specification documents for core system capabilities.
- Document user management features (roles, limits).
- Document tunnel and forwarding management (protocols, rules).
- Document agent interactions and management.
- Document system-level configurations.
## Capabilities
### New Capabilities
- `user-management`: Authentication, user roles, and resource limits.
- `tunnel-management`: Creation and management of traffic tunnels (TCP/UDP).
- `forwarding-rules`: Configuration of port forwarding and tunnel forwarding rules, including rate limiting.
- `agent-management`: Management of forwarding agents, including installation and configuration synchronization.
- `system-config`: Global system settings and configurations.
### Modified Capabilities
<!-- None, as this is a documentation effort for existing features. -->
## Impact
- **Documentation**: New spec files in `openspec/specs/`.
- **No Code Changes**: This change is purely documentation-focused.
@@ -1,29 +0,0 @@
## ADDED Requirements
### Requirement: Agent Registration
The system SHALL require new agents (Nodes) to register using a unique node key/secret.
#### Scenario: Node Connection
- **WHEN** a new agent starts up with a valid configuration
- **THEN** it connects to the backend and is registered as active.
### Requirement: Heartbeat Monitoring
The system SHALL monitor the status of all registered agents using periodic heartbeats.
#### Scenario: Agent Status
- **WHEN** an agent sends periodic heartbeats
- **THEN** the system updates its last-seen timestamp and marks it as online.
### Requirement: Configuration Sync
The system MUST synchronize configuration changes (tunnels, rules) to agents securely and reliably.
#### Scenario: Push Config
- **WHEN** a configuration change is made in the panel
- **THEN** the agent receives the updated configuration via the next heartbeat or push mechanism.
### Requirement: Version Management
The system SHOULD track the version of the agent software running on each node.
#### Scenario: Version Reporting
- **WHEN** an agent connects
- **THEN** it reports its version number to the backend for tracking.
@@ -1,22 +0,0 @@
## ADDED Requirements
### Requirement: Port Forwarding Rules
The system SHALL support configuring port forwarding rules, defining the listening port on the node and the destination IP/port.
#### Scenario: Rule Configuration
- **WHEN** an admin creates a port forwarding rule
- **THEN** the rule is stored and synchronized to the assigned node.
### Requirement: Rate Limiting
The system SHALL support configuring bandwidth rate limits for tunnels and users.
#### Scenario: Bandwidth Restriction
- **WHEN** a rate limit is applied to a user
- **THEN** their total bandwidth usage does not exceed the specified limit across all their tunnels.
### Requirement: Traffic Accounting
The system MUST track incoming and outgoing traffic volume for each tunnel and user for billing and quota enforcement.
#### Scenario: Traffic Calculation
- **WHEN** traffic flows through a tunnel
- **THEN** the system increments the user's traffic usage counter accurately.
@@ -1,22 +0,0 @@
## ADDED Requirements
### Requirement: Site Settings
The system SHALL allow customization of the site title, logo, and other branding elements.
#### Scenario: Update Branding
- **WHEN** an administrator changes the site logo
- **THEN** the new logo is displayed across the interface.
### Requirement: Notification Settings
The system SHALL support configuring notifications for user registration, traffic limits, and other events.
#### Scenario: User Limit Alert
- **WHEN** a user approaches their traffic quota
- **THEN** a notification is sent to the user/admin.
### Requirement: Backup & Restore
The system SHOULD provide a mechanism to backup and restore database configurations.
#### Scenario: Restore Database
- **WHEN** initiating a restore operation
- **THEN** the system accepts a valid backup file and overwrites the current database state.
@@ -1,22 +0,0 @@
## ADDED Requirements
### Requirement: Tunnel Creation
The system SHALL allow administrators to create tunnels, specifying protocols (TCP, UDP), listening ports, and destination endpoints.
#### Scenario: Create TCP Tunnel
- **WHEN** an admin creates a new TCP tunnel configuration
- **THEN** the backend stores the tunnel definition and assigns it to a node.
### Requirement: Tunnel Forwarding Configuration
The system SHALL support both standard port forwarding (listening on a port and forwarding to a destination) and tunnel forwarding modes.
#### Scenario: Configure Port Forwarding
- **WHEN** configuring a tunnel for port forwarding
- **THEN** traffic arriving at the specified port is forwarded to the destination IP:port.
### Requirement: Tunnel Assignment
The system SHALL allow tunnels to be assigned to specific users, tracking their usage against the user's quota.
#### Scenario: User Tunnel Usage
- **WHEN** a user is assigned a tunnel
- **THEN** traffic passing through that tunnel is accounted for under the user's usage.
@@ -1,29 +0,0 @@
## ADDED Requirements
### Requirement: User Registration
The system SHALL allow new users to register an account with a username and password.
#### Scenario: Successful Registration
- **WHEN** a user submits valid registration details
- **THEN** a new user account is created and the user can log in.
### Requirement: User Authentication
The system MUST authenticate users using JWT tokens. The `Authorization` header MUST contain the raw token without a `Bearer` prefix.
#### Scenario: Valid Login
- **WHEN** a user provides correct credentials
- **THEN** the system returns a valid JWT token.
### Requirement: Role Management
The system SHALL support different user roles, specifically Administrator and Regular User, with distinct permissions.
#### Scenario: Admin Access
- **WHEN** an administrator logs in
- **THEN** they have access to system-wide settings and all user management functions.
### Requirement: Resource Quotas
The system SHALL allow administrators to set traffic limits and connection limits for individual users.
#### Scenario: Traffic Limit Enforcement
- **WHEN** a user exceeds their traffic quota
- **THEN** the system prevents further traffic forwarding for that user.
@@ -1,30 +0,0 @@
## 1. User Management Verification
- [ ] 1.1 Verify User Registration logic in backend
- [ ] 1.2 Verify JWT Authentication implementation
- [ ] 1.3 Verify Role Management checks
- [ ] 1.4 Verify Quota Enforcement logic
## 2. Tunnel Management Verification
- [ ] 2.1 Verify Tunnel Creation API
- [ ] 2.2 Verify Forwarding Configuration parsing
- [ ] 2.3 Verify Tunnel Assignment logic
## 3. Forwarding Rules Verification
- [ ] 3.1 Verify Port Forwarding rule processing
- [ ] 3.2 Verify Rate Limiting implementation (token bucket/leaky bucket?)
- [ ] 3.3 Verify Traffic Accounting mechanisms
## 4. Agent Management Verification
- [ ] 4.1 Verify Agent Registration handshake
- [ ] 4.2 Verify Heartbeat processing
- [ ] 4.3 Verify Config Sync protocol
## 5. System Config Verification
- [ ] 5.1 Verify Site Settings API
- [ ] 5.2 Verify Notification triggers
- [ ] 5.3 Verify Backup/Restore functionality
-20
View File
@@ -1,20 +0,0 @@
schema: spec-driven
# Project context (optional)
# This is shown to AI when creating artifacts.
# Add your tech stack, conventions, style guides, domain knowledge, etc.
# Example:
# context: |
# Tech stack: TypeScript, React, Node.js
# We use conventional commits
# Domain: e-commerce platform
# Per-artifact rules (optional)
# Add custom rules for specific artifacts.
# Example:
# rules:
# proposal:
# - Keep proposals under 500 words
# - Always include a "Non-goals" section
# tasks:
# - Break tasks into chunks of max 2 hours
-52
View File
@@ -1,52 +0,0 @@
# Project Overview
**Name**: FLVX (Flux Panel)
**Description**: Traffic forwarding management system built on a forked GOST v3 stack. It provides a web-based panel for managing traffic tunnels, users, and forwarding rules.
**Repository**: Monorepo containing Admin API, Web UI, and Forwarding Agent.
## Tech Stack
### Backend (`go-backend/`)
- **Language**: Go
- **Database**: SQLite (default), PostgreSQL (supported)
- **Framework**: Standard library `net/http` (no heavy framework)
- **ORM**: None (Raw SQL via `database/sql`)
### Frontend (`vite-frontend/`)
- **Framework**: React
- **Build Tool**: Vite (using `rolldown-vite` experimental bundler)
- **UI Library**: HeroUI
- **Styling**: Tailwind CSS
- **Mode**: Hybrid (Desktop + Mobile WebView support)
### Agent (`go-gost/`)
- **Language**: Go
- **Base**: Fork of `gost` v3
- **Extensions**: Custom extensions in `go-gost/x/`
### Infrastructure
- **Containerization**: Docker, Docker Compose (v4/v6)
- **CI/CD**: GitHub Actions
- **Installers**: Shell scripts (`panel_install.sh`, `install.sh`)
## Architecture
- **Panel**: Central management server (Go Backend + React Frontend).
- **Agent**: Forwarding node running on remote servers.
- **Communication**:
- Frontend -> Backend: REST API (JWT Auth, raw token in header).
- Agent -> Backend: AES-encrypted heartbeat/config sync.
## Conventions
- **Authentication**: `Authorization` header expects raw JWT token (do NOT add `Bearer ` prefix).
- **API Response**: Standard envelope `{code, msg, data, ts}` (code 0 = success).
- **Database**: Backend uses raw SQL queries. Do not introduce an ORM.
- **File Structure**: Flat monorepo with language-prefixed directories (`go-backend`, `go-gost`).
- **Protobuf**: Do not edit generated `.pb.go` files manually.
## Development
- **Backend Build**: `cd go-backend && make build`
- **Frontend Dev**: `cd vite-frontend && npm run dev`
- **Agent Run**: `cd go-gost && go run .`
+104
View File
@@ -0,0 +1,104 @@
# 037 - Monitoring: Node Metrics + Service Health Checks
## Context
This worktree introduces a monitoring feature set:
- Node runtime metrics streamed via WebSocket (agent -> panel -> admin clients)
- Metrics ingestion + retention in panel DB
- Service monitoring (TCP/ICMP checks only) + result storage
- Frontend monitor view (charts + monitor CRUD + run + results)
- Dedicated monitor page (`/monitor`) that works for authorized non-admin users
The initial implementation landed without a plan doc and had several correctness issues (API JSON shape mismatch, wrong time units, contract test hangs under SQLite single-connection mode, etc.). This plan documents what exists, what was fixed, and what is still incomplete/needs decisions.
## Goals
- Metrics endpoints return stable JSON fields matching frontend types.
- Contract tests cover metrics + monitor CRUD and are deterministic.
- WebSocket metric messages update node cards correctly.
- Monitoring view queries the correct time range and renders timestamps correctly.
- go-gost/x unit tests do not depend on a local config.json.
## Non-goals (for this plan)
- A full monitor scheduling system (jitter/backoff/concurrency budgets/per-monitor next-run) beyond the current simple loop.
- Building a full alerting pipeline (notifications, thresholds, paging).
## Current Status (as of this worktree)
- Backend models updated with JSON tags for monitoring structs.
- Handler endpoints for metrics + service monitors added.
- Metrics ingestion service implemented with buffering + retention pruning.
- Health checker implemented (panel-side when `nodeId == 0`; node-executed via WS when `nodeId > 0`) and background jobs wired.
- Frontend monitor view added; build passes.
- Contract tests for monitoring added.
- Monitoring endpoints are accessible by admin users and non-admin users explicitly authorized by admin (via `monitor_permission`).
- Frontend exposes monitoring via a dedicated `/monitor` page; admin can grant/revoke monitoring permission from the User permissions modal.
- Frontend includes tunnel metrics charts (backed by `/api/v1/monitor/tunnels` list + `/api/v1/monitor/tunnels/:id/metrics`).
## Known Semantics Gaps (need decisions)
- `service_monitor.intervalSec` is best-effort (checker ticks every 30s; intervals shorter than that won't run faster).
- `service_monitor_result.success` is stored as int (0/1). Frontend currently treats it as number; decide if API should expose boolean.
## Admin Authorization API
Monitoring permission management (admin-only):
- `GET /api/v1/monitor/permission/list`
- `POST /api/v1/monitor/permission/assign` body: `{ "userId": 123 }`
- `POST /api/v1/monitor/permission/remove` body: `{ "userId": 123 }`
## Checklist
### Phase 1: Correctness + Contracts
- [x] Align monitoring JSON response fields with frontend/contract expectations (add json tags or DTO mapping).
- [x] Fix frontend monitor time range query (use ms start/end; avoid `start=60`).
- [x] Fix frontend timestamp rendering (treat timestamp as UnixMilli).
- [x] Fix node realtime metric speed field compatibility (support snake_case speed fields).
- [x] Fix SQLite contract hang by ensuring tunnel-entry precheck uses tx-safe DB reads (no nested connection acquisition).
- [x] Ensure monitoring contract tests pass.
### Phase 2: Semantics Alignment (Decide + Implement)
- [x] Decide "service monitors run where":
- Option B: node-executed when `nodeId > 0` (chosen)
- [ ] Define interval semantics:
- Per-monitor next-run scheduling vs global scan loop
- Backoff on failures
- Maximum monitors + runtime cost guardrails
- [ ] Standardize API type for `success`:
- Keep int for backward compatibility, or
- Return boolean in API responses (DTO) while storing int in DB
### Phase 2.1: Partial Implementation (No Semantics Decision Yet)
- [x] Honor `intervalSec` best-effort in panel-side checker (min cadence still bound by global loop).
### Phase 2.2: Node-Executed Checks
- [x] Add a WebSocket command for node-executed monitor checks (`ServiceMonitorCheck`).
- [x] Panel health checker dispatches checks to the specified node when `nodeId > 0`.
- [x] Allow unrestricted targets by policy; restrict monitoring endpoints to admin + explicitly authorized users.
- [x] Remove HTTP checks; service monitoring supports only `tcp` and `icmp`.
### Phase 3: Hardening + Performance
- [x] Add query limits/guards for metrics endpoints (max range, max rows) to avoid accidental full-history pulls.
- [ ] Consider indexing review and retention configurability (env or config table).
- [ ] Review concurrency: ingestion buffer flush goroutine spawning and DB write pressure.
- [x] Add minimal UI affordances: time range selector, empty/error states, and service monitor run/results UI.
- [x] Ensure monitoring UI works for authorized non-admin users (dedicated `/monitor` page; no reliance on admin-only `/node/*`).
### Phase 4: Hygiene
- [x] Add `.entire/metadata/` to `.gitignore` (should never be committed).
- [ ] Add a short developer note in docs/README if needed (API endpoints + semantics).
## Test Plan
Backend:
```bash
cd go-backend && go test ./... -count=1
cd go-backend && go test ./tests/contract -count=1 -timeout 120s
```
Agent fork:
```bash
cd go-gost/x && go test ./... -count=1
```
Frontend:
```bash
cd vite-frontend && npm run build
```
## Notes
- Node-executed checks can be used for internal probing by design; access is restricted to administrators.
+33
View File
@@ -0,0 +1,33 @@
# 037 Tunnel Chain Failover Repair
## Checklist
- [x] Analyze middle-hop primary/backup failover across backend runtime generation and agent route selection.
- [x] Add regression coverage for a tunnel relay chain where a same-hop `fifo` primary is down and the backup must take over.
- [x] Update tunnel runtime generation so chain services retry route selection when the next hop has multiple candidates.
- [x] Harden agent-side chain failover if backend-configured retries alone does not cover all relay/chain paths.
- N/A: Router retry loop (`go-gost/x/chain/router.go:91`) rebuilds route on each iteration, so FailFilter applies to failed nodes.
- [x] Revalidate diagnosis output so tunnel/forward tests reflect failover behavior instead of looking fully broken.
- N/A: Diagnosis tests individual legs (A→next, B→next) which is correct. Failover is for actual traffic, not diagnosis.
- [x] Run targeted backend and agent test suites.
## Findings
- Backend already emits hop selectors for tunnel chains with `strategy`, `maxFails=1`, and `failTimeout=10m` in `go-backend/internal/http/handler/mutations.go:3243`, so the control plane is not dropping the primary/backup mode itself.
- Agent route construction selects one node per hop up front in `go-gost/x/chain/chain.go:92`. If the chosen primary node is offline, the dial fails inside `go-gost/x/chain/route.go:220` and the node gets marked failed, but that mark only matters on a later route build.
- Tunnel chain services are generated without handler retry settings in `go-backend/internal/http/handler/mutations.go:3274`, while the router only rebuilds a route when `cfg.Handler.Retries` is greater than zero in `go-gost/x/config/parsing/service/parse.go:319`.
- Because the default retry count is effectively one attempt, a relay request never gets a second route selection after the primary middle-hop node is marked down, so traffic does not switch to the backup node.
- The forward handlers already have explicit retry/exclude-node loops in `go-gost/x/handler/forward/local/handler.go:179` and `go-gost/x/handler/forward/remote/handler.go:207`, which explains why failover logic exists in the codebase but is missing on the tunnel relay chain path.
## Repair Direction
- In backend tunnel runtime generation, compute the downstream candidate count for each chain service and set handler `retries` to at least `len(nextTargets) - 1` when a hop has multiple selectable nodes. That gives the router another dial cycle so `FailFilter` can skip the failed primary and pick the backup.
- Keep the retry value scoped to tunnel relay services built from `buildTunnelChainServiceConfig` so single-node hops do not incur unnecessary extra attempts.
- Add an agent-side regression test around relay + chain routing that simulates an offline primary node and asserts the second attempt lands on the backup node after the first node is marked failed.
- Add a backend regression test covering a tunnel definition with two nodes on the same middle hop in `fifo` mode, verifying the generated service config carries the retry budget needed for failover.
- Recheck tunnel/forward diagnosis behavior after the runtime fix. The current diagnosis model probes individual branch legs, so it may need an aggregated result or clearer messaging to avoid reading a partial branch failure as total failover failure.
## Validation
- `cd go-backend && go test ./internal/http/handler/... ./tests/contract/...`
- `cd go-gost/x && go test ./chain/... ./handler/relay/... ./config/parsing/service/...`
@@ -0,0 +1,28 @@
# 038 Federation Middle-Hop Retry Parity
## Checklist
- [x] Reproduce and document the parity gap between local tunnel middle-hop runtime generation and federation-applied middle roles.
- [x] Update federation runtime apply logic so remote middle-hop services set handler `retries` when the next hop has multiple candidates.
- [x] Add regression coverage for federated middle-hop runtime generation or contract behavior, including multi-target `fifo` scenarios.
- [x] Verify release / cleanup paths remain correct when the federated middle service carries retry settings.
- [x] Run targeted backend tests for handler and federation contract coverage.
## Findings
- Local tunnel runtime generation now sets `handler.retries` for middle-hop services based on downstream candidate count in `go-backend/internal/http/handler/mutations.go`, which enables router-level re-selection after a failed primary node.
- Federation runtime apply still creates remote middle-hop services without `handler.retries` in `go-backend/internal/http/handler/federation.go`, even though the remote chain hop itself uses the same selector failover settings (`strategy`, `maxFails=1`, `failTimeout=10m`).
- Because `go-gost/x/config/parsing/service/parse.go` only enables router retries when `cfg.Handler.Retries > 0`, federated middle-hop services can still fail hard on the first offline primary target instead of switching to backup.
- The gap creates inconsistent behavior: identical tunnel topologies can fail over correctly on local middle nodes but not on federated / remote middle nodes.
## Repair Direction
- In `go-backend/internal/http/handler/federation.go`, compute retry budget for `req.Role == "middle"` from `len(req.Targets)` and set `service["handler"]["retries"]` to at least `len(req.Targets) - 1` when there is more than one target.
- Keep retry injection scoped to federated middle roles only; exit roles should continue to omit retries because they do not rebuild downstream chain selection.
- Add regression coverage that proves federated middle runtime application preserves local parity, ideally by asserting the generated remote service config or by exercising a dual-panel contract path with multi-target middle nodes.
- Recheck federation release behavior to ensure added retry fields do not affect idempotent cleanup, service deletion, or re-apply flows.
## Validation
- `cd go-backend && go test ./internal/http/handler/... -count=1`
- `cd go-backend && go test ./tests/contract/... -count=1`
@@ -0,0 +1,59 @@
# 038 - Monitoring Bug Fixes + Optimizations
## Context
Monitoring in FLVX currently spans:
- Agent -> panel WebSocket realtime system metrics (CPU/mem/disk/net/load/conns)
- Panel-side ingestion + retention pruning (`node_metric`)
- Service monitors (TCP/ICMP) with scheduled checks + stored results
- Frontend monitor page (`/monitor`) with charts + monitor CRUD/run/results
While the feature set works end-to-end, there are a few correctness footguns and a couple of obvious performance hot spots (agent-side sampling cost and frontend N+1 polling patterns).
## Goals
- Service monitor updates do not accidentally clear `nodeId` / `enabled` when fields are omitted.
- Checker cadence is explicit (intervals below the scan cadence are clamped / best-effort).
- Reduce frontend requests for service monitor status (avoid per-monitor polling).
- Reduce agent sampling overhead and DB write volume without breaking UI expectations.
- Avoid misclassifying arbitrary JSON as a metric message on the WS channel.
## Non-goals
- A full scheduler (per-monitor next-run queue, jitter/backoff, concurrency budgets).
- Alerting/notifications.
- Implementing full tunnel-metrics ingestion (connections/errors/latency) beyond current endpoints.
## Checklist
### Phase 1: Backend Correctness + Hardening
- [x] Make `/api/v1/monitor/services/update` treat `nodeId` and `enabled` as optional fields (no accidental zeroing).
- [x] Clamp `intervalSec` to a minimum that matches the checker scan cadence (and apply the same clamp in the checker).
- [x] Add `GET /api/v1/monitor/services/latest-results` returning the latest result per monitor (for frontend list rendering).
- [x] WS metric parsing: only treat messages as metrics when they look like a system-metric payload.
### Phase 2: Frontend UX + Request Reduction
- [x] Fix “立即检查” toast severity (failure should be an error toast).
- [x] Use `latest-results` endpoint to render service monitor status without N+1 polling.
- [x] Add a small hint when chart data is truncated by backend row limits.
### Phase 3: Agent Sampling Optimizations
- [x] Reduce default WS metric send interval (2s -> 5s).
- [x] Make CPU sampling non-blocking and cache heavy metrics (e.g. connection counts) to reduce per-sample cost.
## Test Plan
Backend:
```bash
cd go-backend && go test ./... -count=1
```
Agent fork:
```bash
cd go-gost/x && go test ./... -count=1
```
Frontend (best-effort in this environment):
```bash
cd vite-frontend && npm run build
```
## Rollout Notes
- Agent sampling interval change reduces metric resolution and DB growth; charts remain usable and realtime UI remains responsive.
- Existing monitors with very small `intervalSec` are best-effort; effective cadence remains bounded by the checker scan loop.
@@ -0,0 +1,41 @@
# 039 - Monitoring: Tunnel Metrics Ingestion
## Context
The `/monitor` UI includes tunnel metric charts backed by:
- `GET /api/v1/monitor/tunnels` (list)
- `GET /api/v1/monitor/tunnels/:id/metrics` (timeseries)
The backend has the `tunnel_metric` table + query endpoints, but there is no production code path that writes tunnel metrics. As a result, tunnel charts are typically empty.
## Goal
Persist tunnel traffic timeseries based on agent flow uploads (`POST /flow/upload`).
## Scope
- Write `tunnel_metric` rows from flow uploads.
- Keep write volume bounded (aggregate per minute).
- Provide contract coverage that a flow upload creates tunnel metrics.
## Non-goals
- Populate connections/errors/latency for tunnel metrics (remain 0 for now).
- A full aggregation pipeline across multiple nodes per tunnel at query time.
UI note:
- The tunnel chart only exposes the Traffic view for now; other tabs are hidden.
## Design
- Agent reports per-service traffic deltas via `/flow/upload` with items `{n,u,d}`.
- Backend derives `forward_id` from service name (`<forwardID>_<userID>_<userTunnelID>[...suffix]`).
- Map `forward_id -> tunnel_id` in batch.
- Aggregate per `(node_id, tunnel_id, minute_bucket)` and upsert into `tunnel_metric` using an UPDATE-then-INSERT fallback.
## Checklist
- [x] Add repository helper: map forward IDs to tunnel IDs.
- [x] Add repository helper: upsert per-minute tunnel metric buckets.
- [x] Extend `/flow/upload` handler to record tunnel metrics from incoming items.
- [x] Add contract test verifying flow upload produces tunnel metrics.
- [x] Run backend tests.
## Test Plan
```bash
cd go-backend && go test ./... -count=1
```
@@ -0,0 +1,62 @@
# 恢复 PR #322 移除的功能
**状态**: ✅ 已完成
## 背景
PR #322 (https://github.com/Sagit-chu/flvx/pull/322) 原本移除了三个功能,用户要求**加回**这些被移除的功能:
1. 批量操作失败详情弹窗(`BatchOperationFailure` 类型及相关处理)
2. 节点到期提醒关闭功能(`dismissNodeExpiryReminder` API)
3. 更新通道选择功能(稳定版/开发版切换)
用户要求**保留**的改动:
- 版本显示简化(移除 "v" 前缀和更新可用徽章)
## 任务清单
- [x] 检出 PR #322 到本地分支 `pr-322`
- [x] 恢复 `api/types.ts` 中的 `expiryReminderDismissed` 字段
- [x] 恢复 `api/types.ts` 中的 `BatchOperationFailure` 类型和 `failures` 字段
- [x] 恢复 `api/error-message.ts` 中的批量操作失败处理函数
- [x] 恢复 `api/index.ts` 中的 `dismissNodeExpiryReminder` API
- [x] 恢复 `config.tsx` 中的更新通道选择功能
- [x] 恢复 `use-dashboard-data.ts` 中的 `expiryReminderDismissed` 过滤逻辑
- [x] 恢复 `batch-actions.ts` 中的 `BatchOperationFailure` 相关处理
- [x] 恢复 `forward.tsx` 中的 `BatchActionResultModal` 使用
- [x] 恢复 `tunnel.tsx` 中的 `BatchActionResultModal` 使用
- [x] 提交并推送修改
## 修改的文件
- `vite-frontend/src/api/types.ts` - 添加 `expiryReminderDismissed` 和 `BatchOperationFailure`
- `vite-frontend/src/api/error-message.ts` - 添加批量操作失败处理函数
- `vite-frontend/src/api/index.ts` - 添加 `dismissNodeExpiryReminder` API
- `vite-frontend/src/pages/config.tsx` - 添加更新通道选择功能
- `vite-frontend/src/pages/dashboard/use-dashboard-data.ts` - 恢复 `expiryReminderDismissed` 过滤逻辑
- `vite-frontend/src/pages/forward/batch-actions.ts` - 恢复批量操作失败处理
- `vite-frontend/src/pages/forward.tsx` - 恢复 `BatchActionResultModal` 组件使用
- `vite-frontend/src/pages/tunnel.tsx` - 恢复 `BatchActionResultModal` 组件使用
- `vite-frontend/src/pages/node.tsx` - 恢复 `expiryReminderDismissed` 功能和 "关闭提醒" 按钮
## 保留的 UI 改进
- Modal 样式优化(group.tsx, limit.tsx, panel-sharing.tsx)
- 按钮文本简化
- 用户页面隧道列表下拉展开
## forward.tsx 重构审查结果
PR #322 对 forward.tsx 进行了大规模重构(~2200 行 diff),经审查决定**保留**以下改动:
| 改动 | 说明 |
|------|------|
| DnD 碰撞检测 | `closestCenter` → `pointerWithin`,更适合嵌套拖拽 |
| 高级筛选模态框 | 从 SearchBar 改为五合一筛选(名称/用户/隧道/端口/目标地址) |
| 始终显示复选框 | 移除 selectMode 状态,用户无需切换模式即可选择 |
| 组件位置移动 | Sortable 组件移到组件顶部,代码组织更好 |
| UI 改进 | 表头全选、端口独立列、倍率显示优化、Modal 样式、"落地地址"文案 |
## 注意事项
- `version-footer.tsx` 保持简化版本显示(不恢复)
- `batch-action-result-modal.tsx` 组件文件未被 PR 删除,无需恢复(只需恢复 forward.tsx 中的使用)
+178
View File
@@ -0,0 +1,178 @@
# GOST UDP vs Realm UDP Analysis
**Goal:** Compare GOST UDP forwarding through tunnels with Realm's UDP implementation to understand why users report GOST UDP forwarding has problems while Realm works correctly.
## Task Checklist
- [x] Analyze GOST UDP tunnel architecture
- [x] Analyze Realm UDP relay architecture
- [x] Identify architectural differences
- [x] Identify potential issues in GOST implementation
- [ ] Document findings and recommendations
---
## GOST UDP Architecture
### Core Components
1. **UDP Relay** (`x/internal/net/udp/relay.go`)
- Simple bidirectional packet copying between two `net.PacketConn` interfaces
- Uses two goroutines: one for each direction
- Sequential packet processing (no batching)
- No idle timeout or association tracking
2. **UDP over Tunnel** (`x/handler/relay/bind.go`, `x/handler/socks/v5/udp_tun.go`)
- Wraps UDP data with SOCKS5-style framing via `UDPTunServerConn()`
- Adds address headers to each packet
- Uses smux multiplexing for tunnel connections
3. **SOCKS5 UDP Framing** (`x/internal/util/socks/conn.go`, `x/internal/util/relay/conn.go`)
- `udpTunConn.ReadFrom()`: Parses SOCKS5 UDP header to extract target address
- `udpTunConn.WriteTo()`: Wraps data with SOCKS5 UDP header including:
- RSV (data length, 2 bytes)
- Frag (0xff for tunnel relay, 1 byte)
- Address (4/16/1+n bytes for IPv4/IPv6/domain)
4. **Multiplexing** (`x/internal/util/mux/mux.go`)
- Uses smux v1.5.31 for connection multiplexing
- Adds stream framing and flow control overhead
### Data Flow (UDP over Tunnel)
```
Client UDP packet
↓
[Local GOST] SOCKS5 UDP framing (add ~10-26 bytes)
↓
smux stream (add framing, flow control)
↓
TCP tunnel to remote
↓
[Remote GOST] smux demux
↓
SOCKS5 UDP deframing
↓
Forward to target
```
---
## Realm UDP Architecture
### Core Components
1. **Association Model** (`realm_core/src/udp/middle.rs`)
- Per-client socket associations stored in `SockMap`
- Creates dedicated remote socket per client address
- Spawns `send_back` task for return path
- Association timeout for cleanup
2. **Batched I/O** (`realm_core/src/udp/batched.rs`)
- Uses `recvmmsg/sendmmsg` on Linux
- Up to 128 packets per batch
- Significantly higher throughput for high-PPS traffic
3. **No Protocol Overhead**
- Plain UDP forwarding without adding protocol headers
- No SOCKS5 framing or mux layer
### Data Flow
```
Client UDP packet
↓
[Realm] Direct relay via association socket
↓
Target server
```
---
## Key Architectural Differences
| Aspect | GOST | Realm |
|--------|------|-------|
| **UDP over Tunnel** | SOCKS5 framing + smux multiplexing | N/A (direct relay only) |
| **Protocol Overhead** | Extra ~10-26 bytes per packet | None |
| **I/O Model** | Standard Go net.PacketConn | Batched I/O (recvmmsg/sendmmsg) |
| **Connection Tracking** | Via mux session | SockMap with timeout |
| **Association Model** | None (bidirectional copy only) | Per-client socket association |
| **Idle Timeout** | None on UDP relay | Association timeout |
| **Throughput** | Single packet per syscall | Up to 128 packets per syscall |
---
## Identified Potential Issues in GOST
### 1. No Batch I/O Support
- **Impact**: High PPS (packets per second) traffic incurs syscall overhead
- **Realm advantage**: `recvmmsg/sendmmsg` batches up to 128 packets
- **Evidence**: Realm's `batched.rs` implements this; GOST uses standard `ReadFrom/WriteTo`
### 2. Protocol Overhead Per Packet
- **Impact**: Bandwidth waste, extra processing for framing/deframing
- **Overhead**: SOCKS5 UDP header adds ~10 bytes (IPv4) to ~26 bytes (IPv6) per packet
- **Evidence**: `x/internal/util/socks/conn.go:63-78` shows framing overhead
### 3. Mux Layer Overhead
- **Impact**: Latency and throughput degradation
- **smux adds**: Stream framing, flow control, potential backpressure
- **Evidence**: `x/internal/util/mux/mux.go` wraps all tunnel connections
### 4. No Association Tracking
- **Impact**: Cannot properly handle NAT translation for return traffic
- **Realm approach**: `SockMap` tracks client→remote socket mapping
- **Evidence**: GOST's `udp.Relay` just copies packets bidirectionally
### 5. No Idle Timeout on UDP Relay
- **Impact**: Stale connections may persist indefinitely
- **Evidence**: `udp.Relay.Run()` blocks until error or context cancel
- **Contrast**: Realm has association timeout for cleanup
### 6. Sequential Packet Processing
- **Impact**: Cannot pipeline multiple packets
- **Evidence**: `relay.go:44-77` shows sequential `ReadFrom` → `WriteTo` loop
- **Contrast**: Realm's batched I/O handles multiple packets concurrently
### 7. Read Deadline on Underlying TCP
- **Impact**: Could cause unexpected connection termination
- **Default**: 15-second read timeout set on connections
- **Evidence**: `x/handler/relay/metadata.go:readTimeout` defaults to 15s
- **Issue**: UDP relay may not handle deadline properly
---
## Recommendations
1. **Consider Direct UDP Mode**: For scenarios where tunnel is not required, use direct UDP relay (no SOCKS5 framing)
2. **Add Association Tracking**: Implement client→remote socket mapping with timeout
3. **Investigate Batch I/O**: Consider using `recvmmsg/sendmmsg` equivalent in Go (via `x/net` or raw syscalls)
4. **Add Idle Timeout**: Implement timeout-based cleanup for UDP associations
5. **Reduce Framing Overhead**: Consider more compact framing for tunnel UDP
---
## Files Analyzed
### GOST Files
- `go-gost/x/internal/net/udp/relay.go` - Core UDP relay logic
- `go-gost/x/internal/util/mux/mux.go` - smux multiplexing
- `go-gost/x/internal/util/socks/conn.go` - SOCKS5 UDP framing
- `go-gost/x/internal/util/relay/conn.go` - Relay UDP framing
- `go-gost/x/handler/relay/bind.go` - Relay BIND handler
- `go-gost/x/handler/socks/v5/udp_tun.go` - SOCKS5 UDP tunnel handler
- `go-gost/x/handler/tunnel/bind.go` - Tunnel BIND handler
- `go-gost/x/connector/tunnel/bind.go` - Tunnel connector BIND
- `go-gost/x/connector/tunnel/conn.go` - Tunnel connection types
- `go-gost/x/connector/tunnel/listener.go` - Tunnel bind listener
### Realm Files (fetched from GitHub)
- `realm_core/src/udp/mod.rs` - UDP relay entry point
- `realm_core/src/udp/middle.rs` - Association and relay logic with SockMap
- `realm_core/src/udp/socket.rs` - UDP socket binding and association
- `realm_core/src/udp/batched.rs` - Batched I/O implementation
@@ -0,0 +1,40 @@
# 040 - Service Monitor Limits Config + UI Hints
## Goal
Make service monitor interval/timeout constraints configurable (instead of hard-coded clamps) and make the UI clearly communicate the effective limits.
## Current Pain
- Backend clamps `intervalSec` and `timeoutSec` with hard-coded constants.
- Checker scan cadence is also hard-coded, so users can set values that will never be honored.
- Frontend form does not explain allowed ranges or why values may change.
## Approach
- Add frontend-configurable limits stored in `vite_config` (with safe defaults matching current behavior).
- Backend always normalizes using the configured limits.
- Expose the current limits via a monitoring endpoint so the UI can render accurate hints.
- Frontend shows min/max and validates before submit.
- Admin can edit the limits on `/config`.
## Config Keys (vite_config)
- `service_monitor_checker_scan_interval_sec` (default: 30)
- `service_monitor_min_interval_sec` (default: 30; auto-raised to at least scan interval)
- `service_monitor_default_interval_sec` (default: 60)
- `service_monitor_min_timeout_sec` (default: 1)
- `service_monitor_default_timeout_sec` (default: 5)
- `service_monitor_max_timeout_sec` (default: 60)
## Checklist
Backend:
- [x] Introduce shared `ServiceMonitorLimits` config loader.
- [x] Use limits for create/update normalization.
- [x] Use limits in checker (scan interval + timeout clamp).
- [x] Add `GET /api/v1/monitor/services/limits` to return current limits.
Frontend:
- [x] Fetch limits once and render input descriptions.
- [x] Validate interval/timeout client-side and show inline errors.
- [x] Add `/config` items to edit the `vite_config` keys.
Verification:
- [x] `cd go-backend && go test ./... -count=1`
- [x] `cd vite-frontend && npm run lint && npm run build`
@@ -0,0 +1,224 @@
# 041 - Monitoring Reliability, Realtime, and UX Hardening
## Context
Current monitoring support in FLVX already covers three major areas:
- Node runtime metrics from agent WebSocket telemetry, buffered into `node_metric`, exposed by `/api/v1/monitor/nodes*`, and rendered on `/monitor`.
- Tunnel metrics derived from `/flow/upload`, stored in `tunnel_metric`, exposed by `/api/v1/monitor/tunnels*`, and rendered on `/monitor`.
- Service monitoring for `tcp` and `icmp`, including CRUD, scheduled checks, manual run, history, and non-admin authorization via `monitor_permission`.
The feature set is usable, but the audit found several correctness, reliability, and UX gaps:
- The monitor page is not truly realtime and can lag DB ingestion by tens of seconds.
- Tunnel metrics are only partially implemented and are not aggregated correctly for multi-node tunnels.
- Some monitoring writes fail silently, which can hide data-loss and retention issues.
- Service monitor scheduling is functional but too naive for larger monitor sets and restart scenarios.
- The monitoring UI exposes incomplete semantics, weak freshness cues, and inconsistent permission/error affordances.
- Several monitoring endpoints and edge cases still lack direct automated coverage.
This plan collects all currently known monitoring follow-up work into one implementation document.
## Goals
- Make node monitoring data freshness explicit and reduce stale or misleading chart behavior.
- Make tunnel metrics correct for multi-node tunnels and align schema/query/UI semantics.
- Harden service monitor scheduling, persistence, and cleanup behavior.
- Improve observability so monitoring ingestion and result writes never fail silently.
- Upgrade the monitoring UI so operators can understand status, freshness, scope, and failures at a glance.
- Expand automated coverage for all monitoring APIs and the highest-risk aggregation/scheduler cases.
## Non-goals
- Add a full alerting or notification pipeline.
- Add brand-new monitor protocols beyond the current `tcp` and `icmp` scope.
- Build a large analytics dashboard outside the existing monitoring page structure.
- Introduce frontend test infrastructure for broad component/unit testing unless required by an implementation step.
## Audit Findings To Address
- Node metrics on `/monitor` are DB-polled rather than realtime-streamed.
- Node metrics are buffered for 30s, so charts can lag behind observed node state.
- Tunnel metrics are stored per `(tunnel_id, node_id, timestamp)` but queried and rendered as if they were already tunnel-level aggregates.
- Tunnel metric minute-bucket upsert uses update-then-insert without uniqueness guarantees.
- Tunnel metrics only populate `bytesIn` and `bytesOut`; `connections`, `errors`, and `avgLatencyMs` are placeholder values.
- Node/tunnel/service-monitor writes can fail silently due to ignored errors.
- Service monitor scheduler is serial and uses in-memory `lastRun`, causing restart skew and slow-monitor head-of-line blocking.
- Deleting a service monitor does not clean up related historical results.
- `expectedCode` exists on the model but is not implemented in behavior or UX.
- The monitoring page/menu is exposed before permission is known, leading to avoidable denied-entry UX.
- Service monitor UI does not clearly show latest result freshness, last check time, or whether a displayed row is stale.
- Chart labels and units are not operator-friendly for long time windows and network-heavy views.
- Monitoring API coverage is incomplete for list, permission, limits, latest-results, multi-node tunnel aggregation, and concurrency paths.
## Design
### 1. Node Monitoring Freshness and Realtime Model
- Keep the existing WebSocket node telemetry stream as the source of live state.
- Preserve DB-backed metrics queries for historical charts, but explicitly separate them from live cards/status.
- On `/monitor`, add a lightweight realtime subscription path reusing the existing admin WebSocket feed already used by the node page.
- Use realtime events for:
- node online/offline state,
- a small “latest value” strip or summary above charts,
- freshness timestamp display.
- Keep charts historical and DB-backed by default, but add a visible freshness hint such as:
- `历史图表,最近落库延迟约 0-30s`, or
- `最近入库时间: ...`.
- Do not remove buffered ingestion immediately; first make lag transparent in UI and observable in logs/metrics.
- Optional second-step optimization: reduce flush interval or add a bounded flush-on-latest-view mode if DB pressure remains acceptable.
### 2. Tunnel Metrics Data Model and Query Semantics
- Decide and document one API contract:
- `GET /api/v1/monitor/tunnels/:id/metrics` must return tunnel-level aggregated series for the selected time range, not raw per-node rows.
- Keep storage per `(tunnel_id, node_id, timestamp)` because it is useful for future drill-down.
- Change query behavior so the tunnel metrics endpoint aggregates rows by timestamp across all nodes for the tunnel:
- `SUM(bytes_in)`,
- `SUM(bytes_out)`,
- `SUM(connections)`,
- `SUM(errors)`,
- `AVG` or weighted-average strategy for latency, if latency is later implemented.
- Return a single point per timestamp to the frontend.
- If future node drill-down is needed, add a separate endpoint rather than mixing per-node rows into the current chart API.
### 3. Tunnel Metric Upsert Safety
- Replace the current update-then-insert fallback with a uniqueness-backed upsert strategy.
- Add a unique index on `(tunnel_id, node_id, timestamp)`.
- Implement DB-safe upsert behavior compatible with SQLite and PostgreSQL via GORM clauses or equivalent dialect-safe SQL.
- Preserve additive semantics for traffic counters inside the bucket.
- Add concurrency coverage proving that parallel uploads for the same bucket do not create duplicate rows.
### 4. Tunnel Metric Scope Clarification
- Short term: make the UI and API explicitly traffic-only where the backend only has traffic truth.
- Remove or hide unsupported tunnel metric modes from the current UX until real data exists.
- Do not expose zero-filled placeholders as if they were valid telemetry.
- Keep schema fields if future support is planned, but label them as unimplemented in code comments and avoid rendering them as live features.
### 5. Service Monitor Scheduler Hardening
- Replace the current fully serial best-effort loop with bounded concurrency:
- retain a global scan loop or next-run calculation,
- collect monitors due for execution,
- execute them with a configurable worker limit,
- avoid one slow node/target delaying all others.
- Move scheduling semantics from pure in-memory `lastRun` toward persisted or history-derived next-run safety:
- on restart, do not fire an uncontrolled burst for all monitors if they just ran;
- use latest persisted result timestamp or a persisted scheduler state to calculate due-ness.
- Keep interval clamping behavior aligned with configured limits.
- Continue supporting local panel execution for `tcp` and node execution for `tcp`/`icmp`.
### 6. Service Monitor Data Lifecycle
- Define monitor deletion semantics explicitly:
- either cascade-delete historical `service_monitor_result` rows when a monitor is deleted, or
- retain them intentionally and exclude orphan rows from latest/list endpoints.
- Preferred approach: delete associated results with the monitor so the UI/API model stays simple.
- Either implement `expectedCode` fully or remove it from the model/API surface for now.
- Because service monitoring currently supports only `tcp` and `icmp`, and no HTTP checks are implemented, `expectedCode` should likely be removed from the data model/API until a real HTTP monitor exists.
### 7. Observability and Failure Handling
- Stop swallowing monitoring persistence errors.
- For all node/tunnel/service-monitor writes:
- log structured errors with entity identifiers and operation names,
- increment internal counters if an existing metrics/logging primitive exists,
- keep request/loop behavior resilient, but make failure visible.
- Apply this to:
- node metric batch flush,
- tunnel metric bucket writes,
- scheduled service monitor result writes,
- manual service monitor result writes,
- pruning failures.
- Avoid user-facing hard failures for background ingestion, but surface operational diagnostics in logs.
### 8. Monitoring UI Semantics and Navigation
- Keep `/monitor` accessible only to authenticated users, but improve pre-entry affordances:
- hide or disable the navigation item for users without monitor permission when role/permission data is known,
- or show a locked state with explanation instead of allowing a full denied page transition.
- Preserve the backend permission check as the source of truth.
- On the page itself, upgrade semantics:
- distinguish `enabled/disabled` from `healthy/unhealthy` in the service monitor table,
- show `last checked at`,
- show `latest result` separately from monitor switch state,
- show whether the latest displayed result is stale.
- Add an at-a-glance monitoring summary near the top:
- online/offline node counts,
- monitors healthy/unhealthy/disabled counts,
- latest data freshness text.
### 9. Monitoring UI Readability Improvements
- Improve chart axis labeling for long ranges:
- use date + time formatting for 24h windows,
- keep shorter labels for short ranges.
- Format bytes and rates into human-readable units (`KB/s`, `MB/s`, `GB`) instead of raw integers.
- Expose clear empty states and fetch-error states instead of silent failures.
- Make tunnel charts explicitly say `流量趋势` if only traffic is supported.
- Show `statusCode` in the results modal only if the corresponding monitor type ever uses it; otherwise omit it.
### 10. API and Test Coverage Expansion
- Add contract coverage for:
- `GET /api/v1/monitor/nodes`,
- `GET /api/v1/monitor/tunnels`,
- `GET /api/v1/monitor/services/latest-results`,
- `GET /api/v1/monitor/services/limits`,
- monitor permission list/assign/remove endpoints.
- Add backend tests for:
- multi-node tunnel aggregation returning one point per timestamp,
- tunnel upsert concurrency safety,
- service monitor restart/due scheduling semantics,
- service monitor delete cleanup behavior,
- background write failure logging where practical.
- Keep existing build/test targets green for backend, agent, and frontend.
## Checklist
### Phase 1: Correctness Fixes
- [x] Aggregate `GET /api/v1/monitor/tunnels/:id/metrics` by timestamp across all node rows for the selected tunnel.
- [x] Add a unique index for tunnel metric minute buckets and replace the race-prone update-then-insert flow with safe upsert logic.
- [x] Stop exposing unsupported tunnel metric dimensions (`connections`, `errors`, `latency`) as active UI features while the backend still stores placeholders.
- [x] Define and implement service monitor deletion cleanup so history does not leave orphaned result rows.
- [x] Remove or fully implement `expectedCode`; do not keep dead monitoring fields in the live API/model contract.
### Phase 2: Reliability and Scheduling
- [x] Replace serial service monitor execution with bounded-concurrency execution for due monitors.
- [x] Persist or derive service monitor next-run behavior so process restarts do not trigger uncontrolled immediate reruns.
- [x] Ensure monitor scheduler semantics remain aligned with configured min interval and checker scan cadence.
- [x] Add structured logging for all monitoring persistence failures and prune failures.
- [x] Audit all ignored monitoring write errors and convert them into visible operational diagnostics.
### Phase 3: Realtime and Freshness UX
- [x] Reuse the existing admin WebSocket stream on `/monitor` for live node status and latest-value freshness indicators.
- [x] Add visible chart freshness metadata so users know historical charts are DB-backed and may lag ingestion.
- [x] Decide whether to reduce node metric flush interval after instrumentation confirms acceptable DB impact (decision: keep 30s default for now; revisit after observing DB write rate and UI staleness in production).
- [x] Add a monitoring summary strip showing online nodes, unhealthy monitors, and latest data time.
### Phase 4: Monitoring Page UX Cleanup
- [x] Separate service monitor switch state (`启用/禁用`) from probe health (`成功/失败`).
- [x] Add `last checked at` to the service monitor list and results modal context.
- [x] Mark stale results clearly when the latest result is older than the configured interval budget.
- [x] Format traffic and speed values in human-readable units instead of raw bytes.
- [x] Improve chart time labels for 24h windows to include date context.
- [x] Replace silent frontend fetch failures with explicit inline error or toast handling.
- [x] Rename or relabel tunnel chart UI to make its current scope unambiguous.
### Phase 5: Permission and Navigation UX
- [x] Avoid showing a fully interactive monitor nav entry to users who lack monitoring permission once permission state is known.
- [x] Preserve backend authorization as the final gate and keep denied responses intact.
- [x] Improve denied-state copy so users understand whether they need admin grant vs role change.
### Phase 6: Automated Coverage
- [x] Add contract tests for monitor node list, tunnel list, latest service monitor results, limits, and permission endpoints.
- [x] Add contract or repository tests for multi-node tunnel aggregation correctness.
- [x] Add concurrency tests for tunnel metric upsert safety.
- [x] Add scheduler tests covering restart behavior, due monitor selection, and slow-monitor isolation.
- [x] Keep existing monitoring contract tests passing after all changes.
## Implementation Notes
- Prefer backward-compatible API changes where possible, but favor correctness over preserving misleading tunnel metric semantics.
- Do not introduce a fake realtime chart if the data source remains DB-backed; label it honestly.
- If permission visibility requires an extra frontend capability call, keep it lightweight and cacheable.
- If schema/index changes are introduced, they must remain compatible with both SQLite and PostgreSQL.
## Final Verification Targets
- [x] `GET /api/v1/monitor/tunnels/:id/metrics` returns one aggregated point per timestamp even when multiple nodes report the same tunnel bucket.
- [x] Parallel `/flow/upload` calls for the same tunnel/node/minute do not create duplicate bucket rows.
- [x] `/monitor` clearly distinguishes live state from historical persisted charts and surfaces data freshness to the operator.
- [x] Service monitor list shows enabled state, latest health result, latest check time, and stale-state semantics correctly.
- [x] Deleting a service monitor no longer leaves dangling historical data in list-facing APIs.
- [x] Monitoring ingestion/result write failures are visible in logs and no longer fail silently.
- [x] Non-admin users without monitoring permission do not get a confusing monitor-entry experience, while granted users continue to access monitoring successfully.
- [x] Backend monitoring contract tests pass.
- [x] New repository/scheduler tests pass.
- [x] `cd go-backend && go test ./... -count=1` passes.
- [x] `cd go-gost/x && go test ./socket/... -count=1` passes.
- [x] `cd vite-frontend && npm run build` passes.
+18
View File
@@ -0,0 +1,18 @@
# Node Card Info Popover
## Goal
Restore node card's remark and renewal info display to the 2.1.8-beta9 style: an info button (ℹ️) in the CardHeader that shows a hover popover with the info, instead of the current inline display in the CardBody.
## Status: Completed
PR #327 merged
## Tasks
- [x] Add `infoPopoverPlacement` state and `updateInfoPopoverPlacement` callback
- [x] Add info button with popover to CardHeader
- [x] Restore drag handle with touch support
- [x] Remove inline info display from CardBody
- [x] Fix build errors (JSX structure and unused variables)
- [x] Create PR and merge
@@ -0,0 +1,49 @@
# 042 - SQLite 隧道编辑添加入口节点卡死排查
## Issue
- 现象:SQLite 数据库下,编辑已有隧道并新增入口节点时接口卡住;PostgreSQL 下同样操作正常。
- 初步判断:`tunnelUpdate` 在事务尚未提交时触发了额外 repository 读查询,SQLite 配置 `MaxOpenConns(1)`,容易在同一请求内形成自锁等待。
## Goal
- 找出 SQLite 与 PostgreSQL 行为差异的根因。
- 修复隧道编辑新增入口节点时的阻塞问题,同时不破坏现有的入口端口冲突校验。
- 补充最小回归测试,锁定“事务内校验不可再次占用根连接”的场景。
## Checklist
- [x] 复核 `tunnelUpdate` 在新增入口节点路径上的调用链,确认事务内哪些查询绕过了 `tx`。
- [x] 为相关 repository 查询补齐 `Tx` 版本,避免 SQLite 单连接下的自锁等待。
- [x] 调整 handler 中入口端口冲突校验,保证事务内全程复用同一个 `tx`。
- [x] 增加针对 SQLite 的回归测试,验证事务内校验不会阻塞。
- [x] 运行相关 handler/backend 测试并记录结果。
## Notes
- 重点关注 `validateTunnelEntryPortConflictsForNewEntries`:当前它在 `tx.Commit()` 前执行,但内部调用 `ListForwardsByTunnel` / `ListForwardPorts` / `HasOtherForwardOnNodePort` 等非事务查询。
- SQLite 在 `go-backend/internal/store/repo/repository.go` 中显式设置了 `SetMaxOpenConns(1)`,因此这种模式在 SQLite 下会比 PostgreSQL 更容易表现为“卡死”。
## 实际改动
- `go-backend/internal/store/repo/repository_control.go`
- 新增 `ListForwardsByTunnelTx`、`ListForwardPortsTx`、`HasOtherForwardOnNodePortTx`,并让原有非事务方法复用统一实现。
- `go-backend/internal/http/handler/mutations.go`
- `tunnelUpdate` 在事务内执行入口端口冲突校验时显式传入当前 `tx`。
- `validateTunnelEntryPortConflictsForNewEntries` 改为全程使用事务查询。
- 新增 `validateForwardPortAvailabilityTx`,避免事务内回落到根连接查询。
- `go-backend/internal/http/handler/tunnel_entry_sqlite_test.go`
- 新增 SQLite 回归测试,验证开启事务后执行新增入口校验不会阻塞。
## 测试结果
### 通过
```bash
cd go-backend && go test ./internal/http/handler/...
```
- 结果:通过。
### 额外检查
```bash
cd go-backend && go test ./tests/contract/...
```
- 结果:未全绿;当前失败集中在既有的入口端口语义合同用例:
- `TestIssue313_EntryPortCrossTunnelConflictContract`
- `TestTunnelUpdateChangesEntryNodeButLeavesOldForwardRuntimeContract`
- `TestTunnelUpdateEntryTransitionsCleanupForwardRuntimeContract`
- 备注:这些失败反映的是“新增/切换入口时端口校验预期”与现有合同用例之间的行为差异,不是本次 SQLite 事务自锁修复本身的编译或阻塞问题。
@@ -0,0 +1,49 @@
# 043 - Entry Transition Contract Semantics Alignment
## Issue
- SQLite 阻塞修复完成后,`go test ./tests/contract/...` 暴露出 3 个入口变更相关合同用例失败。
- 失败原因分成两类:
- Issue 313 用例的数据构造没有真正制造“新增入口节点已被其他转发占用”的冲突。
- Issue 281 回归用例在后续引入“入口端口必须落在节点端口范围内”后,仍沿用旧的非重叠端口范围数据,和当前产品语义不一致。
## Goal
- 对齐这 3 个合同测试与当前后端语义。
- 保持 SQLite 自锁修复不回退。
- 让入口节点切换/新增相关合同测试重新稳定通过。
## Checklist
- [x] 复核 3 个失败用例的测试数据与当前后端校验语义差异。
- [x] 调整 Issue 313 用例,确保新增入口节点确实命中“其他转发已占用同节点同端口”。
- [x] 调整 Issue 281 两个回归用例,使入口切换场景使用与保留端口兼容的节点端口范围。
- [x] 运行相关合同测试与 handler 测试并记录结果。
## Notes
- `validateTunnelEntryPortConflictsForNewEntries` 的占用判定复用 `HasOtherForwardOnNodePort`,语义是“同节点 + 同端口 + 其他转发”,不是全局无节点维度的端口唯一性。
- 入口切换测试当前更关注 `forward_port` 重建和旧节点运行时清理,因此测试数据应避免被端口范围校验提前拦截。
## 实际调整
- `go-backend/tests/contract/issue313_entry_port_conflict_contract_test.go`
- 将隧道 A 的入口节点改为复用即将添加到隧道 B 的 `entryB2`,确保新增入口时真正命中“同节点同端口已被其他转发占用”。
- 修正错误消息断言,直接检查 `out.Msg`,避免 JSON 解码后再读 `res.Body` 导致误判。
- `go-backend/tests/contract/limiter_sync_failure_contract_test.go`
- 将 issue 281 的新入口节点端口范围调整为包含原有保留端口,保持测试关注点在运行时清理/同步,而不是被后续引入的端口范围校验拦截。
## 测试结果
### 定向回归
```bash
cd go-backend && go test ./tests/contract/... -run 'TestIssue313_EntryPortCrossTunnelConflictContract|TestTunnelUpdateChangesEntryNodeButLeavesOldForwardRuntimeContract|TestTunnelUpdateEntryTransitionsCleanupForwardRuntimeContract' -v
```
- 结果:3/3 通过。
### Handler
```bash
cd go-backend && go test ./internal/http/handler/...
```
- 结果:通过。
### 全量合同测试
```bash
cd go-backend && go test ./tests/contract/...
```
- 结果:通过。
+183
View File
@@ -0,0 +1,183 @@
# 044 - 隧道删除时的规则依赖处理设计
## Goal
- 删除隧道前识别关联规则,避免默认级联误删。
- 在隧道页提供两种处理方式:迁移规则到其他隧道,或连同规则一起删除。
- 第一阶段只覆盖单条隧道删除,先把核心交互和执行链路做稳。
## Checklist
- [x] 复核当前 `tunnel.tsx` 删除交互与 `DeleteTunnelCascade` 行为。
- [x] 梳理可复用的规则换隧道能力(`forward/batch-change-tunnel`)。
- [x] 产出前端交互、接口与执行链路设计。
- [x] 明确第一阶段范围、异常处理与回滚要求。
## Implementation Checklist
- [x] 新增后端删除预检与带规则处理的删除接口。
- [x] 在后端补齐规则迁移/失败明细/回滚链路。
- [x] 接入前端隧道删除预检、替换/删除规则弹窗与结果展示。
- [x] 运行定向测试并记录结果。
## Current State
- `vite-frontend/src/pages/tunnel.tsx` 的单条删除当前直接调用 `/tunnel/delete`,没有前置依赖检查。
- `go-backend/internal/store/repo/repository_mutations.go` 里的 `DeleteTunnelCascade` 会直接删除该隧道下的 `forward`、`forward_port`、`user_tunnel`、`chain_tunnel` 等关联数据。
- `vite-frontend/src/pages/forward.tsx` 已经有“批量换隧道”交互和 `/forward/batch-change-tunnel`,但如果前端直接串联“先换规则、再删隧道”,中间任一步失败都会留下半完成状态,不适合作为删除流程的最终方案。
## Proposed UX
- 点击隧道删除按钮后,前端先请求“删除预检”接口,不直接进入最终确认。
- 若关联规则数为 `0`,继续沿用当前简单确认弹框。
- 若关联规则数大于 `0`,改为展示“处理关联规则”弹框:
- 顶部 `Alert` 提示:`隧道 "A" 正被 12 条规则使用`。
- 展示前 5 条规则摘要:规则名、所属用户、入口端口;剩余规则用“还有 N 条未展开”提示即可。
- 提供 `RadioGroup` 两个动作:
- `替换到其他隧道`(推荐,默认)
- `删除这些规则`
- 选择“替换到其他隧道”时显示 `Select`:
- 数据源直接复用当前页已经加载的 `tunnels`。
- 仅显示 `status === 1` 且 `id !== 当前隧道` 的选项。
- 若没有可选隧道,则禁用该选项并自动回退到“删除这些规则”。
- 确认按钮文案动态变化:
- `替换规则并删除隧道`
- `删除规则并删除隧道`
- 提交时展示 loading 状态;成功后关闭弹框、刷新隧道列表并 toast 成功。
- 如果后端返回规则级失败明细,前端复用现有 `BatchActionResultModal` 展示失败项,而不是只给一个通用 toast。
## API Design
### 1. 删除预检
`POST /api/v1/tunnel/delete-preview`
Request:
```json
{ "id": 12 }
```
Response:
```json
{
"code": 0,
"msg": "",
"data": {
"tunnelId": 12,
"tunnelName": "HK-Entry",
"forwardCount": 12,
"sampleForwards": [
{
"id": 101,
"name": "web-1",
"userId": 9,
"userName": "alice",
"inPort": 443
}
]
}
}
```
- `sampleForwards` 只需要返回前 5 条,前端用 `forwardCount` 决定是否显示“更多”提示。
- 预检只描述现状,不负责最终授权或一致性保证;真正提交删除时,后端必须再校验一次。
### 2. 带规则处理的删除
`POST /api/v1/tunnel/delete-with-forwards`
Request:
```json
{
"id": 12,
"action": "replace",
"targetTunnelId": 18
}
```
或:
```json
{
"id": 12,
"action": "delete_forwards"
}
```
Success response:
```json
{
"code": 0,
"msg": "",
"data": {
"forwardCount": 12,
"migratedCount": 12,
"deletedForwardCount": 0,
"portAdjustedCount": 2
}
}
```
Failure response:
```json
{
"code": -2,
"msg": "部分规则迁移失败",
"data": {
"successCount": 9,
"failCount": 3,
"failures": [
{
"id": 101,
"name": "web-1",
"reason": "目标隧道入口节点端口 443 已占用"
}
]
}
}
```
- 保持现有 `/tunnel/delete` 不动,兼容旧调用和当前批量删除逻辑。
- `vite-frontend/src/pages/tunnel.tsx` 的单条删除新流程全部走新接口。
## Backend Execution Strategy
- `delete-preview`
- 校验 tunnel 是否存在。
- 查询 `forward.tunnel_id = 当前隧道` 的数量和前 5 条摘要。
- `delete-with-forwards`
- 再次查询依赖规则,避免 preview 与提交之间状态变化导致误判。
- `action = delete_forwards`
- 直接复用当前级联删除逻辑。
- `action = replace`
- 校验 `targetTunnelId`:存在、启用、且不等于当前隧道。
- 先对全部关联规则做一次前置校验:目标隧道入口节点、端口范围、端口冲突、监听 IP 约束。
- 全部校验通过后,再逐条迁移规则并同步运行时。
- 任一规则迁移失败时,返回失败明细并中止删除;对已迁移规则做回滚,保证用户感知为“要么都成功,要么都不删”。
- 规则处理完成后,再删除隧道本身及非 `forward` 关联数据。
## Frontend Implementation Notes
- `vite-frontend/src/pages/tunnel.tsx`
- 新增删除预检 loading、依赖摘要、处理动作等 state。
- 复用现有 `Modal`,根据 preview 结果切换普通确认视图和依赖处理视图。
- 复用 `Select`、`RadioGroup`、`Alert`。
- 成功后继续沿用当前 `setTunnels`、`setTunnelOrder`、`setSelectedIds` 清理逻辑。
- `vite-frontend/src/api/index.ts`
- 新增 `previewTunnelDelete`。
- 新增 `deleteTunnelWithForwards`。
- 删除失败且返回批量明细时,复用 `BatchActionResultModal`,避免具体失败规则原因被 toast 吞掉。
## Scope Decision
- 第一阶段只改“隧道页单条删除”。
- `批量删除隧道` 先保持现有“级联删除规则”行为和提示文案不变。
- 如果第一阶段确认体验和后端回滚链路都稳定,再补第二阶段:批量删除时按每条隧道分别预检和处理。
## Edge Cases
- 没有可替换隧道:只允许“删除这些规则”。
- 目标隧道在提交前被禁用或删除:后端返回明确错误,前端保留当前弹框和用户选择。
- preview 时没有规则、提交时新建了规则:以后端最终校验为准,返回需要重新处理的提示。
- 迁移时若原入口端口在目标隧道不可用,沿用现有“换隧道”语义:优先保留原端口,不可用时自动分配可用端口,并通过 `portAdjustedCount` 给前端一个非阻塞提示。
- 第一阶段不自动补发目标隧道的 `user_tunnel` 授权,先与现有“批量换隧道”语义保持一致;如果后续需要让用户也获得目标隧道的新增规则权限,再单独评估自动补授权。
## Implementation Notes
- 后端新增 `/api/v1/tunnel/delete-preview` 和 `/api/v1/tunnel/delete-with-forwards`,单条隧道删除改为先预检再执行。
- 后端新增 `/api/v1/tunnel/batch-delete-preview` 和 `/api/v1/tunnel/batch-delete-with-forwards`,批量删除支持统一预检并选择“批量替换规则”或“批量删除规则”。
- 规则替换删除在后端先做端口/节点占用预检,再执行逐条迁移;执行阶段若任一规则失败,会回滚已迁移规则,并把失败明细返回给前端弹窗展示。
- 前端 `tunnel.tsx` 删除弹窗已支持三种状态:预检中、普通删除确认、有依赖规则时的“替换/删除规则”决策视图。
- 批量删除弹窗现在会汇总每条选中隧道的依赖规则数量,并在批量替换失败时按“隧道级”返回失败原因,避免整批操作信息丢失。
## Verification
- `cd go-backend && go test ./internal/http/handler/...`
- `cd go-backend && go test ./tests/contract/... -run 'TestTunnelDeletePreviewIncludesDependentRulesContract|TestTunnelDeleteWithForwardsDeleteActionRemovesTunnelAndRulesContract|TestTunnelDeleteWithForwardsReplaceReturnsFailureDetailsContract'`
- `cd vite-frontend && npm run build`
+10
View File
@@ -0,0 +1,10 @@
# PLAN: Release 2.1.8
## Overview
Synchronize with the main branch and release a new tag `2.1.8`.
## Tasks
- [x] Pull latest changes from `main`
- [x] Update `AGENTS.md` with new tag and current commit hash (PR #337 created and set to auto-merge)
- [x] Create git tag `2.1.8`
- [x] Push git tag `2.1.8` to origin
+23
View File
@@ -0,0 +1,23 @@
# Monitor Nezha Redesign
## Summary
Redesign the monitor overview to resemble Nezha dashboard. Phase 1 introduced ServerCard grid. Phase 2 hides node metrics from the overview and adds a detail view with charts and service monitor latency sparklines.
## Objectives
- [x] Analyze `monitor-view.tsx` layout boundaries.
- [x] Create `ServerCard` utilizing `Progress` component.
- [x] Render metrics (CPU, RAM, Disk, System Load, Connections, Network speeds).
- [x] Implement robust real-time updates and seamless state linkage via existing hooks.
- [x] Adjust layout positioning for impact at top-of-page.
- [x] Hide node metrics from overview — click node card to enter detail view.
- [x] Detail view: back button + node header + realtime KPI cards.
- [x] Detail view: node metrics chart (CPU/Memory/Disk/Network/Load/Connections).
- [x] Detail view: tunnel traffic chart.
- [x] Detail view: service monitors rendered as latency sparkline charts (Nezha-style).
- [x] Service monitor cards include status dot, type chip, target, interval, and dropdown actions.
## Technical Details
- Phase 1: Injected a `ServerCard` inline component with progress bars and real-time metrics.
- Phase 2: Added `detailNodeId` state for drill-down navigation. Grid view shows only summary bar + clickable server cards. Detail view shows KPI summary cards, node metrics chart (reusing existing recharts setup), tunnel traffic chart, and service monitors as a responsive grid of cards each containing a latency-over-time sparkline chart. Reduced file from 2108 to 1799 lines by consolidating the old overview card into the summary bar.
## Status: Complete
@@ -0,0 +1,7 @@
# 046 - Node Card Cleanup & Monitor Redesign
## Tasks
- [x] 1. Remove duplicate system metrics from node cards in `node.tsx` (CPU, memory, upload/download speed, upload/download traffic, disk, load)
- [x] 2. Redesign monitor `ServerCard` in `monitor-view.tsx` to match the node card style from `node.tsx`
- [x] 3. Make all charts in monitor view update incrementally (streaming) instead of full page reload
+9
View File
@@ -0,0 +1,9 @@
# 047 - Beautify Monitor Tab
## Goal
Redesign the node monitoring cards in the Monitor tab (监控标签) to make them more visually appealing. Incorporate a dynamic, clean layout mimicking the Nezha monitoring dashboard.
## Tasks
- [x] Redesign `ServerCard` in `vite-frontend/src/pages/node/monitor-view.tsx` with gradients, neon statuses, clean layout, up/down arrows for speeds.
- [x] Update the realtime KPI cards in the Monitor detailed view to have subtle gradients and dynamically colored text.
- [x] Fix TypeScript linting issues with `Progress` component colors (`classNames` vs `color`).
+11
View File
@@ -0,0 +1,11 @@
# 048 - Release 2.1.9-alpha5
## Goal
Release the current state of `main` (including the monitor tab beautification and user page improvements) under the tag `2.1.9-alpha5`.
## Tasks
- [x] Update `AGENTS.md` with current date, branch, and anticipated tag.
- [x] Create a release branch `release/2.1.9-alpha5`.
- [x] Commit and push the branch.
- [x] Create a Pull Request and merge it into `main`.
- [x] Publish the new tag `2.1.9-alpha5`.
+32
View File
@@ -0,0 +1,32 @@
# 049 - Monitor List View
## Objective
The user requested that the Monitor page should switch its card-based grid view to a list view similar to a provided screenshot, and a view mode toggle should be added to the top right.
## Expected Features
1. View Mode Toggle
- Add a state `viewMode` in `pages/monitor.tsx`.
- Add a toggle button with LayoutGrid/List icons next to the refresh button.
- Pass `viewMode` down to `MonitorView` component.
2. List View Implementation
- Extend `MonitorViewProps` with `viewMode: "list" | "grid"`.
- Render the `ServerCard` grid when `viewMode === "grid"`.
- Render a `Table` when `viewMode === "list"`.
- The list view should include:
- 状态 (Status: Colored dot depending on `isOnline`).
- 名称 (Name: Node name).
- 速率 (Speed: Up/Down speeds styled appropriately).
- 流量 (Traffic: Total Up/Down bytes).
- 开机时长 (Uptime).
- 连接数 (Connections: TCP/UDP).
- CPU (Progress bar).
- RAM (Progress bar).
- 存储 (Storage / Disk Progress bar).
- 操作 (Actions: Eye view icon to open detailed monitor).
## Checklist
- [x] Create plan document.
- [x] Add viewMode state and toggle in `monitor.tsx`.
- [x] Receive viewMode in `MonitorView` and selectively render Grid vs List views.
- [x] Ensure list correctly visualizes node info, speed, traffic, uptime, conns, CPU/RAM/Disk usages, and action icons.
- [x] Fix HeroUI missing TableProps typings (remove `removeWrapper` prop).
+40
View File
@@ -0,0 +1,40 @@
# 050 - 监控页面隧道独立入口
## 背景
当前监控页面的隧道流量监控嵌套在节点详情内,用户需要先点击某个节点才能看到隧道流量。
需要将隧道监控提升为与节点列表同级的入口,展示隧道质量(延迟)和流量统计。
## 设计
### 监控页面结构变更
在 `monitor.tsx` 页面增加 Tab 式结构(或并列区域),新增"隧道监控"入口与节点列表同级:
- **节点** Tab:保持现有节点卡片/列表 + 详情视图
- **隧道** Tab:新增隧道卡片/列表视图
- 每个隧道卡片显示:隧道名称、状态、质量指标(入口→出口延迟、出口→Bing TCP延迟)
- 点击隧道进入详情:隧道流量趋势图表(已有的 tunnel metrics chart)
### 隧道质量指标
使用已有的隧道诊断 API (`/tunnel/diagnose`) 进行 TCP 延迟测试:
- 入口→出口延迟
- 出口→Bing TCP 延迟(bing.com:443)
新增一个轻量级的后端 API 来获取隧道质量快照(定时 TCP 测试延迟),
或者前端在隧道 Tab 里手动触发诊断。
### 实现方案(前端为主)
由于后端已有完整的 tunnel metrics 和 diagnosis API,本次改动主要在前端:
1. `monitor.tsx` 增加 Tab 切换(节点 / 隧道)
2. 新建 `tunnel-monitor-view.tsx` 组件
3. 隧道列表用卡片展示质量指标
4. 点击进入隧道详情,展示流量图表
5. 从 `monitor-view.tsx` 的节点详情中移除隧道流量图表
## 任务清单
- [x] 1. 修改 `monitor.tsx`,增加节点/隧道 Tab 切换
- [x] 2. 新建 `tunnel-monitor-view.tsx` 组件,实现隧道列表视图
- [x] 3. 隧道卡片展示名称、状态、质量指标(支持手动诊断获取延迟)
- [x] 4. 隧道详情视图,展示流量趋势图表(复用已有 tunnel metrics API)
- [x] 5. 从节点详情视图中移除隧道流量图表
- [x] 6. TypeScript 编译通过验证
+15
View File
@@ -0,0 +1,15 @@
# Plan 051: Sync Main and Push Tag
## Goal
Submit all changes, push them to the remote repository, create a pull request to main, merge it, and push a new tag.
## Tasks
- [ ] Determine the next tag version.
- [ ] Add all changes to the staging area.
- [ ] Commit the changes.
- [ ] Push changes to the current branch.
- [ ] Create a pull request via `gh` CLI.
- [ ] Merge the pull request via `gh` CLI.
- [ ] Sync the `main` branch locally.
- [ ] Create a new tag.
- [ ] Push the new tag to the remote repository.
+10
View File
@@ -0,0 +1,10 @@
# 052 Node and User List View
## Objective
Add a list view and a card view toggle for Node Page and User Page, so they match exactly how the Monitor Page does it.
## Tasks
- [x] Add `viewMode` state toggle with `LayoutGrid` and `List` icons to `pages/node.tsx` header area.
- [x] Implement Table view rendering for Node list in `pages/node.tsx`.
- [x] Add `viewMode` state toggle with `LayoutGrid` and `List` icons to `pages/user.tsx` header area.
- [x] Implement Table view rendering for User list in `pages/user.tsx`.
+3
View File
@@ -7,6 +7,9 @@
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="FLVX" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@100..900&display=swap" rel="stylesheet">
<title>FLVX</title>
<script>
+9
View File
@@ -4,6 +4,7 @@ import { useEffect } from "react";
import IndexPage from "@/pages/index";
import ChangePasswordPage from "@/pages/change-password";
import DashboardPage from "@/pages/dashboard";
import MonitorPage from "@/pages/monitor";
import ForwardPage from "@/pages/forward";
import TunnelPage from "@/pages/tunnel";
import NodePage from "@/pages/node";
@@ -122,6 +123,14 @@ function App() {
}
path="/dashboard"
/>
<Route
element={
<ProtectedRoute>
<MonitorPage />
</ProtectedRoute>
}
path="/monitor"
/>
<Route
element={
<ProtectedRoute>
+120
View File
@@ -6,6 +6,10 @@ import type {
NodeReleaseApiItem,
NodeApiItem,
SpeedLimitApiItem,
TunnelBatchDeletePreviewApiData,
TunnelBatchDeleteWithForwardsApiData,
TunnelDeletePreviewApiData,
TunnelDeleteWithForwardsApiData,
TunnelDiagnosisApiData,
TunnelGroupApiItem,
UserApiItem,
@@ -26,6 +30,16 @@ import type {
SpeedLimitMutationPayload,
UpdatePasswordPayload,
BackupImportPayload,
NodeMetricApiItem,
TunnelMetricApiItem,
ServiceMonitorApiItem,
ServiceMonitorResultApiItem,
ServiceMonitorLimitsApiData,
ServiceMonitorMutationPayload,
MonitorNodeApiItem,
MonitorTunnelApiItem,
MonitorPermissionApiItem,
MonitorAccessApiData,
} from "./types";
import axios from "axios";
@@ -122,6 +136,30 @@ export const updateTunnel = (data: TunnelMutationPayload) =>
Network.post("/tunnel/update", data);
export const deleteTunnel = (id: number) =>
Network.post("/tunnel/delete", { id });
export const previewTunnelDelete = (id: number) =>
Network.post<TunnelDeletePreviewApiData>("/tunnel/delete-preview", { id });
export const deleteTunnelWithForwards = (data: {
id: number;
action: "replace" | "delete_forwards";
targetTunnelId?: number;
}) =>
Network.post<TunnelDeleteWithForwardsApiData | BatchOperationResult>(
"/tunnel/delete-with-forwards",
data,
);
export const previewBatchTunnelDelete = (ids: number[]) =>
Network.post<TunnelBatchDeletePreviewApiData>("/tunnel/batch-delete-preview", {
ids,
});
export const batchDeleteTunnelsWithForwards = (data: {
ids: number[];
action: "replace" | "delete_forwards";
targetTunnelId?: number;
}) =>
Network.post<TunnelBatchDeleteWithForwardsApiData>(
"/tunnel/batch-delete-with-forwards",
data,
);
export const diagnoseTunnel = (tunnelId: number) =>
Network.post<TunnelDiagnosisApiData>(
"/tunnel/diagnose",
@@ -374,3 +412,85 @@ export const getAnnouncement = () =>
Network.get<AnnouncementData>("/announcement/get");
export const updateAnnouncement = (data: AnnouncementData) =>
Network.post("/announcement/update", data);
export const getNodeMetrics = (
nodeId: number,
start?: number,
end?: number,
) => {
const params: Record<string, string> = {};
if (start) params.start = String(start);
if (end) params.end = String(end);
return Network.get<NodeMetricApiItem[]>(
`/monitor/nodes/${nodeId}/metrics`,
params,
);
};
export const getNodeMetricsLatest = (nodeId: number) =>
Network.get<NodeMetricApiItem>(`/monitor/nodes/${nodeId}/metrics/latest`);
export const getTunnelMetrics = (
tunnelId: number,
start?: number,
end?: number,
) => {
const params: Record<string, string> = {};
if (start) params.start = String(start);
if (end) params.end = String(end);
return Network.get<TunnelMetricApiItem[]>(
`/monitor/tunnels/${tunnelId}/metrics`,
params,
);
};
export const getMonitorTunnels = () =>
Network.get<MonitorTunnelApiItem[]>("/monitor/tunnels");
export const getServiceMonitorList = () =>
Network.get<ServiceMonitorApiItem[]>("/monitor/services");
export const getServiceMonitorLimits = () =>
Network.get<ServiceMonitorLimitsApiData>("/monitor/services/limits");
export const createServiceMonitor = (data: ServiceMonitorMutationPayload) =>
Network.post<ServiceMonitorApiItem>("/monitor/services/create", data);
export const updateServiceMonitor = (data: ServiceMonitorMutationPayload) =>
Network.post<ServiceMonitorApiItem>("/monitor/services/update", data);
export const deleteServiceMonitor = (id: number) =>
Network.post("/monitor/services/delete", { id });
export const getServiceMonitorResults = (monitorId: number, limit = 100) =>
Network.get<ServiceMonitorResultApiItem[]>(
`/monitor/services/${monitorId}/results`,
{ limit: String(limit) },
);
export const getServiceMonitorLatestResults = () =>
Network.get<ServiceMonitorResultApiItem[]>(
"/monitor/services/latest-results",
);
export const runServiceMonitor = (id: number) =>
Network.post<ServiceMonitorResultApiItem>("/monitor/services/run", { id });
export const getMonitorNodes = () =>
Network.get<MonitorNodeApiItem[]>("/monitor/nodes");
export const getMonitorAccess = () =>
Network.get<MonitorAccessApiData>("/monitor/access");
export const getMonitorPermissionList = () =>
Network.get<MonitorPermissionApiItem[]>("/monitor/permission/list");
export const assignMonitorPermission = (userId: number) =>
Network.post("/monitor/permission/assign", { userId });
export const removeMonitorPermission = (userId: number) =>
Network.post("/monitor/permission/remove", { userId });
+146
View File
@@ -222,6 +222,50 @@ export interface BatchOperationFailure {
[key: string]: unknown;
}
export interface TunnelDeletePreviewForwardApiItem {
id: number;
name: string;
userId: number;
userName: string;
inPort: number;
[key: string]: unknown;
}
export interface TunnelDeletePreviewApiData {
tunnelId: number;
tunnelName: string;
forwardCount: number;
sampleForwards: TunnelDeletePreviewForwardApiItem[];
[key: string]: unknown;
}
export interface TunnelBatchDeletePreviewApiData {
tunnelCount: number;
totalForwardCount: number;
items: TunnelDeletePreviewApiData[];
[key: string]: unknown;
}
export interface TunnelDeleteWithForwardsApiData {
forwardCount: number;
migratedCount: number;
deletedForwardCount: number;
portAdjustedCount: number;
warnings?: string[];
[key: string]: unknown;
}
export interface TunnelBatchDeleteWithForwardsApiData {
successCount: number;
failCount: number;
failures?: BatchOperationFailure[];
deletedForwardCount: number;
migratedCount: number;
portAdjustedCount: number;
warnings?: string[];
[key: string]: unknown;
}
export interface UserMutationPayload {
id?: number;
user?: string;
@@ -341,3 +385,105 @@ export interface BackupImportPayload {
types: string[];
[key: string]: unknown;
}
export interface NodeMetricApiItem {
id: number;
nodeId: number;
timestamp: number;
cpuUsage: number;
memoryUsage: number;
diskUsage: number;
netInBytes: number;
netOutBytes: number;
netInSpeed: number;
netOutSpeed: number;
load1: number;
load5: number;
load15: number;
tcpConns: number;
udpConns: number;
uptime: number;
}
export interface TunnelMetricApiItem {
id: number;
tunnelId: number;
nodeId: number;
timestamp: number;
bytesIn: number;
bytesOut: number;
connections: number;
errors: number;
avgLatencyMs: number;
}
export interface ServiceMonitorApiItem {
id: number;
name: string;
// Keep as string for forward-compatibility.
type: string;
target: string;
intervalSec: number;
timeoutSec: number;
nodeId: number;
enabled: number;
createdTime: number;
updatedTime: number;
}
export interface ServiceMonitorResultApiItem {
id: number;
monitorId: number;
timestamp: number;
success: number;
latencyMs: number;
statusCode: number;
errorMessage: string;
}
export interface ServiceMonitorMutationPayload {
id?: number;
name: string;
type: "tcp" | "icmp";
target: string;
intervalSec?: number;
timeoutSec?: number;
nodeId?: number;
enabled?: number;
}
export interface ServiceMonitorLimitsApiData {
checkerScanIntervalSec: number;
minIntervalSec: number;
defaultIntervalSec: number;
minTimeoutSec: number;
defaultTimeoutSec: number;
maxTimeoutSec: number;
}
export interface MonitorNodeApiItem {
id: number;
inx: number;
name: string;
status: number;
updatedTime: number;
}
export interface MonitorTunnelApiItem {
id: number;
inx: number;
name: string;
status: number;
updatedTime: number;
}
export interface MonitorPermissionApiItem {
id: number;
userId: number;
createdTime: number;
}
export interface MonitorAccessApiData {
allowed: boolean;
reason?: string;
}
+5 -4
View File
@@ -1,6 +1,6 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import { CheckIcon, MinusIcon } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -11,17 +11,18 @@ function Checkbox({
return (
<CheckboxPrimitive.Root
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow transition-transform duration-100 active:scale-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow transition-transform duration-100 active:scale-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground data-[state=checked]:shadow-none data-[state=indeterminate]:shadow-none",
className,
)}
data-slot="checkbox"
{...props}
>
<CheckboxPrimitive.Indicator
className="flex items-center justify-center text-current data-[state=checked]:animate-in data-[state=checked]:zoom-in-75 data-[state=checked]:duration-150"
className="flex items-center justify-center text-current data-[state=checked]:animate-in data-[state=checked]:zoom-in-75 data-[state=checked]:duration-150 data-[state=indeterminate]:animate-in data-[state=indeterminate]:zoom-in-75 data-[state=indeterminate]:duration-150"
data-slot="checkbox-indicator"
>
<CheckIcon className="h-3.5 w-3.5" />
<CheckIcon className="h-3.5 w-3.5 data-[state=indeterminate]:hidden" />
<MinusIcon className="h-3.5 w-3.5 hidden data-[state=indeterminate]:block" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
+1 -7
View File
@@ -56,18 +56,12 @@ function DialogContent({
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
{...props}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-default-200 bg-white p-6 shadow-lg duration-200 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-[state=open]:slide-in-from-bottom-4 data-[state=closed]:slide-out-to-bottom-2 sm:rounded-lg dark:bg-default-50",
className,
)}
data-slot="dialog-content"
onOpenAutoFocus={(e) => {
e.preventDefault();
}}
onCloseAutoFocus={(e) => {
e.preventDefault();
}}
{...props}
>
{children}
{showCloseButton && (
+87 -3
View File
@@ -21,7 +21,7 @@ import {
import { Input } from "@/shadcn-bridge/heroui/input";
import { BrandLogo } from "@/components/brand-logo";
import { VersionFooter } from "@/components/version-footer";
import { updatePassword } from "@/api";
import { getMonitorAccess, updatePassword } from "@/api";
import { safeLogout } from "@/utils/logout";
import { siteConfig } from "@/config/site";
import { useMobileBreakpoint } from "@/hooks/useMobileBreakpoint";
@@ -56,6 +56,10 @@ export default function AdminLayout({
);
const [username, setUsername] = useState("");
const [isAdmin, setIsAdmin] = useState(false);
const [monitorAllowed, setMonitorAllowed] = useState<boolean | null>(null);
const [monitorAccessReason, setMonitorAccessReason] = useState<string | null>(
null,
);
const [passwordLoading, setPasswordLoading] = useState(false);
const [passwordForm, setPasswordForm] = useState<PasswordForm>({
newUsername: "",
@@ -117,6 +121,19 @@ export default function AdminLayout({
),
adminOnly: true,
},
{
path: "/monitor",
label: "监控",
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path
clipRule="evenodd"
d="M3 3a1 1 0 000 2v11a1 1 0 001 1h13a1 1 0 100-2H5V5a1 1 0 00-1-1H3zm13.707 4.293a1 1 0 00-1.414 0L12 10.586 10.707 9.293a1 1 0 00-1.414 0L7 11.586l-1.293-1.293a1 1 0 10-1.414 1.414l2 2a1 1 0 001.414 0L10 11.414l1.293 1.293a1 1 0 001.414 0l3-3a1 1 0 000-1.414z"
fillRule="evenodd"
/>
</svg>
),
},
{
path: "/limit",
label: "限速",
@@ -184,6 +201,41 @@ export default function AdminLayout({
setUsername(name);
setIsAdmin(adminFlag);
// Monitor permission is not strictly role-based; non-admin users may be
// granted access explicitly. Fetch a lightweight capability flag so we can
// avoid a confusing 403 navigation.
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),
);
return;
}
// Fail open to preserve legacy navigation behavior.
setMonitorAllowed(true);
setMonitorAccessReason(null);
} catch {
if (cancelled) return;
setMonitorAllowed(true);
setMonitorAccessReason(null);
}
})();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
@@ -218,6 +270,23 @@ export default function AdminLayout({
// 菜单点击处理
const handleMenuClick = (path: string) => {
if (path === "/monitor" && monitorAllowed !== true) {
if (monitorAllowed == null) {
toast("正在检查监控权限,请稍后重试");
return;
}
const hint =
monitorAccessReason === "need_admin_grant"
? "暂无监控权限,请联系管理员在用户页面授予监控权限"
: "暂无监控权限,请联系管理员授权";
toast.error(hint);
return;
}
navigate(path);
if (isMobile) {
hideMobileMenu();
@@ -346,6 +415,8 @@ export default function AdminLayout({
<ul className="space-y-1">
{filteredMenuItems.map((item) => {
const isActive = location.pathname === item.path;
const isMonitor = item.path === "/monitor";
const isMonitorBlocked = isMonitor && monitorAllowed !== true;
return (
<li key={item.path}>
@@ -353,13 +424,23 @@ export default function AdminLayout({
className={`
w-full flex items-center p-2 rounded-lg text-left
relative min-h-[44px] overflow-hidden transition-colors
${isMonitorBlocked ? "opacity-60" : ""}
${
isActive
? "text-primary-600 dark:text-primary-300"
: "text-gray-700 dark:text-gray-200"
: isMonitorBlocked
? "text-gray-500 dark:text-gray-400"
: "text-gray-700 dark:text-gray-200"
}
`}
title={isCollapsed ? item.label : undefined}
aria-disabled={isMonitorBlocked}
title={
isCollapsed
? isMonitorBlocked
? `${item.label} (无权限)`
: item.label
: undefined
}
transition={{ duration: 0.15 }}
onClick={() => handleMenuClick(item.path)}
>
@@ -576,6 +657,9 @@ export default function AdminLayout({
{/* 修改密码弹窗 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={isOpen}
placement="center"
scrollBehavior="outside"
+75 -2
View File
@@ -1,8 +1,10 @@
import React, { useState, useEffect } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import toast from "react-hot-toast";
import { BrandLogo } from "@/components/brand-logo";
import { siteConfig } from "@/config/site";
import { getMonitorAccess } from "@/api";
import { getAdminFlag } from "@/utils/session";
import { useScrollTopOnPathChange } from "@/hooks/useScrollTopOnPathChange";
@@ -17,6 +19,10 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
const navigate = useNavigate();
const location = useLocation();
const [isAdmin, setIsAdmin] = useState(false);
const [monitorAllowed, setMonitorAllowed] = useState<boolean | null>(null);
const [monitorAccessReason, setMonitorAccessReason] = useState<string | null>(
null,
);
useScrollTopOnPathChange();
@@ -72,6 +78,19 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
),
adminOnly: true,
},
{
path: "/monitor",
label: "监控",
icon: (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path
clipRule="evenodd"
d="M3 3a1 1 0 000 2v11a1 1 0 001 1h13a1 1 0 100-2H5V5a1 1 0 00-1-1H3zm13.707 4.293a1 1 0 00-1.414 0L12 10.586 10.707 9.293a1 1 0 00-1.414 0L7 11.586l-1.293-1.293a1 1 0 10-1.414 1.414l2 2a1 1 0 001.414 0L10 11.414l1.293 1.293a1 1 0 001.414 0l3-3a1 1 0 000-1.414z"
fillRule="evenodd"
/>
</svg>
),
},
{
path: "/profile",
label: "我的",
@@ -84,11 +103,60 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
];
useEffect(() => {
setIsAdmin(getAdminFlag());
const adminFlag = getAdminFlag();
setIsAdmin(adminFlag);
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),
);
return;
}
setMonitorAllowed(true);
setMonitorAccessReason(null);
} catch {
if (cancelled) return;
setMonitorAllowed(true);
setMonitorAccessReason(null);
}
})();
return () => {
cancelled = true;
};
}, []);
// Tab点击处理
const handleTabClick = (path: string) => {
if (path === "/monitor" && monitorAllowed !== true) {
if (monitorAllowed == null) {
toast("正在检查监控权限,请稍后重试");
return;
}
const hint =
monitorAccessReason === "need_admin_grant"
? "暂无监控权限,请联系管理员授权"
: "暂无监控权限";
toast.error(hint);
return;
}
navigate(path);
};
@@ -121,6 +189,8 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
<nav className="bg-white dark:bg-black border-t border-gray-200 dark:border-gray-600 h-[calc(4rem+var(--safe-area-bottom))] flex-shrink-0 flex items-center justify-around px-2 fixed bottom-0 left-0 right-0 z-30">
{filteredTabItems.map((item) => {
const isActive = location.pathname === item.path;
const isMonitor = item.path === "/monitor";
const isMonitorBlocked = isMonitor && monitorAllowed !== true;
return (
<button
@@ -128,10 +198,13 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
className={`
flex flex-col items-center justify-center flex-1 h-full pb-[var(--safe-area-bottom)]
transition-colors duration-200 min-h-[44px]
${isMonitorBlocked ? "opacity-60" : ""}
${
isActive
? "text-primary-600 dark:text-primary-400"
: "text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
: isMonitorBlocked
? "text-gray-500 dark:text-gray-400"
: "text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
}
`}
onClick={() => handleTabClick(item.path)}
+39 -26
View File
@@ -882,24 +882,13 @@ export default function ConfigPage() {
<Card className="shadow-md">
<CardHeader className="pb-6">
<div className="flex justify-between items-center w-full">
<div className="flex items-center w-full">
<div>
<h2 className="text-xl font-semibold">基本设置</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
配置网站的基本信息,这些设置会影响网站的显示效果
</p>
</div>
<div className="flex gap-2">
<Button
color="primary"
disabled={!hasChanges}
isLoading={saving}
startContent={<SaveIcon className="w-4 h-4" />}
onPress={handleSave}
>
{saving ? "保存中..." : "保存配置"}
</Button>
</div>
</div>
</CardHeader>
@@ -975,19 +964,29 @@ export default function ConfigPage() {
</SelectItem>
</Select>
</div>
<div className="flex justify-end pt-6 border-t border-divider/50 mt-4">
<Button
color="primary"
disabled={!hasChanges}
isLoading={saving}
startContent={<SaveIcon className="w-4 h-4" />}
onPress={handleSave}
>
{saving ? "保存中..." : "保存配置"}
</Button>
</div>
</CardBody>
</Card>
{hasChanges && (
<Card className="mt-4 bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800">
<CardBody className="py-3">
<div className="w-full flex items-center justify-center gap-2 text-warning-700 dark:text-warning-300">
<div className="w-2 h-2 bg-warning-500 rounded-full animate-pulse" />
<span className="text-sm font-medium">
检测到配置变更,请记得保存您的修改
</span>
</div>
</CardBody>
<Card className="mt-4 bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800 shadow-sm overflow-hidden">
<div className="h-10 flex items-center justify-center gap-2 text-warning-700 dark:text-warning-300">
<div className="w-2 h-2 bg-warning-500 rounded-full animate-pulse flex-shrink-0" />
<span className="text-sm font-medium leading-none">
检测到配置变更,请记得保存您的修改
</span>
</div>
</Card>
)}
@@ -1045,7 +1044,7 @@ export default function ConfigPage() {
公告支持 Markdown 语法,链接会在新标签页打开
</p>
<div className="flex justify-end">
<div className="flex justify-end mt-4 pt-4 border-t border-divider/50">
<Button
color="primary"
isLoading={announcementSaving}
@@ -1086,7 +1085,7 @@ export default function ConfigPage() {
当前已选 {exportTypes.length} / {BACKUP_TYPE_VALUES.length}
</p>
<div className="flex gap-3">
<div className="flex justify-end gap-3 pt-4">
<Button
color="primary"
isLoading={exporting}
@@ -1117,7 +1116,7 @@ export default function ConfigPage() {
onChange={handleFileChange}
/>
<div className="flex gap-3">
<div className="flex justify-end gap-3 pt-4">
<Button
color="primary"
isLoading={importing}
@@ -1136,7 +1135,14 @@ export default function ConfigPage() {
</CardBody>
</Card>
<Modal isOpen={exportSelectorOpen} onOpenChange={setExportSelectorOpen}>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={exportSelectorOpen}
onOpenChange={setExportSelectorOpen}
>
<ModalContent>
{(onClose) => (
<>
@@ -1161,7 +1167,14 @@ export default function ConfigPage() {
</ModalContent>
</Modal>
<Modal isOpen={importSelectorOpen} onOpenChange={setImportSelectorOpen}>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={importSelectorOpen}
onOpenChange={setImportSelectorOpen}
>
<ModalContent>
{(onClose) => (
<>
+1 -1
View File
@@ -1110,7 +1110,7 @@ export default function DashboardPage() {
key={item.id}
className="flex justify-between items-center p-3 border border-default-200 dark:border-default-100 rounded-lg"
>
<code className="text-sm flex-1 mr-3 text-foreground">
<code className="font-mono text-sm flex-1 mr-3 text-foreground">
{item.address}
</code>
<Button
@@ -66,12 +66,12 @@ export const AnnouncementBanner = ({
</ol>
),
code: ({ children }) => (
<code className="rounded bg-blue-100/80 dark:bg-blue-900/40 px-1 py-0.5 text-[0.92em]">
<code className="font-mono rounded bg-blue-100/80 dark:bg-blue-900/40 px-1 py-0.5 text-[0.92em]">
{children}
</code>
),
pre: ({ children }) => (
<pre className="mb-2 overflow-x-auto rounded-md bg-blue-100/70 dark:bg-blue-900/40 p-2.5 text-xs leading-relaxed">
<pre className="mb-2 font-mono overflow-x-auto rounded-md bg-blue-100/70 dark:bg-blue-900/40 p-2.5 text-xs leading-relaxed">
{children}
</pre>
),
File diff suppressed because it is too large Load Diff
+37 -8
View File
@@ -477,10 +477,15 @@ export default function GroupPage() {
)}
<Card>
<CardHeader className="flex items-center justify-between">
<CardHeader className="flex flex-row items-center gap-3 pb-2">
<h3 className="text-lg font-semibold">隧道分组</h3>
<Button color="primary" size="sm" onPress={openCreateTunnelGroup}>
新建隧道分组
<Button
className="h-7 px-3 text-xs font-medium min-w-0 shadow-sm"
color="primary"
size="sm"
onPress={openCreateTunnelGroup}
>
新建
</Button>
</CardHeader>
<CardBody>
@@ -544,10 +549,15 @@ export default function GroupPage() {
</Card>
<Card>
<CardHeader className="flex items-center justify-between">
<CardHeader className="flex flex-row items-center gap-3 pb-2">
<h3 className="text-lg font-semibold">用户分组</h3>
<Button color="primary" size="sm" onPress={openCreateUserGroup}>
新建用户分组
<Button
className="h-7 px-3 text-xs font-medium min-w-0 shadow-sm"
color="primary"
size="sm"
onPress={openCreateUserGroup}
>
新建
</Button>
</CardHeader>
<CardBody>
@@ -651,7 +661,7 @@ export default function GroupPage() {
size="sm"
onPress={handleAssignPermission}
>
分配权限
分配
</Button>
</div>
@@ -692,6 +702,10 @@ export default function GroupPage() {
</Card>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={tunnelGroupModalOpen}
onOpenChange={onTunnelGroupModalChange}
>
@@ -735,7 +749,14 @@ export default function GroupPage() {
</ModalContent>
</Modal>
<Modal isOpen={userGroupModalOpen} onOpenChange={onUserGroupModalChange}>
<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 ? "编辑用户分组" : "新建用户分组"}
@@ -777,6 +798,10 @@ export default function GroupPage() {
</Modal>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={tunnelAssignModalOpen}
onOpenChange={onTunnelAssignModalChange}
>
@@ -824,6 +849,10 @@ export default function GroupPage() {
</Modal>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={userAssignModalOpen}
onOpenChange={onUserAssignModalChange}
>
+6
View File
@@ -341,6 +341,9 @@ export default function LimitPage() {
{/* 新增/编辑模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={modalOpen}
placement="center"
scrollBehavior="outside"
@@ -416,6 +419,9 @@ export default function LimitPage() {
{/* 删除确认模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={deleteModalOpen}
placement="center"
scrollBehavior="outside"
+159
View File
@@ -0,0 +1,159 @@
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 { AnimatedPage } from "@/components/animated-page";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { getMonitorNodes } from "@/api";
import { MonitorView } from "@/pages/node/monitor-view";
import { TunnelMonitorView } from "@/pages/node/tunnel-monitor-view";
type MonitorNode = {
id: number;
name: string;
connectionStatus: "online" | "offline";
};
type MonitorTab = "nodes" | "tunnels";
export default function MonitorPage() {
const [nodes, setNodes] = useState<MonitorNodeApiItem[]>([]);
const [nodesLoading, setNodesLoading] = useState(false);
const [nodesError, setNodesError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
const [activeTab, setActiveTab] = useState<MonitorTab>("nodes");
const loadNodes = useCallback(async (options?: { silent?: boolean }) => {
const silent = options?.silent ?? false;
if (!silent) setNodesLoading(true);
try {
const response = await getMonitorNodes();
if (response.code === 0 && Array.isArray(response.data)) {
setNodesError(null);
setNodes(response.data);
return;
}
if (response.code === 403) {
setNodes([]);
setNodesError(response.msg || "暂无监控权限,请联系管理员授权");
return;
}
if (!silent) toast.error(response.msg || "加载节点失败");
} catch {
if (!silent) toast.error("加载节点失败");
} finally {
if (!silent) setNodesLoading(false);
}
}, []);
useEffect(() => {
void loadNodes();
}, [loadNodes]);
useEffect(() => {
const timer = window.setInterval(() => {
void loadNodes({ silent: true });
}, 30_000);
return () => window.clearInterval(timer);
}, [loadNodes]);
const nodeMap = useMemo(() => {
const list: MonitorNode[] = nodes
.filter((n) => Number(n.id) > 0)
.map((n) => ({
id: Number(n.id),
name: String(n.name ?? ""),
connectionStatus: n.status === 1 ? "online" : "offline",
}));
return new Map<number, MonitorNode>(list.map((n) => [n.id, n]));
}, [nodes]);
return (
<AnimatedPage className="px-3 lg:px-6 py-8">
<div className="mb-6 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<h2 className="text-xl font-semibold truncate">监控</h2>
<div className="text-xs text-default-500 truncate">
实时节点状态 + 隧道质量检测 + 历史指标图表 + 服务监控(TCP/ICMP)
</div>
</div>
<div className="flex items-center gap-2">
<Button
isIconOnly
size="sm"
variant="flat"
onPress={() => setViewMode(viewMode === "list" ? "grid" : "list")}
>
{viewMode === "list" ? <LayoutGrid className="w-4 h-4" /> : <List className="w-4 h-4" />}
</Button>
{activeTab === "nodes" && (
<Button
isLoading={nodesLoading}
size="sm"
variant="flat"
onPress={() => loadNodes()}
>
<RefreshCw className="w-4 h-4 mr-1" />
刷新节点
</Button>
)}
</div>
</div>
{/* Tab Switcher */}
<div className="flex items-center gap-1 p-1 rounded-xl bg-default-100 dark:bg-default-50/10 w-fit">
<button
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${
activeTab === "nodes"
? "bg-background shadow-sm text-foreground"
: "text-default-500 hover:text-foreground"
}`}
onClick={() => setActiveTab("nodes")}
>
<Server className="w-4 h-4" />
节点
</button>
<button
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${
activeTab === "tunnels"
? "bg-background shadow-sm text-foreground"
: "text-default-500 hover:text-foreground"
}`}
onClick={() => setActiveTab("tunnels")}
>
<ArrowRightLeft className="w-4 h-4" />
隧道
</button>
</div>
{nodesError && activeTab === "nodes" ? (
<Card>
<CardHeader>
<h3 className="text-sm font-semibold">节点列表</h3>
</CardHeader>
<CardBody>
<div className="text-sm text-default-600">{nodesError}</div>
</CardBody>
</Card>
) : null}
</div>
{activeTab === "nodes" ? (
<MonitorView nodeMap={nodeMap} viewMode={viewMode} />
) : (
<TunnelMonitorView viewMode={viewMode} />
)}
</AnimatedPage>
);
}
+400 -211
View File
@@ -1,6 +1,5 @@
import { useState, useEffect, useMemo, useCallback } from "react";
import toast from "react-hot-toast";
import { parseDate } from "@internationalized/date";
import {
DndContext,
KeyboardSensor,
@@ -21,6 +20,15 @@ import { CSS } from "@dnd-kit/utilities";
import { SearchBar } from "@/components/search-bar";
import { AnimatedPage } from "@/components/animated-page";
import { LayoutGrid, List, Terminal, ArrowUpCircle, RotateCcw, Edit, Trash2 } from "lucide-react";
import {
Table,
TableHeader,
TableColumn,
TableBody,
TableRow,
TableCell,
} from "@/shadcn-bridge/heroui/table";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@/shadcn-bridge/heroui/input";
@@ -40,7 +48,6 @@ import { Progress } from "@/shadcn-bridge/heroui/progress";
import { Accordion, AccordionItem } from "@/shadcn-bridge/heroui/accordion";
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
import { DatePicker } from "@/shadcn-bridge/heroui/date-picker";
import {
createNode,
getNodeList,
@@ -69,7 +76,10 @@ import {
getNodeRenewalCycleLabel,
type NodeRenewalCycle,
} from "@/pages/node/renewal";
import { buildNodeSystemInfo } from "@/pages/node/system-info";
import {
buildNodeSystemInfo,
type NodeSystemInfo,
} from "@/pages/node/system-info";
import { useNodeOfflineTimers } from "@/pages/node/use-node-offline-timers";
import { useNodeRealtime } from "@/pages/node/use-node-realtime";
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
@@ -102,15 +112,7 @@ interface Node {
remoteUrl?: string;
syncError?: string;
connectionStatus: "online" | "offline";
systemInfo?: {
cpuUsage: number;
memoryUsage: number;
uploadTraffic: number;
downloadTraffic: number;
uploadSpeed: number;
downloadSpeed: number;
uptime: number;
} | null;
systemInfo?: NodeSystemInfo | null;
copyLoading?: boolean;
upgradeLoading?: boolean;
rollbackLoading?: boolean;
@@ -252,7 +254,7 @@ const SortableItem = ({
children,
}: {
id: number;
children: (listeners: any) => any;
children: (listeners: any, attributes?: any) => any;
}) => {
const {
attributes,
@@ -277,14 +279,8 @@ const SortableItem = ({
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
className="overflow-visible h-full"
{...listeners}
>
{children(listeners)}
<div ref={setNodeRef} className="overflow-visible h-full" style={style}>
{children(listeners, attributes)}
</div>
);
};
@@ -305,6 +301,17 @@ export default function NodePage() {
"node-active-tab",
"local",
);
const [viewMode, setViewMode] = useLocalStorageState<"list" | "grid">(
"node-view-mode",
"grid",
);
// Backward-compat: older versions stored extra tab values.
useEffect(() => {
if (activeTab !== "local" && activeTab !== "remote") {
setActiveTab("local");
}
}, [activeTab, setActiveTab]);
const [remoteUsageMap, setRemoteUsageMap] = useState<
Record<number, RemoteUsageNode>
>({});
@@ -318,6 +325,8 @@ export default function NodePage() {
const [submitLoading, setSubmitLoading] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [rollbackModalOpen, setRollbackModalOpen] = useState(false);
const [nodeToRollback, setNodeToRollback] = useState<Node | null>(null);
const [nodeToDelete, setNodeToDelete] = useState<Node | null>(null);
const [protocolDisabled, setProtocolDisabled] = useState(false);
const [protocolDisabledReason, setProtocolDisabledReason] = useState("");
@@ -330,7 +339,7 @@ export default function NodePage() {
serverHost: "",
serverIpV4: "",
serverIpV6: "",
port: "1000-65535",
port: "10000-65535",
tcpListenAddr: "[::]",
udpListenAddr: "[::]",
interfaceName: "",
@@ -380,6 +389,7 @@ export default function NodePage() {
const [upgradeProgress, setUpgradeProgress] = useState<
Record<number, { stage: string; percent: number; message: string }>
>({});
const [infoPopoverPlacement, setInfoPopoverPlacement] = useState<
Record<number, "left" | "bottom">
>({});
@@ -586,6 +596,43 @@ export default function NodePage() {
} catch {
// ignore parse errors
}
} else if (type === "metric") {
clearOfflineTimer(nodeId);
setNodeList((prev) =>
prev.map((node) => {
if (node.id !== nodeId) return node;
const metric =
typeof messageData === "string"
? JSON.parse(messageData)
: messageData;
if (!metric || typeof metric !== "object") return node;
return {
...node,
connectionStatus: "online",
systemInfo: {
cpuUsage: metric.cpuUsage ?? metric.cpu_usage ?? 0,
memoryUsage: metric.memoryUsage ?? metric.memory_usage ?? 0,
uploadTraffic:
metric.netOutBytes ?? metric.bytes_transmitted ?? 0,
downloadTraffic: metric.netInBytes ?? metric.bytes_received ?? 0,
uploadSpeed: metric.netOutSpeed ?? metric.net_out_speed ?? 0,
downloadSpeed: metric.netInSpeed ?? metric.net_in_speed ?? 0,
uptime: metric.uptime ?? 0,
diskUsage: metric.diskUsage ?? metric.disk_usage,
load1: metric.load1,
load5: metric.load5,
load15: metric.load15,
tcpConns: metric.tcpConns ?? metric.tcp_conns,
udpConns: metric.udpConns ?? metric.udp_conns,
netInSpeed: metric.netInSpeed ?? metric.net_in_speed,
netOutSpeed: metric.netOutSpeed ?? metric.net_out_speed,
},
};
}),
);
}
};
@@ -649,15 +696,7 @@ export default function NodePage() {
};
// 格式化流量
const formatTraffic = (bytes: number): string => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
const formatFlow = (bytes: number): string => {
if (!Number.isFinite(bytes) || bytes <= 0) {
@@ -685,17 +724,6 @@ export default function NodePage() {
return "未知链路";
};
// 获取进度条颜色
const getProgressColor = (
value: number,
offline = false,
): "default" | "primary" | "secondary" | "success" | "warning" | "danger" => {
if (offline) return "default";
if (value <= 50) return "success";
if (value <= 80) return "warning";
return "danger";
};
// IPv4/IPv6 格式验证(仅用于判定地址族)
const ipv4Regex =
@@ -865,7 +893,7 @@ export default function NodePage() {
serverHost: normalizedHost,
serverIpV4: normalizedV4,
serverIpV6: normalizedV6,
port: node.port || "1000-65535",
port: node.port || "10000-65535",
tcpListenAddr: node.tcpListenAddr || "[::]",
udpListenAddr: node.udpListenAddr || "[::]",
interfaceName: (node as any).interfaceName || "",
@@ -1096,7 +1124,16 @@ export default function NodePage() {
};
// 回退节点
const handleRollbackNode = async (node: Node) => {
const handleRollbackNode = (node: Node) => {
setNodeToRollback(node);
setRollbackModalOpen(true);
};
const confirmRollback = async () => {
if (!nodeToRollback) return;
const node = nodeToRollback;
setRollbackModalOpen(false);
setNodeList((prev) =>
prev.map((n) => (n.id === node.id ? { ...n, rollbackLoading: true } : n)),
);
@@ -1116,6 +1153,7 @@ export default function NodePage() {
n.id === node.id ? { ...n, rollbackLoading: false } : n,
),
);
setNodeToRollback(null);
}
};
@@ -1199,7 +1237,7 @@ export default function NodePage() {
serverHost: "",
serverIpV4: "",
serverIpV6: "",
port: "1000-65535",
port: "10000-65535",
tcpListenAddr: "[::]",
udpListenAddr: "[::]",
interfaceName: "",
@@ -1506,7 +1544,11 @@ export default function NodePage() {
onPress={() => setActiveTab("local")}
>
本地节点
<Chip className="ml-1" size="sm" variant="flat">
<Chip
className="ml-1 shrink-0 whitespace-nowrap"
size="sm"
variant="flat"
>
{localNodes.length}
</Chip>
</Button>
@@ -1518,7 +1560,11 @@ export default function NodePage() {
onPress={() => setActiveTab("remote")}
>
远程节点
<Chip className="ml-1" size="sm" variant="flat">
<Chip
className="ml-1 shrink-0 whitespace-nowrap"
size="sm"
variant="flat"
>
{remoteNodes.length}
</Chip>
</Button>
@@ -1540,7 +1586,17 @@ export default function NodePage() {
value={currentSearchKeyword}
onChange={setCurrentSearchKeyword}
onClose={() => setIsSearchVisible(false)}
onOpen={() => setIsSearchVisible(true)}
onOpen={() => {
setIsSearchVisible(true);
setTimeout(() => {
// 精准抓取带有“搜索”字样的输入框并强制聚焦
const searchInput = document.querySelector(
'input[placeholder*="搜索"]',
);
if (searchInput) (searchInput as HTMLElement).focus();
}, 150); // 150ms 刚好是 CSS 展开动画的时间
}}
/>
</div>
@@ -1596,6 +1652,21 @@ export default function NodePage() {
</>
) : (
<>
{/* 视图切换按钮 */}
<Button
isIconOnly
size="sm"
variant="flat"
className="text-default-600 hidden sm:flex"
onPress={() => setViewMode(viewMode === "list" ? "grid" : "list")}
>
{viewMode === "list" ? (
<LayoutGrid className="w-4 h-4" />
) : (
<List className="w-4 h-4" />
)}
</Button>
{/* 筛选按钮 */}
<Button
isIconOnly
@@ -1695,6 +1766,198 @@ export default function NodePage() {
: "暂无本地节点,点击上方按钮开始创建"
}
/>
) : viewMode === "list" ? (
<Card>
<Table aria-label="节点列表" className="overflow-x-auto min-w-full">
<TableHeader>
<TableColumn>节点名称</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn>协议/端口</TableColumn>
<TableColumn>系统信息</TableColumn>
<TableColumn>到期时间</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody items={displayNodes}>
{(node) => {
const isRemoteNode = node.isRemote === 1;
const remoteUsage = isRemoteNode
? remoteUsageMap[node.id]
: null;
const expiryMeta = getNodeExpiryMeta(
node.expiryTime,
node.renewalCycle,
);
const connectionStatusMeta = getConnectionStatusMeta(
node.connectionStatus,
);
const hasRemark = Boolean(node.remark?.trim());
return (
<TableRow key={node.id}>
<TableCell>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground text-sm">
{node.name}
</span>
{hasRemark && (
<Chip size="sm" variant="flat" className="h-4 px-1 text-[10px]">
{node.remark}
</Chip>
)}
</div>
<span className="text-xs text-default-500 font-mono mt-0.5">
{isRemoteNode ? new URL(node.remoteUrl || "").hostname : node.serverIp}
</span>
</div>
</TableCell>
<TableCell>
<Chip
variant="flat"
size="sm"
color={connectionStatusMeta.color}
className="text-xs"
>
<div className="flex items-center gap-1.5">
<div className={`w-1.5 h-1.5 rounded-full ${node.connectionStatus === "online" ? "bg-success" : node.syncError ? "bg-danger" : "bg-default-400"} ${node.systemInfo && node.connectionStatus === "online" ? "animate-pulse" : ""}`} />
<span>{connectionStatusMeta.text}</span>
</div>
</Chip>
</TableCell>
<TableCell>
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">
{isRemoteNode ? "Remote" : "Local"}
</span>
<span className="text-xs text-default-500 font-mono">
{isRemoteNode
? (remoteUsage ? `${remoteUsage.portRangeStart}-${remoteUsage.portRangeEnd}` : "未知端口")
: node.port}
</span>
</div>
</TableCell>
<TableCell>
<div className="flex flex-col gap-0.5 whitespace-nowrap text-[11px] min-w-[100px]">
{!isRemoteNode && node.systemInfo ? (
<>
<div className="flex items-center justify-between gap-2">
<span className="text-default-500">CPU</span>
<span className={node.systemInfo.cpuUsage > 80 ? "text-danger font-medium" : ""}>
{node.systemInfo.cpuUsage.toFixed(1)}%
</span>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-default-500">RAM</span>
<span className={node.systemInfo.memoryUsage > 80 ? "text-danger font-medium" : ""}>
{node.systemInfo.memoryUsage.toFixed(1)}%
</span>
</div>
</>
) : (
<span className="text-default-400">-</span>
)}
</div>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1 items-start whitespace-nowrap min-w-[100px]">
{node.expiryTime ? (
<>
<span className="text-sm">
{new Date(node.expiryTime).toLocaleString()}
</span>
{expiryMeta && expiryMeta.state !== "healthy" && expiryMeta.state !== "permanent" && (
<Chip
color={expiryMeta.tone}
size="sm"
variant="flat"
className="h-5 px-1 text-[10px]"
>
{expiryMeta.label}
</Chip>
)}
</>
) : (
<span className="text-default-400 text-sm">无限期</span>
)}
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-1.5">
{!isRemoteNode && (
<Button
size="sm"
variant="flat"
color="success"
className="min-w-8 min-h-8 px-1"
title="安装命令"
isLoading={node.copyLoading}
onPress={() => openInstallSelector(node)}
>
<Terminal className="w-4 h-4" />
</Button>
)}
{!isRemoteNode && (
<Button
size="sm"
variant="flat"
color="warning"
className="min-w-8 min-h-8 px-1"
title="升级"
isDisabled={node.connectionStatus !== "online"}
isLoading={node.upgradeLoading}
onPress={() => {
setUpgradeTargetNodeId(node.id);
openUpgradeModal("single");
}}
>
<ArrowUpCircle className="w-4 h-4" />
</Button>
)}
{!isRemoteNode && (
<Button
size="sm"
variant="flat"
color="secondary"
className="min-w-8 min-h-8 px-1"
title="回退"
isDisabled={node.connectionStatus !== "online"}
isLoading={node.rollbackLoading}
onPress={() => handleRollbackNode(node)}
>
<RotateCcw className="w-4 h-4" />
</Button>
)}
{!isRemoteNode && (
<Button
size="sm"
variant="flat"
color="primary"
className="min-w-8 min-h-8 px-1"
title="编辑"
onPress={() => handleEdit(node)}
>
<Edit className="w-4 h-4" />
</Button>
)}
<Button
size="sm"
variant="flat"
color="danger"
className="min-w-8 min-h-8 px-1"
title="删除"
onPress={() => handleDelete(node)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
);
}}
</TableBody>
</Table>
</Card>
) : (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<SortableContext
@@ -1827,9 +2090,7 @@ export default function NodePage() {
type="button"
onClick={(e) => {
e.stopPropagation();
handleDismissExpiryReminder(
node.id,
);
handleDismissExpiryReminder(node.id);
}}
>
关闭提醒
@@ -2030,7 +2291,7 @@ export default function NodePage() {
{remoteUsage.usedPorts.map((port) => (
<Chip
key={`${node.id}-port-${port}`}
className="font-mono"
className="font-mono shrink-0 whitespace-nowrap"
size="sm"
variant="flat"
>
@@ -2102,130 +2363,7 @@ export default function NodePage() {
</div>
)}
{!isRemoteNode && (
<>
{/* 系统监控 */}
<div className="space-y-3 mb-4">
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex justify-between text-xs mb-1">
<span>CPU</span>
<span className="font-mono">
{node.connectionStatus === "online" &&
node.systemInfo
? `${node.systemInfo.cpuUsage.toFixed(1)}%`
: "-"}
</span>
</div>
<Progress
aria-label="CPU使用率"
color={getProgressColor(
node.connectionStatus === "online" &&
node.systemInfo
? node.systemInfo.cpuUsage
: 0,
node.connectionStatus !== "online",
)}
size="sm"
value={
node.connectionStatus === "online" &&
node.systemInfo
? node.systemInfo.cpuUsage
: 0
}
/>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span>内存</span>
<span className="font-mono">
{node.connectionStatus === "online" &&
node.systemInfo
? `${node.systemInfo.memoryUsage.toFixed(1)}%`
: "-"}
</span>
</div>
<Progress
aria-label="内存使用率"
color={getProgressColor(
node.connectionStatus === "online" &&
node.systemInfo
? node.systemInfo.memoryUsage
: 0,
node.connectionStatus !== "online",
)}
size="sm"
value={
node.connectionStatus === "online" &&
node.systemInfo
? node.systemInfo.memoryUsage
: 0
}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
<div className="text-default-600 mb-0.5">
上传
</div>
<div className="font-mono">
{node.connectionStatus === "online" &&
node.systemInfo
? formatSpeed(
node.systemInfo.uploadSpeed,
)
: "-"}
</div>
</div>
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
<div className="text-default-600 mb-0.5">
下载
</div>
<div className="font-mono">
{node.connectionStatus === "online" &&
node.systemInfo
? formatSpeed(
node.systemInfo.downloadSpeed,
)
: "-"}
</div>
</div>
</div>
{/* 流量统计 */}
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="text-center p-2 bg-primary-50 dark:bg-primary-100/20 rounded border border-primary-200 dark:border-primary-300/20">
<div className="text-primary-600 dark:text-primary-400 mb-0.5">
↑ 上行流量
</div>
<div className="font-mono text-primary-700 dark:text-primary-300">
{node.connectionStatus === "online" &&
node.systemInfo
? formatTraffic(
node.systemInfo.uploadTraffic,
)
: "-"}
</div>
</div>
<div className="text-center p-2 bg-success-50 dark:bg-success-100/20 rounded border border-success-200 dark:border-success-300/20">
<div className="text-success-600 dark:text-success-400 mb-0.5">
↓ 下行流量
</div>
<div className="font-mono text-success-700 dark:text-success-300">
{node.connectionStatus === "online" &&
node.systemInfo
? formatTraffic(
node.systemInfo.downloadTraffic,
)
: "-"}
</div>
</div>
</div>
</div>
</>
)}
<div className="mt-auto space-y-3">
{/* 操作按钮 */}
@@ -2308,10 +2446,12 @@ export default function NodePage() {
</SortableContext>
</DndContext>
)}
{/* 新增/编辑节点对话框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={dialogVisible}
placement="center"
scrollBehavior="outside"
@@ -2376,37 +2516,27 @@ export default function NodePage() {
</Select>
</div>
<DatePicker
showMonthAndYearPickers
<Input
description="填写最近一次续费时间或周期起始时间,系统会按月/季/年自动推算下次续费"
errorMessage={errors.expiryTime}
isInvalid={!!errors.expiryTime}
label="续费基准时间"
max="9999-12-31"
type="date"
value={
form.expiryTime > 0
? (parseDate(
new Date(form.expiryTime).toISOString().split("T")[0],
) as any)
: null
? new Date(form.expiryTime).toISOString().slice(0, 10)
: ""
}
variant="bordered"
onChange={(e) =>
setForm((prev) => ({
...prev,
expiryTime: e.target.value
? new Date(e.target.value).getTime()
: 0,
}))
}
onChange={(date) => {
if (date) {
const jsDate = new Date(
date.year,
date.month - 1,
date.day,
);
setForm((prev) => ({
...prev,
expiryTime: jsDate.getTime(),
}));
} else {
setForm((prev) => ({
...prev,
expiryTime: 0,
}));
}
}}
/>
<Alert
@@ -2416,11 +2546,11 @@ export default function NodePage() {
/>
<Input
description="可选:不带协议、不带端口。建议在 IPv4 和 IPv6 都未填写时使用。至少填写一个 IPv4/IPv6/域名"
description="可选:不带协议、不带端口。建议在 IPv4 和 IPv6 都未填写时使用。至少填写一个 IPv4/IPv6 域名"
errorMessage={errors.serverHost}
isInvalid={!!errors.serverHost}
label="服务器域名/主机名"
placeholder="例如: node.example.com"
label="域名/主机名"
placeholder="例如: test.example.com"
value={form.serverHost}
variant="bordered"
onChange={(e) =>
@@ -2430,11 +2560,11 @@ export default function NodePage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
description="双栈节点组隧道时优先使用 IPv4"
description="填写一个 IPv4 地址"
errorMessage={errors.serverIpV4}
isInvalid={!!errors.serverIpV4}
label="服务器IPv4"
placeholder="例如: 203.0.113.10"
label="IPv4 地址"
placeholder="例如: 192.168.1.100"
value={form.serverIpV4}
variant="bordered"
onChange={(e) =>
@@ -2443,10 +2573,10 @@ export default function NodePage() {
/>
<Input
description="至少填写一个 IPv4/IPv6/域名"
description="填写一个 IPv6 地址"
errorMessage={errors.serverIpV6}
isInvalid={!!errors.serverIpV6}
label="服务器IPv6"
label="IPv6 地址"
placeholder="例如: 2001:db8::10"
value={form.serverIpV6}
variant="bordered"
@@ -2460,11 +2590,11 @@ export default function NodePage() {
classNames={{
input: "font-mono",
}}
description="支持单个端口(80)、多个端口(80,443)或端口范围(1000-65535),多个可用逗号分隔"
description="支持单个端口(80)、多个端口(80,443)或端口范围(10000-65535),多个可用逗号分隔"
errorMessage={errors.port}
isInvalid={!!errors.port}
label="可用端口"
placeholder="例如: 80,443,1000-65535"
placeholder="例如: 80,443,10000-65535"
value={form.port}
variant="bordered"
onChange={(e) =>
@@ -2717,7 +2847,7 @@ export default function NodePage() {
<Alert
className="mt-4"
color="primary"
description="服务器ip是你要添加的服务器的ip地址,不是面板的ip地址。"
description="节点ip地址是你要添加的入口/出口的ip地址,不是面板的ip地址。"
variant="flat"
/>
</div>
@@ -2737,9 +2867,53 @@ export default function NodePage() {
</ModalContent>
</Modal>
{/* 回退确认模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={rollbackModalOpen}
placement="center"
scrollBehavior="outside"
size="2xl"
onOpenChange={setRollbackModalOpen}
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<h2 className="text-xl font-bold">确认回退</h2>
</ModalHeader>
<ModalBody>
<p>
确定要将节点{" "}
<strong>&quot;{nodeToRollback?.name}&quot;</strong>{" "}
回退到上一个版本吗?
</p>
<p className="text-small text-default-500">
节点将执行版本回退并自动重启,期间会导致节点短暂离线。
</p>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
取消
</Button>
<Button color="secondary" onPress={confirmRollback}>
确认回退
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
{/* 删除确认模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={deleteModalOpen}
placement="center"
scrollBehavior="outside"
@@ -2780,6 +2954,9 @@ export default function NodePage() {
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={installSelectorOpen}
placement="center"
size="md"
@@ -2830,6 +3007,9 @@ export default function NodePage() {
{/* 安装命令模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={installCommandModal}
placement="center"
scrollBehavior="outside"
@@ -2884,6 +3064,9 @@ export default function NodePage() {
{/* 版本选择升级模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={upgradeModalOpen}
placement="center"
scrollBehavior="outside"
@@ -2946,7 +3129,7 @@ export default function NodePage() {
: ""}
{r.channel === "dev" && (
<Chip
className="ml-1"
className="ml-1 shrink-0 whitespace-nowrap"
color="warning"
size="sm"
variant="flat"
@@ -2987,6 +3170,9 @@ export default function NodePage() {
{/* 批量删除确认模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={batchDeleteModalOpen}
placement="center"
scrollBehavior="outside"
@@ -3026,6 +3212,9 @@ export default function NodePage() {
</Modal>
<Modal
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={isFilterModalOpen}
placement="center"
size="md"
File diff suppressed because it is too large Load Diff
@@ -6,6 +6,14 @@ export interface NodeSystemInfo {
uploadSpeed: number;
downloadSpeed: number;
uptime: number;
diskUsage?: number;
load1?: number;
load5?: number;
load15?: number;
tcpConns?: number;
udpConns?: number;
netInSpeed?: number;
netOutSpeed?: number;
}
type RawSystemInfo = Record<string, string | number | undefined>;
@@ -82,5 +90,13 @@ export const buildNodeSystemInfo = (
uploadSpeed,
downloadSpeed,
uptime,
diskUsage: toFloat(raw.disk_usage),
load1: toFloat(raw.load1),
load5: toFloat(raw.load5),
load15: toFloat(raw.load15),
tcpConns: toInteger(raw.tcp_conns),
udpConns: toInteger(raw.udp_conns),
netInSpeed: toInteger(raw.net_in_speed),
netOutSpeed: toInteger(raw.net_out_speed),
};
};
@@ -0,0 +1,829 @@
import type {
MonitorTunnelApiItem,
TunnelMetricApiItem,
TunnelDiagnosisApiItem,
} from "@/api/types";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import {
RefreshCw,
ArrowLeft,
Activity,
Zap,
Globe,
ArrowRightLeft,
Wifi,
WifiOff,
Stethoscope,
} from "lucide-react";
import toast from "react-hot-toast";
import {
getMonitorTunnels,
getTunnelMetrics,
diagnoseTunnel,
} from "@/api";
import { diagnoseTunnelStream } from "@/api/diagnosis-stream";
import { getDiagnosisQualityDisplay } from "@/pages/tunnel/diagnosis";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Chip } from "@/shadcn-bridge/heroui/chip";
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import {
Table,
TableHeader,
TableColumn,
TableBody,
TableRow,
TableCell,
} from "@/shadcn-bridge/heroui/table";
interface TunnelMonitorViewProps {
viewMode?: "list" | "grid";
}
const METRICS_MAX_ROWS = 5000;
interface TunnelQuality {
loading: boolean;
entryToExitLatency?: number;
exitToBingLatency?: number;
entryToExitLoss?: number;
exitToBingLoss?: number;
results?: TunnelDiagnosisApiItem[];
timestamp?: number;
error?: string;
}
const formatTimestamp = (ts: number, rangeMs?: number): string => {
const date = new Date(ts);
const includeDate = (rangeMs ?? 0) >= 24 * 60 * 60 * 1000;
if (includeDate) {
return date.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
return date.toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
});
};
const formatBytes = (bytes: number): string => {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
};
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);
// Detail view state
const [detailTunnelId, setDetailTunnelId] = useState<number | null>(null);
const [tunnelMetrics, setTunnelMetrics] = useState<TunnelMetricApiItem[]>([]);
const [tunnelMetricsLoading, setTunnelMetricsLoading] = useState(false);
const [tunnelMetricsError, setTunnelMetricsError] = useState<string | null>(null);
const [, setTunnelMetricsTruncated] = useState(false);
const [tunnelRangeMs, setTunnelRangeMs] = useState(60 * 60 * 1000);
// Tunnel quality (diagnosis) state
const [tunnelQualities, setTunnelQualities] = useState<Record<number, TunnelQuality>>({});
const diagnosisAbortRef = useRef<Record<number, AbortController>>({});
// Cleanup abort controllers on unmount
useEffect(() => {
return () => {
Object.values(diagnosisAbortRef.current).forEach((c) => c.abort());
diagnosisAbortRef.current = {};
};
}, []);
const loadTunnels = useCallback(async (options?: { silent?: boolean }) => {
const silent = options?.silent ?? false;
if (!silent) setTunnelsLoading(true);
try {
const response = await getMonitorTunnels();
if (response.code === 0 && response.data) {
setAccessDenied(null);
setTunnelsError(null);
setTunnels(response.data);
return;
}
if (response.code === 403) {
setAccessDenied(response.msg || "暂无监控权限,请联系管理员授权");
setTunnelsError(null);
setTunnels([]);
return;
}
setTunnelsError(response.msg || "加载隧道列表失败");
if (!silent) toast.error(response.msg || "加载隧道列表失败");
} catch {
if (!silent) {
setTunnelsError("加载隧道列表失败");
toast.error("加载隧道列表失败");
}
} finally {
if (!silent) setTunnelsLoading(false);
}
}, []);
useEffect(() => {
void loadTunnels();
}, [loadTunnels]);
useEffect(() => {
const timer = window.setInterval(() => {
void loadTunnels({ silent: true });
}, 60_000);
return () => window.clearInterval(timer);
}, [loadTunnels]);
// Load tunnel metrics for detail view
const loadTunnelMetrics = useCallback(
async (tunnelId: number, options?: { silent?: boolean }) => {
const silent = options?.silent ?? false;
if (!silent) setTunnelMetricsLoading(true);
try {
const end = Date.now();
const start = end - tunnelRangeMs;
const response = await getTunnelMetrics(tunnelId, start, end);
if (response.code === 0 && Array.isArray(response.data)) {
setAccessDenied(null);
setTunnelMetricsError(null);
setTunnelMetricsTruncated(response.data.length >= METRICS_MAX_ROWS);
const ordered = [...response.data].sort(
(a, b) => a.timestamp - b.timestamp,
);
setTunnelMetrics(ordered);
return;
}
if (response.code === 403) {
setAccessDenied(response.msg || "暂无监控权限,请联系管理员授权");
setTunnelMetricsTruncated(false);
setTunnelMetricsError(null);
return;
}
setTunnelMetricsTruncated(false);
setTunnelMetricsError(response.msg || "加载隧道指标失败");
if (!silent) toast.error(response.msg || "加载隧道指标失败");
} catch {
setTunnelMetricsTruncated(false);
if (!silent) setTunnelMetricsError("加载隧道指标失败");
} finally {
if (!silent) setTunnelMetricsLoading(false);
}
},
[tunnelRangeMs],
);
useEffect(() => {
if (detailTunnelId) {
void loadTunnelMetrics(detailTunnelId);
}
}, [detailTunnelId, loadTunnelMetrics]);
useEffect(() => {
if (!detailTunnelId) return;
const timer = window.setInterval(() => {
void loadTunnelMetrics(detailTunnelId, { silent: true });
}, 30_000);
return () => window.clearInterval(timer);
}, [detailTunnelId, loadTunnelMetrics]);
// Diagnose tunnel quality
const diagnoseTunnelQuality = useCallback(async (tunnelId: number) => {
// Abort if already running
if (diagnosisAbortRef.current[tunnelId]) {
diagnosisAbortRef.current[tunnelId].abort();
}
const abortController = new AbortController();
diagnosisAbortRef.current[tunnelId] = abortController;
setTunnelQualities((prev) => ({
...prev,
[tunnelId]: { loading: true },
}));
try {
// Try stream first
const results: TunnelDiagnosisApiItem[] = [];
const streamResult = await diagnoseTunnelStream(
tunnelId,
{
onItem: (payload) => {
results.push(payload.result);
},
onError: (msg) => {
setTunnelQualities((prev) => ({
...prev,
[tunnelId]: { loading: false, error: msg },
}));
},
},
abortController.signal,
);
if (streamResult.fallback) {
// Fallback to non-stream API
try {
const response = await diagnoseTunnel(tunnelId);
if (response.code === 0 && response.data?.results) {
const apiResults = response.data.results;
const quality = extractQualityFromResults(apiResults);
setTunnelQualities((prev) => ({
...prev,
[tunnelId]: {
loading: false,
...quality,
results: apiResults,
timestamp: Date.now(),
},
}));
} else {
setTunnelQualities((prev) => ({
...prev,
[tunnelId]: { loading: false, error: response.msg || "诊断失败" },
}));
}
} catch {
setTunnelQualities((prev) => ({
...prev,
[tunnelId]: { loading: false, error: "诊断请求失败" },
}));
}
return;
}
// Process stream results
if (results.length > 0) {
const quality = extractQualityFromResults(results);
setTunnelQualities((prev) => ({
...prev,
[tunnelId]: {
loading: false,
...quality,
results,
timestamp: Date.now(),
},
}));
} else {
setTunnelQualities((prev) => ({
...prev,
[tunnelId]: { loading: false, error: "未获取到诊断结果" },
}));
}
} catch {
if (!abortController.signal.aborted) {
setTunnelQualities((prev) => ({
...prev,
[tunnelId]: { loading: false, error: "诊断失败" },
}));
}
} finally {
delete diagnosisAbortRef.current[tunnelId];
}
}, []);
const extractQualityFromResults = (
results: TunnelDiagnosisApiItem[],
): Pick<TunnelQuality, "entryToExitLatency" | "exitToBingLatency" | "entryToExitLoss" | "exitToBingLoss"> => {
// The diagnosis results contain hop-by-hop tests
// We look for entry→exit (hop between entry and exit nodes)
// and exit→Bing (the last hop to external target like bing.com)
let entryToExitLatency: number | undefined;
let exitToBingLatency: number | undefined;
let entryToExitLoss: number | undefined;
let exitToBingLoss: number | undefined;
for (const r of results) {
if (!r.success) continue;
// Entry to Exit: chainType transitions from 1 (entry) to 3 (exit)
if (r.fromChainType === 1 && r.toChainType === 3) {
entryToExitLatency = r.averageTime;
entryToExitLoss = r.packetLoss;
}
// Or if it's a mid-chain to exit
if (r.fromChainType === 2 && r.toChainType === 3) {
// Use this if no direct entry→exit
if (entryToExitLatency === undefined) {
entryToExitLatency = r.averageTime;
entryToExitLoss = r.packetLoss;
}
}
// Exit to external target (Bing / external)
if (r.toChainType === undefined || r.toChainType === 0) {
// This typically means it's the exit node testing external
if (r.fromChainType === 3) {
exitToBingLatency = r.averageTime;
exitToBingLoss = r.packetLoss;
}
}
}
// If no chainType-based matching, use position-based heuristics
if (entryToExitLatency === undefined && exitToBingLatency === undefined) {
const successResults = results.filter((r) => r.success);
if (successResults.length >= 2) {
entryToExitLatency = successResults[0].averageTime;
entryToExitLoss = successResults[0].packetLoss;
exitToBingLatency = successResults[successResults.length - 1].averageTime;
exitToBingLoss = successResults[successResults.length - 1].packetLoss;
} else if (successResults.length === 1) {
entryToExitLatency = successResults[0].averageTime;
entryToExitLoss = successResults[0].packetLoss;
}
}
return { entryToExitLatency, exitToBingLatency, entryToExitLoss, exitToBingLoss };
};
// Chart data
const tunnelChartData = tunnelMetrics.map((m) => ({
time: formatTimestamp(m.timestamp, tunnelRangeMs),
bytesIn: m.bytesIn,
bytesOut: m.bytesOut,
connections: m.connections,
}));
const tunnelYAxisTickFormatter = (value: unknown) => {
const n = Number(value);
if (!Number.isFinite(n)) return "";
return formatBytes(n);
};
const tunnelTooltipFormatter = (value: unknown) => {
const n = Number(value);
if (!Number.isFinite(n)) return "-";
return formatBytes(n);
};
const detailTunnel = detailTunnelId != null
? tunnels.find((t) => t.id === detailTunnelId)
: null;
// Aggregate stats
const tunnelStats = useMemo(() => {
const enabled = tunnels.filter((t) => t.status === 1).length;
const disabled = tunnels.length - enabled;
const diagnosed = Object.keys(tunnelQualities).filter((k) => {
const q = tunnelQualities[Number(k)];
return q && !q.loading && !q.error;
}).length;
return { total: tunnels.length, enabled, disabled, diagnosed };
}, [tunnels, tunnelQualities]);
// =====================
// RENDER
// =====================
if (accessDenied) {
return (
<Card>
<CardHeader className="flex flex-row items-center gap-2">
<Activity className="w-5 h-5 text-warning" />
<h3 className="text-lg font-semibold">监控权限</h3>
</CardHeader>
<CardBody>
<div className="text-sm text-default-600">{accessDenied}</div>
<div className="text-xs text-default-500 mt-2">
如需使用监控功能,请联系管理员在用户页面授予监控权限。
</div>
</CardBody>
</Card>
);
}
// ===== DETAIL VIEW =====
if (detailTunnelId && detailTunnel) {
const quality = tunnelQualities[detailTunnelId];
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3 flex-wrap">
<Button size="sm" variant="flat" onPress={() => {
setDetailTunnelId(null);
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"}`} />
<h3 className="text-lg font-semibold">{detailTunnel.name}</h3>
<Chip size="sm" color={detailTunnel.status === 1 ? "success" : "danger"} variant="flat">
{detailTunnel.status === 1 ? "启用" : "禁用"}
</Chip>
</div>
</div>
{/* Quality KPI Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<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?.entryToExitLatency !== undefined ? "text-primary" : ""}`}>
{quality?.loading ? "检测中..." : quality?.entryToExitLatency !== undefined ? `${quality.entryToExitLatency.toFixed(0)}ms` : "-"}
</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?.exitToBingLatency !== undefined ? "text-success" : ""}`}>
{quality?.loading ? "检测中..." : quality?.exitToBingLatency !== undefined ? `${quality.exitToBingLatency.toFixed(0)}ms` : "-"}
</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">入口 → 出口 丢包</span>
<span className={`text-sm font-semibold font-mono ${(quality?.entryToExitLoss ?? 0) > 0 ? "text-warning" : ""}`}>
{quality?.loading ? "检测中..." : 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?.loading ? "检测中..." : quality?.exitToBingLoss !== undefined ? `${quality.exitToBingLoss.toFixed(1)}%` : "-"}
</span>
</CardBody>
</Card>
</div>
{/* Diagnose Button */}
<div className="flex items-center gap-2">
<Button
size="sm"
color="primary"
variant="flat"
isLoading={quality?.loading}
onPress={() => diagnoseTunnelQuality(detailTunnelId)}
>
<Stethoscope className="w-4 h-4 mr-1" />
{quality?.timestamp ? "重新检测质量" : "检测隧道质量"}
</Button>
{quality?.timestamp && (
<span className="text-xs text-default-500">
上次检测: {new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
</span>
)}
{quality?.error && (
<span className="text-xs text-danger">{quality.error}</span>
)}
</div>
{/* Diagnosis Details */}
{quality?.results && quality.results.length > 0 && (
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">诊断详情</h3>
</CardHeader>
<CardBody>
<Table aria-label="诊断结果">
<TableHeader>
<TableColumn>描述</TableColumn>
<TableColumn>节点</TableColumn>
<TableColumn>目标</TableColumn>
<TableColumn>延迟</TableColumn>
<TableColumn>丢包</TableColumn>
<TableColumn>状态</TableColumn>
</TableHeader>
<TableBody>
{quality.results.map((r, idx) => (
<TableRow key={idx}>
<TableCell>
<span className="text-sm">{r.description || "-"}</span>
</TableCell>
<TableCell>
<span className="text-sm font-mono">{r.nodeName || "-"}</span>
</TableCell>
<TableCell>
<span className="text-sm font-mono">
{r.targetIp || "-"}{r.targetPort ? `:${r.targetPort}` : ""}
</span>
</TableCell>
<TableCell>
<span className="text-sm font-mono">
{r.averageTime !== undefined ? `${r.averageTime.toFixed(0)}ms` : "-"}
</span>
</TableCell>
<TableCell>
<span className="text-sm font-mono">
{r.packetLoss !== undefined ? `${r.packetLoss.toFixed(1)}%` : "-"}
</span>
</TableCell>
<TableCell>
<Chip size="sm" color={r.success ? "success" : "danger"} variant="flat">
{r.success ? "成功" : "失败"}
</Chip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardBody>
</Card>
)}
{/* Tunnel traffic chart */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<h3 className="text-lg font-semibold">隧道流量趋势</h3>
<div className="flex items-center gap-2">
<Select
className="w-36"
selectedKeys={[String(tunnelRangeMs)]}
onSelectionChange={(keys) => {
const v = Number(Array.from(keys)[0]);
if (v > 0) setTunnelRangeMs(v);
}}
>
<SelectItem key={String(15 * 60 * 1000)}>15分钟</SelectItem>
<SelectItem key={String(60 * 60 * 1000)}>1小时</SelectItem>
<SelectItem key={String(6 * 60 * 60 * 1000)}>6小时</SelectItem>
<SelectItem key={String(24 * 60 * 60 * 1000)}>24小时</SelectItem>
</Select>
<Button
isLoading={tunnelMetricsLoading}
size="sm"
variant="flat"
onPress={() => detailTunnelId && loadTunnelMetrics(detailTunnelId)}
>
<RefreshCw className="w-4 h-4 mr-1" />
刷新
</Button>
</div>
</CardHeader>
<CardBody>
{tunnelMetricsLoading ? (
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
) : tunnelMetricsError ? (
<div className="text-center py-8 text-danger text-sm">{tunnelMetricsError}</div>
) : tunnelMetrics.length > 0 ? (
<div className="h-64">
<ResponsiveContainer height="100%" width="100%">
<LineChart data={tunnelChartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" fontSize={12} />
<YAxis fontSize={12} tickFormatter={tunnelYAxisTickFormatter} />
<Tooltip
contentStyle={{ backgroundColor: "rgba(0,0,0,0.8)", border: "none", borderRadius: "8px" }}
labelStyle={{ color: "#fff" }}
formatter={tunnelTooltipFormatter}
/>
<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>
) : (
<div className="text-center py-8 text-default-500">暂无指标数据</div>
)}
</CardBody>
</Card>
</div>
);
}
// ===== LIST/GRID VIEW =====
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>
{tunnelStats.diagnosed > 0 && (
<Chip color="success" size="sm" variant="flat">已诊断 {tunnelStats.diagnosed}</Chip>
)}
<div className="ml-auto">
<Button isLoading={tunnelsLoading} size="sm" variant="flat" onPress={() => loadTunnels()}>
<RefreshCw className="w-4 h-4 mr-1" />
刷新
</Button>
</div>
</div>
{tunnelsError ? (
<Card>
<CardBody>
<div className="text-sm text-default-600">{tunnelsError}</div>
</CardBody>
</Card>
) : null}
{viewMode === "grid" ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{tunnels.map((tunnel) => {
const quality = tunnelQualities[tunnel.id];
const isEnabled = tunnel.status === 1;
const overallQuality = quality?.entryToExitLatency !== undefined
? getDiagnosisQualityDisplay(quality.entryToExitLatency, quality.entryToExitLoss ?? 0)
: null;
return (
<Card
key={tunnel.id}
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)}
>
{/* Top gradient bar */}
<div className={`absolute top-0 left-0 right-0 h-1 ${isEnabled ? "bg-success" : "bg-danger"}`} />
{/* Decorative background glow */}
<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"}`} />
</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"}`} />
</div>
<div className="flex flex-col min-w-0">
<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>
</div>
</div>
</div>
{overallQuality && (
<Chip size="sm" color={overallQuality.color} variant="flat">
{overallQuality.text}
</Chip>
)}
</CardHeader>
<CardBody className="py-3 px-5 flex-1 flex flex-col justify-end gap-3 z-10 w-full overflow-hidden">
{/* Quality metrics */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<div className="text-[10px] text-default-500 flex items-center gap-1">
<Zap className="w-3 h-3" />
入口→出口
</div>
<span className="font-mono text-xs font-semibold">
{quality?.loading ? (
<RefreshCw className="w-3 h-3 animate-spin inline" />
) : quality?.entryToExitLatency !== undefined ? (
`${quality.entryToExitLatency.toFixed(0)}ms`
) : "-"}
</span>
</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>
<span className="font-mono text-xs font-semibold">
{quality?.loading ? (
<RefreshCw className="w-3 h-3 animate-spin inline" />
) : quality?.exitToBingLatency !== undefined ? (
`${quality.exitToBingLatency.toFixed(0)}ms`
) : "-"}
</span>
</div>
</div>
{/* Action area */}
<div className="flex justify-between items-center pt-2 border-t border-divider/50">
{quality?.error ? (
<span className="text-[11px] text-danger truncate">{quality.error}</span>
) : quality?.timestamp ? (
<span className="text-[11px] text-default-500">
{new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
</span>
) : (
<span className="text-[11px] text-default-400">未检测</span>
)}
<Button
isIconOnly
size="sm"
variant="light"
isLoading={quality?.loading}
onPress={() => {
diagnoseTunnelQuality(tunnel.id);
}}
onClick={(e) => e.stopPropagation()}
>
<Stethoscope className="w-4 h-4 text-default-500" />
</Button>
</div>
</CardBody>
</Card>
);
})}
</div>
) : (
<Card className="w-full">
<Table aria-label="隧道列表">
<TableHeader>
<TableColumn>状态</TableColumn>
<TableColumn>名称</TableColumn>
<TableColumn>入口→出口</TableColumn>
<TableColumn>出口→Bing</TableColumn>
<TableColumn>质量</TableColumn>
<TableColumn align="center">操作</TableColumn>
</TableHeader>
<TableBody emptyContent="暂无隧道">
{tunnels.map((tunnel) => {
const quality = tunnelQualities[tunnel.id];
const isEnabled = tunnel.status === 1;
const overallQuality = quality?.entryToExitLatency !== undefined
? getDiagnosisQualityDisplay(quality.entryToExitLatency, quality.entryToExitLoss ?? 0)
: null;
return (
<TableRow key={tunnel.id} className="border-b border-divider/50 last:border-b-0 cursor-pointer" onClick={() => setDetailTunnelId(tunnel.id)}>
<TableCell>
<div className="flex items-center gap-1.5">
{isEnabled ? (
<Wifi className="w-3.5 h-3.5 text-success" />
) : (
<WifiOff className="w-3.5 h-3.5 text-danger" />
)}
</div>
</TableCell>
<TableCell>
<span className="font-semibold text-sm whitespace-nowrap">{tunnel.name}</span>
</TableCell>
<TableCell>
<span className="font-mono text-xs whitespace-nowrap">
{quality?.loading ? (
<RefreshCw className="w-3 h-3 animate-spin inline" />
) : quality?.entryToExitLatency !== undefined ? (
`${quality.entryToExitLatency.toFixed(0)}ms`
) : "-"}
</span>
</TableCell>
<TableCell>
<span className="font-mono text-xs whitespace-nowrap">
{quality?.loading ? (
<RefreshCw className="w-3 h-3 animate-spin inline" />
) : quality?.exitToBingLatency !== undefined ? (
`${quality.exitToBingLatency.toFixed(0)}ms`
) : "-"}
</span>
</TableCell>
<TableCell>
{overallQuality ? (
<Chip size="sm" color={overallQuality.color} variant="flat">
{overallQuality.text}
</Chip>
) : (
<span className="text-xs text-default-400">-</span>
)}
</TableCell>
<TableCell>
<div className="flex justify-center gap-1">
<Button
isIconOnly
size="sm"
variant="light"
isLoading={quality?.loading}
onPress={() => diagnoseTunnelQuality(tunnel.id)}
onClick={(e) => e.stopPropagation()}
>
<Stethoscope className="w-4 h-4 text-default-500" />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Card>
)}
</div>
);
}
+16 -1
View File
@@ -678,6 +678,10 @@ export default function PanelSharingPage() {
{/* Create Share Modal */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={createShareOpen}
scrollBehavior="inside"
onClose={() => setCreateShareOpen(false)}
@@ -785,6 +789,10 @@ export default function PanelSharingPage() {
{/* Edit Share Modal */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={editShareOpen}
scrollBehavior="inside"
onClose={() => setEditShareOpen(false)}
@@ -883,7 +891,14 @@ export default function PanelSharingPage() {
</Modal>
{/* Import Node Modal */}
<Modal isOpen={importNodeOpen} onClose={() => setImportNodeOpen(false)}>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={importNodeOpen}
onClose={() => setImportNodeOpen(false)}
>
<ModalContent>
<ModalHeader>导入远程节点</ModalHeader>
<ModalBody>
+3
View File
@@ -327,6 +327,9 @@ export default function ProfilePage() {
{/* 修改密码弹窗 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={isOpen}
placement="center"
scrollBehavior="outside"
+665 -50
View File
@@ -1,4 +1,8 @@
import type { BatchOperationFailure } from "@/api/types";
import type {
BatchOperationFailure,
TunnelBatchDeletePreviewApiData,
TunnelDeletePreviewApiData,
} from "@/api/types";
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
import toast from "react-hot-toast";
@@ -40,16 +44,19 @@ import { Divider } from "@/shadcn-bridge/heroui/divider";
import { Alert } from "@/shadcn-bridge/heroui/alert";
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
import { Progress } from "@/shadcn-bridge/heroui/progress";
import { Radio, RadioGroup } from "@/shadcn-bridge/heroui/radio";
import {
createTunnel,
batchDeleteTunnelsWithForwards,
getTunnelList,
updateTunnel,
deleteTunnel,
deleteTunnelWithForwards,
getNodeList,
diagnoseTunnel,
updateTunnelOrder,
batchDeleteTunnels,
batchRedeployTunnels,
previewBatchTunnelDelete,
previewTunnelDelete,
} from "@/api";
import { PageLoadingState } from "@/components/page-state";
import {
@@ -136,6 +143,8 @@ interface BatchResultModalState {
title: string;
}
type TunnelDeleteAction = "replace" | "delete_forwards";
const EMPTY_BATCH_RESULT_MODAL_STATE: BatchResultModalState = {
failures: [],
open: false,
@@ -143,6 +152,8 @@ const EMPTY_BATCH_RESULT_MODAL_STATE: BatchResultModalState = {
title: "",
};
const DEFAULT_TUNNEL_DELETE_ACTION: TunnelDeleteAction = "replace";
const TUNNEL_ORDER_KEY = "tunnel-order";
const mapTunnelApiItems = (items: any[]): Tunnel[] => {
@@ -178,8 +189,16 @@ export default function TunnelPage() {
const [isEdit, setIsEdit] = useState(false);
const [submitLoading, setSubmitLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [deletePreviewLoading, setDeletePreviewLoading] = useState(false);
const [diagnosisLoading, setDiagnosisLoading] = useState(false);
const [tunnelToDelete, setTunnelToDelete] = useState<Tunnel | null>(null);
const [tunnelDeletePreview, setTunnelDeletePreview] =
useState<TunnelDeletePreviewApiData | null>(null);
const [deleteAction, setDeleteAction] =
useState<TunnelDeleteAction>(DEFAULT_TUNNEL_DELETE_ACTION);
const [deleteTargetTunnelId, setDeleteTargetTunnelId] = useState<number | null>(
null,
);
const [currentDiagnosisTunnel, setCurrentDiagnosisTunnel] =
useState<Tunnel | null>(null);
const [diagnosisResult, setDiagnosisResult] =
@@ -247,14 +266,23 @@ export default function TunnelPage() {
const [selectMode, setSelectMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);
const [batchResultModal, setBatchResultModal] =
useState<BatchResultModalState>(EMPTY_BATCH_RESULT_MODAL_STATE);
const [batchDeletePreviewLoading, setBatchDeletePreviewLoading] =
useState(false);
const [batchDeletePreview, setBatchDeletePreview] =
useState<TunnelBatchDeletePreviewApiData | null>(null);
const [batchDeleteAction, setBatchDeleteAction] =
useState<TunnelDeleteAction>(DEFAULT_TUNNEL_DELETE_ACTION);
const [batchDeleteTargetTunnelId, setBatchDeleteTargetTunnelId] = useState<
number | null
>(null);
const [batchLoading, setBatchLoading] = useState(false);
const [batchProgress, setBatchProgress] = useState<BatchProgressState>({
active: false,
label: "",
percent: 0,
});
const [batchResultModal, setBatchResultModal] =
useState<BatchResultModalState>(EMPTY_BATCH_RESULT_MODAL_STATE);
useEffect(() => {
return () => {
@@ -339,6 +367,42 @@ export default function TunnelPage() {
loadData();
}, [loadData]);
const resetDeleteState = useCallback(() => {
setDeleteLoading(false);
setDeletePreviewLoading(false);
setTunnelToDelete(null);
setTunnelDeletePreview(null);
setDeleteAction(DEFAULT_TUNNEL_DELETE_ACTION);
setDeleteTargetTunnelId(null);
}, []);
const handleDeleteModalOpenChange = useCallback(
(open: boolean) => {
setDeleteModalOpen(open);
if (!open) {
resetDeleteState();
}
},
[resetDeleteState],
);
const resetBatchDeleteState = useCallback(() => {
setBatchDeletePreviewLoading(false);
setBatchDeletePreview(null);
setBatchDeleteAction(DEFAULT_TUNNEL_DELETE_ACTION);
setBatchDeleteTargetTunnelId(null);
}, []);
const handleBatchDeleteModalOpenChange = useCallback(
(open: boolean) => {
setBatchDeleteModalOpen(open);
if (!open) {
resetBatchDeleteState();
}
},
[resetBatchDeleteState],
);
// 表单验证
const validateForm = (): boolean => {
const newErrors = validateTunnelForm(form, nodes);
@@ -384,22 +448,78 @@ export default function TunnelPage() {
};
// 删除隧道
const handleDelete = (tunnel: Tunnel) => {
const handleDelete = async (tunnel: Tunnel) => {
setTunnelToDelete(tunnel);
setDeleteModalOpen(true);
setDeletePreviewLoading(true);
setTunnelDeletePreview(null);
setDeleteAction(DEFAULT_TUNNEL_DELETE_ACTION);
setDeleteTargetTunnelId(null);
try {
const response = await previewTunnelDelete(tunnel.id);
if (response.code !== 0 || !response.data) {
toast.error(response.msg || "获取删除依赖失败");
setDeleteModalOpen(false);
resetDeleteState();
return;
}
setTunnelDeletePreview(response.data);
} catch (error) {
toast.error(extractApiErrorMessage(error, "获取删除依赖失败"));
setDeleteModalOpen(false);
resetDeleteState();
} finally {
setDeletePreviewLoading(false);
}
};
const confirmDelete = async () => {
if (!tunnelToDelete) return;
const forwardCount = tunnelDeletePreview?.forwardCount ?? 0;
const action: TunnelDeleteAction =
forwardCount > 0 ? deleteAction : "delete_forwards";
if (
action === "replace" &&
forwardCount > 0 &&
(!deleteTargetTunnelId ||
!deleteReplacementTunnels.some(
(tunnel) => tunnel.id === deleteTargetTunnelId,
))
) {
toast.error("请选择替换规则的目标隧道");
return;
}
setDeleteLoading(true);
try {
const response = await deleteTunnel(tunnelToDelete.id);
const response = await deleteTunnelWithForwards({
id: tunnelToDelete.id,
action,
targetTunnelId:
action === "replace" ? deleteTargetTunnelId ?? undefined : undefined,
});
if (response.code === 0) {
toast.success("删除成功");
const deleteResult = (response.data || null) as {
warnings?: string[];
} | null;
if ((deleteResult?.warnings?.length ?? 0) > 0) {
toast.success(
`删除成功,另有 ${deleteResult?.warnings?.length ?? 0} 条节点清理提示`,
);
} else {
toast.success("删除成功");
}
setDeleteModalOpen(false);
setTunnelToDelete(null);
setTunnels((prev) =>
prev.filter((tunnel) => tunnel.id !== tunnelToDelete.id),
);
@@ -417,11 +537,32 @@ export default function TunnelPage() {
return next;
});
resetDeleteState();
} else if (
response.data &&
typeof response.data === "object" &&
Number((response.data as { failCount?: number }).failCount ?? 0) > 0
) {
const result = response.data as {
failCount?: number;
successCount?: number;
};
const failures = extractBatchFailures(response.data);
if (failures.length > 0) {
setBatchResultModal({
failures,
open: true,
summary: `成功 ${Number(result.successCount ?? 0)} 项,失败 ${Number(result.failCount ?? failures.length)} 项`,
title: "规则处理失败",
});
}
toast.error(response.msg || "删除失败");
} else {
toast.error(response.msg || "删除失败");
}
} catch {
toast.error("删除失败");
} catch (error) {
toast.error(extractApiErrorMessage(error, "删除失败"));
} finally {
setDeleteLoading(false);
}
@@ -877,8 +1018,48 @@ export default function TunnelPage() {
[],
);
const handleOpenBatchDeleteModal = async () => {
if (selectedIds.size === 0) return;
setBatchDeleteModalOpen(true);
setBatchDeletePreviewLoading(true);
setBatchDeletePreview(null);
setBatchDeleteAction(DEFAULT_TUNNEL_DELETE_ACTION);
setBatchDeleteTargetTunnelId(null);
try {
const response = await previewBatchTunnelDelete(selectedTunnelIdList);
if (response.code !== 0 || !response.data) {
toast.error(response.msg || "获取批量删除依赖失败");
setBatchDeleteModalOpen(false);
resetBatchDeleteState();
return;
}
setBatchDeletePreview(response.data);
} catch (error) {
toast.error(extractApiErrorMessage(error, "获取批量删除依赖失败"));
setBatchDeleteModalOpen(false);
resetBatchDeleteState();
} finally {
setBatchDeletePreviewLoading(false);
}
};
const handleBatchDelete = async () => {
if (selectedIds.size === 0) return;
if (
batchDeleteHasForwardDependencies &&
batchDeleteAction === "replace" &&
(!batchDeleteTargetTunnelId || batchDeleteReplaceUnavailable)
) {
toast.error("请选择替换规则的目标隧道");
return;
}
setBatchLoading(true);
setBatchProgress({
active: true,
@@ -886,28 +1067,40 @@ export default function TunnelPage() {
percent: 30,
});
try {
const res = await batchDeleteTunnels(Array.from(selectedIds));
const res = await batchDeleteTunnelsWithForwards({
ids: selectedTunnelIdList,
action: batchDeleteHasForwardDependencies
? batchDeleteAction
: "delete_forwards",
targetTunnelId:
batchDeleteHasForwardDependencies && batchDeleteAction === "replace"
? batchDeleteTargetTunnelId ?? undefined
: undefined,
});
if (res.code === 0) {
const result = res.data;
const result = (res.data || {
successCount: 0,
failCount: 0,
warnings: [],
}) as {
successCount: number;
failCount: number;
warnings?: string[];
};
const warningCount = result?.warnings?.length ?? 0;
if (result.failCount === 0) {
toast.success(`成功删除 ${result.successCount} 项`);
toast.success(
warningCount > 0
? `成功删除 ${result.successCount} 项,另有 ${warningCount} 条节点清理提示`
: `成功删除 ${result.successCount} 项`,
);
setBatchProgress({
active: true,
label: `删除完成:成功 ${result.successCount} 项`,
percent: 100,
});
setTunnels((prev) =>
prev.filter((tunnel) => !selectedIds.has(tunnel.id)),
);
setTunnelOrder((prev) => {
const next = prev.filter((id) => !selectedIds.has(id));
saveOrder(TUNNEL_ORDER_KEY, next);
return next;
});
} else {
const failures = extractBatchFailures(result);
@@ -927,11 +1120,12 @@ export default function TunnelPage() {
label: `部分完成:成功 ${result.successCount} 项,正在刷新列表...`,
percent: 75,
});
await refreshTunnelList(false);
}
await refreshTunnelList(false);
setSelectedIds(new Set());
setSelectMode(false);
setBatchDeleteModalOpen(false);
resetBatchDeleteState();
} else {
toast.error(res.msg || "删除失败");
}
@@ -1069,6 +1263,150 @@ export default function TunnelPage() {
[sortedTunnels],
);
const deleteReplacementTunnels = useMemo(() => {
if (!tunnelToDelete) {
return [] as Tunnel[];
}
return tunnels
.filter((tunnel) => tunnel.id !== tunnelToDelete.id && tunnel.status === 1)
.sort((a, b) => {
const aInx = a.inx ?? 0;
const bInx = b.inx ?? 0;
return aInx - bInx;
});
}, [tunnelToDelete, tunnels]);
useEffect(() => {
if (!deleteModalOpen) {
return;
}
if ((tunnelDeletePreview?.forwardCount ?? 0) <= 0) {
return;
}
if (deleteReplacementTunnels.length === 0) {
setDeleteAction("delete_forwards");
setDeleteTargetTunnelId(null);
return;
}
if (deleteAction !== "replace") {
return;
}
setDeleteTargetTunnelId((prev) => {
if (prev && deleteReplacementTunnels.some((tunnel) => tunnel.id === prev)) {
return prev;
}
return deleteReplacementTunnels[0]?.id ?? null;
});
}, [
deleteAction,
deleteModalOpen,
deleteReplacementTunnels,
tunnelDeletePreview?.forwardCount,
]);
const deletePreviewForwardCount = tunnelDeletePreview?.forwardCount ?? 0;
const deleteHasForwardDependencies = deletePreviewForwardCount > 0;
const deleteReplaceUnavailable =
deleteHasForwardDependencies && deleteReplacementTunnels.length === 0;
const deleteConfirmLabel = deleteHasForwardDependencies
? deleteAction === "replace"
? "迁移规则后删除该隧道"
: "删除规则并删除该隧道"
: "删除该隧道";
const selectedTunnelIdList = useMemo(
() => Array.from(selectedIds),
[selectedIds],
);
const batchDeleteReplacementTunnels = useMemo(() => {
if (selectedIds.size === 0) {
return [] as Tunnel[];
}
return tunnels
.filter((tunnel) => !selectedIds.has(tunnel.id) && tunnel.status === 1)
.sort((a, b) => {
const aInx = a.inx ?? 0;
const bInx = b.inx ?? 0;
return aInx - bInx;
});
}, [selectedIds, tunnels]);
useEffect(() => {
if (!batchDeleteModalOpen) {
return;
}
if ((batchDeletePreview?.totalForwardCount ?? 0) <= 0) {
return;
}
if (batchDeleteReplacementTunnels.length === 0) {
setBatchDeleteAction("delete_forwards");
setBatchDeleteTargetTunnelId(null);
return;
}
if (batchDeleteAction !== "replace") {
return;
}
setBatchDeleteTargetTunnelId((prev) => {
if (
prev &&
batchDeleteReplacementTunnels.some((tunnel) => tunnel.id === prev)
) {
return prev;
}
return batchDeleteReplacementTunnels[0]?.id ?? null;
});
}, [
batchDeleteAction,
batchDeleteModalOpen,
batchDeletePreview?.totalForwardCount,
batchDeleteReplacementTunnels,
]);
const batchDeleteTotalForwardCount = batchDeletePreview?.totalForwardCount ?? 0;
const batchDeleteHasForwardDependencies = batchDeleteTotalForwardCount > 0;
const batchDeleteDependentTunnelCount =
batchDeletePreview?.items?.filter((item) => item.forwardCount > 0).length ?? 0;
const batchDeleteDirectDeleteTunnelCount = Math.max(
selectedTunnelIdList.length - batchDeleteDependentTunnelCount,
0,
);
const batchDeletePreviewItems = useMemo(() => {
return [...(batchDeletePreview?.items ?? [])].sort((a, b) => {
if ((a.forwardCount > 0) === (b.forwardCount > 0)) {
return a.tunnelName.localeCompare(b.tunnelName, "zh-CN");
}
return a.forwardCount > 0 ? -1 : 1;
});
}, [batchDeletePreview?.items]);
const batchDeleteDependentItems = useMemo(
() => batchDeletePreviewItems.filter((item) => item.forwardCount > 0),
[batchDeletePreviewItems],
);
const batchDeleteReplaceUnavailable =
batchDeleteHasForwardDependencies && batchDeleteReplacementTunnels.length === 0;
const batchDeleteConfirmLabel = batchDeleteHasForwardDependencies
? batchDeleteAction === "replace"
? `迁移规则后删除这 ${selectedTunnelIdList.length} 条隧道`
: `删除规则并删除 ${selectedTunnelIdList.length} 条隧道`
: `删除这 ${selectedTunnelIdList.length} 条隧道`;
const SortableItem = ({
id,
children,
@@ -1151,7 +1489,7 @@ export default function TunnelPage() {
isDisabled={selectedIds.size === 0}
size="sm"
variant="flat"
onPress={() => setBatchDeleteModalOpen(true)}
onPress={handleOpenBatchDeleteModal}
>
删除
</Button>
@@ -1263,7 +1601,7 @@ export default function TunnelPage() {
</div>
</div>
<div
className="cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
className="cursor-grab active:cursor-grabbing p-1 -mr-1 text-default-400 hover:text-default-600 transition-colors touch-manipulation flex-shrink-0"
{...listeners}
style={{ touchAction: "none" }}
title="拖拽排序"
@@ -1515,6 +1853,9 @@ export default function TunnelPage() {
{/* 新增/编辑模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={modalOpen}
placement="center"
scrollBehavior="outside"
@@ -2449,26 +2790,150 @@ export default function TunnelPage() {
{/* 删除确认模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={deleteModalOpen}
placement="center"
scrollBehavior="outside"
size="2xl"
onOpenChange={setDeleteModalOpen}
onOpenChange={handleDeleteModalOpenChange}
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<h2 className="text-xl font-bold">确认删除</h2>
<h2 className="text-lg font-bold sm:text-xl">删除隧道</h2>
<p className="text-xs font-normal leading-5 text-default-500 sm:text-sm">
{tunnelDeletePreview?.tunnelName || tunnelToDelete?.name
? `即将删除“${tunnelDeletePreview?.tunnelName || tunnelToDelete?.name}”,删除前会先检查是否有关联规则。`
: "删除前会先检查是否有关联规则。"}
</p>
</ModalHeader>
<ModalBody>
<p>
确定要删除隧道{" "}
<strong>&quot;{tunnelToDelete?.name}&quot;</strong> 吗?
</p>
<p className="text-small text-default-500">
此操作不可恢复,请谨慎操作。
</p>
<ModalBody className="space-y-3 sm:space-y-4">
{deletePreviewLoading ? (
<div className="flex items-center gap-3 rounded-xl border border-divider bg-content2/40 px-3 py-5 text-sm text-default-600 sm:px-4 sm:py-6">
<Spinner size="sm" />
正在检查是否有规则正在使用该隧道...
</div>
) : deleteHasForwardDependencies ? (
<>
<Alert
color="warning"
description={`隧道 \"${tunnelDeletePreview?.tunnelName || tunnelToDelete?.name || ""}\" 当前被 ${deletePreviewForwardCount} 条规则使用。删除前需要先处理这些规则。`}
title="发现关联规则"
variant="flat"
/>
{(tunnelDeletePreview?.sampleForwards?.length ?? 0) > 0 ? (
<div className="space-y-3 rounded-xl border border-divider bg-content2/40 p-3 sm:p-4">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-foreground">
关联规则预览
</h3>
<span className="text-xs text-default-500">
前 {tunnelDeletePreview?.sampleForwards?.length ?? 0} 条
</span>
</div>
<div className="space-y-2">
{tunnelDeletePreview?.sampleForwards?.map((forward) => (
<div
key={forward.id}
className="rounded-lg border border-divider/70 bg-background/80 px-2.5 py-2 sm:px-3"
>
<div className="flex items-center justify-between gap-3">
<span className="truncate text-sm font-medium text-foreground">
{forward.name}
</span>
<span className="shrink-0 font-mono text-xs text-default-500">
:{forward.inPort || 0}
</span>
</div>
<p className="mt-1 text-xs text-default-500">
用户:{forward.userName || `#${forward.userId}`}
</p>
</div>
))}
</div>
{deletePreviewForwardCount >
(tunnelDeletePreview?.sampleForwards?.length ?? 0) ? (
<p className="text-xs text-default-500">
还有 {deletePreviewForwardCount - (tunnelDeletePreview?.sampleForwards?.length ?? 0)} 条规则未展开显示。
</p>
) : null}
</div>
) : null}
<RadioGroup
label="处理方式"
value={deleteAction}
onValueChange={(value) => {
const nextAction = value as TunnelDeleteAction;
setDeleteAction(nextAction);
if (nextAction !== "replace") {
setDeleteTargetTunnelId(null);
return;
}
setDeleteTargetTunnelId(
deleteReplacementTunnels[0]?.id ?? null,
);
}}
>
<Radio value="replace">
保留规则,迁移到其他隧道{deleteReplaceUnavailable ? "(当前无可用目标)" : "(推荐)"}
</Radio>
<Radio value="delete_forwards">直接删除这些关联规则</Radio>
</RadioGroup>
{deleteReplaceUnavailable ? (
<Alert
color="warning"
description="当前没有其他启用中的隧道可用于承接这些规则,只能删除关联规则后再删除该隧道。"
variant="flat"
/>
) : null}
{deleteAction === "replace" && !deleteReplaceUnavailable ? (
<div className="space-y-2">
<Select
label="目标隧道"
placeholder="请选择目标隧道"
selectedKeys={
deleteTargetTunnelId
? [String(deleteTargetTunnelId)]
: []
}
variant="bordered"
onSelectionChange={(keys) => {
const selected = Array.from(keys)[0];
setDeleteTargetTunnelId(
selected ? Number(selected) : null,
);
}}
>
{deleteReplacementTunnels.map((tunnel) => (
<SelectItem key={String(tunnel.id)}>
{tunnel.name}
</SelectItem>
))}
</Select>
<p className="text-xs text-default-500">
关联规则会迁移到这里,当前要删除的隧道不会出现在可选项里。
</p>
</div>
) : null}
</>
) : (
<Alert
color="warning"
description={`当前未发现关联规则。确认后将直接删除“${tunnelToDelete?.name || "该隧道"}”,此操作不可撤销。`}
title="可以直接删除"
variant="flat"
/>
)}
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
@@ -2476,10 +2941,16 @@ export default function TunnelPage() {
</Button>
<Button
color="danger"
isDisabled={
deletePreviewLoading ||
(deleteHasForwardDependencies &&
deleteAction === "replace" &&
(!deleteTargetTunnelId || deleteReplaceUnavailable))
}
isLoading={deleteLoading}
onPress={confirmDelete}
>
{deleteLoading ? "删除中..." : "确认删除"}
{deleteLoading ? "删除中..." : deleteConfirmLabel}
</Button>
</ModalFooter>
</>
@@ -2491,10 +2962,7 @@ export default function TunnelPage() {
<Modal
backdrop="blur"
classNames={{
base: "rounded-2xl",
header: "rounded-t-2xl",
body: "rounded-none",
footer: "rounded-b-2xl",
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={diagnosisModalOpen}
placement="center"
@@ -3077,18 +3545,159 @@ export default function TunnelPage() {
</Modal>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={batchDeleteModalOpen}
onOpenChange={setBatchDeleteModalOpen}
onOpenChange={handleBatchDeleteModalOpenChange}
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>确认删除</ModalHeader>
<ModalBody>
<p>
确定要删除选中的 {selectedIds.size}{" "}
项隧道吗?此操作不可撤销,相关规则也将被删除。
<ModalHeader className="flex flex-col gap-1">
<h2 className="text-lg font-bold sm:text-xl">批量删除隧道</h2>
<p className="text-xs font-normal leading-5 text-default-500 sm:text-sm">
即将删除这 {selectedTunnelIdList.length} 条隧道,删除前会先检查是否有关联规则。
</p>
</ModalHeader>
<ModalBody className="space-y-3 sm:space-y-4">
{batchDeletePreviewLoading ? (
<div className="flex items-center gap-3 rounded-xl border border-divider bg-content2/40 px-3 py-5 text-sm text-default-600 sm:px-4 sm:py-6">
<Spinner size="sm" />
正在检查选中隧道是否有关联规则...
</div>
) : batchDeleteHasForwardDependencies ? (
<>
<Alert
color="warning"
description={`已选 ${selectedTunnelIdList.length} 条隧道,其中 ${batchDeleteDependentTunnelCount} 条仍被规则使用,共 ${batchDeleteTotalForwardCount} 条规则待处理。${batchDeleteDirectDeleteTunnelCount > 0 ? `其余 ${batchDeleteDirectDeleteTunnelCount} 条会直接删除。` : ""}`}
title="发现关联规则"
variant="flat"
/>
<div className="max-h-64 space-y-3 overflow-y-auto rounded-xl border border-divider bg-content2/40 p-3 sm:max-h-72 sm:p-4">
{batchDeleteDependentItems.map((item) => (
<div
key={item.tunnelId}
className="rounded-lg border border-divider/70 bg-background/80 p-2.5 sm:p-3"
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{item.tunnelName}
</p>
<p className="mt-1 text-xs text-default-500">
{item.forwardCount} 条规则依赖
</p>
</div>
<Chip color="warning" size="sm" variant="flat">
有关联
</Chip>
</div>
{item.sampleForwards.length > 0 ? (
<div className="mt-3 space-y-2">
{item.sampleForwards.map((forward) => (
<div
key={forward.id}
className="rounded-md bg-content1/70 px-2.5 py-2 sm:px-3"
>
<div className="flex items-center justify-between gap-3">
<span className="truncate text-xs font-medium text-foreground">
{forward.name}
</span>
<span className="shrink-0 font-mono text-[11px] text-default-500">
:{forward.inPort || 0}
</span>
</div>
<p className="mt-1 text-[11px] text-default-500">
用户:{forward.userName || `#${forward.userId}`}
</p>
</div>
))}
{item.forwardCount > item.sampleForwards.length ? (
<p className="text-[11px] text-default-500">
还有 {item.forwardCount - item.sampleForwards.length} 条规则未展开显示。
</p>
) : null}
</div>
) : null}
</div>
))}
</div>
<RadioGroup
label="处理方式"
value={batchDeleteAction}
onValueChange={(value) => {
const nextAction = value as TunnelDeleteAction;
setBatchDeleteAction(nextAction);
if (nextAction !== "replace") {
setBatchDeleteTargetTunnelId(null);
return;
}
setBatchDeleteTargetTunnelId(
batchDeleteReplacementTunnels[0]?.id ?? null,
);
}}
>
<Radio value="replace">
保留规则,统一迁移到其他隧道{batchDeleteReplaceUnavailable ? "(当前无可用目标)" : "(推荐)"}
</Radio>
<Radio value="delete_forwards">直接删除这些关联规则</Radio>
</RadioGroup>
{batchDeleteReplaceUnavailable ? (
<Alert
color="warning"
description="当前没有可承接这些规则的启用隧道,只能删除关联规则后再删除所选隧道。"
variant="flat"
/>
) : null}
{batchDeleteAction === "replace" &&
!batchDeleteReplaceUnavailable ? (
<div className="space-y-2">
<Select
label="目标隧道"
placeholder="请选择目标隧道"
selectedKeys={
batchDeleteTargetTunnelId
? [String(batchDeleteTargetTunnelId)]
: []
}
variant="bordered"
onSelectionChange={(keys) => {
const selected = Array.from(keys)[0];
setBatchDeleteTargetTunnelId(
selected ? Number(selected) : null,
);
}}
>
{batchDeleteReplacementTunnels.map((tunnel) => (
<SelectItem key={String(tunnel.id)}>
{tunnel.name}
</SelectItem>
))}
</Select>
<p className="text-xs text-default-500">
所有关联规则都会迁移到这里,删除列表中的隧道不会出现在可选项里。
</p>
</div>
) : null}
</>
) : (
<Alert
color="warning"
description={`已选 ${selectedTunnelIdList.length} 条隧道,当前未发现关联规则。确认后将直接删除这些隧道,此操作不可撤销。`}
title="可以直接删除"
variant="flat"
/>
)}
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
@@ -3096,10 +3705,17 @@ export default function TunnelPage() {
</Button>
<Button
color="danger"
isDisabled={
batchDeletePreviewLoading ||
(batchDeleteHasForwardDependencies &&
batchDeleteAction === "replace" &&
(!batchDeleteTargetTunnelId ||
batchDeleteReplaceUnavailable))
}
isLoading={batchLoading}
onPress={handleBatchDelete}
>
确认删除
{batchLoading ? "删除中..." : batchDeleteConfirmLabel}
</Button>
</ModalFooter>
</>
@@ -3118,7 +3734,6 @@ export default function TunnelPage() {
return;
}
setBatchResultModal(EMPTY_BATCH_RESULT_MODAL_STATE);
}}
/>
+347 -40
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
import toast from "react-hot-toast";
import { parseDate } from "@internationalized/date";
import { LayoutGrid, List } from "lucide-react";
import {
AnimatedPage,
@@ -30,6 +31,7 @@ import { Chip } from "@/shadcn-bridge/heroui/chip";
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import { RadioGroup, Radio } from "@/shadcn-bridge/heroui/radio";
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
import { Switch } from "@/shadcn-bridge/heroui/switch";
import { DatePicker } from "@/shadcn-bridge/heroui/date-picker";
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
import { Progress } from "@/shadcn-bridge/heroui/progress";
@@ -58,12 +60,15 @@ import {
resetUserQuota,
getUserGroupList,
getUserGroups,
getMonitorPermissionList,
assignMonitorPermission,
removeMonitorPermission,
} from "@/api";
import {
SearchIcon,
EditIcon,
DeleteIcon,
SettingsIcon,
SearchIcon,
} from "@/components/icons";
import { PageLoadingState } from "@/components/page-state";
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
@@ -186,6 +191,10 @@ export default function UserPage() {
"",
);
const [isSearchVisible, setIsSearchVisible] = useState(false);
const [viewMode, setViewMode] = useLocalStorageState<"list" | "grid">(
"user-view-mode",
"grid",
);
const [pagination, setPagination] = useState<PaginationType>({
current: 1,
size: 10,
@@ -204,7 +213,7 @@ export default function UserPage() {
user: "",
pwd: "",
status: 1,
flow: 100,
flow: 1000,
dailyQuotaGB: 0,
monthlyQuotaGB: 0,
num: 10,
@@ -229,6 +238,13 @@ export default function UserPage() {
onClose: onTunnelModalClose,
} = useDisclosure();
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [monitorPermissionUserIds, setMonitorPermissionUserIds] = useState<
Set<number>
>(new Set());
const [monitorPermissionLoading, setMonitorPermissionLoading] =
useState(false);
const [monitorPermissionMutatingUserId, setMonitorPermissionMutatingUserId] =
useState<number | null>(null);
const [userTunnels, setUserTunnels] = useState<UserTunnel[]>([]);
const [tunnelListLoading, setTunnelListLoading] = useState(false);
@@ -395,6 +411,32 @@ export default function UserPage() {
} catch {}
}, []);
const loadMonitorPermissions = useCallback(async () => {
setMonitorPermissionLoading(true);
try {
const response = await getMonitorPermissionList();
if (response.code === 0) {
const ids = new Set<number>();
if (Array.isArray(response.data)) {
response.data.forEach((item: any) => {
const id = Number(item?.userId ?? 0);
if (id > 0) ids.add(id);
});
}
setMonitorPermissionUserIds(ids);
} else if (response.code !== 403) {
toast.error(response.msg || "获取监控权限失败");
}
} catch {
// ignore
} finally {
setMonitorPermissionLoading(false);
}
}, []);
const loadUserTunnels = useCallback(async (userId: number) => {
setTunnelListLoading(true);
try {
@@ -421,7 +463,8 @@ export default function UserPage() {
void loadTunnels();
void loadSpeedLimits();
void loadUserGroups();
}, [loadSpeedLimits, loadTunnels, loadUserGroups]);
void loadMonitorPermissions();
}, [loadMonitorPermissions, loadSpeedLimits, loadTunnels, loadUserGroups]);
useEffect(() => {
void loadUsers();
@@ -469,7 +512,7 @@ export default function UserPage() {
user: "",
pwd: "",
status: 1,
flow: 100,
flow: 1000,
dailyQuotaGB: 0,
monthlyQuotaGB: 0,
num: 10,
@@ -597,6 +640,63 @@ export default function UserPage() {
}
};
const setUserMonitorPermission = useCallback(
async (userId: number, enabled: boolean) => {
if (userId <= 0) return;
if (monitorPermissionMutatingUserId === userId) return;
const prevEnabled = monitorPermissionUserIds.has(userId);
if (prevEnabled === enabled) return;
setMonitorPermissionMutatingUserId(userId);
// Optimistic update for better UX.
setMonitorPermissionUserIds((prev) => {
const next = new Set(prev);
if (enabled) {
next.add(userId);
} else {
next.delete(userId);
}
return next;
});
try {
const response = enabled
? await assignMonitorPermission(userId)
: await removeMonitorPermission(userId);
if (response.code === 0) {
toast.success(enabled ? "已授权监控" : "已撤销监控");
return;
}
toast.error(response.msg || "操作失败");
throw new Error("mutation failed");
} catch {
// Revert optimistic update on failure.
setMonitorPermissionUserIds((prev) => {
const next = new Set(prev);
if (prevEnabled) {
next.add(userId);
} else {
next.delete(userId);
}
return next;
});
} finally {
setMonitorPermissionMutatingUserId(null);
}
},
[monitorPermissionMutatingUserId, monitorPermissionUserIds],
);
// 隧道权限管理操作
const handleManageTunnels = (user: User) => {
setCurrentUser(user);
@@ -945,9 +1045,23 @@ export default function UserPage() {
)}
</div>
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
新增
</Button>
<div className="flex items-center gap-2">
<Button
isIconOnly
size="sm"
variant="flat"
onPress={() => setViewMode(viewMode === "list" ? "grid" : "list")}
>
{viewMode === "list" ? (
<LayoutGrid className="w-4 h-4" />
) : (
<List className="w-4 h-4" />
)}
</Button>
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
新增
</Button>
</div>
</div>
{/* 用户列表 */}
@@ -964,6 +1078,179 @@ export default function UserPage() {
</p>
</CardBody>
</Card>
) : viewMode === "list" ? (
<Card>
<Table aria-label="用户列表" className="overflow-x-auto min-w-full">
<TableHeader>
<TableColumn>用户名</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn>流量统计</TableColumn>
<TableColumn>配额限制</TableColumn>
<TableColumn>规则数量</TableColumn>
<TableColumn>到期时间</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody items={users}>
{(user) => {
const userStatus = getUserStatus(user);
const expStatus = user.expTime
? getExpireStatus(user.expTime)
: null;
const usedFlow = calculateUserTotalUsedFlow(user);
return (
<TableRow key={user.id}>
<TableCell>
<div className="flex flex-col">
<span className="font-medium text-foreground text-sm">
{user.name || user.user}
</span>
<span className="text-xs text-default-500">
@{user.user}
</span>
</div>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1 items-start">
<Chip
className="text-xs"
color={userStatus.color}
size="sm"
variant="flat"
>
{userStatus.text}
</Chip>
{user.disabledByQuota ? (
<Chip
className="text-[10px] h-5 px-1"
color="danger"
size="sm"
variant="flat"
>
配额超额
</Chip>
) : null}
</div>
</TableCell>
<TableCell>
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-1 text-xs">
<span className="text-default-500">已用:</span>
<span className="text-danger font-medium whitespace-nowrap">
{formatFlow(usedFlow)}
</span>
</div>
<div className="flex items-center gap-1 text-xs">
<span className="text-default-500">限制:</span>
<span className="text-default-700 font-medium whitespace-nowrap">
{formatFlow(user.flow, "gb")}
</span>
</div>
</div>
</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>
</>
) : (
<span className="text-default-400">无配额</span>
)}
</div>
</TableCell>
<TableCell>
<span className="text-sm font-medium">{user.num}</span>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1 items-start whitespace-nowrap">
{user.expTime ? (
<>
<span className="text-sm">
{new Date(user.expTime).toLocaleString()}
</span>
{expStatus && (
<Chip
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>
)}
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="flat"
color="success"
className="min-h-8 min-w-[50px] px-2"
startContent={<SettingsIcon className="w-3 h-3" />}
onPress={() => handleManageTunnels(user)}
>
权限
</Button>
<Button
isIconOnly
size="sm"
variant="light"
className="text-default-600 hover:text-primary min-h-8 min-w-8"
onPress={() => handleEdit(user)}
>
<EditIcon className="w-4 h-4" />
</Button>
<Button
isIconOnly
size="sm"
variant="light"
className="text-default-600 hover:text-warning min-h-8 min-w-8"
title="重置流量"
onPress={() => handleResetFlow(user)}
>
<svg
aria-hidden="true"
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
fillRule="evenodd"
/>
</svg>
</Button>
<Button
isIconOnly
size="sm"
variant="light"
className="text-default-600 hover:text-danger min-h-8 min-w-8"
onPress={() => handleDelete(user)}
>
<DeleteIcon className="w-4 h-4" />
</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">
{users.map((user) => {
@@ -1180,6 +1467,9 @@ export default function UserPage() {
{/* 用户表单模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl",
}}
isOpen={isUserModalOpen}
placement="center"
scrollBehavior="outside"
@@ -1224,38 +1514,6 @@ export default function UserPage() {
setUserForm((prev) => ({ ...prev, flow: value }));
}}
/>
<Input
label="每日配额(GB)"
max="99999"
min="0"
placeholder="0 表示不限"
type="number"
value={userForm.dailyQuotaGB.toString()}
onChange={(e) => {
const value = Math.min(
Math.max(Number(e.target.value) || 0, 0),
99999,
);
setUserForm((prev) => ({ ...prev, dailyQuotaGB: value }));
}}
/>
<Input
label="每月配额(GB)"
max="99999"
min="0"
placeholder="0 表示不限"
type="number"
value={userForm.monthlyQuotaGB.toString()}
onChange={(e) => {
const value = Math.min(
Math.max(Number(e.target.value) || 0, 0),
99999,
);
setUserForm((prev) => ({ ...prev, monthlyQuotaGB: value }));
}}
/>
<Input
isRequired
label="规则数量"
@@ -1462,9 +1720,43 @@ export default function UserPage() {
onClose={onTunnelModalClose}
>
<ModalContent>
<ModalHeader>用户 {currentUser?.user} 的隧道权限管理</ModalHeader>
<ModalHeader>用户 {currentUser?.user} 的权限管理</ModalHeader>
<ModalBody>
<div className="space-y-6">
{/* 监控权限部分 */}
<div>
<h3 className="text-lg font-semibold mb-4">监控权限</h3>
<div className="flex items-center justify-between gap-4 bg-default-100 dark:bg-default-50 p-4 rounded-lg border border-default-200 dark:border-default-100/30">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">
允许访问监控功能
</div>
<div className="text-xs text-default-500 mt-1">
授予后,该用户可以访问监控页面并管理服务监控(TCP/ICMP)。
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{monitorPermissionLoading ? <Spinner size="sm" /> : null}
<Switch
isDisabled={
!currentUser ||
monitorPermissionLoading ||
monitorPermissionMutatingUserId === currentUser.id
}
isSelected={
currentUser
? monitorPermissionUserIds.has(currentUser.id)
: false
}
onValueChange={(v) =>
currentUser &&
void setUserMonitorPermission(currentUser.id, v)
}
/>
</div>
</div>
</div>
{/* 分配新权限部分 */}
<div>
<h3 className="text-lg font-semibold mb-4">分配新权限</h3>
@@ -1753,6 +2045,9 @@ export default function UserPage() {
{/* 编辑隧道权限模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl",
}}
isDismissable={false}
isOpen={isEditTunnelModalOpen}
placement="center"
@@ -1931,6 +2226,9 @@ export default function UserPage() {
{/* 删除确认对话框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl",
}}
isOpen={isDeleteModalOpen}
placement="center"
scrollBehavior="outside"
@@ -1974,6 +2272,9 @@ export default function UserPage() {
{/* 删除隧道权限确认对话框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl",
}}
isOpen={isDeleteTunnelModalOpen}
placement="center"
scrollBehavior="outside"
@@ -2019,6 +2320,9 @@ export default function UserPage() {
{/* 重置流量确认对话框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl",
}}
isOpen={isResetFlowModalOpen}
placement="center"
scrollBehavior="outside"
@@ -2108,6 +2412,9 @@ export default function UserPage() {
{/* 重置隧道流量确认对话框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl",
}}
isOpen={isResetTunnelFlowModalOpen}
placement="center"
scrollBehavior="outside"
@@ -11,6 +11,7 @@ export interface CheckboxProps
classNames?: Record<string, string>;
color?: string;
isDisabled?: boolean;
isIndeterminate?: boolean;
isSelected?: boolean;
onValueChange?: (value: boolean) => void;
size?: string;
@@ -20,6 +21,7 @@ export function Checkbox({
children,
className,
isDisabled,
isIndeterminate,
isSelected,
onValueChange,
...props
@@ -32,6 +34,10 @@ export function Checkbox({
onValueChange?.(value === true);
};
const checkedValue: boolean | "indeterminate" = isIndeterminate
? "indeterminate"
: Boolean(isSelected);
return (
<div
className={cn(
@@ -41,7 +47,7 @@ export function Checkbox({
)}
>
<BaseCheckbox
checked={Boolean(isSelected)}
checked={checkedValue}
disabled={isDisabled}
onCheckedChange={handleCheckedChange}
{...props}
@@ -145,7 +145,6 @@ export function ModalContent({
return (
<BaseDialogContent
{...props}
className={cn(
mapSize(resolvedSize),
context?.classNames?.base,
@@ -158,12 +157,7 @@ export function ModalContent({
className,
)}
showCloseButton={false}
onOpenAutoFocus={(e) => {
e.preventDefault();
}}
onCloseAutoFocus={(e) => {
e.preventDefault();
}}
{...props}
>
{renderedChildren}
</BaseDialogContent>
@@ -399,7 +399,7 @@ export function Select<T>({
) : (
<select
className={cn(
"w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"w-full rounded-md border border-input bg-background px-3 py-2 text-foreground shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:[color-scheme:dark]",
sizeClass(size),
classNames?.trigger,
className,
@@ -414,6 +414,7 @@ export function Select<T>({
<option value="">{placeholder ?? "请选择"}</option>
{options.map((option) => (
<option
className="bg-background text-foreground"
key={option.key}
disabled={disabled.has(option.key)}
value={option.key}
@@ -1,4 +1,6 @@
@theme inline {
--font-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-border: var(--border);