Compare commits

...

24 Commits

Author SHA1 Message Date
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
77 changed files with 10814 additions and 1350 deletions
-165
View File
@@ -1,165 +0,0 @@
---
name: security-scan
description: Scan your Claude Code configuration (.claude/ directory) for security vulnerabilities, misconfigurations, and injection risks using AgentShield. Checks CLAUDE.md, settings.json, MCP servers, hooks, and agent definitions.
origin: ECC
---
# Security Scan Skill
Audit your Claude Code configuration for security issues using [AgentShield](https://github.com/affaan-m/agentshield).
## When to Activate
- Setting up a new Claude Code project
- After modifying `.claude/settings.json`, `CLAUDE.md`, or MCP configs
- Before committing configuration changes
- When onboarding to a new repository with existing Claude Code configs
- Periodic security hygiene checks
## What It Scans
| File | Checks |
|------|--------|
| `CLAUDE.md` | Hardcoded secrets, auto-run instructions, prompt injection patterns |
| `settings.json` | Overly permissive allow lists, missing deny lists, dangerous bypass flags |
| `mcp.json` | Risky MCP servers, hardcoded env secrets, npx supply chain risks |
| `hooks/` | Command injection via interpolation, data exfiltration, silent error suppression |
| `agents/*.md` | Unrestricted tool access, prompt injection surface, missing model specs |
## Prerequisites
AgentShield must be installed. Check and install if needed:
```bash
# Check if installed
npx ecc-agentshield --version
# Install globally (recommended)
npm install -g ecc-agentshield
# Or run directly via npx (no install needed)
npx ecc-agentshield scan .
```
## Usage
### Basic Scan
Run against the current project's `.claude/` directory:
```bash
# Scan current project
npx ecc-agentshield scan
# Scan a specific path
npx ecc-agentshield scan --path /path/to/.claude
# Scan with minimum severity filter
npx ecc-agentshield scan --min-severity medium
```
### Output Formats
```bash
# Terminal output (default) — colored report with grade
npx ecc-agentshield scan
# JSON — for CI/CD integration
npx ecc-agentshield scan --format json
# Markdown — for documentation
npx ecc-agentshield scan --format markdown
# HTML — self-contained dark-theme report
npx ecc-agentshield scan --format html > security-report.html
```
### Auto-Fix
Apply safe fixes automatically (only fixes marked as auto-fixable):
```bash
npx ecc-agentshield scan --fix
```
This will:
- Replace hardcoded secrets with environment variable references
- Tighten wildcard permissions to scoped alternatives
- Never modify manual-only suggestions
### Opus 4.6 Deep Analysis
Run the adversarial three-agent pipeline for deeper analysis:
```bash
# Requires ANTHROPIC_API_KEY
export ANTHROPIC_API_KEY=your-key
npx ecc-agentshield scan --opus --stream
```
This runs:
1. **Attacker (Red Team)** — finds attack vectors
2. **Defender (Blue Team)** — recommends hardening
3. **Auditor (Final Verdict)** — synthesizes both perspectives
### Initialize Secure Config
Scaffold a new secure `.claude/` configuration from scratch:
```bash
npx ecc-agentshield init
```
Creates:
- `settings.json` with scoped permissions and deny list
- `CLAUDE.md` with security best practices
- `mcp.json` placeholder
### GitHub Action
Add to your CI pipeline:
```yaml
- uses: affaan-m/agentshield@v1
with:
path: '.'
min-severity: 'medium'
fail-on-findings: true
```
## Severity Levels
| Grade | Score | Meaning |
|-------|-------|---------|
| A | 90-100 | Secure configuration |
| B | 75-89 | Minor issues |
| C | 60-74 | Needs attention |
| D | 40-59 | Significant risks |
| F | 0-39 | Critical vulnerabilities |
## Interpreting Results
### Critical Findings (fix immediately)
- Hardcoded API keys or tokens in config files
- `Bash(*)` in the allow list (unrestricted shell access)
- Command injection in hooks via `${file}` interpolation
- Shell-running MCP servers
### High Findings (fix before production)
- Auto-run instructions in CLAUDE.md (prompt injection vector)
- Missing deny lists in permissions
- Agents with unnecessary Bash access
### Medium Findings (recommended)
- Silent error suppression in hooks (`2>/dev/null`, `|| true`)
- Missing PreToolUse security hooks
- `npx -y` auto-install in MCP server configs
### Info Findings (awareness)
- Missing descriptions on MCP servers
- Prohibitive instructions correctly flagged as good practice
## Links
- **GitHub**: [github.com/affaan-m/agentshield](https://github.com/affaan-m/agentshield)
- **npm**: [npmjs.com/package/ecc-agentshield](https://www.npmjs.com/package/ecc-agentshield)
-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/**)"
]
}
}
-1
View File
@@ -1 +0,0 @@
../../.agents/skills/security-scan
+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()
}
+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())
}
+43 -11
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) {
@@ -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.
@@ -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,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.
@@ -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).
-10
View File
@@ -1,10 +0,0 @@
{
"version": 1,
"skills": {
"security-scan": {
"source": "affaan-m/everything-claude-code",
"sourceType": "github",
"computedHash": "92cdcaddc554e318402f066ccc073c2e3dbcfda8c2730ec62ec373f805c41a57"
}
}
}
+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;
}
+1 -1
View File
@@ -11,7 +11,7 @@ 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 data-[state=indeterminate]:bg-primary data-[state=indeterminate]: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"
+84 -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)}
>
+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)}
+159 -118
View File
@@ -651,7 +651,7 @@ const SortableTunnelGroupContainer = ({
</Button>
{/* 倍率 */}
<span className={titleClassName}>{tunnel.tunnelName}</span>
<span className="text-primary font-bold text-[10px] mr-1.5">
<span className="text-default-500 font-semibold text-[10px] mr-1.5">
[{formatTunnelTrafficRatio(tunnel.tunnelTrafficRatio)}]
</span>
</div>
@@ -753,7 +753,7 @@ const SortableTableRow = ({
<TableCell className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.select}>
<Checkbox
isSelected={selectedIds.has(forward.id)}
onValueChange={() => toggleSelect(forward.id)}
onValueChange={(checked) => toggleSelect(forward.id, checked)}
/>
</TableCell>
)}
@@ -775,13 +775,13 @@ const SortableTableRow = ({
</div>
</TableCell>
<TableCell
className={`${selectedIds.has(forward.id) ? "bg-primary-50/70 dark:bg-primary-900/40" : ""} ${FORWARD_GROUPED_TABLE_COLUMN_CLASS.name} whitespace-nowrap text-foreground cursor-pointer hover:text-primary transition-colors`}
className={`${FORWARD_GROUPED_TABLE_COLUMN_CLASS.name} whitespace-nowrap text-foreground cursor-pointer hover:text-primary transition-colors`}
onClick={() => copyToClipboard(forward.name, "规则名")}
>
{forward.name}
</TableCell>
<TableCell
className={`${FORWARD_GROUPED_TABLE_COLUMN_CLASS.inbound} max-w-[280px] ${selectedIds.has(forward.id) ? "bg-primary-50/70 dark:bg-primary-900/40" : ""}`}
className={`${FORWARD_GROUPED_TABLE_COLUMN_CLASS.inbound} max-w-[280px]`}
>
<button
className="w-full truncate rounded-md bg-default-100/50 px-2.5 py-1.5 text-left font-mono text-xs font-medium text-default-700 transition-all hover:bg-default-200 hover:shadow-sm cursor-pointer"
@@ -795,7 +795,7 @@ const SortableTableRow = ({
</button>
</TableCell>
<TableCell
className={`${FORWARD_GROUPED_TABLE_COLUMN_CLASS.target} max-w-[280px] ${selectedIds.has(forward.id) ? "bg-primary-50/70 dark:bg-primary-900/40" : ""}`}
className={`${FORWARD_GROUPED_TABLE_COLUMN_CLASS.target} max-w-[280px]`}
>
<button
className="w-full truncate rounded-md bg-default-100/50 px-2.5 py-1.5 text-left font-mono text-xs font-medium text-default-700 transition-all hover:bg-default-200 hover:shadow-sm cursor-pointer"
@@ -817,16 +817,13 @@ const SortableTableRow = ({
</Chip>
</TableCell>
<TableCell
className={`${FORWARD_GROUPED_TABLE_COLUMN_CLASS.totalFlow} whitespace-nowrap ${selectedIds.has(forward.id) ? "bg-primary-50/70 dark:bg-primary-900/40" : ""}`}
className={`${FORWARD_GROUPED_TABLE_COLUMN_CLASS.totalFlow} whitespace-nowrap`}
>
<span className="text-sm font-medium text-default-600 font-mono">
{formatFlow(getForwardDisplayFlow(forward))}
</span>
</TableCell>
<TableCell
className={`${FORWARD_GROUPED_TABLE_COLUMN_CLASS.status} cursor-pointer hover:underline text-primary font-bold`}
onClick={() => copyToClipboard(forward.inPort.toString(), "入口端口")}
>
<TableCell className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.status}>
<div className="flex items-center gap-2.5 whitespace-nowrap">
<Switch
color="success"
@@ -948,12 +945,10 @@ const SortableCompactTableRow = ({
return (
<TableRow key={forward.id} ref={setNodeRef} style={style}>
{true && (
<TableCell
className={`${selectedIds.has(forward.id) ? "bg-primary-50/70 dark:bg-primary-900/40" : ""}`}
>
<TableCell>
<Checkbox
isSelected={selectedIds.has(forward.id)}
onValueChange={() => toggleSelect(forward.id)}
onValueChange={(checked) => toggleSelect(forward.id, checked)}
/>
</TableCell>
)}
@@ -3069,15 +3064,22 @@ export default function ForwardPage() {
}
}
};
const toggleSelect = (id: number) => {
const newSet = new Set(selectedIds);
const toggleSelect = (id: number, explicitSelected?: boolean) => {
setSelectedIds((prev) => {
const next = new Set(prev);
const shouldSelect =
typeof explicitSelected === "boolean"
? explicitSelected
: !next.has(id);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
setSelectedIds(newSet);
if (shouldSelect) {
next.add(id);
} else {
next.delete(id);
}
return next;
});
};
const deselectAll = () => {
setSelectedIds(new Set());
@@ -3691,7 +3693,7 @@ export default function ForwardPage() {
<Checkbox
className="mr-2"
isSelected={selectedIds.has(forward.id)}
onValueChange={() => toggleSelect(forward.id)}
onValueChange={(checked) => toggleSelect(forward.id, checked)}
/>
)}
<div className="flex-1 min-w-0">
@@ -4358,6 +4360,42 @@ export default function ForwardPage() {
const tunnelSortableForwardIds = tunnel.items
.map((item) => item.id)
.filter((id) => id > 0);
const tunnelSelectedCount =
tunnelSortableForwardIds.reduce(
(count, id) =>
count + (selectedIds.has(id) ? 1 : 0),
0,
);
const isTunnelAllSelected =
tunnelSortableForwardIds.length > 0 &&
tunnelSelectedCount ===
tunnelSortableForwardIds.length;
const isTunnelIndeterminate =
tunnelSelectedCount > 0 &&
tunnelSelectedCount <
tunnelSortableForwardIds.length;
const handleTunnelSelectAllToggle = (
isSelected: boolean,
) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (isSelected) {
tunnelSortableForwardIds.forEach((id) => {
next.add(id);
});
return next;
}
tunnelSortableForwardIds.forEach((id) => {
next.delete(id);
});
return next;
});
};
const collapsed =
sanitizedCollapsedTunnelGroups[
buildTunnelGroupCollapseKey(
@@ -4371,12 +4409,12 @@ export default function ForwardPage() {
key={`grouped-table-${group.userId}-${tunnel.tunnelKey}`}
bodyClassName=""
collapsed={collapsed}
countClassName="text-xs text-secondary-700"
countClassName="text-xs text-default-600"
groupUserId={group.userId}
headerClassName="flex items-center justify-between border-b border-secondary/20 bg-secondary/10 px-4 py-2.5"
titleClassName="truncate text-sm font-semibold text-secondary-700"
headerClassName="flex items-center justify-between border-b border-divider bg-default-100/60 px-4 py-2.5"
titleClassName="truncate text-sm font-semibold text-default-700"
tunnel={tunnel}
wrapperClassName="overflow-hidden rounded-lg border border-secondary/20 bg-secondary/5"
wrapperClassName="overflow-hidden rounded-lg border border-divider bg-default-50/60"
onToggleCollapsed={() =>
toggleTunnelGroupCollapsed(
group.userId,
@@ -4389,92 +4427,95 @@ export default function ForwardPage() {
sensors={sensors}
onDragEnd={handleDragEnd}
>
<Table
aria-label={`${group.userName}-${tunnel.tunnelName}规则列表`}
className={`table-fixed ${FORWARD_GROUPED_TABLE_MIN_WIDTH_CLASS}`}
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
}}
<SortableContext
items={tunnelSortableForwardIds}
strategy={verticalListSortingStrategy}
>
<TableHeader>
{true && (
<TableColumn className="w-14">
{/* @ts-ignore */}
<Checkbox
aria-label="全选"
isIndeterminate={isIndeterminate}
isSelected={isAllSelected}
onValueChange={handleSelectAllToggle}
/>
</TableColumn>
)}
<TableColumn
className={
FORWARD_GROUPED_TABLE_COLUMN_CLASS.drag
}
/>
<TableColumn
className={
FORWARD_GROUPED_TABLE_COLUMN_CLASS.name
}
>
名称
</TableColumn>
<TableColumn
className={
FORWARD_GROUPED_TABLE_COLUMN_CLASS.inbound
}
>
入口
</TableColumn>
<TableColumn
className={
FORWARD_GROUPED_TABLE_COLUMN_CLASS.target
}
>
目标
</TableColumn>
<TableColumn
className={
FORWARD_GROUPED_TABLE_COLUMN_CLASS.strategy
}
>
策略
</TableColumn>
<TableColumn
className={
FORWARD_GROUPED_TABLE_COLUMN_CLASS.totalFlow
}
>
总流量
</TableColumn>
<TableColumn
className={
FORWARD_GROUPED_TABLE_COLUMN_CLASS.status
}
>
状态
</TableColumn>
<TableColumn
className={
FORWARD_GROUPED_TABLE_COLUMN_CLASS.actions
}
>
操作
</TableColumn>
</TableHeader>
<TableBody
emptyContent="暂无规则配置"
items={tunnel.items}
<Table
aria-label={`${group.userName}-${tunnel.tunnelName}规则列表`}
className={`table-fixed ${FORWARD_GROUPED_TABLE_MIN_WIDTH_CLASS}`}
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
}}
>
{(forward) => (
<SortableContext
key={forward.id}
items={tunnelSortableForwardIds}
strategy={verticalListSortingStrategy}
<TableHeader>
{true && (
<TableColumn className="w-14">
{/* @ts-ignore */}
<Checkbox
aria-label="全选"
isIndeterminate={
isTunnelIndeterminate
}
isSelected={isTunnelAllSelected}
onValueChange={
handleTunnelSelectAllToggle
}
/>
</TableColumn>
)}
<TableColumn
className={
FORWARD_GROUPED_TABLE_COLUMN_CLASS.drag
}
/>
<TableColumn
className={
FORWARD_GROUPED_TABLE_COLUMN_CLASS.name
}
>
名称
</TableColumn>
<TableColumn
className={
FORWARD_GROUPED_TABLE_COLUMN_CLASS.inbound
}
>
入口
</TableColumn>
<TableColumn
className={
FORWARD_GROUPED_TABLE_COLUMN_CLASS.target
}
>
目标
</TableColumn>
<TableColumn
className={
FORWARD_GROUPED_TABLE_COLUMN_CLASS.strategy
}
>
策略
</TableColumn>
<TableColumn
className={
FORWARD_GROUPED_TABLE_COLUMN_CLASS.totalFlow
}
>
总流量
</TableColumn>
<TableColumn
className={
FORWARD_GROUPED_TABLE_COLUMN_CLASS.status
}
>
状态
</TableColumn>
<TableColumn
className={
FORWARD_GROUPED_TABLE_COLUMN_CLASS.actions
}
>
操作
</TableColumn>
</TableHeader>
<TableBody
emptyContent="暂无规则配置"
items={tunnel.items}
>
{(forward) => (
<SortableTableRow
copyToClipboard={copyToClipboard}
formatFlow={formatFlow}
@@ -4500,10 +4541,10 @@ export default function ForwardPage() {
showAddressModal={showAddressModal}
toggleSelect={toggleSelect}
/>
</SortableContext>
)}
</TableBody>
</Table>
)}
</TableBody>
</Table>
</SortableContext>
</DndContext>
</SortableTunnelGroupContainer>
);
@@ -4590,12 +4631,12 @@ export default function ForwardPage() {
key={`direct-group-${group.userId}-${tunnel.tunnelKey}`}
bodyClassName="p-3"
collapsed={collapsed}
countClassName="text-xs text-secondary-700"
countClassName="text-xs text-default-600"
groupUserId={group.userId}
headerClassName="flex items-center justify-between rounded-lg bg-secondary/10 px-3 py-2"
titleClassName="truncate text-sm font-semibold text-secondary-700"
headerClassName="flex items-center justify-between rounded-lg bg-default-100/60 px-3 py-2"
titleClassName="truncate text-sm font-semibold text-default-700"
tunnel={tunnel}
wrapperClassName="rounded-xl border border-secondary/20 bg-secondary/5 space-y-3"
wrapperClassName="rounded-xl border border-divider bg-default-50/60 space-y-3"
onToggleCollapsed={() =>
toggleTunnelGroupCollapsed(
group.userId,
+123
View File
@@ -0,0 +1,123 @@
import type { MonitorNodeApiItem } from "@/api/types";
import { useCallback, useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { RefreshCw, LayoutGrid, List } 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";
type MonitorNode = {
id: number;
name: string;
connectionStatus: "online" | "offline";
};
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 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>
<Button
isLoading={nodesLoading}
size="sm"
variant="flat"
onPress={() => loadNodes()}
>
<RefreshCw className="w-4 h-4 mr-1" />
刷新节点
</Button>
</div>
</div>
{nodesError ? (
<Card>
<CardHeader>
<h3 className="text-sm font-semibold">节点列表</h3>
</CardHeader>
<CardBody>
<div className="text-sm text-default-600">{nodesError}</div>
</CardBody>
</Card>
) : null}
</div>
<MonitorView nodeMap={nodeMap} viewMode={viewMode} />
</AnimatedPage>
);
}
+49 -153
View File
@@ -67,7 +67,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";
@@ -100,15 +103,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;
@@ -297,6 +292,13 @@ export default function NodePage() {
"node-active-tab",
"local",
);
// 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>
>({});
@@ -581,6 +583,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,
},
};
}),
);
}
};
@@ -644,15 +683,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) {
@@ -680,17 +711,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 =
@@ -2123,130 +2143,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">
{/* 操作按钮 */}
@@ -2329,7 +2226,6 @@ export default function NodePage() {
</SortableContext>
</DndContext>
)}
{/* 新增/编辑节点对话框 */}
<Modal
backdrop="blur"
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),
};
};
+651 -42
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,6 +266,15 @@ export default function TunnelPage() {
const [selectMode, setSelectMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);
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,
@@ -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>
@@ -2459,22 +2797,143 @@ export default function TunnelPage() {
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}>
@@ -2482,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>
</>
@@ -3085,17 +3550,154 @@ export default function TunnelPage() {
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}>
@@ -3103,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>
</>
+337 -176
View File
@@ -30,6 +30,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,6 +59,9 @@ import {
resetUserQuota,
getUserGroupList,
getUserGroups,
getMonitorPermissionList,
assignMonitorPermission,
removeMonitorPermission,
} from "@/api";
import {
EditIcon,
@@ -229,12 +233,18 @@ 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);
// 分配新隧道权限相关状态
const [assignLoading, setAssignLoading] = useState(false);
const [isTunnelListExpanded, setIsTunnelListExpanded] = useState(false);
const [batchTunnelSelections, setBatchTunnelSelections] = useState<
Map<number, number | null>
>(new Map());
@@ -396,6 +406,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 {
@@ -422,7 +458,8 @@ export default function UserPage() {
void loadTunnels();
void loadSpeedLimits();
void loadUserGroups();
}, [loadSpeedLimits, loadTunnels, loadUserGroups]);
void loadMonitorPermissions();
}, [loadMonitorPermissions, loadSpeedLimits, loadTunnels, loadUserGroups]);
useEffect(() => {
void loadUsers();
@@ -598,6 +635,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);
@@ -1050,6 +1144,26 @@ export default function UserPage() {
{/* 其他信息 */}
<div className="space-y-1.5 pt-2 border-t border-divider">
{(user.dailyQuotaGB ?? 0) > 0 ||
(user.monthlyQuotaGB ?? 0) > 0 ||
(user.disabledByQuota ?? 0) > 0 ? (
<>
<div className="flex justify-between text-sm">
<span className="text-default-600">每日配额</span>
<span className="font-medium text-xs">
{formatFlow(Number(user.dailyUsedBytes ?? 0))} /{" "}
{formatQuotaLimit(user.dailyQuotaGB)}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-default-600">每月配额</span>
<span className="font-medium text-xs">
{formatFlow(Number(user.monthlyUsedBytes ?? 0))}{" "}
/ {formatQuotaLimit(user.monthlyQuotaGB)}
</span>
</div>
</>
) : null}
<div className="flex justify-between text-sm">
<span className="text-default-600">规则数量</span>
<span className="font-medium text-xs">
@@ -1404,7 +1518,7 @@ export default function UserPage() {
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full sm:max-w-xl rounded-2xl",
base: "max-w-[95vw] sm:max-w-4xl",
}}
isDismissable={false}
isOpen={isTunnelModalOpen}
@@ -1414,154 +1528,177 @@ 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>
<div className="space-y-4">
<div className="flex flex-col gap-2 relative">
<p className="text-base text-default-700 ml-1 font-medium">
隧道列表
</p>
<div className="text-sm text-default-500 bg-default-100 dark:bg-default-50 p-3 rounded-lg border border-default-200 dark:border-default-100/30">
流量限制、规则数量、到期时间、流量重置时间将自动继承用户设置
</div>
{/* 顶部触发框 */}
<div
className={`group flex items-center justify-between px-4 py-3 rounded-xl border-2 transition-all cursor-pointer shadow-sm w-[320px] ${isTunnelListExpanded ? "border-primary bg-white ring-2 ring-primary/10" : "border-default-200 bg-default-50 hover:border-primary-300"}`}
onClick={() =>
setIsTunnelListExpanded(!isTunnelListExpanded)
}
>
<span
className={`text-sm truncate ${batchTunnelSelections.size > 0 ? "text-primary-500 font-bold" : "text-default-400"}`}
>
{batchTunnelSelections.size > 0
? `已选 ${batchTunnelSelections.size} 项:` +
Array.from(batchTunnelSelections.keys())
.map(
(id) => tunnels.find((t) => t.id === id)?.name,
)
.join("、")
: "请选择隧道(勾选后配置限速)"}
</span>
<svg
className={`w-5 h-5 text-default-400 transition-transform ${isTunnelListExpanded ? "rotate-180 text-primary" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path d="M19 9l-7 7-7-7" strokeWidth={2.5} />
</svg>
</div>
<div className="grid gap-2 max-h-72 overflow-y-auto pr-1">
{tunnels.map((tunnel) => {
const isAssigned = isTunnelAssigned(tunnel.id);
const isSelected = batchTunnelSelections.has(tunnel.id);
const tunnelSpeedLimits = getSpeedLimitsForTunnel(
tunnel.id,
);
{/* 列表悬浮层 */}
{isTunnelListExpanded && (
<div
className="absolute top-[calc(100%+8px)] left-0 w-[320px] border border-default-200 rounded-2xl bg-white dark:bg-default-900 shadow-2xl overflow-hidden z-[999]"
onClick={(e) => e.stopPropagation()}
>
<div className="max-h-[350px] overflow-y-auto p-2 custom-scrollbar">
{tunnels.map((tunnel) => {
const isAssigned = isTunnelAssigned(tunnel.id);
const isSelected = batchTunnelSelections.has(
tunnel.id,
);
const tunnelSpeedLimits = getSpeedLimitsForTunnel(
tunnel.id,
);
const currentSpeedId = batchTunnelSelections.get(
tunnel.id,
);
return (
<div
key={tunnel.id}
aria-disabled={isAssigned}
className={`
px-4 py-3 rounded-lg border transition-all duration-200 cursor-pointer
${
isAssigned
? "bg-default-100/50 dark:bg-default-50/50 border-default-200/50 dark:border-default-100/20 opacity-60 cursor-not-allowed"
: isSelected
? "bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-500/50 shadow-sm"
: "bg-white dark:bg-default-50 border-default-200 dark:border-default-100/30 hover:border-primary-200 dark:hover:border-primary-500/30 hover:shadow-sm"
}
`}
role="button"
tabIndex={isAssigned ? -1 : 0}
onClick={() =>
!isAssigned && toggleTunnelSelection(tunnel.id)
}
onKeyDown={(event) => {
if (isAssigned) {
return;
}
return (
<div
key={tunnel.id}
className={`flex items-center justify-between px-4 py-2.5 rounded-xl mb-1 border transition-all ${isSelected ? "bg-primary-50/60 border-primary-200" : "bg-transparent border-transparent hover:bg-default-100"} ${isAssigned ? "opacity-40 grayscale cursor-not-allowed" : "cursor-pointer"}`}
// 核心:整行点击直接控制状态
onClick={(e) => {
if (isAssigned) return;
e.stopPropagation();
toggleTunnelSelection(tunnel.id);
}}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
toggleTunnelSelection(tunnel.id);
}
}}
>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 flex-1 min-w-0">
<Checkbox
color="primary"
isDisabled={isAssigned}
isSelected={isSelected}
size="md"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
onValueChange={() =>
toggleTunnelSelection(tunnel.id)
}
/>
<span
className={`font-medium truncate ${isAssigned ? "text-default-400" : "text-default-700 dark:text-default-600"}`}
>
<div className="flex items-center gap-4 min-w-0 flex-1">
<Checkbox
color="primary"
isSelected={isSelected}
isDisabled={isAssigned}
// 关键:禁用 Checkbox 自身的点击,防止它跟父容器打架
className="pointer-events-none"
/>
<span
className={`text-sm font-medium truncate ${isSelected ? "text-primary-700" : ""}`}
>
{tunnel.name}
</span>
</div>
{tunnel.name}
</span>
{isAssigned && (
<Chip
className="shrink-0"
color="default"
size="sm"
variant="flat"
>
已分配
</Chip>
)}
</div>
{/* 右侧限速选择:复刻 image_24879b */}
{isSelected && !isAssigned && (
<div
className="flex items-center ml-2"
onClick={(e) => e.stopPropagation()}
>
<Select
aria-label="限速选择"
className="w-32"
placeholder="不限速"
selectedKeys={
currentSpeedId
? [currentSpeedId.toString()]
: []
}
size="sm"
variant="flat"
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0];
{isSelected && !isAssigned && (
<div>
<Select
className="w-36"
classNames={{
trigger: "min-h-10 h-10",
}}
placeholder="不限速"
selectedKeys={
batchTunnelSelections.get(tunnel.id) !==
null &&
batchTunnelSelections.get(tunnel.id) !==
undefined
? [
batchTunnelSelections
.get(tunnel.id)!
.toString(),
]
: []
}
size="sm"
onClick={(e) => e.stopPropagation()}
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as
| string
| undefined;
updateTunnelSpeedLimit(
tunnel.id,
selectedKey
? Number(selectedKey)
: null,
);
}}
updateTunnelSpeedLimit(
tunnel.id,
selectedKey ? Number(selectedKey) : null,
);
}}
>
{tunnelSpeedLimits.map((sl) => (
<SelectItem
key={sl.id.toString()}
textValue={sl.name}
>
{tunnelSpeedLimits.map((sl) => (
<SelectItem key={sl.id.toString()}>
{sl.name}
</SelectItem>
))}
</Select>
</div>
)}
{isAssigned && (
<span className="text-[10px] text-default-400 italic pr-2">
已分配
</span>
)}
{sl.name}
</SelectItem>
))}
</Select>
</div>
);
})}
</div>
<div className="bg-default-50/80 border-t p-2 flex justify-end">
<Button
className="font-bold"
color="primary"
size="md"
variant="light"
onPress={() => setIsTunnelListExpanded(false)}
>
完成配置
</Button>
)}
</div>
</div>
);
})}
{tunnels.length === 0 && (
<div className="p-8 text-center text-default-400 bg-default-50 dark:bg-default-100/50 rounded-lg border border-dashed border-default-200 dark:border-default-100/30">
暂无可用隧道
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
className="w-fit px-8"
className="w-full sm:w-auto"
color="primary"
isDisabled={batchTunnelSelections.size === 0}
isLoading={assignLoading}
@@ -1583,19 +1720,20 @@ export default function UserPage() {
<h3 className="text-lg font-semibold mb-4">已有权限</h3>
<Table
aria-label="用户隧道权限列表"
// 1. 去掉 layout="fixed",改为在 classNames.table 里写 table-fixed
classNames={{
wrapper: "shadow-none p-0 min-w-[380px] overflow-x-auto",
th: "bg-default-50 text-default-600 font-semibold",
td: "py-4 border-b border-divider",
wrapper: "shadow-none",
th: "bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300 font-medium",
}}
>
<TableHeader>
<TableColumn className="w-[35%]">隧道名称</TableColumn>
<TableColumn className="w-[35%]">流量统计</TableColumn>
<TableColumn className="w-[30%] text-right">
操作
</TableColumn>
<TableColumn>隧道名称</TableColumn>
<TableColumn>流量统计</TableColumn>
<TableColumn>规则数量</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn>限速规则</TableColumn>
<TableColumn>重置时间</TableColumn>
<TableColumn>到期时间</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody
emptyContent="暂无隧道权限"
@@ -1604,36 +1742,61 @@ export default function UserPage() {
loadingContent={<Spinner />}
>
{(userTunnel) => (
<TableRow
key={userTunnel.id}
className="hover:bg-default-50/50 transition-colors"
>
<TableRow key={userTunnel.id}>
<TableCell>{userTunnel.tunnelName}</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
<div className="flex justify-between text-small">
<span className="text-gray-600">限制:</span>
<span className="font-medium">
{formatFlow(userTunnel.flow, "gb")}
</span>
</div>
<div className="flex justify-between text-small">
<span className="text-gray-600">已用:</span>
<span className="font-medium text-danger">
{formatFlow(
calculateTunnelUsedFlow(userTunnel),
)}
</span>
</div>
</div>
</TableCell>
<TableCell>{userTunnel.num}</TableCell>
<TableCell>
<Chip
color={
userTunnel.status === 1 ? "success" : "danger"
}
size="sm"
variant="flat"
>
{userTunnel.status === 1 ? "正常" : "禁用"}
</Chip>
</TableCell>
<TableCell>
<Chip
color={
userTunnel.speedLimitName ? "warning" : "success"
}
size="sm"
variant="flat"
>
{userTunnel.speedLimitName || "不限速"}
</Chip>
</TableCell>
<TableCell>
{userTunnel.flowResetTime === 0
? "不重置"
: `每月${userTunnel.flowResetTime}号`}
</TableCell>
<TableCell>{formatDate(userTunnel.expTime)}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<span className="font-bold text-default-700 whitespace-nowrap">
{userTunnel.tunnelName}
</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1 whitespace-nowrap text-sm">
<span className="text-danger font-mono font-bold">
{formatFlow(calculateTunnelUsedFlow(userTunnel))}
</span>
<span className="text-default-300">/</span>
<span className="text-default-500 font-mono">
{formatFlow(userTunnel.flow, "gb")}
</span>
</div>
</TableCell>
<TableCell>
{/* 5. justify-end 确保按钮群组整体靠右 */}
<div className="flex items-center justify-end gap-2">
<Button
isIconOnly
className="bg-blue-50 text-blue-600 hover:bg-blue-100 w-8 h-8 min-w-8"
aria-label="编辑隧道权限"
color="primary"
size="sm"
variant="flat"
onPress={() => handleEditTunnel(userTunnel)}
@@ -1642,12 +1805,15 @@ export default function UserPage() {
</Button>
<Button
isIconOnly
className="bg-orange-50 text-orange-600 hover:bg-orange-100 w-8 h-8 min-w-8"
aria-label="重置隧道流量"
color="warning"
size="sm"
title="重置流量"
variant="flat"
onPress={() => handleResetTunnelFlow(userTunnel)}
>
<svg
aria-hidden="true"
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
@@ -1661,7 +1827,8 @@ export default function UserPage() {
</Button>
<Button
isIconOnly
className="bg-danger-50 text-danger hover:bg-danger-100 w-8 h-8 min-w-8"
aria-label="删除隧道权限"
color="danger"
size="sm"
variant="flat"
onPress={() => handleRemoveTunnel(userTunnel)}
@@ -1677,14 +1844,8 @@ export default function UserPage() {
</div>
</div>
</ModalBody>
<ModalFooter className="justify-end">
<Button
color="primary"
variant="flat" // 建议加个 variant 保持和你其他按钮风格一致
onPress={onTunnelModalClose}
>
关闭
</Button>
<ModalFooter>
<Button onPress={onTunnelModalClose}>关闭</Button>
</ModalFooter>
</ModalContent>
</Modal>
@@ -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}