Compare commits

...

83 Commits

Author SHA1 Message Date
sagit 701b4011cb feat: move tunnel quality monitor toggle from /settings to /config (#393)
将实时隧道质量检测开关从 /settings 移到 /config 页面,统一管理全局配置项。
2026-03-29 15:48:45 +08:00
sagitchu d128d2f657 feat: move tunnel quality monitor toggle from /settings to /config 2026-03-29 15:47:10 +08:00
sagit 400a40fe80 fix: 节点离线时允许删除隧道关联,但禁止新增隧道 (#392)
## 修复内容

修复 #342

### 问题
当节点离线时,用户无法编辑隧道配置(包括更换节点),也无法修改相关的转发规则。

### 变更

**Backend:**
- `prepareTunnelCreateState`: 更新隧道时,允许已关联的离线节点保留(用户可能在移除它们),仅拒绝新增的离线节点
- `syncForwardServicesWithWarnings`: 离线节点跳过下发并返回警告,不再硬性失败
- `applyTunnelRuntime`: 所有节点类型(入口/转发链/出口)均支持离线错误延迟处理
- 新增 `isNodeOfflineOrTimeoutError` 辅助函数

**Frontend:**
- `validateTunnelForm`: 新增 `isEdit` 参数,编辑模式下跳过离线节点验证
- `tunnel.tsx`: 传递 `isEdit` 标志到表单验证
2026-03-28 19:31:54 +08:00
sagitchu 103290ed35 fix: 节点离线时允许删除隧道关联,但禁止新增隧道 (#342)
- prepareTunnelCreateState: 更新隧道时允许已关联的离线节点,仅拒绝新增的离线节点
- syncForwardServicesWithWarnings: 离线节点跳过下发并返回警告,不再硬性失败
- applyTunnelRuntime: 所有节点类型均支持离线错误延迟处理
- 前端 validateTunnelForm: 编辑模式下跳过离线节点验证

Closes #342
2026-03-28 19:30:30 +08:00
sagitchu 363e714603 fix: 支持跨版本隧道链路 (v6入v4出 / v4入v6出)
问题:selectTunnelDialHost 只检查同版本兼容 (v4->v4, v6->v6),
导致 v6-only 入口节点连接 v4-only 出口节点时报错:
"节点链路不兼容"

修复:在 default 分支增加跨版本支持:
- fromV6 && toV4 → 返回出口 v4 地址
- fromV4 && toV6 → 返回出口 v6 地址

更新测试用例以反映新行为
2026-03-28 10:35:47 +08:00
sagit afd1258fcd fix(monitor): add tunnel quality detection toggle (#386)
## Summary
- add a global settings toggle to enable or disable real-time tunnel
quality detection
- stop frontend tunnel quality polling and related status UI when the
toggle is off
- gate the backend tunnel quality prober so disabling the setting also
stops server-side probing

## Test plan
- [x] cd go-backend && go test ./...
- [x] cd vite-frontend && npm run build

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-03-26 20:10:31 +08:00
sagitchu e69082a596 fix(monitor): add tunnel quality detection toggle
Allow admins to disable real-time tunnel quality probing from settings so the monitor UI and backend probe loop stop together.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:08:40 +08:00
sagit d30363d164 docs: update agent context to 2.1.9-rc10 (#385) 2026-03-25 20:27:40 +08:00
sagitchu 8e1a87bf5a docs: update agent context to 2.1.9-rc10 2026-03-25 20:26:09 +08:00
sagit 6180b5a198 fix(backend): clean up forwards when revoking tunnel permissions (#384) 2026-03-25 20:25:49 +08:00
sagitchu 61d95ab5d5 fix(backend): clean up forwards when revoking tunnel permissions 2026-03-25 20:24:27 +08:00
sagit c27be19915 fix: bracket IPv6 forward entry addresses (#383)
Return standard [host]:port values from forward list responses so IPv6 rule entries can be copied directly from the UI.
2026-03-24 22:16:55 +08:00
sagit f62a35c3f9 fix: resolve PostgreSQL type encoding issues for node metrics (#381)
Fixed OID 25 (text) inference failing to encode int64 by embedding the
node ID format string directly. Replaced CAST(x AS INTEGER) with CAST(x
AS BIGINT) to prevent 32-bit overflow on Unix milliseconds timestamps.
2026-03-24 10:46:29 +08:00
sagitchu 2a1caf32c4 fix(monitor): resolve PostgreSQL type encoding issues for node metrics 2026-03-24 10:45:17 +08:00
sagit fdcc30a493 release: 2.1.9-rc8 (#380)
Release 2.1.9-rc8
2026-03-24 09:46:39 +08:00
sagitchu efaffb0475 chore: release 2.1.9-rc8 2026-03-24 09:45:21 +08:00
sagitchu 4954526cbc chore: finalize release plan 064 2026-03-24 07:23:23 +08:00
sagit 9d50071915 chore: release 2.1.9-rc7 (#379)
Bump version to 2.1.9-rc7 in AGENTS.md
2026-03-24 07:22:52 +08:00
sagitchu ceceee6ebd chore: bump version to 2.1.9-rc7 2026-03-24 07:21:20 +08:00
sagitchu 11051f5517 chore: finalize release plan 063 2026-03-24 07:18:56 +08:00
sagit ff2c7c4959 chore: release 2.1.9-rc6 (#378)
Bump version to 2.1.9-rc6 in AGENTS.md
2026-03-24 07:18:26 +08:00
sagitchu 6364b96935 chore: bump version to 2.1.9-rc6 2026-03-24 07:16:01 +08:00
sagit 409f0a232a fix: bump npm package version to 2.1.9-rc5 (#376)
Bumps version in package.json and AGENTS.md
2026-03-24 00:58:34 +08:00
sagitchu f79994e0e0 fix: bump npm package version to 2.1.9-rc5 to fix CI error 2026-03-24 00:56:08 +08:00
sagit 9fdb16d035 chore: bump version to 2.1.9-rc4 (#375)
Bump version tag in AGENTS.md
2026-03-23 23:01:58 +08:00
sagitchu 53b632a6f7 chore: bump version to 2.1.9-rc4 2026-03-23 22:59:47 +08:00
sagit bf7b2a0740 fix: re-assign tunnel ports automatically for out-of-range entries (#373) (#374)
Automatically re-assign ports from available ranges instead of rejecting
tunnel modification when adding new entry nodes.
2026-03-23 22:59:13 +08:00
sagitchu 8475bc27bb fix: re-assign port automatically if out of range for new tunnel entries (fixes #373) 2026-03-23 22:57:49 +08:00
sagit 5d01572eff release: 2.1.9-rc3 (#371)
Sync all changes and fix service monitor stale detection
2026-03-22 19:06:43 +08:00
sagitchu a353faaa71 chore: release 2.1.9-rc3 (fix service monitor stale detection) 2026-03-22 19:05:31 +08:00
sagit e7b25004ba Update monitor rendering (#370)
Merge all changes into main for RC2 release.
2026-03-22 17:56:14 +08:00
sagitchu 3826cb02c0 chore: update monitor rendering and release rc2 2026-03-22 17:55:10 +08:00
sagitchu a1fee8e432 Merge remote-tracking branch 'origin/main' into fix-agents-v14-final 2026-03-22 13:33:58 +08:00
sagitchu aafdb78482 feat: node logo by distro types and official icons 2026-03-22 13:33:37 +08:00
sagit 16b545d8cd chore: update AGENTS.md for release 2.1.9-beta14 (#368) 2026-03-22 05:05:50 +00:00
sagitchu 45065178b8 chore: update AGENTS.md for release 2.1.9-beta14 2026-03-22 13:04:43 +08:00
sagit 8ebde9dca9 feat: node OS logo and UI fixes (#367)
* chore: update AGENTS.md with next release info

* feat: node OS logo, UI rate fix, and monitor trend updates
2026-03-22 05:03:04 +00:00
sagit 0a1ec60750 fix: qualify tunnel_metric columns (#366)
fix ambiguous column reference in tunnel_metric upserts
2026-03-21 19:42:00 +08:00
sagitchu 9ec35d2f2f merge main into sync-agents-v12 2026-03-21 19:40:44 +08:00
sagitchu c914040b7d fix(repo): qualify tunnel_metric columns to avoid ambiguity in ON CONFLICT 2026-03-21 19:40:16 +08:00
sagit 822362c44c Update AGENTS.md for release v2.1.9-beta12 (#365)
Updating commit SHA and tag in AGENTS.md for the latest release.
2026-03-21 19:24:51 +08:00
sagitchu 80f5935b76 chore: update AGENTS.md for release v2.1.9-beta12 2026-03-21 19:23:20 +08:00
sagit 433c8aab13 Tunnel metrics ingestion logging improvements (#364)
Added debug logging for better tunnel metrics monitoring.
2026-03-21 19:22:32 +08:00
sagitchu 949dfcd42d Merge branch 'main' into sync-all-changes
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2026-03-21 19:21:26 +08:00
sagitchu 1580e4ee10 chore: [monitoring] improve tunnel metric ingestion logging 2026-03-21 19:20:23 +08:00
sagit 32e4f0f514 feat: sync all changes (#363)
Automated changes to monitoring and system info
2026-03-21 18:41:42 +08:00
sagitchu ca3a643ef7 feat: optimize node monitoring and system info 2026-03-21 18:40:28 +08:00
sagitchu ce2b234843 chore: update AGENTS.md for release v2.1.9-beta10 2026-03-21 17:31:08 +08:00
sagit ac3506847c feat: implement tunnel quality polling and service monitor tuning (#362)
Implement 1s test, 30s report pattern across all monitoring subsystems.
2026-03-21 17:30:07 +08:00
sagitchu 6e3d604618 feat: implement tunnel quality polling and service monitor tuning to 1s/30s intervals 2026-03-21 17:28:52 +08:00
sagit 4417ece7cd fix: renewal reminder styling and position (#361)
Fixes the styling of the renewal reminder for nodes that have been
dismissed.
2026-03-21 10:04:02 +08:00
sagitchu bab4371ba7 fix: renewal reminder styling and position 2026-03-21 10:02:52 +08:00
sagit 960c97cee4 release: v2.1.9-beta9 (#360)
Theme persistence fix and version bump
2026-03-20 23:39:42 +08:00
sagitchu 9d05d75fd6 docs: update AGENTS.md for release v2.1.9-beta9 2026-03-20 23:37:47 +08:00
sagitchu c137bdcc63 fix: theme persistence and update version to 2.1.9-beta9 2026-03-20 23:37:35 +08:00
sagit b7065f6e99 feat: implement theme selection and system integration (#359)
Integrated theme selection into settings page and unified theme
provider.
2026-03-20 22:49:06 +08:00
sagitchu bd4e1f66cb feat: implement theme selection and system integration 2026-03-20 22:47:49 +08:00
sagitchu 9f0670f4d0 docs: complete plan 058 2026-03-20 22:20:49 +08:00
sagit 32e338d295 fix: include missing field in repository (#358)
Included missing field in repository.
2026-03-20 22:20:15 +08:00
sagitchu f6a753baa3 fix: include missing field in repository 2026-03-20 22:18:45 +08:00
sagit 322a10bb9d chore: release 2.1.9-beta7 (#357)
Sync current changes to 2.1.9-beta7.
2026-03-20 22:18:17 +08:00
sagitchu 27c13d6c47 chore: release 2.1.9-beta7 2026-03-20 22:15:51 +08:00
sagit f45f96063a fix: hide remote nodes from monitor view (#356)
Filter out nodes with is_remote=1 from the monitor API endpoint, as per
user requirement.
2026-03-20 18:42:58 +08:00
sagitchu 1780be73b9 fix(monitor): hide remote nodes from monitor view 2026-03-20 18:41:31 +08:00
sagit addf8e2089 feat: implement Uptime Kuma style historic quality bars for tunnel monitor (#355) 2026-03-20 14:17:16 +08:00
sagit 75cd60ea3e feat(ui): update list view tables styling for Node, Monitor, User, Tunnel, and Limit (#354) 2026-03-20 13:15:15 +08:00
sagit fe42a77409 feat: periodic tunnel quality probing (#353)
Implement tunnel quality metrics collection and display
2026-03-20 12:38:08 +08:00
sagitchu ce9abf457f fix(frontend): recharts Legend export and implicit any in monitoring 2026-03-20 12:36:48 +08:00
sagitchu 3c57a5ac84 feat: periodic tunnel quality probing and monitoring 2026-03-20 12:34:09 +08:00
sagit ff7c91d277 chore: optimize agent-panel metrics communication (#352) 2026-03-20 03:39:49 +00:00
sagit 1498f3052d feat: align node and user list view UI (#351)
Match monitor page list view style per user request.
2026-03-20 11:03:01 +08:00
sagitchu 6b1264ae90 feat: align node and user list view UI 2026-03-20 11:01:37 +08:00
sagit 18445ec063 feat(ui): add list view for node and user pages (#350)
add list view feature
2026-03-20 10:02:11 +08:00
sagitchu 08bc91e5c9 feat(ui): add list view for node and user pages 2026-03-20 10:00:56 +08:00
sagit 2f424bea31 feat: monitor tunnel top level (#347)
Merge chore/force-update sync
2026-03-19 15:46:02 +08:00
sagitchu bc75ed745d feat: monitor tunnel top level 2026-03-19 15:44:25 +08:00
sagit 78fb9a31d6 chore: Delete openspec documentation, AI agent configurations, and development plans. (#346) 2026-03-19 06:22:51 +00:00
sagitchu d3ed2e8856 chore: Delete openspec documentation, AI agent configurations, and development plans. 2026-03-19 14:16:07 +08:00
sagit db21ce6bb4 feat: implement monitor page list view (#345) 2026-03-19 13:54:12 +08:00
sagit 76ad841231 chore: release 2.1.9-alpha5 (#344)
Release 2.1.9-alpha5
2026-03-19 11:49:43 +08:00
sagitchu d0535707dc chore: release 2.1.9-alpha5 and update project knowledge base 2026-03-19 11:48:03 +08:00
sagit 6458b5af00 feat: beautify monitor tab and improve user page (#343) 2026-03-18 22:46:02 +08:00
sagit 555039e028 feat: monitor page redesign and node card cleanup (#340) 2026-03-18 18:57:23 +08:00
111 changed files with 6659 additions and 2195 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
@@ -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:** Wed Mar 18 2026
**Commit:** ea21a7d
**Generated:** Tue Mar 24 2026
**Commit:** 8ebde9d
**Branch:** main
**Tag:** 2.1.8
**Tag:** 2.1.9-rc10
## 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
+4 -4
View File
@@ -33,13 +33,13 @@ curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh -
#### 安装特定版本
从 [Releases](https://github.com/Sagit-chu/flux-panel/releases) 页面复制对应版本的安装命令,脚本会自动安装该版本而非最新版。
面板端(以 2.1.0 为例):
面板端(以 2.1.9-beta6 为例):
```bash
curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.0/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.9-beta6/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
```
节点端(以 2.1.0 为例):
节点端(以 2.1.9-beta6 为例):
```bash
curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.0/install.sh -o install.sh && chmod +x install.sh && ./install.sh
curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.9-beta6/install.sh -o install.sh && chmod +x install.sh && ./install.sh
```
#### PostgreSQL 部署(Docker Compose)
+5
View File
@@ -1,5 +1,10 @@
# GO BACKEND KNOWLEDGE BASE
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Go-based Admin API for FLVX. Replaced legacy Spring Boot backend.
**Stack:** Go 1.24, net/http (std lib), GORM + SQLite/PostgreSQL (glebarez/sqlite - CGO-free).
+53 -11
View File
@@ -8,6 +8,7 @@ import (
"net"
"strings"
"sync"
"sync/atomic"
"time"
"go-backend/internal/monitoring"
@@ -20,26 +21,47 @@ type nodeCommander interface {
SendCommand(nodeID int64, cmdType string, data interface{}, timeout time.Duration) (ws.CommandResult, error)
}
const serviceMonitorReportInterval = 30 * time.Second // DB write interval per monitor
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
// In-memory latest result per monitor (for real-time API reads)
latestResults map[int64]*model.ServiceMonitorResult
lastDBWrite map[int64]int64 // last DB write timestamp per monitorID
mu sync.RWMutex
cancel context.CancelFunc
wg sync.WaitGroup
checking int32 // atomic flag: 1 = runChecks running, 0 = idle
}
func NewChecker(repo *repo.Repository, commander nodeCommander) *Checker {
return &Checker{
repo: repo,
commander: commander,
lastRun: make(map[int64]int64),
inFlight: make(map[int64]struct{}),
repo: repo,
commander: commander,
lastRun: make(map[int64]int64),
inFlight: make(map[int64]struct{}),
latestResults: make(map[int64]*model.ServiceMonitorResult),
lastDBWrite: make(map[int64]int64),
}
}
// GetLatestCached returns the in-memory latest results (updated every 1s).
// Returns nil if no results are cached.
func (c *Checker) GetLatestCached() []*model.ServiceMonitorResult {
c.mu.RLock()
defer c.mu.RUnlock()
results := make([]*model.ServiceMonitorResult, 0, len(c.latestResults))
for _, r := range c.latestResults {
results = append(results, r)
}
return results
}
func (c *Checker) Start(ctx context.Context) {
c.mu.Lock()
ctx, cancel := context.WithCancel(ctx)
@@ -52,7 +74,7 @@ func (c *Checker) Start(ctx context.Context) {
limits := c.loadServiceMonitorLimits()
scanInterval := time.Duration(limits.CheckerScanIntervalSec) * time.Second
if scanInterval <= 0 {
scanInterval = 30 * time.Second
scanInterval = 1 * time.Second
}
timer := time.NewTimer(scanInterval)
@@ -87,6 +109,12 @@ func (c *Checker) RunOnce(m *model.ServiceMonitor) (*model.ServiceMonitorResult,
}
func (c *Checker) runChecks(ctx context.Context) {
// Skip if previous round is still running (interval < timeout guard)
if !atomic.CompareAndSwapInt32(&c.checking, 0, 1) {
return
}
defer atomic.StoreInt32(&c.checking, 0)
if c == nil || c.repo == nil {
return
}
@@ -174,6 +202,8 @@ func (c *Checker) runChecks(ctx context.Context) {
}
close(jobs)
reportIntervalMs := int64(serviceMonitorReportInterval / time.Millisecond)
for i := 0; i < workerLimit; i++ {
c.wg.Add(1)
go func() {
@@ -188,14 +218,26 @@ func (c *Checker) runChecks(ctx context.Context) {
}
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)
}
// Always update in-memory cache for real-time reads
c.mu.Lock()
c.latestResults[m.ID] = result
c.lastRun[m.ID] = result.Timestamp
delete(c.inFlight, m.ID)
// Only write to DB every 30s per monitor
lastWrite := c.lastDBWrite[m.ID]
writeToDB := ts-lastWrite >= reportIntervalMs
if writeToDB {
c.lastDBWrite[m.ID] = ts
}
c.mu.Unlock()
if writeToDB {
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)
}
}
}
}
}()
+4 -1
View File
@@ -1,6 +1,9 @@
# BACKEND HTTP HANDLER KNOWLEDGE BASE
**Generated:** Thu Feb 26 2026
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
HTTP request handlers for FLVX Admin API. Core business logic layer.
@@ -280,6 +280,16 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
for _, fp := range ports {
if limiterID != nil && speed != nil {
if err := h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed); err != nil {
// If the limiter push fails because the node is offline, skip it with a warning
if isNodeOfflineOrTimeoutError(err) {
node, _ := h.getNodeRecord(fp.NodeID)
nodeName := fmt.Sprintf("%d", fp.NodeID)
if node != nil && strings.TrimSpace(node.Name) != "" {
nodeName = strings.TrimSpace(node.Name)
}
warnings = append(warnings, fmt.Sprintf("节点 %s 不在线,已跳过下发", nodeName))
continue
}
return nil, err
}
}
@@ -308,6 +318,12 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
warnings = append(warnings, warning)
}
}
// When a node is offline, skip it with a warning instead of failing.
// This lets users modify forward rules even when some entry nodes are down.
if err != nil && isNodeOfflineOrTimeoutError(err) {
warnings = append(warnings, fmt.Sprintf("节点 %s 不在线,已跳过下发", node.Name))
continue
}
if err != nil {
return warnings, fmt.Errorf("节点 %s 下发失败: %w", node.Name, err)
}
@@ -345,21 +345,39 @@ func TestSelectTunnelDialHost_V6Only_PreferV4Fallback(t *testing.T) {
}
}
func TestSelectTunnelDialHost_Incompatible(t *testing.T) {
func TestSelectTunnelDialHost_CrossVersion_V4ToV6(t *testing.T) {
// v4-only -> v6-only: 跨版本支持,应成功返回 v6 地址
from := v4OnlyNode("from", "10.0.0.1")
to := v6OnlyNode("to", "2001:db8::2")
_, err := selectTunnelDialHost(from, to, "", "")
if err == nil {
t.Fatal("expected error for incompatible nodes (v4-only -> v6-only)")
host, err := selectTunnelDialHost(from, to, "", "")
if err != nil {
t.Fatalf("unexpected error for cross-version (v4-only -> v6-only): %v", err)
}
if host != "2001:db8::2" {
t.Fatalf("expected v6 address for cross-version, got %q", host)
}
}
func TestSelectTunnelDialHost_Incompatible_Reverse(t *testing.T) {
func TestSelectTunnelDialHost_CrossVersion_V6ToV4(t *testing.T) {
// v6-only -> v4-only: 跨版本支持,应成功返回 v4 地址
from := v6OnlyNode("from", "2001:db8::1")
to := v4OnlyNode("to", "10.0.0.2")
host, err := selectTunnelDialHost(from, to, "", "")
if err != nil {
t.Fatalf("unexpected error for cross-version (v6-only -> v4-only): %v", err)
}
if host != "10.0.0.2" {
t.Fatalf("expected v4 address for cross-version, got %q", host)
}
}
func TestSelectTunnelDialHost_TrulyIncompatible(t *testing.T) {
// 真正不兼容:两个节点都没有任何 IP
from := &nodeRecord{Name: "empty-from", ServerIPv4: "", ServerIPv6: "", ServerIP: ""}
to := &nodeRecord{Name: "empty-to", ServerIPv4: "", ServerIPv6: "", ServerIP: ""}
_, err := selectTunnelDialHost(from, to, "", "")
if err == nil {
t.Fatal("expected error for incompatible nodes (v6-only -> v4-only)")
t.Fatal("expected error for nodes with no IP addresses")
}
}
+44 -2
View File
@@ -42,8 +42,12 @@ type Handler struct {
upgradeMu sync.Mutex
pendingUpgradeRedeploy map[int64]struct{}
qualityProber *tunnelQualityProber
}
const monitorTunnelQualityEnabledConfigKey = "monitor_tunnel_quality_enabled"
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
@@ -93,6 +97,7 @@ func New(repo *repo.Repository, jwtSecret string) *Handler {
pendingUpgradeRedeploy: make(map[int64]struct{}),
}
h.healthCheck = health.NewChecker(repo, h.wsServer)
h.qualityProber = newTunnelQualityProber(h)
h.wsServer.SetNodeOnlineHook(h.onNodeOnline)
h.wsServer.SetNodeMetricHook(func(nodeID int64, info ws.SystemInfo) {
metricInfo := metrics.SystemInfo{
@@ -229,6 +234,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
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/quality", h.monitorTunnelQualityHandler)
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)
@@ -882,11 +888,32 @@ func normalizeAndValidateConfigValue(key, value string) (string, error) {
}
return pngDataURLPrefix + payload, nil
case monitorTunnelQualityEnabledConfigKey:
normalized := strings.TrimSpace(strings.ToLower(value))
switch normalized {
case "true", "false":
return normalized, nil
default:
return "", fmt.Errorf("隧道质量检测开关配置值无效")
}
default:
return value, nil
}
}
func (h *Handler) isTunnelQualityMonitoringEnabled() bool {
if h == nil || h.repo == nil {
return true
}
cfg, err := h.repo.GetConfigByName(monitorTunnelQualityEnabledConfigKey)
if err != nil || cfg == nil {
return true
}
return strings.TrimSpace(strings.ToLower(cfg.Value)) != "false"
}
func (h *Handler) userPackage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
@@ -1234,6 +1261,21 @@ func nullableNullInt64(v sql.NullInt64) interface{} {
return nil
}
// flowCryptoCache caches AES crypto instances by secret to avoid per-request SHA256+GCM init.
var flowCryptoCache sync.Map
func getOrCreateFlowCrypto(secret string) *security.AESCrypto {
if v, ok := flowCryptoCache.Load(secret); ok {
return v.(*security.AESCrypto)
}
c, err := security.NewAESCrypto(secret)
if err != nil {
return nil
}
flowCryptoCache.Store(secret, c)
return c
}
func readAndDecryptFlowBody(body io.ReadCloser, secret string) (string, error) {
defer body.Close()
raw, err := io.ReadAll(body)
@@ -1254,8 +1296,8 @@ func readAndDecryptFlowBody(body io.ReadCloser, secret string) (string, error) {
return text, nil
}
crypto, err := security.NewAESCrypto(secret)
if err != nil {
crypto := getOrCreateFlowCrypto(secret)
if crypto == nil {
return text, nil
}
plain, err := crypto.Decrypt(wrap.Data)
+11 -1
View File
@@ -18,7 +18,7 @@ func (h *Handler) StartBackgroundJobs() {
ctx, cancel := context.WithCancel(context.Background())
h.jobsCancel = cancel
h.jobsStarted = true
h.jobsWG.Add(5)
h.jobsWG.Add(6)
h.jobsMu.Unlock()
go h.runHourlyStatsLoop(ctx)
@@ -26,6 +26,7 @@ func (h *Handler) StartBackgroundJobs() {
go h.runNodeRenewalCycleLoop(ctx)
go h.runMetricsIngestion(ctx)
go h.runHealthChecks(ctx)
go h.runTunnelQualityProber(ctx)
}
func (h *Handler) StopBackgroundJobs() {
@@ -63,6 +64,15 @@ func (h *Handler) runHealthChecks(ctx context.Context) {
}
}
func (h *Handler) runTunnelQualityProber(ctx context.Context) {
defer h.jobsWG.Done()
if h == nil || h.qualityProber == nil || !h.isTunnelQualityMonitoringEnabled() {
return
}
h.qualityProber.Start(ctx)
}
func (h *Handler) runHourlyStatsLoop(ctx context.Context) {
defer h.jobsWG.Done()
+145 -1
View File
@@ -71,6 +71,7 @@ type monitorNodeListItem struct {
Inx int `json:"inx"`
Name string `json:"name"`
Status int `json:"status"`
Version string `json:"version"`
UpdatedTime int64 `json:"updatedTime"`
}
@@ -100,6 +101,7 @@ func (h *Handler) monitorNodeListHandler(w http.ResponseWriter, r *http.Request)
Inx: n.Inx,
Name: n.Name,
Status: n.Status,
Version: n.Version.String,
UpdatedTime: updated,
})
}
@@ -207,6 +209,98 @@ func (h *Handler) handleNodeMetricsLatest(w http.ResponseWriter, _ *http.Request
response.WriteJSON(w, response.OK(metric))
}
func (h *Handler) monitorTunnelQualityHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureMonitoringAccess(w, r) {
return
}
// Try in-memory cache first
if h.qualityProber != nil {
items := h.qualityProber.GetAll()
if len(items) > 0 {
response.WriteJSON(w, response.OK(items))
return
}
}
// Fallback to database (latest per tunnel)
qualities, err := h.repo.GetLatestTunnelQualities()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
snapshots := make([]tunnelQualitySnapshot, 0, len(qualities))
for _, q := range qualities {
snapshots = append(snapshots, tunnelQualitySnapshot{
TunnelID: q.TunnelID,
EntryToExitLatency: q.EntryToExitLatency,
ExitToBingLatency: q.ExitToBingLatency,
EntryToExitLoss: q.EntryToExitLoss,
ExitToBingLoss: q.ExitToBingLoss,
Success: q.Success == 1,
ErrorMessage: q.ErrorMessage,
Timestamp: q.Timestamp,
})
}
response.WriteJSON(w, response.OK(snapshots))
}
// monitorTunnelQualityHistory returns quality probe history for charting.
// GET /api/v1/monitor/tunnels/{id}/quality?start=...&end=...
// Mirrors monitorTunnelMetrics / monitorServiceResultsHandler pattern.
func (h *Handler) monitorTunnelQualityHistory(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/", "/quality")
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 || endMs < startMs {
response.WriteJSON(w, response.ErrDefault("无效的时间范围"))
return
}
if endMs-startMs > maxMetricsRangeMs {
response.WriteJSON(w, response.ErrDefault("时间范围过大"))
return
}
results, err := h.repo.GetTunnelQualityHistory(tunnelID, startMs, endMs)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(results))
}
func (h *Handler) monitorTunnelMetrics(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
response.WriteJSON(w, response.ErrDefault("请求失败"))
@@ -216,7 +310,23 @@ func (h *Handler) monitorTunnelMetrics(w http.ResponseWriter, r *http.Request) {
return
}
tunnelIDStr := extractPathParam(r.URL.Path, "/api/v1/monitor/tunnels/", "/metrics")
path := r.URL.Path
prefix := "/api/v1/monitor/tunnels/"
if !strings.HasPrefix(path, prefix) {
response.WriteJSON(w, response.ErrDefault("无效的路径"))
return
}
rest := strings.TrimPrefix(path, prefix)
// Route: /api/v1/monitor/tunnels/{id}/quality
if strings.HasSuffix(rest, "/quality") {
h.monitorTunnelQualityHistory(w, r)
return
}
// Route: /api/v1/monitor/tunnels/{id}/metrics (original)
tunnelIDStr := extractPathParam(path, prefix, "/metrics")
tunnelID, err := strconv.ParseInt(tunnelIDStr, 10, 64)
if err != nil || tunnelID <= 0 {
response.WriteJSON(w, response.ErrDefault("无效的隧道ID"))
@@ -587,6 +697,30 @@ func (h *Handler) monitorServiceResultsHandler(w http.ResponseWriter, r *http.Re
return
}
// If start/end time range is provided, use time-based query (mirrors node metrics / tunnel quality pattern).
startStr := r.URL.Query().Get("start")
endStr := r.URL.Query().Get("end")
if startStr != "" && endStr != "" {
startMs, err1 := strconv.ParseInt(startStr, 10, 64)
endMs, err2 := strconv.ParseInt(endStr, 10, 64)
if err1 != nil || err2 != nil || startMs <= 0 || endMs <= 0 || endMs < startMs {
response.WriteJSON(w, response.ErrDefault("无效的时间范围"))
return
}
if endMs-startMs > maxMetricsRangeMs {
response.WriteJSON(w, response.ErrDefault("时间范围过大"))
return
}
results, err := h.repo.GetServiceMonitorResultsByTimeRange(monitorID, startMs, endMs)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(results))
return
}
// Fallback: count-based limit query (backward compat).
limit := 100
if l := r.URL.Query().Get("limit"); l != "" {
if v, err := strconv.Atoi(l); err == nil && v > 0 && v <= 1000 {
@@ -612,6 +746,16 @@ func (h *Handler) monitorServiceLatestResultsHandler(w http.ResponseWriter, r *h
return
}
// Try in-memory cache first (updated every 1s)
if h.healthCheck != nil {
cached := h.healthCheck.GetLatestCached()
if len(cached) > 0 {
response.WriteJSON(w, response.OK(cached))
return
}
}
// Fallback to database
results, err := h.repo.GetLatestServiceMonitorResults()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
+194 -18
View File
@@ -1102,24 +1102,113 @@ func (h *Handler) syncTunnelForwardsEntryPorts(tunnelID int64, entryNodeIDs []in
if err != nil {
continue
}
port := pickForwardPortFromRecords(oldPorts)
if port <= 0 {
referencePort := pickForwardPortFromRecords(oldPorts)
if referencePort <= 0 {
continue
}
var entries []forwardPortReplaceEntry
if allowInIP {
entries = buildForwardPortEntriesWithPreservedInIP(entryNodeIDs, oldPorts, port)
} else {
entries = make([]forwardPortReplaceEntry, 0, len(entryNodeIDs))
for _, nid := range entryNodeIDs {
entries = append(entries, forwardPortReplaceEntry{NodeID: nid, Port: port, InIP: ""})
// Build a map of existing node → port/inIP from old records.
oldPortByNode := make(map[int64]forwardPortRecord)
for _, fp := range oldPorts {
if fp.NodeID > 0 {
oldPortByNode[fp.NodeID] = fp
}
}
entries := make([]forwardPortReplaceEntry, 0, len(entryNodeIDs))
for _, nid := range entryNodeIDs {
if existing, ok := oldPortByNode[nid]; ok && existing.Port > 0 {
// Existing entry node: keep its current port.
inIP := existing.InIP
if !allowInIP {
inIP = ""
}
entries = append(entries, forwardPortReplaceEntry{NodeID: nid, Port: existing.Port, InIP: inIP})
continue
}
// New entry node: try to follow the reference port.
port := h.resolvePortForNewEntryNode(nid, referencePort, f.ID)
inIP := ""
if allowInIP {
// For single-entry tunnels, try to preserve inIP from old records.
for _, fp := range oldPorts {
if strings.TrimSpace(fp.InIP) != "" {
inIP = fp.InIP
break
}
}
}
entries = append(entries, forwardPortReplaceEntry{NodeID: nid, Port: port, InIP: inIP})
}
_ = h.repo.ReplaceForwardPorts(f.ID, entries)
}
}
// resolvePortForNewEntryNode determines the port for a forward on a newly added
// entry node. It tries to reuse referencePort (from existing entries); if that
// port is out of range or already occupied, it picks a random available port
// for this specific node.
func (h *Handler) resolvePortForNewEntryNode(nodeID int64, referencePort int, forwardID int64) int {
node, err := h.getNodeRecord(nodeID)
if err != nil {
return referencePort
}
// Check if referencePort is within the node's allowed range.
if validateLocalNodePort(node, referencePort) == nil &&
validateRemoteNodePort(node, referencePort) == nil {
// In range — check availability.
occupied, occErr := h.repo.HasOtherForwardOnNodePort(nodeID, referencePort, forwardID)
if occErr == nil && !occupied {
return referencePort
}
}
// referencePort doesn't work for this node; pick a random one.
newPort := h.pickRandomPortForNode(nodeID)
if newPort > 0 {
return newPort
}
return referencePort // last resort fallback
}
// pickRandomPortForNode picks a random available port from a single node's
// port range, excluding ports already occupied by other forwards or chains.
func (h *Handler) pickRandomPortForNode(nodeID int64) int {
portRange, err := h.repo.GetNodePortRange(nodeID)
if err != nil {
return 0
}
if portRange == "" {
portRange = "1000-65535"
}
nodePorts, err := parsePorts(portRange)
if err != nil || len(nodePorts) == 0 {
return 0
}
used, err := h.getUsedPorts(nodeID)
if err != nil {
return 0
}
var available []int
for _, p := range nodePorts {
if !used[p] {
available = append(available, p)
}
}
if len(available) == 0 {
return 0
}
idx, _ := rand.Int(rand.Reader, big.NewInt(int64(len(available))))
return available[idx.Int64()]
}
func (h *Handler) tunnelDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
@@ -1468,6 +1557,12 @@ func (h *Handler) userTunnelRemove(w http.ResponseWriter, r *http.Request) {
if id <= 0 {
return
}
userID, tunnelID, lookupErr := h.repo.GetUserTunnelUserAndTunnel(id)
if lookupErr != nil {
response.WriteJSON(w, response.Err(-2, lookupErr.Error()))
return
}
h.cleanupForwardsForUserTunnel(userID, tunnelID)
if err := h.repo.DeleteUserTunnel(id); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
@@ -2394,14 +2489,18 @@ func (h *Handler) groupUserAssign(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := h.repo.RevokeGroupGrantsForRemovedUsersTx(tx, req.GroupID, previousUserIDs, req.UserIDs); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
revokedPairs, revokeErr := h.repo.RevokeGroupGrantsForRemovedUsersTx(tx, req.GroupID, previousUserIDs, req.UserIDs)
if revokeErr != nil {
response.WriteJSON(w, response.Err(-2, revokeErr.Error()))
return
}
if err := tx.Commit().Error; err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
for _, pair := range revokedPairs {
h.cleanupForwardsForUserTunnel(pair.UserID, pair.TunnelID)
}
_ = h.syncPermissionsByUserGroup(req.GroupID)
response.WriteJSON(w, response.OKEmpty())
}
@@ -2445,9 +2544,12 @@ func (h *Handler) groupPermissionRemove(w http.ResponseWriter, r *http.Request)
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
var revokedPairs []repo.RevokedUserTunnelPair
if exists {
if err := h.repo.RevokeGroupPermissionPairTx(tx, ug, tg); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
var revokeErr error
revokedPairs, revokeErr = h.repo.RevokeGroupPermissionPairTx(tx, ug, tg)
if revokeErr != nil {
response.WriteJSON(w, response.Err(-2, revokeErr.Error()))
return
}
}
@@ -2456,6 +2558,9 @@ func (h *Handler) groupPermissionRemove(w http.ResponseWriter, r *http.Request)
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
for _, pair := range revokedPairs {
h.cleanupForwardsForUserTunnel(pair.UserID, pair.TunnelID)
}
response.WriteJSON(w, response.OKEmpty())
}
@@ -2675,6 +2780,22 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface
}
}
// When updating an existing tunnel (excludeTunnelID > 0), build a set of
// node IDs that already belong to the tunnel so we can tolerate offline
// nodes that the user is keeping or removing, while still rejecting newly
// added offline nodes.
existingNodeIDs := make(map[int64]struct{})
if excludeTunnelID > 0 {
var existIDs []int64
if err := tx.Model(&model.ChainTunnel{}).
Where("tunnel_id = ?", excludeTunnelID).
Pluck("node_id", &existIDs).Error; err == nil {
for _, eid := range existIDs {
existingNodeIDs[eid] = struct{}{}
}
}
}
seen := make(map[int64]struct{}, len(nodeIDs))
for _, nodeID := range nodeIDs {
if _, ok := seen[nodeID]; ok {
@@ -2693,7 +2814,12 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface
return nil, errors.New("节点不存在")
}
if node.IsRemote != 1 && node.Status != 1 {
return nil, errors.New("部分节点不在线")
// For tunnel updates, allow offline nodes that already belong to the
// tunnel (user may be removing them). Only reject genuinely new offline nodes.
_, isExisting := existingNodeIDs[nodeID]
if excludeTunnelID <= 0 || !isExisting {
return nil, errors.New("部分节点不在线")
}
}
state.Nodes[nodeID] = node
}
@@ -3093,7 +3219,6 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
}
for _, inNode := range state.InNodes {
node := state.Nodes[inNode.NodeID]
targets := state.OutNodes
if len(state.ChainHops) > 0 {
targets = state.ChainHops[0]
@@ -3103,7 +3228,7 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
return createdChains, createdServices, err
}
if _, err := h.sendNodeCommand(inNode.NodeID, "AddChains", chainData, true, false); err != nil {
if node != nil && node.IsRemote == 1 && shouldDeferTunnelRuntimeApplyError(err) {
if shouldDeferTunnelRuntimeApplyError(err) {
continue
}
return createdChains, createdServices, fmt.Errorf("入口节点 %s 下发转发链失败: %w", nodeDisplayName(state.Nodes[inNode.NodeID]), err)
@@ -3117,7 +3242,8 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
nextTargets = state.ChainHops[i+1]
}
for _, chainNode := range hop {
if node := state.Nodes[chainNode.NodeID]; node != nil && node.IsRemote == 1 {
node := state.Nodes[chainNode.NodeID]
if node != nil && (node.IsRemote == 1 || node.Status != 1) {
continue
}
chainData, err := buildTunnelChainConfig(state.TunnelID, chainNode.NodeID, nextTargets, state.Nodes, state.IPPreference)
@@ -3125,12 +3251,18 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
return createdChains, createdServices, err
}
if _, err := h.sendNodeCommand(chainNode.NodeID, "AddChains", chainData, true, false); err != nil {
if shouldDeferTunnelRuntimeApplyError(err) {
continue
}
return createdChains, createdServices, fmt.Errorf("转发链节点 %s 下发转发链失败: %w", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)
}
createdChains = append(createdChains, chainNode.NodeID)
serviceData := buildTunnelChainServiceConfig(state.TunnelID, chainNode, state.Nodes[chainNode.NodeID], len(nextTargets))
if err := h.addTunnelServiceOnNode(chainNode.NodeID, state.TunnelID, serviceData); err != nil {
if shouldDeferTunnelRuntimeApplyError(err) {
continue
}
return createdChains, createdServices, fmt.Errorf("转发链节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)
}
createdServices = append(createdServices, chainNode.NodeID)
@@ -3138,11 +3270,15 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
}
for _, outNode := range state.OutNodes {
if node := state.Nodes[outNode.NodeID]; node != nil && node.IsRemote == 1 {
node := state.Nodes[outNode.NodeID]
if node != nil && (node.IsRemote == 1 || node.Status != 1) {
continue
}
serviceData := buildTunnelChainServiceConfig(state.TunnelID, outNode, state.Nodes[outNode.NodeID], 1)
if err := h.addTunnelServiceOnNode(outNode.NodeID, state.TunnelID, serviceData); err != nil {
if shouldDeferTunnelRuntimeApplyError(err) {
continue
}
return createdChains, createdServices, fmt.Errorf("出口节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[outNode.NodeID]), err)
}
createdServices = append(createdServices, outNode.NodeID)
@@ -3233,6 +3369,13 @@ func shouldDeferTunnelRuntimeApplyError(err error) bool {
return false
}
// isNodeOfflineOrTimeoutError returns true when the error indicates a node
// is unreachable (offline or timed out), matching the same patterns used by
// shouldDeferTunnelRuntimeApplyError.
func isNodeOfflineOrTimeoutError(err error) bool {
return shouldDeferTunnelRuntimeApplyError(err)
}
func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRuntimeNode, nodes map[int64]*nodeRecord, ipPreference string) (map[string]interface{}, error) {
fromNode := nodes[fromNodeID]
if fromNode == nil {
@@ -3359,6 +3502,7 @@ func selectTunnelDialHost(fromNode, toNode *nodeRecord, ipPreference string, con
}
}
default:
// 同版本优先
if fromV4 && toV4 {
if host := pickNodeAddressV4(toNode); host != "" {
return host, nil
@@ -3369,6 +3513,17 @@ func selectTunnelDialHost(fromNode, toNode *nodeRecord, ipPreference string, con
return host, nil
}
}
// 跨版本支持:v6入v4出 / v4入v6出
if fromV6 && toV4 {
if host := pickNodeAddressV4(toNode); host != "" {
return host, nil
}
}
if fromV4 && toV6 {
if host := pickNodeAddressV6(toNode); host != "" {
return host, nil
}
}
}
return "", fmt.Errorf("节点链路不兼容:%s(v4=%t,v6=%t) -> %s(v4=%t,v6=%t)", nodeDisplayName(fromNode), fromV4, fromV6, nodeDisplayName(toNode), toV4, toV6)
}
@@ -3911,6 +4066,27 @@ func (h *Handler) syncUserTunnelForwards(userID, tunnelID int64) error {
return nil
}
// cleanupForwardsForUserTunnel deletes all forwarding rules belonging to a
// specific user+tunnel pair. It notifies nodes to remove the runtime services
// first, then deletes the DB records. This is best-effort: individual failures
// do not abort the overall cleanup so that remaining forwards are still cleaned.
func (h *Handler) cleanupForwardsForUserTunnel(userID, tunnelID int64) {
if userID <= 0 || tunnelID <= 0 {
return
}
forwards, err := h.repo.ListForwardsByUserAndTunnel(userID, tunnelID)
if err != nil || len(forwards) == 0 {
return
}
for i := range forwards {
f := &forwards[i]
if f.Status == 1 {
_ = h.controlForwardServices(f, "DeleteService", true)
}
_ = h.deleteForwardByID(f.ID)
}
}
func (h *Handler) normalizeSpeedLimitReference(speedID *int64) (*int64, error) {
if speedID == nil {
return nil, nil
@@ -35,6 +35,7 @@ func (h *Handler) recordTunnelMetricsFromFlowItems(nodeID int64, items []flowIte
}
forwardDeltas := make(map[int64]tunnelTrafficDelta)
var skippedParse, skippedZero int
for _, item := range items {
name := strings.TrimSpace(item.N)
if name == "" || name == "web_api" {
@@ -42,9 +43,11 @@ func (h *Handler) recordTunnelMetricsFromFlowItems(nodeID int64, items []flowIte
}
forwardID, _, _, ok := parseFlowServiceIDs(name)
if !ok {
skippedParse++
continue
}
if item.D == 0 && item.U == 0 {
skippedZero++
continue
}
d := forwardDeltas[forwardID]
@@ -53,6 +56,9 @@ func (h *Handler) recordTunnelMetricsFromFlowItems(nodeID int64, items []flowIte
forwardDeltas[forwardID] = d
}
if len(forwardDeltas) == 0 {
if len(items) > 0 {
log.Printf("monitoring debug op=tunnel_metric.no_forward_deltas node_id=%d items=%d skipped_parse=%d skipped_zero=%d", nodeID, len(items), skippedParse, skippedZero)
}
return
}
@@ -67,6 +73,7 @@ func (h *Handler) recordTunnelMetricsFromFlowItems(nodeID int64, items []flowIte
return
}
if len(forwardTunnelMap) == 0 {
log.Printf("monitoring debug op=tunnel_metric.no_tunnel_map node_id=%d forward_ids=%v", nodeID, forwardIDs)
return
}
@@ -107,5 +114,7 @@ func (h *Handler) recordTunnelMetricsFromFlowItems(nodeID int64, items []flowIte
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)
} else {
log.Printf("monitoring ok op=tunnel_metric.upsert_buckets node_id=%d bucket_ts=%d count=%d", nodeID, bucketTs, len(metrics))
}
}
@@ -0,0 +1,381 @@
package handler
import (
"context"
"log"
"sync"
"sync/atomic"
"time"
"go-backend/internal/store/model"
)
const (
tunnelQualityProbeInterval = 1 * time.Second
tunnelQualityProbeTimeout = 8 * time.Second
tunnelQualityPingTimeoutMs = 5000
tunnelQualityRetention = 24 * time.Hour // keep 24h of history
tunnelQualityPruneInterval = 10 * time.Minute
tunnelQualityReportInterval = 30 * time.Second // DB save interval
)
// tunnelQualitySnapshot is the in-memory latest probe result for a tunnel.
type tunnelQualitySnapshot struct {
TunnelID int64 `json:"tunnelId"`
EntryToExitLatency float64 `json:"entryToExitLatency"`
ExitToBingLatency float64 `json:"exitToBingLatency"`
EntryToExitLoss float64 `json:"entryToExitLoss"`
ExitToBingLoss float64 `json:"exitToBingLoss"`
Success bool `json:"success"`
ErrorMessage string `json:"errorMessage,omitempty"`
Timestamp int64 `json:"timestamp"`
// internal fields for db reporting
lastDBWrite int64 `json:"-"`
}
// tunnelQualityProber runs periodic TCP ping probes against all enabled tunnels.
// Design mirrors health.Checker: background goroutine with worker pool + scheduled cleanup.
type tunnelQualityProber struct {
handler *Handler
cache sync.Map // tunnelID (int64) → *tunnelQualitySnapshot
ctx context.Context
cancel context.CancelFunc
interval time.Duration
lastPrune int64
probing int32 // atomic flag: 1 = probeAll running, 0 = idle
}
// newTunnelQualityProber creates a new prober (not yet running).
func newTunnelQualityProber(h *Handler) *tunnelQualityProber {
return &tunnelQualityProber{
handler: h,
interval: tunnelQualityProbeInterval,
}
}
// Start launches the background probe loop (call from jobs.go).
func (p *tunnelQualityProber) Start(ctx context.Context) {
// Use the provided context so we stop with other background jobs.
p.ctx, p.cancel = context.WithCancel(ctx)
p.loop()
}
// Stop halts the background probe loop.
func (p *tunnelQualityProber) Stop() {
if p == nil || p.cancel == nil {
return
}
p.cancel()
}
// GetAll returns all cached quality snapshots (latest per tunnel).
func (p *tunnelQualityProber) GetAll() []tunnelQualitySnapshot {
var items []tunnelQualitySnapshot
p.cache.Range(func(_, value interface{}) bool {
if snap, ok := value.(*tunnelQualitySnapshot); ok {
items = append(items, *snap)
}
return true
})
return items
}
func (p *tunnelQualityProber) loop() {
// Initial delay to let the system boot up
select {
case <-time.After(5 * time.Second):
case <-p.ctx.Done():
return
}
// Run once immediately
p.probeAll()
ticker := time.NewTicker(p.interval)
defer ticker.Stop()
for {
select {
case <-p.ctx.Done():
return
case <-ticker.C:
p.probeAll()
p.maybePrune()
}
}
}
func (p *tunnelQualityProber) isEnabled() bool {
if p == nil || p.handler == nil {
return true
}
return p.handler.isTunnelQualityMonitoringEnabled()
}
// maybePrune deletes old quality rows periodically (mirrors PruneServiceMonitorResults).
func (p *tunnelQualityProber) maybePrune() {
if !p.isEnabled() {
return
}
now := time.Now().UnixMilli()
if p.lastPrune > 0 && now-p.lastPrune < int64(tunnelQualityPruneInterval/time.Millisecond) {
return
}
p.lastPrune = now
h := p.handler
if h == nil || h.repo == nil {
return
}
cutoff := now - int64(tunnelQualityRetention/time.Millisecond)
if err := h.repo.PruneTunnelQualityResults(cutoff); err != nil {
log.Printf("tunnel_quality_prober: prune err=%v", err)
}
}
func (p *tunnelQualityProber) probeAll() {
if !p.isEnabled() {
return
}
// Skip if previous probe round is still running (interval < timeout guard)
if !atomic.CompareAndSwapInt32(&p.probing, 0, 1) {
return
}
defer atomic.StoreInt32(&p.probing, 0)
h := p.handler
if h == nil || h.repo == nil {
return
}
tunnelIDs, err := h.repo.ListEnabledTunnelIDs()
if err != nil {
log.Printf("tunnel_quality_prober: list enabled tunnels err=%v", err)
return
}
if len(tunnelIDs) == 0 {
return
}
// Probe tunnels concurrently with a worker limit
// (mirrors health.Checker worker pool pattern)
const maxWorkers = 20
sem := make(chan struct{}, maxWorkers)
var wg sync.WaitGroup
for _, tunnelID := range tunnelIDs {
select {
case <-p.ctx.Done():
return
default:
}
wg.Add(1)
sem <- struct{}{}
go func(tid int64) {
defer wg.Done()
defer func() { <-sem }()
p.probeTunnel(tid)
}(tunnelID)
}
wg.Wait()
}
func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
h := p.handler
if h == nil || h.repo == nil {
return
}
now := time.Now().UnixMilli()
snap := &tunnelQualitySnapshot{
TunnelID: tunnelID,
Timestamp: now,
}
// Get tunnel chain info
tunnel, err := h.getTunnelRecord(tunnelID)
if err != nil {
snap.ErrorMessage = "隧道不存在"
p.storeResult(snap)
return
}
chainRows, err := h.listChainNodesForTunnel(tunnelID)
if err != nil || len(chainRows) == 0 {
snap.ErrorMessage = "隧道配置不完整"
p.storeResult(snap)
return
}
ipPreference := h.repo.GetTunnelIPPreference(tunnelID)
inNodes, _, outNodes := splitChainNodeGroups(chainRows)
options := diagnosisExecOptions{
commandTimeout: tunnelQualityProbeTimeout,
pingTimeoutMS: tunnelQualityPingTimeoutMs,
timeoutMessage: "探测超时",
}
switch tunnel.Type {
case 1:
// Port forwarding: entry → Bing only
if len(inNodes) > 0 {
lat, loss, err := p.tcpPingNode(inNodes[0].NodeID, "www.bing.com", 443, options)
if err == nil {
snap.ExitToBingLatency = lat
snap.ExitToBingLoss = loss
snap.Success = true
} else {
snap.ErrorMessage = err.Error()
}
}
case 2:
// Tunnel forwarding: entry → exit + exit → Bing
probeOK := true
if len(inNodes) > 0 && len(outNodes) > 0 {
// Entry → Exit
targetNode, nodeErr := h.getNodeRecord(outNodes[0].NodeID)
if nodeErr == nil && targetNode != nil {
fromNode, _ := h.getNodeRecord(inNodes[0].NodeID)
targetIP, targetPort, resolveErr := resolveChainProbeTarget(fromNode, targetNode, outNodes[0].Port, ipPreference, outNodes[0].ConnectIP)
if resolveErr == nil {
lat, loss, err := p.tcpPingNode(inNodes[0].NodeID, targetIP, targetPort, options)
if err == nil {
snap.EntryToExitLatency = lat
snap.EntryToExitLoss = loss
} else {
snap.EntryToExitLatency = -1
snap.EntryToExitLoss = 100
probeOK = false
}
} else {
snap.ErrorMessage = resolveErr.Error()
probeOK = false
}
} else {
snap.ErrorMessage = "出口节点不可用"
probeOK = false
}
}
// Exit → Bing
if len(outNodes) > 0 {
lat, loss, err := p.tcpPingNode(outNodes[0].NodeID, "www.bing.com", 443, options)
if err == nil {
snap.ExitToBingLatency = lat
snap.ExitToBingLoss = loss
} else {
if snap.ErrorMessage == "" {
snap.ErrorMessage = err.Error()
}
probeOK = false
}
}
snap.Success = probeOK
default:
// Unknown type: entry → Bing
if len(inNodes) > 0 {
lat, loss, err := p.tcpPingNode(inNodes[0].NodeID, "www.bing.com", 443, options)
if err == nil {
snap.ExitToBingLatency = lat
snap.ExitToBingLoss = loss
snap.Success = true
} else {
snap.ErrorMessage = err.Error()
}
}
}
p.storeResult(snap)
}
func (p *tunnelQualityProber) tcpPingNode(nodeID int64, ip string, port int, options diagnosisExecOptions) (latency float64, loss float64, err error) {
h := p.handler
if h == nil {
return 0, 100, nil
}
node, nodeErr := h.getNodeRecord(nodeID)
if nodeErr != nil {
return 0, 100, nodeErr
}
var pingData map[string]interface{}
var pingErr error
if node != nil && node.IsRemote == 1 {
pingData, pingErr = h.tcpPingViaRemoteNode(node, ip, port, options)
} else {
pingData, pingErr = h.tcpPingViaNode(nodeID, ip, port, options)
}
if pingErr != nil {
return 0, 100, pingErr
}
avgTime := asFloat(pingData["averageTime"], 0)
packetLoss := asFloat(pingData["packetLoss"], 100)
return avgTime, packetLoss, nil
}
func (p *tunnelQualityProber) storeResult(snap *tunnelQualitySnapshot) {
if snap == nil {
return
}
// Update in-memory cache (latest per tunnel)
// Retain the lastDBWrite timestamp if it exists, so we only DB write every 30s
var lastWrite int64
if existing, ok := p.cache.Load(snap.TunnelID); ok {
if eg, ok := existing.(*tunnelQualitySnapshot); ok {
lastWrite = eg.lastDBWrite
}
}
snap.lastDBWrite = lastWrite
now := time.Now().UnixMilli()
writeToDB := false
if now-snap.lastDBWrite >= int64(tunnelQualityReportInterval/time.Millisecond) {
writeToDB = true
snap.lastDBWrite = now
}
p.cache.Store(snap.TunnelID, snap)
if !writeToDB {
return
}
// Persist to database (history)
h := p.handler
if h == nil || h.repo == nil {
return
}
successInt := 0
if snap.Success {
successInt = 1
}
q := &model.TunnelQuality{
TunnelID: snap.TunnelID,
EntryToExitLatency: snap.EntryToExitLatency,
ExitToBingLatency: snap.ExitToBingLatency,
EntryToExitLoss: snap.EntryToExitLoss,
ExitToBingLoss: snap.ExitToBingLoss,
Success: successInt,
ErrorMessage: snap.ErrorMessage,
Timestamp: snap.Timestamp,
}
if err := h.repo.InsertTunnelQuality(q); err != nil {
log.Printf("tunnel_quality_prober: insert db err=%v tunnel_id=%d", err, snap.TunnelID)
}
}
@@ -36,7 +36,7 @@ func TestRecordNodeMetric(t *testing.T) {
svc.RecordNodeMetric(1, info)
svc.flushNodeMetrics()
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
@@ -86,7 +86,7 @@ func TestRecordNodeMetricAutoFlush(t *testing.T) {
time.Sleep(100 * time.Millisecond)
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
@@ -123,7 +123,7 @@ func TestIngestionServiceStart(t *testing.T) {
<-ctx.Done()
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
@@ -198,7 +198,7 @@ func TestGetMetricsWithTimeRange(t *testing.T) {
svc.flushNodeMetrics()
metrics, err := svc.GetMetrics(1, 0, now+1000)
metrics, err := svc.GetMetrics(1, now-60000, now+1000)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
@@ -224,7 +224,7 @@ func TestPruneMetrics(t *testing.T) {
svc.pruneMetrics()
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
@@ -255,7 +255,7 @@ func TestMultipleNodes(t *testing.T) {
svc.flushNodeMetrics()
for nodeID := int64(1); nodeID <= 3; nodeID++ {
metrics, err := r.GetNodeMetrics(nodeID, 0, time.Now().UnixMilli()+1000)
metrics, err := r.GetNodeMetrics(nodeID, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
if err != nil {
t.Fatalf("get metrics for node %d: %v", nodeID, err)
}
@@ -279,7 +279,7 @@ func TestZeroValues(t *testing.T) {
svc.RecordNodeMetric(1, info)
svc.flushNodeMetrics()
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
+4 -4
View File
@@ -29,10 +29,10 @@ const (
func DefaultServiceMonitorLimits() ServiceMonitorLimits {
return ServiceMonitorLimits{
CheckerScanIntervalSec: 30,
WorkerLimit: 5,
MinIntervalSec: 30,
DefaultIntervalSec: 60,
CheckerScanIntervalSec: 1,
WorkerLimit: 20,
MinIntervalSec: 1,
DefaultIntervalSec: 1,
MinTimeoutSec: 1,
DefaultTimeoutSec: 5,
MaxTimeoutSec: 60,
+20 -3
View File
@@ -675,9 +675,9 @@ 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"`
TunnelID int64 `gorm:"column:tunnel_id;not null;uniqueIndex:idx_tunnel_metric_tunnel_time,priority:1" json:"tunnelId"`
NodeID int64 `gorm:"column:node_id;not null;uniqueIndex:idx_tunnel_metric_tunnel_time,priority:2" json:"nodeId"`
Timestamp int64 `gorm:"not null;uniqueIndex: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"`
@@ -714,3 +714,20 @@ type ServiceMonitorResult struct {
}
func (ServiceMonitorResult) TableName() string { return "service_monitor_result" }
// TunnelQuality stores periodic probe results for a tunnel.
// Unlike the old upsert model, rows accumulate for history/charting.
// Old rows are pruned periodically (default: keep 24h).
type TunnelQuality struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
TunnelID int64 `gorm:"column:tunnel_id;not null;index:idx_tunnel_quality_tunnel_time,priority:1" json:"tunnelId"`
EntryToExitLatency float64 `gorm:"column:entry_to_exit_latency" json:"entryToExitLatency"`
ExitToBingLatency float64 `gorm:"column:exit_to_bing_latency" json:"exitToBingLatency"`
EntryToExitLoss float64 `gorm:"column:entry_to_exit_loss" json:"entryToExitLoss"`
ExitToBingLoss float64 `gorm:"column:exit_to_bing_loss" json:"exitToBingLoss"`
Success int `gorm:"not null;default:1" json:"success"`
ErrorMessage string `gorm:"column:error_message;type:text" json:"errorMessage,omitempty"`
Timestamp int64 `gorm:"not null;index:idx_tunnel_quality_tunnel_time,priority:2;index:idx_tunnel_quality_time" json:"timestamp"`
}
func (TunnelQuality) TableName() string { return "tunnel_quality" }
+96 -16
View File
@@ -5,9 +5,11 @@ import (
"errors"
"fmt"
"log"
"net"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
@@ -51,6 +53,7 @@ type NodeMetric = model.NodeMetric
type TunnelMetric = model.TunnelMetric
type ServiceMonitor = model.ServiceMonitor
type ServiceMonitorResult = model.ServiceMonitorResult
type TunnelQuality = model.TunnelQuality
// ─── Repository ──────────────────────────────────────────────────────
@@ -191,6 +194,7 @@ func autoMigrateAll(db *gorm.DB) error {
&model.TunnelMetric{},
&model.ServiceMonitor{},
&model.ServiceMonitorResult{},
&model.TunnelQuality{},
}
if db.Dialector.Name() != "sqlite" {
@@ -675,9 +679,10 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) {
"version": nullableString(n.Version),
"http": n.HTTP, "tls": n.TLS, "socks": n.Socks,
"status": n.Status, "isRemote": n.IsRemote,
"remoteUrl": nullableString(n.RemoteURL),
"remoteToken": nullableString(n.RemoteToken),
"remoteConfig": nullableString(n.RemoteConfig),
"remoteUrl": nullableString(n.RemoteURL),
"remoteToken": nullableString(n.RemoteToken),
"remoteConfig": nullableString(n.RemoteConfig),
"expiryReminderDismissed": n.ExpiryReminderDismissed,
})
}
return items, nil
@@ -3238,7 +3243,7 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string
}
if ip != "" {
pair := fmt.Sprintf("%s:%d", ip, row.Port.Int64)
pair := formatForwardIngressAddress(ip, row.Port.Int64)
if _, ok := seenPairs[pair]; !ok {
seenPairs[pair] = struct{}{}
entries = append(entries, pair)
@@ -3255,6 +3260,17 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string
return strings.Join(entries, ","), inPort, nil
}
func formatForwardIngressAddress(host string, port int64) string {
host = strings.TrimSpace(host)
if host == "" || port <= 0 {
return ""
}
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
host = strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")
}
return net.JoinHostPort(host, strconv.FormatInt(port, 10))
}
func nullableString(v sql.NullString) interface{} {
if v.Valid {
return v.String
@@ -3317,15 +3333,60 @@ func (r *Repository) GetNodeMetrics(nodeID int64, startMs, endMs int64) ([]model
if r == nil || r.db == nil {
return nil, nil
}
rangeMs := endMs - startMs
const maxRawRangeMs = int64(60 * 60 * 1000) // 1 hour — return raw data for short ranges
const targetPoints = 500 // target number of chart points for downsampled data
// For short ranges, return raw data (full resolution).
if rangeMs <= maxRawRangeMs {
var metrics []model.NodeMetric
err := r.db.Where("node_id = ? AND timestamp >= ? AND timestamp <= ?", nodeID, startMs, endMs).
Order("timestamp ASC").
Limit(5000).
Find(&metrics).Error
return metrics, err
}
// For longer ranges, downsample via SQL aggregation to keep the response small and fast.
bucketMs := rangeMs / targetPoints
if bucketMs < 1000 {
bucketMs = 1000 // minimum 1-second buckets
}
bucketExpr := fmt.Sprintf("(timestamp / %d * %d)", bucketMs, bucketMs)
groupExpr := fmt.Sprintf("timestamp / %d", bucketMs)
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]
}
err := r.db.Model(&model.NodeMetric{}).
Select(
fmt.Sprintf(
"%d AS node_id, "+
"CAST(%s AS BIGINT) AS timestamp, "+
"AVG(cpu_usage) AS cpu_usage, "+
"AVG(mem_usage) AS mem_usage, "+
"AVG(disk_usage) AS disk_usage, "+
"CAST(AVG(net_in_bytes) AS BIGINT) AS net_in_bytes, "+
"CAST(AVG(net_out_bytes) AS BIGINT) AS net_out_bytes, "+
"CAST(AVG(net_in_speed) AS BIGINT) AS net_in_speed, "+
"CAST(AVG(net_out_speed) AS BIGINT) AS net_out_speed, "+
"AVG(load1) AS load1, "+
"AVG(load5) AS load5, "+
"AVG(load15) AS load15, "+
"CAST(AVG(tcp_conns) AS BIGINT) AS tcp_conns, "+
"CAST(AVG(udp_conns) AS BIGINT) AS udp_conns, "+
"CAST(MAX(uptime) AS BIGINT) AS uptime",
nodeID, bucketExpr,
),
).
Where("node_id = ? AND timestamp >= ? AND timestamp <= ?", nodeID, startMs, endMs).
Group(groupExpr).
Order("timestamp ASC").
Limit(targetPoints + 100). // safety margin
Scan(&metrics).Error
if metrics == nil {
metrics = make([]model.NodeMetric, 0)
}
return metrics, err
}
@@ -3419,10 +3480,10 @@ func (r *Repository) UpsertTunnelMetricBuckets(metrics []*model.TunnelMetric) er
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"),
"bytes_in": gorm.Expr("tunnel_metric.bytes_in + excluded.bytes_in"),
"bytes_out": gorm.Expr("tunnel_metric.bytes_out + excluded.bytes_out"),
"connections": gorm.Expr("tunnel_metric.connections + excluded.connections"),
"errors": gorm.Expr("tunnel_metric.errors + excluded.errors"),
// avg_latency_ms is not additive; keep the existing bucket value.
}),
}).CreateInBatches(rows, 100).Error
@@ -3566,6 +3627,25 @@ func (r *Repository) GetServiceMonitorResults(monitorID int64, limit int) ([]mod
return results, err
}
// GetServiceMonitorResultsByTimeRange returns results for a monitor within [startMs, endMs].
// Mirrors GetNodeMetrics / GetTunnelMetrics pattern for time-range based charting.
func (r *Repository) GetServiceMonitorResultsByTimeRange(monitorID int64, startMs, endMs int64) ([]model.ServiceMonitorResult, error) {
if r == nil || r.db == nil {
return nil, nil
}
var results []model.ServiceMonitorResult
err := r.db.Where("monitor_id = ? AND timestamp >= ? AND timestamp <= ?", monitorID, startMs, endMs).
Order("timestamp DESC").
Limit(5000).
Find(&results).Error
if len(results) > 1 {
for i, j := 0, len(results)-1; i < j; i, j = i+1, j-1 {
results[i], results[j] = results[j], results[i]
}
}
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) {
@@ -80,6 +80,37 @@ func (r *Repository) ListActiveForwardsByUserTunnel(userID, tunnelID int64) ([]m
return rows, nil
}
func (r *Repository) ListForwardsByUserAndTunnel(userID, tunnelID int64) ([]model.ForwardRecord, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var forwards []model.Forward
err := r.db.Where("user_id = ? AND tunnel_id = ?", userID, tunnelID).Order("id ASC").Find(&forwards).Error
if err != nil {
return nil, err
}
rows := make([]model.ForwardRecord, 0, len(forwards))
for _, f := range forwards {
rows = append(rows, model.ForwardRecord{
ID: f.ID,
UserID: f.UserID,
UserName: f.UserName,
Name: f.Name,
TunnelID: f.TunnelID,
RemoteAddr: f.RemoteAddr,
Strategy: f.Strategy,
Status: f.Status,
SpeedID: f.SpeedID,
})
}
for i := range rows {
if strings.TrimSpace(rows[i].Strategy) == "" {
rows[i].Strategy = "fifo"
}
}
return rows, nil
}
func (r *Repository) GetForwardRecord(forwardID int64) (*model.ForwardRecord, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
@@ -11,7 +11,8 @@ func (r *Repository) ListMonitorNodes() ([]model.Node, error) {
return nil, errors.New("repository not initialized")
}
var nodes []model.Node
err := r.db.Select("id", "inx", "name", "status", "updated_time").
err := r.db.Select("id", "inx", "name", "status", "version", "updated_time").
Where("is_remote = ?", 0).
Order("inx ASC, id ASC").
Find(&nodes).Error
return nodes, err
@@ -997,9 +997,16 @@ func (r *Repository) DeleteGroupPermissionByIDTx(tx *gorm.DB, id int64) error {
return tx.Where("id = ?", id).Delete(&model.GroupPermission{}).Error
}
func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID int64, previousUserIDs, currentUserIDs []int64) error {
// RevokedUserTunnelPair holds the (userID, tunnelID) of a deleted user_tunnel row,
// so the handler layer can clean up associated forwarding rules.
type RevokedUserTunnelPair struct {
UserID int64
TunnelID int64
}
func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID int64, previousUserIDs, currentUserIDs []int64) ([]RevokedUserTunnelPair, error) {
if tx == nil {
return errors.New("database unavailable")
return nil, errors.New("database unavailable")
}
currentSet := make(map[int64]struct{}, len(currentUserIDs))
for _, uid := range currentUserIDs {
@@ -1018,7 +1025,7 @@ func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID
}
}
if len(removedUserIDs) == 0 {
return nil
return nil, nil
}
type grantRow struct {
@@ -1026,6 +1033,8 @@ func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID
CreatedByGroup int
}
var revoked []RevokedUserTunnelPair
for _, userID := range removedUserIDs {
var rows []grantRow
if err := tx.Model(&model.GroupPermissionGrant{}).
@@ -1033,7 +1042,7 @@ func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID
Joins("JOIN user_tunnel ON user_tunnel.id = group_permission_grant.user_tunnel_id").
Where("group_permission_grant.user_group_id = ? AND user_tunnel.user_id = ?", userGroupID, userID).
Find(&rows).Error; err != nil {
return err
return revoked, err
}
groupCreatedTunnelIDs := make(map[int64]struct{})
@@ -1046,28 +1055,32 @@ func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID
userTunnelIDs := tx.Model(&model.UserTunnel{}).Select("id").Where("user_id = ?", userID)
if err := tx.Where("user_group_id = ? AND user_tunnel_id IN (?)", userGroupID, userTunnelIDs).
Delete(&model.GroupPermissionGrant{}).Error; err != nil {
return err
return revoked, err
}
for userTunnelID := range groupCreatedTunnelIDs {
var remaining int64
if err := tx.Model(&model.GroupPermissionGrant{}).Where("user_tunnel_id = ?", userTunnelID).Count(&remaining).Error; err != nil {
return err
return revoked, err
}
if remaining == 0 {
var ut model.UserTunnel
if lookupErr := tx.Select("user_id", "tunnel_id").Where("id = ?", userTunnelID).First(&ut).Error; lookupErr == nil {
revoked = append(revoked, RevokedUserTunnelPair{UserID: ut.UserID, TunnelID: ut.TunnelID})
}
if err := tx.Where("id = ?", userTunnelID).Delete(&model.UserTunnel{}).Error; err != nil {
return err
return revoked, err
}
}
}
}
return nil
return revoked, nil
}
func (r *Repository) RevokeGroupPermissionPairTx(tx *gorm.DB, userGroupID, tunnelGroupID int64) error {
func (r *Repository) RevokeGroupPermissionPairTx(tx *gorm.DB, userGroupID, tunnelGroupID int64) ([]RevokedUserTunnelPair, error) {
if tx == nil {
return errors.New("database unavailable")
return nil, errors.New("database unavailable")
}
type grantRow struct {
@@ -1080,7 +1093,7 @@ func (r *Repository) RevokeGroupPermissionPairTx(tx *gorm.DB, userGroupID, tunne
Select("user_tunnel_id, created_by_group").
Where("user_group_id = ? AND tunnel_group_id = ?", userGroupID, tunnelGroupID).
Find(&rows).Error; err != nil {
return err
return nil, err
}
groupCreatedTunnelIDs := make(map[int64]struct{})
@@ -1092,22 +1105,27 @@ func (r *Repository) RevokeGroupPermissionPairTx(tx *gorm.DB, userGroupID, tunne
if err := tx.Where("user_group_id = ? AND tunnel_group_id = ?", userGroupID, tunnelGroupID).
Delete(&model.GroupPermissionGrant{}).Error; err != nil {
return err
return nil, err
}
var revoked []RevokedUserTunnelPair
for userTunnelID := range groupCreatedTunnelIDs {
var remaining int64
if err := tx.Model(&model.GroupPermissionGrant{}).Where("user_tunnel_id = ?", userTunnelID).Count(&remaining).Error; err != nil {
return err
return revoked, err
}
if remaining == 0 {
var ut model.UserTunnel
if lookupErr := tx.Select("user_id", "tunnel_id").Where("id = ?", userTunnelID).First(&ut).Error; lookupErr == nil {
revoked = append(revoked, RevokedUserTunnelPair{UserID: ut.UserID, TunnelID: ut.TunnelID})
}
if err := tx.Where("id = ?", userTunnelID).Delete(&model.UserTunnel{}).Error; err != nil {
return err
return revoked, err
}
}
}
return nil
return revoked, nil
}
func (r *Repository) ReplaceFederationTunnelBindingsTx(tx *gorm.DB, tunnelID int64, bindings []FederationTunnelBinding) error {
@@ -0,0 +1,98 @@
package repo
import (
"errors"
"go-backend/internal/store/model"
)
// InsertTunnelQuality appends a tunnel quality probe result.
// (Follows the same pattern as InsertServiceMonitorResult.)
func (r *Repository) InsertTunnelQuality(q *model.TunnelQuality) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
if q == nil || q.TunnelID <= 0 {
return nil
}
return r.db.Create(q).Error
}
// GetTunnelQualityHistory returns quality probe results for a tunnel
// within a time range, ordered by timestamp ascending.
// (Mirrors GetServiceMonitorResults pattern.)
func (r *Repository) GetTunnelQualityHistory(tunnelID int64, startMs, endMs int64) ([]model.TunnelQuality, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var results []model.TunnelQuality
err := r.db.Where("tunnel_id = ? AND timestamp >= ? AND timestamp <= ?", tunnelID, startMs, endMs).
Order("timestamp ASC").
Find(&results).Error
return results, err
}
// GetLatestTunnelQualities returns the newest quality result per tunnel_id.
// (Mirrors GetLatestServiceMonitorResults pattern.)
func (r *Repository) GetLatestTunnelQualities() ([]model.TunnelQuality, error) {
if r == nil || r.db == nil {
return nil, nil
}
var results []model.TunnelQuality
// Use window function (works on modern SQLite 3.25+ and PostgreSQL).
q := `
SELECT id, tunnel_id, entry_to_exit_latency, exit_to_bing_latency,
entry_to_exit_loss, exit_to_bing_loss, success, error_message, timestamp
FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY tunnel_id ORDER BY timestamp DESC, id DESC) AS rn
FROM tunnel_quality
) t
WHERE rn = 1
ORDER BY tunnel_id ASC
`
if err := r.db.Raw(q).Scan(&results).Error; err == nil {
return results, nil
}
// Fallback for older SQLite
results = nil
err := r.db.Order("timestamp DESC, id DESC").Limit(5000).Find(&results).Error
if err != nil {
return nil, err
}
seen := make(map[int64]struct{}, len(results))
out := make([]model.TunnelQuality, 0, len(results))
for _, row := range results {
if row.TunnelID <= 0 {
continue
}
if _, ok := seen[row.TunnelID]; ok {
continue
}
seen[row.TunnelID] = struct{}{}
out = append(out, row)
}
return out, nil
}
// PruneTunnelQualityResults deletes quality results older than the given timestamp.
// (Mirrors PruneServiceMonitorResults pattern.)
func (r *Repository) PruneTunnelQualityResults(olderThanMs int64) error {
if r == nil || r.db == nil {
return nil
}
return r.db.Where("timestamp < ?", olderThanMs).Delete(&model.TunnelQuality{}).Error
}
// ListEnabledTunnelIDs returns IDs of all tunnels with status=1.
func (r *Repository) ListEnabledTunnelIDs() ([]int64, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var ids []int64
err := r.db.Model(&model.Tunnel{}).Where("status = ?", 1).Pluck("id", &ids).Error
return ids, err
}
+44 -13
View File
@@ -39,6 +39,7 @@ type nodeSession struct {
nodeID int64
secret string
conn *connWrap
crypto *security.AESCrypto // 缓存的 AES 加密器,避免每条消息重建
}
type commandResponse struct {
@@ -211,7 +212,12 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
_ = old.conn.conn.Close()
delete(s.byConn, old.conn.conn)
}
ns := &nodeSession{nodeID: nodeID, secret: secret, conn: cw}
// 初始化 AES 加密器并缓存(仅创建一次)
var nodeCrypto *security.AESCrypto
if strings.TrimSpace(secret) != "" {
nodeCrypto, _ = security.NewAESCrypto(secret)
}
ns := &nodeSession{nodeID: nodeID, secret: secret, conn: cw, crypto: nodeCrypto}
s.nodes[nodeID] = ns
s.byConn[conn] = ns
s.mu.Unlock()
@@ -251,7 +257,7 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
return
}
msg := decryptIfNeeded(payload, secret)
msg := decryptIfNeeded(payload, ns.crypto, secret)
s.tryResolvePending(nodeID, msg)
var parsed struct {
@@ -259,6 +265,26 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
}
if json.Unmarshal([]byte(msg), &parsed) == nil && parsed.Type != "" {
switch parsed.Type {
case "metric":
// Agent 新版指标消息:{type:"metric", data:{...}}
var envelope struct {
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal([]byte(msg), &envelope); err == nil && len(envelope.Data) > 0 {
// 解析 SystemInfo 并调用 hook
var sysInfo SystemInfo
if json.Unmarshal(envelope.Data, &sysInfo) == nil {
s.mu.RLock()
onMetric := s.onNodeMetric
s.mu.RUnlock()
if onMetric != nil {
go onMetric(nodeID, sysInfo)
}
}
// 广播内层 data 给前端(保持平坦结构兼容性)
s.broadcastTyped(nodeID, "metric", string(envelope.Data))
}
continue
case "UpgradeProgress":
s.broadcastTyped(nodeID, "upgrade_progress", msg)
continue
@@ -270,6 +296,7 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
}
}
// 兼容旧版 Agent:无 type 字段的系统信息消息
if looksLikeSystemInfoMessage(msg) {
var sysInfo SystemInfo
if err := json.Unmarshal([]byte(msg), &sysInfo); err == nil {
@@ -372,13 +399,8 @@ func (s *Server) SendCommand(nodeID int64, cmdType string, data interface{}, tim
}
messageData := rawCmd
if strings.TrimSpace(ns.secret) != "" {
crypto, err := security.NewAESCrypto(ns.secret)
if err != nil {
cleanup()
return CommandResult{}, err
}
encrypted, err := crypto.Encrypt(rawCmd)
if ns.crypto != nil {
encrypted, err := ns.crypto.Encrypt(rawCmd)
if err != nil {
cleanup()
return CommandResult{}, err
@@ -428,6 +450,11 @@ func (s *Server) tryResolvePending(nodeID int64, message string) {
return
}
// 快速短路:指标消息永远不含 requestId,跳过完整 JSON 解析
if !strings.Contains(message, "\"requestId\"") {
return
}
var resp commandResponse
if err := json.Unmarshal([]byte(message), &resp); err != nil {
return
@@ -545,18 +572,22 @@ func (s *Server) broadcastToAdmins(message string) {
}
}
func decryptIfNeeded(payload []byte, secret string) string {
func decryptIfNeeded(payload []byte, crypto *security.AESCrypto, secret string) string {
text := string(payload)
var wrap encryptedMessage
if err := json.Unmarshal(payload, &wrap); err != nil || !wrap.Encrypted || strings.TrimSpace(wrap.Data) == "" {
return text
}
crypto, err := security.NewAESCrypto(secret)
if err != nil {
// 优先使用缓存的 crypto 实例
c := crypto
if c == nil && strings.TrimSpace(secret) != "" {
c, _ = security.NewAESCrypto(secret)
}
if c == nil {
return text
}
plain, err := crypto.Decrypt(wrap.Data)
plain, err := c.Decrypt(wrap.Data)
if err != nil {
return text
}
@@ -0,0 +1,80 @@
package contract_test
import (
"testing"
"time"
)
func TestIssue349_ForwardListFormatsIPv6EntryAddressesContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
adminToken := mustAdminToken(t, secret)
now := time.Now().UnixMilli()
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "issue349-tunnel", 1.0, 1, "tcp", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, repo, "issue349-tunnel")
if err := repo.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "issue349-entry-node-a", "entry-secret-a", "2001:db8::10", "", "2001:db8::10", "32000-32010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node a: %v", err)
}
nodeAID := mustLastInsertID(t, repo, "issue349-entry-node-a")
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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "issue349-entry-node-b", "entry-secret-b", "2001:db8::30", "", "2001:db8::30", "32000-32010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 1).Error; err != nil {
t.Fatalf("insert node b: %v", err)
}
nodeBID := mustLastInsertID(t, repo, "issue349-entry-node-b")
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(?, ?, ?, ?, ?, ?, 0, 0, ?, ?, 1, ?)
`, 1, "admin_user", "issue349-forward", tunnelID, "1.1.1.1:443", "fifo", now, now, 0).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID := mustLastInsertID(t, repo, "issue349-forward")
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeAID, 32001).Error; err != nil {
t.Fatalf("insert forward_port a: %v", err)
}
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port, in_ip) VALUES(?, ?, ?, ?)`, forwardID, nodeBID, 32002, "2001:db8::20").Error; err != nil {
t.Fatalf("insert forward_port b: %v", err)
}
out := requestContractEnvelope(t, router, adminToken, "/api/v1/forward/list", nil)
if out.Code != 0 {
t.Fatalf("forward list failed: code=%d msg=%q", out.Code, out.Msg)
}
rows := mustContractSlice(t, out.Data, "forward list data")
var target map[string]interface{}
for _, row := range rows {
item, ok := row.(map[string]interface{})
if !ok {
continue
}
if contractValueAsInt64(item["id"]) == forwardID {
target = item
break
}
}
if target == nil {
t.Fatalf("target forward %d not found in /forward/list response", forwardID)
}
if got := contractValueAsString(target["inIp"]); got != "[2001:db8::10]:32001,[2001:db8::20]:32002" {
t.Fatalf("expected bracketed IPv6 entry list, got %q", got)
}
if got := contractValueAsInt64(target["inPort"]); got != 32001 {
t.Fatalf("expected first entry port 32001, got %d", got)
}
}
@@ -1175,7 +1175,7 @@ func TestMetricBatchInsert(t *testing.T) {
t.Fatalf("batch insert: %v", err)
}
retrieved, err := repo.GetNodeMetrics(1, 0, now+1000)
retrieved, err := repo.GetNodeMetrics(1, now-10000, now+1000)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
+4 -1
View File
@@ -1,6 +1,9 @@
# GO-GOST SERVICE KNOWLEDGE BASE
**Generated:** Thu Feb 26 2026
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Forwarding agent built on GOST v3 with a local fork of `github.com/go-gost/x` under `x/`.
+3 -1
View File
@@ -119,7 +119,9 @@ func main() {
log := xlogger.NewLogger()
logger.SetDefault(log)
wsReporter := socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret, config.Http, config.Tls, config.Socks, version)
distro := socket.DetectDistro()
fullVersion := fmt.Sprintf("%s (%s/%s)", version, distro, runtime.GOARCH)
wsReporter := socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret, config.Http, config.Tls, config.Socks, fullVersion)
defer wsReporter.Stop()
service.SetHTTPReportURL(config.Addr, config.Secret)
+5
View File
@@ -1,5 +1,10 @@
# GO-GOST/X KNOWLEDGE BASE
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Local fork of `github.com/go-gost/x` used by `go-gost/` via `replace github.com/go-gost/x => ./x`. Most protocol/runtime behavior changes happen here. 30+ top-level packages - framework-style layout.
+5
View File
@@ -1,5 +1,10 @@
# GO-GOST/X API KNOWLEDGE BASE
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Gin-based management API for reading/writing config and controlling services at runtime.
+5
View File
@@ -1,5 +1,10 @@
# GO-GOST/X CONFIG KNOWLEDGE BASE
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Config model + parsing/loading pipeline for the `go-gost/x` runtime. This is the bridge between `gost.json`/`gost.yaml` and in-memory registries/services.
+4 -1
View File
@@ -1,6 +1,9 @@
# GOST CONNECTOR KNOWLEDGE BASE
**Generated:** Fri Feb 13 2026
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Connection initiators (clients) for various protocols in GOST forwarding.
+5
View File
@@ -1,5 +1,10 @@
# GO-GOST/X DIALERS KNOWLEDGE BASE
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Outbound dialers (client-side connection establishment) used by connectors/handlers.
+5
View File
@@ -1,5 +1,10 @@
# GO-GOST/X HANDLERS KNOWLEDGE BASE
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Protocol handlers (server-side request handling) used by services defined in the GOST config.
+5
View File
@@ -1,5 +1,10 @@
# GO-GOST/X LISTENERS KNOWLEDGE BASE
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Inbound listeners (transport-level accept loops) used by services defined in the GOST config.
+4 -1
View File
@@ -1,6 +1,9 @@
# GO-GOST REGISTRY KNOWLEDGE BASE
**Generated:** Wed Feb 04 2026
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Central registration point for all pluggable GOST components (handlers, listeners, dialers, etc.).
+4 -1
View File
@@ -1,6 +1,9 @@
# GOST SOCKET KNOWLEDGE BASE
**Generated:** Sun Feb 15 2026
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
WebSocket reporter and socket utilities for panel integration.
+21
View File
@@ -0,0 +1,21 @@
package socket
import (
"strings"
"github.com/shirou/gopsutil/v3/host"
)
// DetectDistro returns the Linux distribution name (e.g. "ubuntu", "centos",
// "debian"). Falls back to "linux" when detection fails.
func DetectDistro() string {
info, err := host.Info()
if err != nil || info == nil {
return "linux"
}
platform := strings.ToLower(strings.TrimSpace(info.Platform))
if platform == "" {
return "linux"
}
return platform
}
+102 -90
View File
@@ -9,6 +9,7 @@ import (
"encoding/json"
"fmt"
"io"
"math/rand"
"net"
"net/http"
"net/url"
@@ -146,6 +147,9 @@ type ServiceMonitorCheckResult struct {
const (
reporterReadWait = 60 * time.Second
reporterWriteWait = 5 * time.Second
wsPingInterval = 20 * time.Second // 独立 WebSocket ping 间隔
initialBackoff = 2 * time.Second // 重连初始退避
maxBackoff = 2 * time.Minute // 重连最大退避
)
type WebSocketReporter struct {
@@ -155,15 +159,15 @@ type WebSocketReporter struct {
version string // 保存版本号
preferredWSScheme string
conn *websocket.Conn
reconnectTime time.Duration
curBackoff time.Duration // 当前重连退避间隔
pingInterval time.Duration
configInterval time.Duration
ctx context.Context
cancel context.CancelFunc
connected bool
connecting bool // 新增:正在连接状态
connMutex sync.Mutex // 新增:连接状态锁
aesCrypto *crypto.AESCrypto // 新增:AES加密器
connecting bool // 正在连接状态
connMutex sync.Mutex // 连接状态锁
aesCrypto *crypto.AESCrypto // AES加密器
}
var wsDial = func(dialer *websocket.Dialer, rawURL string) (*websocket.Conn, *http.Response, error) {
@@ -185,8 +189,8 @@ func NewWebSocketReporter(serverURL string, secret string) *WebSocketReporter {
return &WebSocketReporter{
url: serverURL,
reconnectTime: 5 * time.Second, // 重连间隔
pingInterval: 5 * time.Second, // 指标上报间隔
curBackoff: initialBackoff, // 当前退避间隔
pingInterval: 1 * time.Second, // 指标上报间隔(每秒采集)
configInterval: 10 * time.Minute, // 配置上报间隔
ctx: ctx,
cancel: cancel,
@@ -204,10 +208,17 @@ func (w *WebSocketReporter) Start() {
// Stop 停止WebSocket报告器
func (w *WebSocketReporter) Stop() {
w.cancel()
w.connMutex.Lock()
if w.conn != nil {
w.conn.Close()
}
w.connMutex.Unlock()
}
// backoffWithJitter 返回带随机抖动的退避时间(±25%)
func backoffWithJitter(base time.Duration) time.Duration {
jitter := time.Duration(float64(base) * (0.75 + rand.Float64()*0.5))
return jitter
}
// run 主运行循环
@@ -224,23 +235,32 @@ func (w *WebSocketReporter) run() {
if needConnect {
if err := w.connect(); err != nil {
fmt.Printf("❌ WebSocket连接失败: %v,%v后重试\n", err, w.reconnectTime)
wait := backoffWithJitter(w.curBackoff)
fmt.Printf("❌ WebSocket连接失败: %v,%v后重试\n", err, wait)
// 指数退避:翻倍当前退避间隔,上限 maxBackoff
w.curBackoff *= 2
if w.curBackoff > maxBackoff {
w.curBackoff = maxBackoff
}
select {
case <-time.After(w.reconnectTime):
case <-time.After(wait):
continue
case <-w.ctx.Done():
return
}
}
// 连接成功:重置退避
w.curBackoff = initialBackoff
}
// 连接成功,开始发送消息
if w.connected {
w.handleConnection()
} else {
wait := backoffWithJitter(w.curBackoff)
// 如果连接失败,等待重试
select {
case <-time.After(w.reconnectTime):
case <-time.After(wait):
continue
case <-w.ctx.Done():
return
@@ -328,7 +348,7 @@ func buildWebSocketCandidates(addr string, secret string, version string, http i
normalizedAddr = strings.TrimSpace(addr)
}
query := "/system-info?type=1&secret=" + secret + "&version=" + version +
query := "/system-info?type=1&secret=" + url.QueryEscape(secret) + "&version=" + url.QueryEscape(version) +
"&http=" + strconv.Itoa(http) + "&tls=" + strconv.Itoa(tls) + "&socks=" + strconv.Itoa(socks)
schemes := []string{"wss", "ws"}
@@ -473,15 +493,34 @@ func (w *WebSocketReporter) handleConnection() {
// 启动消息接收goroutine
go w.receiveMessages()
// 主发送循环
ticker := time.NewTicker(w.pingInterval)
defer ticker.Stop()
// 指标上报 ticker
metricTicker := time.NewTicker(w.pingInterval)
defer metricTicker.Stop()
// 独立 WebSocket keepalive ping ticker
pingTicker := time.NewTicker(wsPingInterval)
defer pingTicker.Stop()
for {
select {
case <-w.ctx.Done():
return
case <-ticker.C:
case <-pingTicker.C:
// 发送 WebSocket ping 保活,独立于指标上报
w.connMutex.Lock()
conn := w.conn
isConnected := w.connected
w.connMutex.Unlock()
if !isConnected || conn == nil {
return
}
if err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(reporterWriteWait)); err != nil {
fmt.Printf("❌ 发送WebSocket ping失败: %v,准备重连\n", err)
return
}
case <-metricTicker.C:
// 检查连接状态
w.connMutex.Lock()
isConnected := w.connected
@@ -548,6 +587,37 @@ func (w *WebSocketReporter) collectSystemInfo() SystemInfo {
}
}
// encryptPayload 加密 JSON 数据,返回加密后的消息字节(若加密失败则回退到原始数据)
func (w *WebSocketReporter) encryptPayload(jsonData []byte) []byte {
if w.aesCrypto == nil {
return jsonData
}
encryptedData, err := w.aesCrypto.Encrypt(jsonData)
if err != nil {
fmt.Printf("⚠️ 加密失败,发送原始数据: %v\n", err)
return jsonData
}
encryptedMessage := map[string]interface{}{
"encrypted": true,
"data": encryptedData,
"timestamp": time.Now().Unix(),
}
messageData, err := json.Marshal(encryptedMessage)
if err != nil {
fmt.Printf("⚠️ 序列化加密消息失败,发送原始数据: %v\n", err)
return jsonData
}
return messageData
}
// metricEnvelope wraps SystemInfo with a type field for fast identification on the panel side.
type metricEnvelope struct {
Type string `json:"type"`
Data SystemInfo `json:"data"`
}
// sendSystemInfo 发送系统信息
func (w *WebSocketReporter) sendSystemInfo(sysInfo SystemInfo) error {
w.connMutex.Lock()
@@ -557,42 +627,19 @@ func (w *WebSocketReporter) sendSystemInfo(sysInfo SystemInfo) error {
return fmt.Errorf("连接未建立")
}
// 转换为JSON
jsonData, err := json.Marshal(sysInfo)
// 使用 type:"metric" 信封包装,Panel 可通过 type 字段直接识别指标消息
envelope := metricEnvelope{Type: "metric", Data: sysInfo}
jsonData, err := json.Marshal(envelope)
if err != nil {
return fmt.Errorf("序列化系统信息失败: %v", err)
}
var messageData []byte
messageData := w.encryptPayload(jsonData)
// 如果有加密器,则加密数据
if w.aesCrypto != nil {
encryptedData, err := w.aesCrypto.Encrypt(jsonData)
if err != nil {
fmt.Printf("⚠️ 加密失败,发送原始数据: %v\n", err)
messageData = jsonData
} else {
// 创建加密消息包装器
encryptedMessage := map[string]interface{}{
"encrypted": true,
"data": encryptedData,
"timestamp": time.Now().Unix(),
}
messageData, err = json.Marshal(encryptedMessage)
if err != nil {
fmt.Printf("⚠️ 序列化加密消息失败,发送原始数据: %v\n", err)
messageData = jsonData
}
}
} else {
messageData = jsonData
}
// 设置写入超时
w.conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
if err := w.conn.WriteMessage(websocket.TextMessage, messageData); err != nil {
w.connected = false // 标记连接已断开
w.connected = false
return fmt.Errorf("写入消息失败: %v", err)
}
@@ -601,23 +648,19 @@ func (w *WebSocketReporter) sendSystemInfo(sysInfo SystemInfo) error {
// receiveMessages 接收服务端发送的消息
func (w *WebSocketReporter) receiveMessages() {
// 获取连接引用一次即可,连接生命周期由 handleConnection 管理
w.connMutex.Lock()
conn := w.conn
w.connMutex.Unlock()
if conn == nil {
return
}
for {
select {
case <-w.ctx.Done():
return
default:
w.connMutex.Lock()
conn := w.conn
connected := w.connected
w.connMutex.Unlock()
if conn == nil || !connected {
return
}
// 设置读取超时
conn.SetReadDeadline(time.Now().Add(reporterReadWait))
messageType, message, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
@@ -705,12 +748,8 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
}
if cmdMsg.Type != "call" {
// 其他状态变更命令保持同步,确保顺序执行
if cmdMsg.Type == "TcpPing" || cmdMsg.Type == "ServiceMonitorCheck" || cmdMsg.Type == "UpgradeAgent" || cmdMsg.Type == "RollbackAgent" {
go w.routeCommand(cmdMsg)
} else {
w.routeCommand(cmdMsg)
}
// 所有命令统一异步执行,避免阻塞消息接收循环
go w.routeCommand(cmdMsg)
}
} else {
// 处理普通消息
@@ -721,12 +760,8 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
return
}
if cmdMsg.Type != "call" {
// 其他状态变更命令保持同步,确保顺序执行
if cmdMsg.Type == "TcpPing" || cmdMsg.Type == "ServiceMonitorCheck" || cmdMsg.Type == "UpgradeAgent" || cmdMsg.Type == "RollbackAgent" {
go w.routeCommand(cmdMsg)
} else {
w.routeCommand(cmdMsg)
}
// 所有命令统一异步执行,避免阻塞消息接收循环
go w.routeCommand(cmdMsg)
}
}
@@ -1400,30 +1435,7 @@ func (w *WebSocketReporter) sendResponse(response CommandResponse) {
return
}
var messageData []byte
// 如果有加密器,则加密数据
if w.aesCrypto != nil {
encryptedData, err := w.aesCrypto.Encrypt(jsonData)
if err != nil {
fmt.Printf("⚠️ 加密响应失败,发送原始数据: %v\n", err)
messageData = jsonData
} else {
// 创建加密消息包装器
encryptedMessage := map[string]interface{}{
"encrypted": true,
"data": encryptedData,
"timestamp": time.Now().Unix(),
}
messageData, err = json.Marshal(encryptedMessage)
if err != nil {
fmt.Printf("⚠️ 序列化加密响应失败,发送原始数据: %v\n", err)
messageData = jsonData
}
}
} else {
messageData = jsonData
}
messageData := w.encryptPayload(jsonData)
// 检查消息大小,如果超过10MB则记录警告
if len(messageData) > 10*1024*1024 {
@@ -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 .`
+21
View File
@@ -0,0 +1,21 @@
# 013 - 隧道入口端口校验不严格修复
## Issue
- GitHub Issue: [#373](https://github.com/Sagit-chu/flvx/issues/373)
## 修复方案
在 `syncTunnelForwardsEntryPorts` 中实现逐节点端口分配:
- **旧入口节点**:保留原端口不变
- **新入口节点**:通过 `resolvePortForNewEntryNode` 决策:
- 参考端口在范围内且未被占用 → 跟随设置一样的端口
- 参考端口超出范围或被占用 → 通过 `pickRandomPortForNode` 为该节点单独随机分配
## 任务清单
- [x] 1. 实现 `pickRandomPortForNode` 辅助方法(单节点端口随机分配)
- [x] 2. 实现 `resolvePortForNewEntryNode` 方法(端口决策逻辑)
- [x] 3. 重写 `syncTunnelForwardsEntryPorts` 为逐节点分配
- [x] 4. 移除不再需要的 `isPortValidForAllEntryNodes`
- [x] 5. 构建通过 + 全量测试通过
@@ -0,0 +1,7 @@
# 046 - Node Card Cleanup & Monitor Redesign
## Tasks
- [x] 1. Remove duplicate system metrics from node cards in `node.tsx` (CPU, memory, upload/download speed, upload/download traffic, disk, load)
- [x] 2. Redesign monitor `ServerCard` in `monitor-view.tsx` to match the node card style from `node.tsx`
- [x] 3. Make all charts in monitor view update incrementally (streaming) instead of full page reload
+9
View File
@@ -0,0 +1,9 @@
# 047 - Beautify Monitor Tab
## Goal
Redesign the node monitoring cards in the Monitor tab (监控标签) to make them more visually appealing. Incorporate a dynamic, clean layout mimicking the Nezha monitoring dashboard.
## Tasks
- [x] Redesign `ServerCard` in `vite-frontend/src/pages/node/monitor-view.tsx` with gradients, neon statuses, clean layout, up/down arrows for speeds.
- [x] Update the realtime KPI cards in the Monitor detailed view to have subtle gradients and dynamically colored text.
- [x] Fix TypeScript linting issues with `Progress` component colors (`classNames` vs `color`).
+11
View File
@@ -0,0 +1,11 @@
# 048 - Release 2.1.9-alpha5
## Goal
Release the current state of `main` (including the monitor tab beautification and user page improvements) under the tag `2.1.9-alpha5`.
## Tasks
- [x] Update `AGENTS.md` with current date, branch, and anticipated tag.
- [x] Create a release branch `release/2.1.9-alpha5`.
- [x] Commit and push the branch.
- [x] Create a Pull Request and merge it into `main`.
- [x] Publish the new tag `2.1.9-alpha5`.
+32
View File
@@ -0,0 +1,32 @@
# 049 - Monitor List View
## Objective
The user requested that the Monitor page should switch its card-based grid view to a list view similar to a provided screenshot, and a view mode toggle should be added to the top right.
## Expected Features
1. View Mode Toggle
- Add a state `viewMode` in `pages/monitor.tsx`.
- Add a toggle button with LayoutGrid/List icons next to the refresh button.
- Pass `viewMode` down to `MonitorView` component.
2. List View Implementation
- Extend `MonitorViewProps` with `viewMode: "list" | "grid"`.
- Render the `ServerCard` grid when `viewMode === "grid"`.
- Render a `Table` when `viewMode === "list"`.
- The list view should include:
- 状态 (Status: Colored dot depending on `isOnline`).
- 名称 (Name: Node name).
- 速率 (Speed: Up/Down speeds styled appropriately).
- 流量 (Traffic: Total Up/Down bytes).
- 开机时长 (Uptime).
- 连接数 (Connections: TCP/UDP).
- CPU (Progress bar).
- RAM (Progress bar).
- 存储 (Storage / Disk Progress bar).
- 操作 (Actions: Eye view icon to open detailed monitor).
## Checklist
- [x] Create plan document.
- [x] Add viewMode state and toggle in `monitor.tsx`.
- [x] Receive viewMode in `MonitorView` and selectively render Grid vs List views.
- [x] Ensure list correctly visualizes node info, speed, traffic, uptime, conns, CPU/RAM/Disk usages, and action icons.
- [x] Fix HeroUI missing TableProps typings (remove `removeWrapper` prop).
+40
View File
@@ -0,0 +1,40 @@
# 050 - 监控页面隧道独立入口
## 背景
当前监控页面的隧道流量监控嵌套在节点详情内,用户需要先点击某个节点才能看到隧道流量。
需要将隧道监控提升为与节点列表同级的入口,展示隧道质量(延迟)和流量统计。
## 设计
### 监控页面结构变更
在 `monitor.tsx` 页面增加 Tab 式结构(或并列区域),新增"隧道监控"入口与节点列表同级:
- **节点** Tab:保持现有节点卡片/列表 + 详情视图
- **隧道** Tab:新增隧道卡片/列表视图
- 每个隧道卡片显示:隧道名称、状态、质量指标(入口→出口延迟、出口→Bing TCP延迟)
- 点击隧道进入详情:隧道流量趋势图表(已有的 tunnel metrics chart)
### 隧道质量指标
使用已有的隧道诊断 API (`/tunnel/diagnose`) 进行 TCP 延迟测试:
- 入口→出口延迟
- 出口→Bing TCP 延迟(bing.com:443)
新增一个轻量级的后端 API 来获取隧道质量快照(定时 TCP 测试延迟),
或者前端在隧道 Tab 里手动触发诊断。
### 实现方案(前端为主)
由于后端已有完整的 tunnel metrics 和 diagnosis API,本次改动主要在前端:
1. `monitor.tsx` 增加 Tab 切换(节点 / 隧道)
2. 新建 `tunnel-monitor-view.tsx` 组件
3. 隧道列表用卡片展示质量指标
4. 点击进入隧道详情,展示流量图表
5. 从 `monitor-view.tsx` 的节点详情中移除隧道流量图表
## 任务清单
- [x] 1. 修改 `monitor.tsx`,增加节点/隧道 Tab 切换
- [x] 2. 新建 `tunnel-monitor-view.tsx` 组件,实现隧道列表视图
- [x] 3. 隧道卡片展示名称、状态、质量指标(支持手动诊断获取延迟)
- [x] 4. 隧道详情视图,展示流量趋势图表(复用已有 tunnel metrics API)
- [x] 5. 从节点详情视图中移除隧道流量图表
- [x] 6. TypeScript 编译通过验证
+15
View File
@@ -0,0 +1,15 @@
# Plan 051: Sync Main and Push Tag
## Goal
Submit all changes, push them to the remote repository, create a pull request to main, merge it, and push a new tag.
## Tasks
- [ ] Determine the next tag version.
- [ ] Add all changes to the staging area.
- [ ] Commit the changes.
- [ ] Push changes to the current branch.
- [ ] Create a pull request via `gh` CLI.
- [ ] Merge the pull request via `gh` CLI.
- [ ] Sync the `main` branch locally.
- [ ] Create a new tag.
- [ ] Push the new tag to the remote repository.
+10
View File
@@ -0,0 +1,10 @@
# 052 Node and User List View
## Objective
Add a list view and a card view toggle for Node Page and User Page, so they match exactly how the Monitor Page does it.
## Tasks
- [x] Add `viewMode` state toggle with `LayoutGrid` and `List` icons to `pages/node.tsx` header area.
- [x] Implement Table view rendering for Node list in `pages/node.tsx`.
- [x] Add `viewMode` state toggle with `LayoutGrid` and `List` icons to `pages/user.tsx` header area.
- [x] Implement Table view rendering for User list in `pages/user.tsx`.
+16
View File
@@ -0,0 +1,16 @@
# 053 List View Styling
## Objective
Update the design of Node and User list table views to match the provided screenshot:
- Use colored dots for status instead of separate "Status" text columns.
- The action buttons should have text labels (e.g. "安装", "编辑") with `variant="flat"` instead of icons.
- Add version column for Node list.
- Remove traffic columns from Node list as requested.
- Adjust User list to match this clean style.
## Tasks
- [x] Update `pages/node.tsx` list view to use the new column layout (Node name with dot, Address, Version, Actions).
- [x] Update action buttons in `pages/node.tsx` list view to use text instead of icons.
- [x] Update `pages/user.tsx` list view to use the status dot pattern.
- [x] Update action buttons in `pages/user.tsx` list view to use text instead of icons.
- [x] Ensure `selectionMode="multiple"` (or similar) is properly reflected.
+147
View File
@@ -0,0 +1,147 @@
# Agent-Panel 通信优化:提升稳定性与效率
## 背景
Agent(`go-gost/x/socket/websocket_reporter.go`)与 Panel(`go-backend/internal/ws/server.go`)之间通过 WebSocket 进行实时通信,包括指标上报(每 5s)、命令下发/响应、和流量上报(HTTP)。经过代码审查,以下是发现的问题和优化建议。
---
## 发现的问题
### 1. Keepalive 时序不匹配 —— 导致误断连
| 参数 | Agent 侧 | Panel 侧 |
|------|----------|----------|
| Read deadline | `reporterReadWait` = 60s | `wsPongWait` = 45s |
| Ping 发送间隔 | 无主动 ping(靠指标数据 5s 续命) | `wsPingPeriod` = 15s |
| Write timeout | `reporterWriteWait` = 5s | `wsWriteWait` = 5s |
**问题**:Panel 每 15s 发 ping,Agent read deadline 60s,但 Panel pong deadline 只有 45s。如果 Agent 的指标消息被延迟(网络抖动),Panel 可能因 pong 超时而关闭连接。两侧的超时参数缺乏协调设计。
### 2. 固定重连间隔 —— 无退避策略
Agent 断线后以固定 5s 间隔重试(`reconnectTime = 5 * time.Second`),在 Panel 长时间不可用(升级、网络故障)的情况下,会产生大量无用连接尝试。
### 3. Panel 侧每次解密都重建 AES 加密器
`ws/server.go` 的 `decryptIfNeeded()` 和 `SendCommand()` 每次调用都 `security.NewAESCrypto(secret)` 重新创建 cipher(SHA256 + AES-GCM 初始化),对于高频指标消息(5s/次 × N 节点),有不必要的 CPU 开销。
### 4. 指标消息使用 JSON Text 格式传输
每 5s 发送一次包含 13 个字段的 SystemInfo JSON,加密后还需 base64 编码,一条消息约 300-500 bytes(加密后约 700 bytes)。对于大量节点场景,存在优化空间。
### 5. `receiveMessages` 紧循环中有频繁锁竞争
`receiveMessages()` 在每次 `ReadMessage()` 前都要 `Lock/Unlock connMutex` 检查连接状态,但 `ReadMessage` 本身是阻塞的,实际不需要在循环外检查。
### 6. 状态变更命令阻塞读消息循环
`routeCommand` 中的 Service/Chain/Limiter CRUD 命令是同步执行的,包括 `saveConfig()` 文件写入。执行期间会阻塞 `receiveMessages` 的读取循环。
---
## 推荐的优化方案(按优先级排列)
### P0 — 高收益、低风险
#### 优化 1:协调 Keepalive 参数
**文件**:`websocket_reporter.go`
- Agent 增加独立的 WebSocket ping 发送(每 20s),不依赖指标数据来维持连接
- 统一 read deadline 设置,确保两侧 read timeout > 2×ping interval
#### 优化 2:指数退避重连
**文件**:`websocket_reporter.go`
- 初始间隔 2s,按指数退避增长至最大 2 分钟
- 连接成功后立即重置退避
- 增加随机抖动(jitter)避免大量 Agent 同时重连
#### 优化 3:Panel 侧缓存 AES 加密器
**文件**:`ws/server.go`
- 将 `AESCrypto` 实例缓存在 `nodeSession` 中,避免每条消息重建
- `SendCommand` 复用缓存实例
### P1 — 中等收益
#### 优化 4:减少 `receiveMessages` 锁竞争
**文件**:`websocket_reporter.go`
- 将连接状态检查移到循环外,只在出错/关闭时通过 channel 通知退出
- 用 `context.WithCancel` 代替锁检查 `connected` flag 来控制生命周期
#### 优化 5:异步化状态变更命令处理
**文件**:`websocket_reporter.go`
- 所有命令统一异步执行(通过 goroutine + response channel),避免阻塞 readLoop
- 当前只有 TcpPing/ServiceMonitorCheck/UpgradeAgent/RollbackAgent 是异步的
---
## 具体代码变更
### Agent 侧 (`go-gost/x/socket`)
---
#### [MODIFY] [websocket_reporter.go](file:///Users/sagit/Documents/github/flvx/go-gost/x/socket/websocket_reporter.go)
1. **指数退避重连**:将 `reconnectTime` 从固定 `5s` 改为动态退避字段,增加 `curBackoff/maxBackoff` 字段
2. **独立 Ping 发送**:在 `handleConnection()` 中增加 WebSocket ping ticker(20s),独立于指标上报
3. **减少锁竞争**:`receiveMessages` 中只在循环入口检查一次连接,此后靠 `ReadMessage` 的 error 退出
4. **统一命令异步化**:所有 `routeCommand` 调用统一使用 goroutine
---
### Panel 侧 (`go-backend/internal/ws`)
---
#### [MODIFY] [server.go](file:///Users/sagit/Documents/github/flvx/go-backend/internal/ws/server.go)
1. **缓存 AES 加密器**:在 `nodeSession` 中增加 `crypto *security.AESCrypto` 字段,节点连接时初始化
2. **`decryptIfNeeded` 接收 crypto 参数**而非 secret 字符串
3. **`SendCommand` 使用缓存 crypto** 实例
---
## Verification Plan
### Automated Tests
```bash
# 运行现有 agent 侧单元测试(验证不回归)
(cd go-gost/x && go test ./socket/... -v -count=1)
# 运行现有流量上报测试
(cd go-gost/x && go test ./service/... -v -count=1)
# 运行 panel 侧全部测试
(cd go-backend && go test ./... -count=1)
```
### Manual Verification
> [!IMPORTANT]
> 本次改动涉及实时通信核心路径,建议在 staging 环境部署后观察至少 30 分钟:
> 1. 检查节点在面板中状态是否正常显示为在线
> 2. 手动停止面板后观察 Agent 日志,确认重连间隔呈指数增长
> 3. 恢复面板后确认 Agent 能自动恢复连接并恢复指标上报
> 4. 通过面板下发命令(如添加/删除 service),确认命令执行成功
---
## 任务清单
- [x] 优化 1:Agent 增加独立 WebSocket ping 发送
- [x] 优化 2:Agent 指数退避重连
- [x] 优化 3:Panel 缓存 AES 加密器
- [x] 优化 4:Agent 减少 receiveMessages 锁竞争
- [x] 优化 5:Agent 命令处理统一异步化
- [x] 运行现有测试验证不回归
@@ -0,0 +1,31 @@
# 055 - 隧道质量定时探测 + 实时展示 + 历史图表
## 背景
当前隧道质量检测是手动触发的:用户点击"诊断"按钮 → 后端调用节点 TcpPing → 返回结果。
需求:改为**后端定时(每10秒)自动探测**所有启用隧道的质量(入口→出口延迟、出口→Bing延迟),
结果保留历史(24h),前端隧道 Tab 实时展示 + 图表历史趋势。
## 设计原则:与服务监控复用
| 复用点 | 服务监控 | 隧道质量 |
|--------|---------|---------|
| 调度方式 | `health.Checker.Start(ctx)` via `jobs.go` | `tunnelQualityProber.Start(ctx)` via `jobs.go` |
| 存储模式 | `service_monitor_result` (history, insert) | `tunnel_quality` (history, insert) |
| 清理方式 | `PruneServiceMonitorResults(olderThanMs)` | `PruneTunnelQualityResults(olderThanMs)` |
| 最新查询 | `GetLatestServiceMonitorResults()` (window func) | `GetLatestTunnelQualities()` (window func) |
| 历史查询 | `GetServiceMonitorResults(id, limit)` | `GetTunnelQualityHistory(id, start, end)` |
| API 模式 | `GET /monitor/services/{id}/results` | `GET /monitor/tunnels/{id}/quality` |
| 前端图表 | Recharts LineChart (延迟趋势) | Recharts LineChart (同样模式) |
## 任务清单
- [x] 1. `TunnelQuality` model 改为历史存储(composite index, 非 unique)
- [x] 2. Repo 改为 insert(非 upsert),复用服务监控的查询模式
- [x] 3. 添加 `PruneTunnelQualityResults` + `GetLatestTunnelQualities` + `GetTunnelQualityHistory`
- [x] 4. Prober 生命周期集成到 `jobs.go`(与 healthCheck 同级)
- [x] 5. Prober 添加 24h 清理周期
- [x] 6. 添加 API `GET /monitor/tunnels/{id}/quality` 返回历史
- [x] 7. 前端添加 `getMonitorTunnelQualityHistory()` API
- [x] 8. 前端详情页添加质量趋势图表(复用服务监控图表组件模式)
- [x] 9. Go 编译 + 测试通过
- [x] 10. TypeScript 编译通过
+16
View File
@@ -0,0 +1,16 @@
# Tunnel Quality Uptime Kuma Display
**Plan ID**: 056-tunnel-uptime-kuma-bars.md
## Objective
The goal is to modify the "Monitor - Tunnel" UI page to remove the isolated "Quality" column/chip, and replace the specific latency readouts for entry->exit and exit->Bing with an Uptime Kuma style row of visual history bars.
## Steps
- [x] Remove the individual "Quality" column from the tunnel monitor table view.
- [x] Remove the individual "Quality" chip from the tunnel monitor grid view.
- [x] Implement `<UptimeHistoryBar />` to display a historical sequence of up to 30 metrics, coloring by latency (success, warning, danger) and packet loss (danger).
- [x] Modify the front-end to poll/load initial tunnel quality history, effectively padding the history bars instead of them starting empty.
- [x] Update `getMonitorTunnelQuality` auto-polling locally to append strictly to the local history state, truncating appropriately.
- [x] Retain current latency display next to or below the Uptime bars to allow numerical visibility.
## Status
Completed. The interface will now load history for all displayed tunnels upon open, and continue tracking with bars appending in real time every 10-second polling interval.
+13
View File
@@ -0,0 +1,13 @@
# 057 Hide Remote Nodes from Monitor View
## Goal
Do not display remote nodes on the Monitoring page.
## Changes Made
- Modified `ListMonitorNodes` in `go-backend/internal/store/repo/repository_monitor_nodes.go` by adding a `.Where("is_remote = ?", 0)` constraint so that remote nodes (imported via Federation feature) are entirely excluded from the returned payload for API `/api/v1/monitor/nodes`.
- This efficiently removes remote nodes from both the grid/list displaying Node stats in the Monitoring tab and also eliminates remote nodes from the selection dropdown when creating new Service Monitors.
## Checklist
- [x] Identify how "remote node" is defined in the database structure (`IsRemote` = 1 or 0).
- [x] Add SQL query constraint to filter out remote nodes from the `/monitor/nodes` API response.
- [x] Verify changes compile successfully.
+13
View File
@@ -0,0 +1,13 @@
# Plan 058: Sync Main and Push Tag
Sync current changes to `main`, merge to `master` (if needed) or just PR/merge on `main` (if that's the flow), and push a new tag `2.1.9-beta7`.
## Tasks
- [x] Determine the next tag version (`2.1.9-beta7`).
- [x] Create a feature branch `release/2.1.9-beta7`.
- [x] Commit all current changes to the feature branch.
- [x] Push the feature branch to remote.
- [x] Create a PR to `main`.
- [x] Merge the PR to `main`.
- [x] Create and push tag `2.1.9-beta7`.
+59
View File
@@ -0,0 +1,59 @@
# 059 - 主题系统设计(v2 — 完整可扩展架构)
## 概述
设计一个高度可扩展的主题包架构,允许第三方作者通过代码提交的方式创建主题,覆盖前端所有元素——从 CSS 变量到组件实现、布局结构、甚至整个页面。
## 架构
```
src/themes/
├── types.ts # ThemePackage 接口定义
├── registry.ts # 主题注册表 + CSS 注入引擎
├── context.tsx # React Context + Provider + Hooks
├── index.ts # 公共 API barrel
├── loader.ts # 主题加载器(注册所有内置主题)
├── README.md # 主题开发指南
│
├── default/ # 默认主题(参考实现)
│ └── index.ts
│
├── example-cyberpunk/ # 示例主题(赛博朋克)
│ ├── index.ts
│ └── components/
│ └── button.tsx # 组件覆盖示范
│
└── <your-theme>/ # 第三方主题
├── index.ts
├── components/
├── layouts/
├── pages/
└── assets/
```
## 覆盖层级
| 层级 | 字段 | 说明 |
|------|------|------|
| CSS 变量 | `tokens.light` / `tokens.dark` | 80+ 个设计 token(颜色、字体、圆角) |
| 原始 CSS | `css` | 注入自定义 CSS(动画、字体、阴影等) |
| 组件替换 | `components` | 替换任意 UI 组件(30+ 个可替换组件键) |
| 布局替换 | `layouts` | 替换 4 种布局(Admin / H5 / H5Simple / Default) |
| 页面替换 | `pages` | 替换 14 个页面路由实现 |
| 生命周期 | `onActivate` / `onDeactivate` | 主题启用/停用回调 |
## 任务清单
- [x] **T1**: 创建 `src/themes/types.ts` — ThemePackage 接口 + 所有可覆盖键定义
- [x] **T2**: 创建 `src/themes/registry.ts` — 主题注册/激活/停用/CSS 注入引擎
- [x] **T3**: 创建 `src/themes/context.tsx` — React Context + ThemeProvider + hooks
- [x] **T4**: 创建 `src/themes/index.ts` — 公共 API barrel
- [x] **T5**: 创建 `src/themes/loader.ts` — 自动加载所有内置主题
- [x] **T6**: 创建 `src/themes/default/` — 默认主题参考实现
- [x] **T7**: 创建 `src/themes/example-cyberpunk/` — 完整示例主题(含组件覆盖 + CSS + 生命周期)
- [x] **T8**: 重构 `use-theme.tsx` — 向后兼容包装
- [x] **T9**: 重构 `theme-provider.tsx` — 集成新主题系统
- [x] **T10**: 编写 `README.md` — 主题开发完整指南
- [x] **T11**: TypeScript 编译验证通过
- [ ] **T12**: (后续) 设置页面集成主题选择器 UI
- [ ] **T13**: (后续) 将现有组件导入逐步迁移到 `useThemedComponent` 模式
+42
View File
@@ -0,0 +1,42 @@
# 060 Nezha-style Monitoring (1s test, 30s report)
## Objective
Update all monitoring subsystems to test every 1 second and report (write to DB) every 30 seconds, matching Nezha-style monitoring behavior.
## Changes
### 1. Tunnel Quality Prober (`go-backend/internal/http/handler/tunnel_quality_prober.go`)
- [x] Change `tunnelQualityProbeInterval` from 10s to 1s
- [x] Add `tunnelQualityReportInterval = 30s` for DB write throttling
- [x] Update `storeResult` to cache in-memory every tick, write to DB only every 30s per tunnel
- [x] Add atomic `probing` flag to prevent overlapping `probeAll()` goroutine pile-up
- [x] Increase `maxWorkers` from 4 to 20
### 2. Service Monitor Checker (`go-backend/internal/health/checker.go`)
- [x] Add `serviceMonitorReportInterval = 30s` for DB write throttling
- [x] Add `latestResults` in-memory map and `lastDBWrite` map per monitor
- [x] Add `GetLatestCached()` method for real-time API reads
- [x] Add atomic `checking` flag to prevent overlapping `runChecks()` goroutine pile-up
- [x] Modify worker goroutines to always update in-memory cache, only write to DB every 30s
### 3. Service Monitor Limits (`go-backend/internal/monitoring/limits.go`)
- [x] Change `CheckerScanIntervalSec` default from 30 to 1
- [x] Change `WorkerLimit` default from 5 to 20
- [x] Change `MinIntervalSec` default from 30 to 1
- [x] Change `DefaultIntervalSec` default from 60 to 1
### 4. Monitoring API Handler (`go-backend/internal/http/handler/monitoring.go`)
- [x] Update `monitorServiceLatestResultsHandler` to prefer in-memory cached results from `healthCheck.GetLatestCached()`
### 5. Agent WebSocket Reporter (`go-gost/x/socket/websocket_reporter.go`)
- [x] Change `pingInterval` (metric reporting) from 5s to 1s
### 6. Frontend - Tunnel Monitor (`vite-frontend/src/pages/node/tunnel-monitor-view.tsx`)
- [x] Change `QUALITY_POLL_INTERVAL` from 10s to 1s
- [x] Update detail view text: "自动探测中(每秒测试,30秒上报)"
- [x] Update list view text: "每秒探测 · 更新于 ..."
### 7. Frontend - Service Monitor (`vite-frontend/src/pages/node/monitor-view.tsx`)
- [x] Change `DEFAULT_SERVICE_MONITOR_LIMITS` defaults to match backend (1s intervals)
- [x] Change service monitor + latest results polling from 30s to 1s
- [x] Update info bar text: "每秒测试,30秒上报"
+15
View File
@@ -0,0 +1,15 @@
# Plan 061: Node Logo By OS Type (Linux Distro)
## Objective
Display different logos for nodes in the 'Monitor - Node - Card/List View' based on their Linux distribution (Ubuntu, Debian, CentOS, Alpine, etc.).
## Tasks
- [x] Agent: Use `gopsutil/v3/host.Info().Platform` to detect the Linux distro and include it in the version string (`distro.go`).
- [x] Agent: Update `main.go` to call `socket.DetectDistro()` instead of `runtime.GOOS`.
- [x] Agent: Ensure version parameter is URL-escaped since it now includes distro info with special chars.
- [x] Backend: Select `version` column in `ListMonitorNodes`.
- [x] Backend: Include `version` in `monitorNodeListItem` JSON response.
- [x] Frontend: Add `version` to TypeScript interfaces (`MonitorNodeApiItem`, `MonitorNode`, `MonitorViewProps`).
- [x] Frontend: Create `distro-icon.tsx` component with SVG logos for Ubuntu, Debian, CentOS/Rocky/Alma, Alpine, Fedora, Arch/Manjaro, and a default Linux (Tux) fallback.
- [x] Frontend: Use `DistroIcon` in `ServerCard` (card view) and list view name column with branded colors per distro.
- [x] All three projects compile cleanly (`go build`, `tsc --noEmit`).
+49
View File
@@ -0,0 +1,49 @@
# Plan 062: Commit, PR, Merge and Tag
## Overview
This plan outlines the steps to commit all changes, create a PR, merge it, and then publish a new tag for the FLVX project.
## Checklist
- [ ] Check current git status for any unexpected changes
- [ ] Create a feature branch `feat-node-os-logo-release`
- [ ] Stage and commit all modifications and untracked files
- [ ] Push the feature branch to origin
- [ ] Create a Pull Request (PR) from the feature branch to `main`
- [ ] Merge the PR to `main`
- [ ] Update `AGENTS.md` with the new tag and commit hash
- [ ] Create and push new tag `2.1.9-beta14`
## Detailed Steps
### 1. Create Feature Branch
```bash
git checkout -b feat-node-os-logo-release
```
### 2. Commit all changes
Add all modified and untracked files:
```bash
git add .
git commit -m "feat: node OS logo support, UI rate overlap fix and tunnel monitoring updates"
```
### 3. Push and PR
Push to `origin`:
```bash
git push origin feat-node-os-logo-release
```
Create PR via `gh pr create` if possible.
### 4. Merge to Main
```bash
git checkout main
git merge feat-node-os-logo-release
git push origin main
```
### 5. Create Tag
Increment the current tag `2.1.9-beta13` to `2.1.9-beta14`.
```bash
git tag 2.1.9-beta14
git push origin 2.1.9-beta14
```
+12
View File
@@ -0,0 +1,12 @@
# Plan 063: Release 2.1.9-rc6
Sync all changes, bump version to `2.1.9-rc6`, create PR, merge, and publish tag.
## Tasks
- [x] Update `AGENTS.md` with new tag (`2.1.9-rc6`) and today's date (`Tue Mar 24 2026`).
- [x] Commit all changes to branch `chore/rc6-bump`.
- [x] Push branch to remote.
- [x] Create Pull Request using `gh`.
- [x] Merge Pull Request using `gh`.
- [x] Create and push tag `2.1.9-rc6`.
+12
View File
@@ -0,0 +1,12 @@
# Plan 064: Release 2.1.9-rc7
Sync all changes, bump version to `2.1.9-rc7`, create PR, merge, and publish tag.
## Tasks
- [x] Update `AGENTS.md` with new tag (`2.1.9-rc7`) and today's date (`Tue Mar 24 2026`).
- [x] Commit all changes to branch `chore/rc7-bump`.
- [x] Push branch to remote.
- [x] Create Pull Request using `gh`.
- [x] Merge Pull Request using `gh`.
- [x] Create and push tag `2.1.9-rc7`.
@@ -0,0 +1,11 @@
# Fix Node Metrics PostgreSQL Type Encoding
## Objective
Fix the PostgreSQL type encoding error (`failed to encode args[0]: unable to encode 5 into text format for text (OID 25)`) and `integer out of range` error when querying node metrics for time ranges greater than 1 hour.
## Tasks
- [x] Identify the problematic downsampled SQL aggregation in `GetNodeMetrics`.
- [x] Fix the `? AS node_id` placeholder which confused PostgreSQL's type inference by directly embedding the `nodeID` using `fmt.Sprintf("%d AS node_id")`.
- [x] Change all `CAST(X AS INTEGER)` to `CAST(X AS BIGINT)` to prevent 32-bit integer overflow on Unix millisecond timestamps in PostgreSQL.
- [x] Verify the build and tests pass.
- [ ] Commit all changes, create a new branch, push, create a Pull Request, merge the PR into `main`, and publish a new tag `2.1.9-rc9`.
+11
View File
@@ -0,0 +1,11 @@
# 066 - Issue 349 IPv6 Entry Format
## Goal
- 修复规则入口 IPv6 地址在列表/复制场景下缺少方括号的问题,确保 API 返回与前端展示都能直接使用 `[IPv6]:port` 格式。
## Checklist
- [x] 定位规则入口地址的生成链路,确认问题来自后端入口地址拼接格式。
- [x] 修复 IPv6 入口地址拼接逻辑,统一输出可直接复制的标准格式。
- [x] 增加回归验证,覆盖 `/api/v1/forward/list` 的 IPv6 入口地址格式。
- [x] 运行相关测试并确认通过。
- [ ] 提交修复分支、创建 PR,并合并到 `main`。
@@ -0,0 +1,22 @@
# 067 - Issue #342: Allow Tunnel Edit with Offline Nodes
**Issue:** https://github.com/Sagit-chu/flvx/issues/342
## Problem
When a node goes offline, users cannot edit tunnel configurations at all — including removing the faulty offline node. This creates a deadlock where users must wait for the offline node to recover or manually edit the database.
## Changes Required
### Backend
- [x] 1. **`prepareTunnelCreateState`** (`mutations.go:2800`): Split the offline check into two modes:
- **Create (excludeTunnelID == 0)**: Keep current behavior — reject any offline non-remote node.
- **Update (excludeTunnelID > 0)**: Only reject **newly added** offline non-remote nodes. Allow existing offline nodes to remain (they'll be removed or kept). Query existing chain_tunnel records to determine which nodes are "old".
- [x] 2. **`syncForwardServicesWithWarnings`** (`control_plane.go:231`): When a node is offline (sendNodeCommand fails with "节点不在线"), skip it and add a warning instead of returning a hard error. This allows forward rule modifications to succeed partially.
- [x] 3. **`applyTunnelRuntime`** (`mutations.go:3190`): For non-remote local entry nodes, treat offline errors as deferrable (like remote nodes) so tunnel updates don't fail entirely when some nodes are offline.
### Frontend
- [x] 4. **`validateTunnelForm`** (`tunnel/form.ts`): Change validation to only block adding NEW offline nodes. When editing, offline nodes that are being removed should not block submission. Add isEdit parameter to distinguish create vs. edit.
-10
View File
@@ -1,10 +0,0 @@
{
"version": 1,
"skills": {
"security-scan": {
"source": "affaan-m/everything-claude-code",
"sourceType": "github",
"computedHash": "92cdcaddc554e318402f066ccc073c2e3dbcfda8c2730ec62ec373f805c41a57"
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@flvx/skill-api",
"version": "2.1.5",
"version": "2.1.9-rc4",
"description": "Skill for AI assistants to operate FLVX panel via REST API. Supports OpenCode, OpenClaw, Claude Code.",
"keywords": [
"opencode",
+3 -3
View File
@@ -1,9 +1,9 @@
# VITE FRONTEND KNOWLEDGE BASE
**Generated:** Thu Feb 26 2026
**Commit:** 21008cc
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.5-rc15
**Tag:** 2.1.9-beta6
## OVERVIEW
Web management console for FLVX.
+23 -5
View File
@@ -7,6 +7,9 @@
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="FLVX" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@100..900&display=swap" rel="stylesheet">
<title>FLVX</title>
<script>
@@ -82,11 +85,22 @@
shortcutIconLink.removeAttribute('type');
}
// 立即检测系统主题并应用
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
// 立即检测主题并应用(优先读取 localStorage 中的用户偏好)
var savedMode = '';
try { savedMode = localStorage.getItem('flvx:theme') || ''; } catch (_) {}
var useDark;
if (savedMode === 'dark') {
useDark = true;
} else if (savedMode === 'light') {
useDark = false;
} else {
// "system" 或无保存:跟随系统
useDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
}
// 立即设置主题类,避免闪烁
if (isDarkMode) {
if (useDark) {
document.documentElement.classList.add('dark');
document.documentElement.style.colorScheme = 'dark';
} else {
@@ -94,9 +108,13 @@
document.documentElement.style.colorScheme = 'light';
}
// 监听系统主题变化
if (window.matchMedia) {
// 仅在"跟随系统"模式下监听系统主题变化
if ((!savedMode || savedMode === 'system') && window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
// 运行时可能已由 React 接管,这里仅做 fallback
var currentMode = '';
try { currentMode = localStorage.getItem('flvx:theme') || ''; } catch (_) {}
if (currentMode && currentMode !== 'system') return; // 用户已手动选择,不干预
if (e.matches) {
document.documentElement.classList.add('dark');
document.documentElement.style.colorScheme = 'dark';
+35 -3
View File
@@ -40,6 +40,7 @@ import type {
MonitorTunnelApiItem,
MonitorPermissionApiItem,
MonitorAccessApiData,
TunnelQualityApiItem,
} from "./types";
import axios from "axios";
@@ -426,6 +427,7 @@ export const getNodeMetrics = (
return Network.get<NodeMetricApiItem[]>(
`/monitor/nodes/${nodeId}/metrics`,
params,
{ timeout: 60_000 },
);
};
@@ -451,6 +453,25 @@ export const getTunnelMetrics = (
export const getMonitorTunnels = () =>
Network.get<MonitorTunnelApiItem[]>("/monitor/tunnels");
export const getMonitorTunnelQuality = () =>
Network.get<TunnelQualityApiItem[]>("/monitor/tunnels/quality");
export const getMonitorTunnelQualityHistory = (
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<TunnelQualityApiItem[]>(
`/monitor/tunnels/${tunnelId}/quality`,
params,
);
};
export const getServiceMonitorList = () =>
Network.get<ServiceMonitorApiItem[]>("/monitor/services");
@@ -466,11 +487,22 @@ export const updateServiceMonitor = (data: ServiceMonitorMutationPayload) =>
export const deleteServiceMonitor = (id: number) =>
Network.post("/monitor/services/delete", { id });
export const getServiceMonitorResults = (monitorId: number, limit = 100) =>
Network.get<ServiceMonitorResultApiItem[]>(
export const getServiceMonitorResults = (
monitorId: number,
options?: { limit?: number; start?: number; end?: number },
) => {
const params: Record<string, string> = {};
if (options?.start != null && options?.end != null) {
params.start = String(options.start);
params.end = String(options.end);
} else if (options?.limit != null) {
params.limit = String(options.limit);
}
return Network.get<ServiceMonitorResultApiItem[]>(
`/monitor/services/${monitorId}/results`,
{ limit: String(limit) },
params,
);
};
export const getServiceMonitorLatestResults = () =>
Network.get<ServiceMonitorResultApiItem[]>(
+12
View File
@@ -466,6 +466,7 @@ export interface MonitorNodeApiItem {
inx: number;
name: string;
status: number;
version?: string;
updatedTime: number;
}
@@ -487,3 +488,14 @@ export interface MonitorAccessApiData {
allowed: boolean;
reason?: string;
}
export interface TunnelQualityApiItem {
tunnelId: number;
entryToExitLatency: number;
exitToBingLatency: number;
entryToExitLoss: number;
exitToBingLoss: number;
success: boolean;
errorMessage?: string;
timestamp: number;
}
File diff suppressed because one or more lines are too long
+20 -43
View File
@@ -1,51 +1,28 @@
import React, { useEffect } from "react";
/**
* ThemeProvider — app-level wrapper
* =================================
* Loads all registered themes and wraps children with the theme context.
* Import the loader to ensure all built-in themes are registered before
* the provider mounts.
*/
import { useTheme } from "@/shadcn-bridge/heroui/use-theme";
import React from "react";
// Side-effect: registers all built-in themes
import "@/themes/loader";
import { ThemeProvider as ThemeContextProvider } from "@/themes/context";
import { initThemeSystem } from "@/themes/registry";
// Restore saved theme SYNCHRONOUSLY at module load time — before first render.
// This ensures the CSS tokens are injected before any component renders,
// preventing a flash of the default theme.
initThemeSystem();
interface ThemeProviderProps {
children: React.ReactNode;
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const { theme, setTheme } = useTheme();
useEffect(() => {
// 确保主题与HTML class同步
const updateThemeClass = (currentTheme: string) => {
if (currentTheme === "dark") {
document.documentElement.classList.add("dark");
document.documentElement.style.colorScheme = "dark";
} else {
document.documentElement.classList.remove("dark");
document.documentElement.style.colorScheme = "light";
}
};
// 始终跟随系统主题
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
if (systemTheme !== theme) {
setTheme(systemTheme);
}
// 监听主题变化
updateThemeClass(theme);
// 监听系统主题变化
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleThemeChange = (e: MediaQueryListEvent) => {
const newTheme = e.matches ? "dark" : "light";
setTheme(newTheme);
};
mediaQuery.addEventListener("change", handleThemeChange);
return () => mediaQuery.removeEventListener("change", handleThemeChange);
}, [theme, setTheme]);
return <>{children}</>;
return <ThemeContextProvider>{children}</ThemeContextProvider>;
};
@@ -0,0 +1,179 @@
/**
* ThemeSettings — theme picker card for the Settings page
* ========================================================
* Shows:
* • Mode toggle (light / dark / system)
* • Grid of registered themes with preview dots
* • "Reset to default" option
*/
import React from "react";
import toast from "react-hot-toast";
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import { useThemeContext } from "@/themes/context";
import type { ThemeMode } from "@/themes/registry";
// ─── Constants ──────────────────────────────────────────────────────────────
const MODE_OPTIONS: Array<{ value: ThemeMode; label: string; icon: string }> = [
{ value: "light", label: "亮色", icon: "☀️" },
{ value: "dark", label: "暗色", icon: "🌙" },
{ value: "system", label: "跟随系统", icon: "🖥️" },
];
// ─── Component ──────────────────────────────────────────────────────────────
export const ThemeSettings: React.FC = () => {
const {
themes,
activeThemeId,
mode,
effectiveMode,
switchTheme,
resetTheme,
setMode,
} = useThemeContext();
const handleModeChange = (m: ThemeMode) => {
setMode(m);
const label = m === "light" ? "亮色" : m === "dark" ? "暗色" : "跟随系统";
toast.success(`已切换为${label}模式`);
};
const handleThemeSelect = (id: string) => {
switchTheme(id);
const theme = themes.find((t) => t.id === id);
toast.success(`已切换主题「${theme?.name ?? id}」`);
};
const handleReset = () => {
resetTheme();
toast.success("已恢复默认主题");
};
return (
<Card className="border border-gray-200 dark:border-gray-700">
<CardBody className="p-6">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-5">
主题设置
</h2>
{/* ── Mode toggle ────────────────────────────────────── */}
<div className="mb-6">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
外观模式
</p>
<div className="inline-flex rounded-lg border border-gray-200 dark:border-gray-600 p-1 gap-1">
{MODE_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
mode === opt.value
? "bg-primary text-white shadow-sm"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700/50"
}`}
type="button"
onClick={() => handleModeChange(opt.value)}
>
<span className="mr-1.5">{opt.icon}</span>
{opt.label}
</button>
))}
</div>
</div>
{/* ── Theme grid ─────────────────────────────────────── */}
<div className="mb-4">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
选择主题
<span className="ml-2 text-xs text-gray-400 dark:text-gray-500 font-normal">
共 {themes.length} 个可用主题
</span>
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{themes.map((theme) => {
const isActive = activeThemeId === theme.id;
// Pick the right token set for preview
const previewTokens =
effectiveMode === "dark" && theme.tokens?.dark
? theme.tokens.dark
: theme.tokens?.light;
const primary = previewTokens?.["--primary"] ?? "#2563eb";
const secondary = previewTokens?.["--secondary"] ?? "#6366f1";
const success = previewTokens?.["--success"] ?? "#16a34a";
const danger = previewTokens?.["--danger"] ?? "#dc2626";
const bg = previewTokens?.["--background"] ?? "#ffffff";
return (
<button
key={theme.id}
className={`relative rounded-xl text-left transition-all duration-200 border-2 overflow-hidden ${
isActive
? "border-primary shadow-lg shadow-primary/15 scale-[1.01]"
: "border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500 hover:shadow-md"
}`}
type="button"
onClick={() => handleThemeSelect(theme.id)}
>
{/* Colour strip preview */}
<div className="flex h-8">
<div className="flex-1" style={{ background: primary }} />
<div className="flex-1" style={{ background: secondary }} />
<div className="flex-1" style={{ background: success }} />
<div className="flex-1" style={{ background: danger }} />
<div className="flex-1" style={{ background: bg, borderLeft: "1px solid rgba(0,0,0,0.06)" }} />
</div>
{/* Info */}
<div className="p-3">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-gray-900 dark:text-white truncate">
{theme.name}
</p>
{isActive && (
<span className="shrink-0 px-1.5 py-0.5 rounded text-[10px] font-bold bg-primary/15 text-primary">
当前
</span>
)}
</div>
{theme.description && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
{theme.description}
</p>
)}
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-1">
{theme.author} · v{theme.version}
</p>
</div>
{/* Active indicator dot */}
{isActive && (
<span className="absolute top-2 right-2 w-2.5 h-2.5 rounded-full bg-primary shadow-sm shadow-primary/50 animate-pulse" />
)}
</button>
);
})}
</div>
</div>
{/* ── Reset ──────────────────────────────────────────── */}
{activeThemeId && activeThemeId !== "default" && (
<div className="pt-2">
<Button
className="text-gray-500 dark:text-gray-400"
size="sm"
variant="light"
onPress={handleReset}
>
↩ 恢复默认主题
</Button>
</div>
)}
</CardBody>
</Card>
);
};
+22
View File
@@ -27,6 +27,7 @@ import {
type AnnouncementData,
} from "@/api";
import { BackIcon, SettingsIcon } from "@/components/icons";
import { ThemeSettings } from "@/components/theme-settings";
import { isAdmin } from "@/utils/auth";
import { getCachedConfigs, configCache, updateSiteConfig } from "@/config/site";
import {
@@ -123,6 +124,12 @@ const CONFIG_ITEMS: ConfigItem[] = [
description: "开启后,规则页面列表使用 2.1.6-alpha8 样式(全局配置)",
type: "switch",
},
{
key: "monitor_tunnel_quality_enabled",
label: "实时隧道质量检测",
description: "关闭后,前端停止自动刷新,后端停止实时隧道质量探测(全局配置)",
type: "switch",
},
{
key: "captcha_enabled",
label: "启用验证码",
@@ -174,6 +181,7 @@ const getInitialConfigs = (): Record<string, string> => {
"cloudflare_site_key",
"cloudflare_secret_key",
"forward_compact_mode",
"monitor_tunnel_quality_enabled",
"ip",
"panel_domain",
"app_logo",
@@ -406,6 +414,15 @@ export default function ConfigPage() {
detail: { changedKeys },
}),
);
// 如果隧道质量检测开关变更,通知 tunnel-monitor-view
if (changedKeys.includes("monitor_tunnel_quality_enabled")) {
window.dispatchEvent(
new CustomEvent("monitorTunnelQualityEnabledChanged", {
detail: { enabled: configs["monitor_tunnel_quality_enabled"] === "true" },
}),
);
}
} else {
toast.error("保存配置失败: " + response.msg);
}
@@ -979,6 +996,11 @@ export default function ConfigPage() {
</CardBody>
</Card>
{/* 主题设置 */}
<div className="mt-6">
<ThemeSettings />
</div>
{hasChanges && (
<Card className="mt-4 bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800 shadow-sm overflow-hidden">
<div className="h-10 flex items-center justify-center gap-2 text-warning-700 dark:text-warning-300">
+1 -1
View File
@@ -1110,7 +1110,7 @@ export default function DashboardPage() {
key={item.id}
className="flex justify-between items-center p-3 border border-default-200 dark:border-default-100 rounded-lg"
>
<code className="text-sm flex-1 mr-3 text-foreground">
<code className="font-mono text-sm flex-1 mr-3 text-foreground">
{item.address}
</code>
<Button
@@ -66,12 +66,12 @@ export const AnnouncementBanner = ({
</ol>
),
code: ({ children }) => (
<code className="rounded bg-blue-100/80 dark:bg-blue-900/40 px-1 py-0.5 text-[0.92em]">
<code className="font-mono rounded bg-blue-100/80 dark:bg-blue-900/40 px-1 py-0.5 text-[0.92em]">
{children}
</code>
),
pre: ({ children }) => (
<pre className="mb-2 overflow-x-auto rounded-md bg-blue-100/70 dark:bg-blue-900/40 p-2.5 text-xs leading-relaxed">
<pre className="mb-2 font-mono overflow-x-auto rounded-md bg-blue-100/70 dark:bg-blue-900/40 p-2.5 text-xs leading-relaxed">
{children}
</pre>
),
+1 -1
View File
@@ -4977,7 +4977,7 @@ export default function ForwardPage() {
key={item.id}
className="flex justify-between items-center p-3 border border-default-200 dark:border-default-100 rounded-lg"
>
<code className="text-sm flex-1 mr-3 text-foreground">
<code className="font-mono text-sm flex-1 mr-3 text-foreground">
{item.address}
</code>
<Button
+89 -3
View File
@@ -18,6 +18,15 @@ import {
ModalFooter,
} from "@/shadcn-bridge/heroui/modal";
import { Chip } from "@/shadcn-bridge/heroui/chip";
import { LayoutGrid, List } from "lucide-react";
import {
Table,
TableHeader,
TableColumn,
TableBody,
TableRow,
TableCell,
} from "@/shadcn-bridge/heroui/table";
import {
createSpeedLimit,
getSpeedLimitList,
@@ -51,6 +60,10 @@ export default function LimitPage() {
"",
);
const [isSearchVisible, setIsSearchVisible] = useState(false);
const [viewMode, setViewMode] = useLocalStorageState<"list" | "grid">(
"limit-view-mode",
"grid",
);
const filteredRules = useMemo(() => {
if (!searchKeyword.trim()) return rules;
@@ -231,13 +244,85 @@ export default function LimitPage() {
/>
</div>
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
新增
</Button>
<div className="flex items-center gap-2">
<Button
isIconOnly
size="sm"
variant="flat"
onPress={() => setViewMode(viewMode === "list" ? "grid" : "list")}
>
{viewMode === "list" ? <LayoutGrid className="w-4 h-4" /> : <List className="w-4 h-4" />}
</Button>
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
新增
</Button>
</div>
</div>
{/* 统一卡片网格 */}
{filteredRules.length > 0 ? (
viewMode === "list" ? (
<Card>
<Table
aria-label="限速规则列表"
className="overflow-x-auto min-w-full"
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",
}}
>
<TableHeader>
<TableColumn>规则名称</TableColumn>
<TableColumn>速度限制</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody items={filteredRules}>
{(rule) => (
<TableRow key={rule.id}>
<TableCell>
<div className="flex items-center gap-2">
<div
className={`shrink-0 w-2 h-2 rounded-full ${
rule.status === 1 ? "bg-success" : "bg-danger"
}`}
/>
<span className="font-medium text-foreground text-sm">
{rule.name}
</span>
</div>
</TableCell>
<TableCell>
<span className="text-sm font-mono text-default-600">
{rule.speed} Mbps
</span>
</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
<Button
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
size="sm"
variant="flat"
onPress={() => handleEdit(rule)}
>
编辑
</Button>
<Button
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
size="sm"
variant="flat"
onPress={() => handleDelete(rule)}
>
删除
</Button>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Card>
) : (
<StaggerList className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{filteredRules.map((rule) => (
<StaggerItem key={rule.id}>
@@ -324,6 +409,7 @@ export default function LimitPage() {
</StaggerItem>
))}
</StaggerList>
)
) : (
/* 空状态 */
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
+69 -19
View File
@@ -2,27 +2,34 @@ import type { MonitorNodeApiItem } from "@/api/types";
import { useCallback, useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { RefreshCw } from "lucide-react";
import { RefreshCw, LayoutGrid, List, Server, ArrowRightLeft } from "lucide-react";
import { AnimatedPage } from "@/components/animated-page";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { getMonitorNodes } from "@/api";
import { MonitorView } from "@/pages/node/monitor-view";
import { TunnelMonitorView } from "@/pages/node/tunnel-monitor-view";
type MonitorNode = {
id: number;
name: string;
connectionStatus: "online" | "offline";
version?: string;
};
type MonitorTab = "nodes" | "tunnels";
export default function MonitorPage() {
const [nodes, setNodes] = useState<MonitorNodeApiItem[]>([]);
const [nodesLoading, setNodesLoading] = useState(false);
const [nodesError, setNodesError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
const [activeTab, setActiveTab] = useState<MonitorTab>("nodes");
const loadNodes = useCallback(async () => {
setNodesLoading(true);
const loadNodes = useCallback(async (options?: { silent?: boolean }) => {
const silent = options?.silent ?? false;
if (!silent) setNodesLoading(true);
try {
const response = await getMonitorNodes();
@@ -40,11 +47,11 @@ export default function MonitorPage() {
return;
}
toast.error(response.msg || "加载节点失败");
if (!silent) toast.error(response.msg || "加载节点失败");
} catch {
toast.error("加载节点失败");
if (!silent) toast.error("加载节点失败");
} finally {
setNodesLoading(false);
if (!silent) setNodesLoading(false);
}
}, []);
@@ -54,7 +61,7 @@ export default function MonitorPage() {
useEffect(() => {
const timer = window.setInterval(() => {
void loadNodes();
void loadNodes({ silent: true });
}, 30_000);
return () => window.clearInterval(timer);
@@ -67,6 +74,7 @@ export default function MonitorPage() {
id: Number(n.id),
name: String(n.name ?? ""),
connectionStatus: n.status === 1 ? "online" : "offline",
version: n.version,
}));
return new Map<number, MonitorNode>(list.map((n) => [n.id, n]));
@@ -79,21 +87,59 @@ export default function MonitorPage() {
<div className="min-w-0">
<h2 className="text-xl font-semibold truncate">监控</h2>
<div className="text-xs text-default-500 truncate">
实时节点状态 + 历史指标图表 + 隧道流量 + 服务监控(TCP/ICMP)
实时节点状态 + 隧道质量检测 + 历史指标图表 + 服务监控(TCP/ICMP)
</div>
</div>
<Button
isLoading={nodesLoading}
size="sm"
variant="flat"
onPress={loadNodes}
>
<RefreshCw className="w-4 h-4 mr-1" />
刷新节点
</Button>
<div className="flex items-center gap-2">
<Button
isIconOnly
size="sm"
variant="flat"
onPress={() => setViewMode(viewMode === "list" ? "grid" : "list")}
>
{viewMode === "list" ? <LayoutGrid className="w-4 h-4" /> : <List className="w-4 h-4" />}
</Button>
{activeTab === "nodes" && (
<Button
isLoading={nodesLoading}
size="sm"
variant="flat"
onPress={() => loadNodes()}
>
<RefreshCw className="w-4 h-4 mr-1" />
刷新节点
</Button>
)}
</div>
</div>
{nodesError ? (
{/* Tab Switcher */}
<div className="flex items-center gap-1 p-1 rounded-xl bg-default-100 dark:bg-default-50/10 w-fit">
<button
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${
activeTab === "nodes"
? "bg-background shadow-sm text-foreground"
: "text-default-500 hover:text-foreground"
}`}
onClick={() => setActiveTab("nodes")}
>
<Server className="w-4 h-4" />
节点
</button>
<button
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${
activeTab === "tunnels"
? "bg-background shadow-sm text-foreground"
: "text-default-500 hover:text-foreground"
}`}
onClick={() => setActiveTab("tunnels")}
>
<ArrowRightLeft className="w-4 h-4" />
隧道
</button>
</div>
{nodesError && activeTab === "nodes" ? (
<Card>
<CardHeader>
<h3 className="text-sm font-semibold">节点列表</h3>
@@ -105,7 +151,11 @@ export default function MonitorPage() {
) : null}
</div>
<MonitorView nodeMap={nodeMap} />
{activeTab === "nodes" ? (
<MonitorView nodeMap={nodeMap} viewMode={viewMode} />
) : (
<TunnelMonitorView viewMode={viewMode} />
)}
</AnimatedPage>
);
}
+185 -256
View File
@@ -20,6 +20,15 @@ import { CSS } from "@dnd-kit/utilities";
import { SearchBar } from "@/components/search-bar";
import { AnimatedPage } from "@/components/animated-page";
import { LayoutGrid, List } from "lucide-react";
import {
Table,
TableHeader,
TableColumn,
TableBody,
TableRow,
TableCell,
} from "@/shadcn-bridge/heroui/table";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@/shadcn-bridge/heroui/input";
@@ -292,6 +301,10 @@ export default function NodePage() {
"node-active-tab",
"local",
);
const [viewMode, setViewMode] = useLocalStorageState<"list" | "grid">(
"node-view-mode",
"grid",
);
// Backward-compat: older versions stored extra tab values.
useEffect(() => {
@@ -596,6 +609,8 @@ export default function NodePage() {
if (!metric || typeof metric !== "object") return node;
const incomingUptime = metric.uptime ?? 0;
return {
...node,
connectionStatus: "online",
@@ -607,7 +622,7 @@ export default function NodePage() {
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,
uptime: incomingUptime || node.systemInfo?.uptime || 0,
diskUsage: metric.diskUsage ?? metric.disk_usage,
load1: metric.load1,
load5: metric.load5,
@@ -683,15 +698,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) {
@@ -719,17 +726,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 =
@@ -1413,13 +1409,6 @@ export default function NodePage() {
if (!nodeList || nodeList.length === 0) return [];
const sortedByDb = [...nodeList].sort((a, b) => {
const expiryDiff =
getNodeExpiryMeta(a.expiryTime, a.renewalCycle).sortWeight -
getNodeExpiryMeta(b.expiryTime, b.renewalCycle).sortWeight;
if (expiryDiff !== 0) {
return expiryDiff;
}
const aInx = a.inx ?? 0;
const bInx = b.inx ?? 0;
@@ -1658,6 +1647,21 @@ export default function NodePage() {
</>
) : (
<>
{/* 视图切换按钮 */}
<Button
isIconOnly
size="sm"
variant="flat"
className="text-default-600 hidden sm:flex"
onPress={() => setViewMode(viewMode === "list" ? "grid" : "list")}
>
{viewMode === "list" ? (
<LayoutGrid className="w-4 h-4" />
) : (
<List className="w-4 h-4" />
)}
</Button>
{/* 筛选按钮 */}
<Button
isIconOnly
@@ -1757,6 +1761,159 @@ export default function NodePage() {
: "暂无本地节点,点击上方按钮开始创建"
}
/>
) : viewMode === "list" ? (
<Card>
<Table
aria-label="节点列表"
className="overflow-x-auto min-w-full"
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",
}}
>
<TableHeader>
<TableColumn className="w-12 px-4 whitespace-nowrap overflow-hidden">
<Checkbox
isSelected={selectMode && selectedIds.size === displayNodes.length && displayNodes.length > 0}
onValueChange={(checked) => {
if (checked) {
selectAll();
setSelectMode(true);
} else {
deselectAll();
setSelectMode(false);
}
}}
/>
</TableColumn>
<TableColumn>节点名称</TableColumn>
<TableColumn>地址</TableColumn>
<TableColumn>版本</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody items={displayNodes}>
{(node) => {
const isRemoteNode = node.isRemote === 1;
const hasRemark = Boolean(node.remark?.trim());
return (
<TableRow key={node.id}>
<TableCell className="px-4">
<Checkbox
isSelected={selectedIds.has(node.id)}
onValueChange={(checked) => {
if (checked) {
setSelectMode(true);
setSelectedIds((prev) => new Set([...prev, node.id]));
} else {
setSelectedIds((prev) => {
const next = new Set(prev);
next.delete(node.id);
if (next.size === 0) setSelectMode(false);
return next;
});
}
}}
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div
className={`shrink-0 w-2 h-2 rounded-full ${
node.connectionStatus === "online"
? "bg-success"
: node.syncError
? "bg-danger"
: "bg-default-400"
} ${node.systemInfo && node.connectionStatus === "online" ? "animate-pulse" : ""}`}
/>
<span className="font-medium text-foreground text-sm">
{node.name}
</span>
{hasRemark && (
<Chip size="sm" variant="flat" className="h-4 px-1 text-[10px]">
{node.remark}
</Chip>
)}
</div>
</TableCell>
<TableCell>
<span className="text-sm font-mono text-default-600">
{isRemoteNode ? new URL(node.remoteUrl || "").hostname : node.serverIp}
</span>
</TableCell>
<TableCell>
<span className="text-sm text-default-500">
{node.version || "-"}
</span>
</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
{!isRemoteNode && (
<Button
size="sm"
variant="flat"
className="h-6 px-2 min-w-0 text-xs bg-emerald-50 text-emerald-600 hover:bg-emerald-100 dark:bg-emerald-950/30 dark:text-emerald-400"
isLoading={node.copyLoading}
onPress={() => openInstallSelector(node)}
>
安装
</Button>
)}
{!isRemoteNode && (
<Button
size="sm"
variant="flat"
className="h-6 px-2 min-w-0 text-xs bg-amber-50 text-amber-600 hover:bg-amber-100 dark:bg-amber-950/30 dark:text-amber-400"
isDisabled={node.connectionStatus !== "online"}
isLoading={node.upgradeLoading}
onPress={() => {
setUpgradeTargetNodeId(node.id);
openUpgradeModal("single");
}}
>
升级
</Button>
)}
{!isRemoteNode && (
<Button
size="sm"
variant="flat"
className="h-6 px-2 min-w-0 text-xs bg-blue-50 text-blue-600 hover:bg-blue-100 dark:bg-blue-950/30 dark:text-blue-400"
isDisabled={node.connectionStatus !== "online"}
isLoading={node.rollbackLoading}
onPress={() => handleRollbackNode(node)}
>
回退
</Button>
)}
{!isRemoteNode && (
<Button
size="sm"
variant="flat"
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
onPress={() => handleEdit(node)}
>
编辑
</Button>
)}
<Button
size="sm"
variant="flat"
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
onPress={() => handleDelete(node)}
>
删除
</Button>
</div>
</TableCell>
</TableRow>
);
}}
</TableBody>
</Table>
</Card>
) : (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<SortableContext
@@ -1792,7 +1949,7 @@ export default function NodePage() {
{(listeners) => (
<Card
key={node.id}
className={`group relative overflow-visible shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 h-full flex flex-col ${expiryMeta.accentClassName}`}
className={`group relative overflow-visible shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 h-full flex flex-col ${node.expiryReminderDismissed ? "" : expiryMeta.accentClassName}`}
data-node-card="true"
>
<CardHeader className="pb-3 md:pb-3">
@@ -2162,235 +2319,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>
{/* 扩展指标:磁盘/负载/连接 */}
{node.connectionStatus === "online" &&
node.systemInfo && (
<div className="space-y-2">
{node.systemInfo.diskUsage !==
undefined && (
<div>
<div className="flex justify-between text-xs mb-1">
<span>磁盘</span>
<span className="font-mono">
{node.systemInfo.diskUsage.toFixed(
1,
)}
%
</span>
</div>
<Progress
aria-label="磁盘使用率"
color={getProgressColor(
node.systemInfo.diskUsage,
false,
)}
size="sm"
value={node.systemInfo.diskUsage}
/>
</div>
)}
{(node.systemInfo.load1 !== undefined ||
node.systemInfo.load5 !== undefined ||
node.systemInfo.load15 !==
undefined) && (
<div className="grid grid-cols-3 gap-1 text-xs">
{node.systemInfo.load1 !==
undefined && (
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100 rounded">
<div className="text-default-500 text-[10px]">
负载 1m
</div>
<div className="font-mono">
{node.systemInfo.load1.toFixed(
2,
)}
</div>
</div>
)}
{node.systemInfo.load5 !==
undefined && (
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100 rounded">
<div className="text-default-500 text-[10px]">
负载 5m
</div>
<div className="font-mono">
{node.systemInfo.load5.toFixed(
2,
)}
</div>
</div>
)}
{node.systemInfo.load15 !==
undefined && (
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100 rounded">
<div className="text-default-500 text-[10px]">
负载 15m
</div>
<div className="font-mono">
{node.systemInfo.load15.toFixed(
2,
)}
</div>
</div>
)}
</div>
)}
{(node.systemInfo.tcpConns !==
undefined ||
node.systemInfo.udpConns !==
undefined) && (
<div className="grid grid-cols-2 gap-2 text-xs">
{node.systemInfo.tcpConns !==
undefined && (
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100 rounded">
<div className="text-default-500 text-[10px]">
TCP 连接
</div>
<div className="font-mono">
{node.systemInfo.tcpConns}
</div>
</div>
)}
{node.systemInfo.udpConns !==
undefined && (
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100 rounded">
<div className="text-default-500 text-[10px]">
UDP 连接
</div>
<div className="font-mono">
{node.systemInfo.udpConns}
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
</>
)}
<div className="mt-auto space-y-3">
{/* 操作按钮 */}
File diff suppressed because it is too large Load Diff
+37 -1
View File
@@ -48,13 +48,49 @@ const parseRawSystemInfo = (messageData: unknown): RawSystemInfo | null => {
return null;
};
/**
* Heuristic to verify the parsed object actually contains system-info fields.
* Prevents non-metric messages (command responses, etc.) from being mistakenly
* treated as system info, which would reset all values to 0 and cause UI flicker.
*/
const SYSTEM_INFO_KEYS = [
"uptime",
"cpu_usage",
"memory_usage",
"disk_usage",
"bytes_received",
"bytes_transmitted",
"net_in_speed",
"net_out_speed",
"tcp_conns",
"udp_conns",
"load1",
"load5",
"load15",
] as const;
const looksLikeSystemInfo = (raw: RawSystemInfo): boolean => {
let matched = 0;
for (const key of SYSTEM_INFO_KEYS) {
if (key in raw && raw[key] !== undefined) {
matched++;
if (matched >= 3) {
return true;
}
}
}
return false;
};
export const buildNodeSystemInfo = (
messageData: unknown,
previous: NodeSystemInfo | null | undefined,
): NodeSystemInfo | null => {
const raw = parseRawSystemInfo(messageData);
if (!raw) {
if (!raw || !looksLikeSystemInfo(raw)) {
return null;
}
@@ -0,0 +1,988 @@
import type {
MonitorTunnelApiItem,
TunnelMetricApiItem,
TunnelQualityApiItem,
} from "@/api/types";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import {
RefreshCw,
ArrowLeft,
Activity,
Zap,
Globe,
ArrowRightLeft,
Wifi,
WifiOff,
} from "lucide-react";
import toast from "react-hot-toast";
import {
getMonitorTunnels,
getTunnelMetrics,
getMonitorTunnelQuality,
getMonitorTunnelQualityHistory,
getConfigByName,
} from "@/api";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Chip } from "@/shadcn-bridge/heroui/chip";
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import {
Table,
TableHeader,
TableColumn,
TableBody,
TableRow,
TableCell,
} from "@/shadcn-bridge/heroui/table";
interface TunnelMonitorViewProps {
viewMode?: "list" | "grid";
}
const QUALITY_POLL_INTERVAL = 1_000; // 1 second
const MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY = "monitor_tunnel_quality_enabled";
const MONITOR_TUNNEL_QUALITY_ENABLED_EVENT =
"monitorTunnelQualityEnabledChanged";
const formatTimestamp = (ts: number, rangeMs?: number): string => {
const date = new Date(ts);
const includeDate = (rangeMs ?? 0) >= 24 * 60 * 60 * 1000;
if (includeDate) {
return date.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
return date.toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
};
function LatencyDisplay({ value, loading }: { value?: number; loading?: boolean }) {
if (loading) {
return <RefreshCw className="w-3 h-3 animate-spin inline text-primary" />;
}
if (value === undefined || value < 0) {
return <span className="text-default-400">-</span>;
}
const ms = value.toFixed(0);
let colorClass = "text-success";
if (value > 200) colorClass = "text-danger";
else if (value > 100) colorClass = "text-warning";
else if (value > 50) colorClass = "text-primary";
return <span className={`font-mono text-xs font-semibold ${colorClass}`}>{ms}ms</span>;
}
function LiveDot() {
return (
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-success opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-success" />
</span>
);
}
const UPTIME_KUMA_BARS_COUNT = 30;
function UptimeHistoryBar({
history = [],
type,
latestValue,
}: {
history: TunnelQualityApiItem[];
type: "entryToExit" | "exitToBing";
latestValue?: number;
}) {
const bars = [...Array(UPTIME_KUMA_BARS_COUNT)].map((_, i) => {
const historyIndex = history.length - UPTIME_KUMA_BARS_COUNT + i;
if (historyIndex >= 0 && historyIndex < history.length) {
return history[historyIndex];
}
return null;
});
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-[3px]">
{bars.map((q, idx) => {
if (!q) {
return (
<div
key={idx}
className="w-1.5 h-4 rounded-full bg-default-200 dark:bg-default-100/50"
title="暂无数据"
/>
);
}
const isEntry = type === "entryToExit";
const latency = isEntry ? q.entryToExitLatency : q.exitToBingLatency;
const loss = isEntry ? q.entryToExitLoss : q.exitToBingLoss;
const timeStr = new Date(q.timestamp).toLocaleTimeString("zh-CN");
let colorClass = "bg-success";
let statusText = "优秀";
if (q.errorMessage || (loss !== undefined && loss > 0)) {
colorClass = "bg-danger";
statusText = q.errorMessage ? "错误" : `丢包 ${loss?.toFixed(1)}%`;
} else if (latency < 0) {
colorClass = "bg-danger";
statusText = "探测失败";
} else if (latency > 200) {
colorClass = "bg-danger";
statusText = "极差";
} else if (latency > 100) {
colorClass = "bg-warning";
statusText = "较差";
}
const displayLatency = latency >= 0 ? `${latency.toFixed(0)}ms` : "-";
const tooltip = `${timeStr} | ${displayLatency} | ${statusText}`;
return (
<div
key={q.timestamp || idx}
className={`w-1.5 h-4 rounded-full transition-colors cursor-help hover:opacity-80 ${colorClass}`}
title={tooltip}
/>
);
})}
</div>
<div>
<LatencyDisplay value={latestValue} />
</div>
</div>
);
}
const TIME_RANGE_OPTIONS = [
{ key: String(15 * 60 * 1000), label: "15分钟" },
{ key: String(60 * 60 * 1000), label: "1小时" },
{ key: String(6 * 60 * 60 * 1000), label: "6小时" },
{ key: String(24 * 60 * 60 * 1000), label: "24小时" },
];
function TimeRangeSelect({ value, onChange }: { value: number; onChange: (v: number) => void }) {
return (
<Select
className="w-36"
selectedKeys={[String(value)]}
onSelectionChange={(keys) => {
const v = Number(Array.from(keys)[0]);
if (v > 0) onChange(v);
}}
>
{TIME_RANGE_OPTIONS.map((opt) => (
<SelectItem key={opt.key}>{opt.label}</SelectItem>
))}
</Select>
);
}
interface QualityChartCardProps {
rangeMs: number;
onRangeChange: (v: number) => void;
loading: boolean;
error: string | null;
data: Array<{ time: string; entryToExit: number | null; exitToBing: number | null }>;
tunnelId: number;
onRefresh: (id: number) => void;
}
const QualityChartCard = React.memo(function QualityChartCard({
rangeMs, onRangeChange, loading, error, data, tunnelId, onRefresh,
}: QualityChartCardProps) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<h3 className="text-lg font-semibold">质量趋势</h3>
<div className="flex items-center gap-2">
<TimeRangeSelect value={rangeMs} onChange={onRangeChange} />
<Button isLoading={loading} size="sm" variant="flat" onPress={() => onRefresh(tunnelId)}>
<RefreshCw className="w-4 h-4 mr-1" />
刷新
</Button>
</div>
</CardHeader>
<CardBody>
{loading ? (
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
) : error ? (
<div className="text-center py-8 text-danger text-sm">{error}</div>
) : data.length > 0 ? (
<div className="h-64">
<ResponsiveContainer height="100%" width="100%">
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="time" fontSize={11} tick={{ fill: "#888" }} />
<YAxis
fontSize={11}
tick={{ fill: "#888" }}
tickFormatter={(v: any) => `${Number(v).toFixed(0)}ms`}
label={{ value: "延迟 (ms)", angle: -90, position: "insideLeft", style: { fontSize: 11, fill: "#888" } }}
/>
<Tooltip
contentStyle={{ backgroundColor: "rgba(0,0,0,0.85)", border: "none", borderRadius: "8px", fontSize: 12 }}
labelStyle={{ color: "#fff" }}
formatter={(value: unknown, name: string) => {
const n = Number(value);
if (!Number.isFinite(n)) return "-";
const label = name === "entryToExit" ? "入口→出口" : name === "exitToBing" ? "出口→Bing" : name;
return [`${n.toFixed(1)}ms`, label];
}}
/>
<Line connectNulls dataKey="entryToExit" dot={false} name="entryToExit" stroke="#10b981" strokeWidth={2} type="monotone" />
<Line connectNulls dataKey="exitToBing" dot={false} name="exitToBing" stroke="#3b82f6" strokeWidth={2} type="monotone" />
</LineChart>
</ResponsiveContainer>
</div>
) : (
<div className="text-center py-8 text-default-500">暂无质量历史数据</div>
)}
</CardBody>
</Card>
);
});
interface TrafficChartCardProps {
rangeMs: number;
onRangeChange: (v: number) => void;
loading: boolean;
error: string | null;
data: Array<{ time: string; bytesIn: number; bytesOut: number; connections: number }>;
tunnelId: number;
onRefresh: (id: number) => void;
}
const TrafficChartCard = React.memo(function TrafficChartCard({
rangeMs, onRangeChange, loading, error, data, tunnelId, onRefresh,
}: TrafficChartCardProps) {
const yFormatter = (value: unknown) => {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(n) / Math.log(k));
return `${parseFloat((n / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
};
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<h3 className="text-lg font-semibold">流量趋势</h3>
<div className="flex items-center gap-2">
<TimeRangeSelect value={rangeMs} onChange={onRangeChange} />
<Button isLoading={loading} size="sm" variant="flat" onPress={() => onRefresh(tunnelId)}>
<RefreshCw className="w-4 h-4 mr-1" />
刷新
</Button>
</div>
</CardHeader>
<CardBody>
{loading ? (
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
) : error ? (
<div className="text-center py-8 text-danger text-sm">{error}</div>
) : data.length > 0 ? (
<div className="h-64">
<ResponsiveContainer height="100%" width="100%">
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="time" fontSize={11} tick={{ fill: "#888" }} />
<YAxis fontSize={11} tick={{ fill: "#888" }} tickFormatter={yFormatter} />
<Tooltip
contentStyle={{ backgroundColor: "rgba(0,0,0,0.85)", border: "none", borderRadius: "8px", fontSize: 12 }}
labelStyle={{ color: "#fff" }}
formatter={yFormatter}
/>
<Line dataKey="bytesIn" dot={false} name="入站流量" stroke="#10b981" strokeWidth={2} type="monotone" />
<Line dataKey="bytesOut" dot={false} name="出站流量" stroke="#ef4444" strokeWidth={2} type="monotone" />
</LineChart>
</ResponsiveContainer>
</div>
) : (
<div className="text-center py-8 text-default-500">暂无流量数据</div>
)}
</CardBody>
</Card>
);
});
export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) {
const [tunnels, setTunnels] = useState<MonitorTunnelApiItem[]>([]);
const [tunnelsLoading, setTunnelsLoading] = useState(false);
const [tunnelsError, setTunnelsError] = useState<string | null>(null);
const [accessDenied, setAccessDenied] = useState<string | null>(null);
// Quality data from backend periodic probing (latest per tunnel)
const [qualityMap, setQualityMap] = useState<Record<number, TunnelQualityApiItem>>({});
const [qualityHistoryMap, setQualityHistoryMap] = useState<Record<number, TunnelQualityApiItem[]>>({});
const initialHistoryFetched = useRef(false);
const [qualityLoading, setQualityLoading] = useState(false);
const qualityTimerRef = useRef<number | null>(null);
const [monitorTunnelQualityEnabled, setMonitorTunnelQualityEnabled] =
useState(true);
// Detail view state
const [detailTunnelId, setDetailTunnelId] = useState<number | null>(null);
// Quality history for chart (mirrors service monitor results)
const [qualityHistory, setQualityHistory] = useState<TunnelQualityApiItem[]>([]);
const [qualityHistoryLoading, setQualityHistoryLoading] = useState(false);
const [qualityHistoryError, setQualityHistoryError] = useState<string | null>(null);
const [qualityRangeMs, setQualityRangeMs] = useState(60 * 60 * 1000);
// Tunnel traffic metrics for chart
const [tunnelMetrics, setTunnelMetrics] = useState<TunnelMetricApiItem[]>([]);
const [tunnelMetricsLoading, setTunnelMetricsLoading] = useState(false);
const [tunnelMetricsError, setTunnelMetricsError] = useState<string | null>(null);
const [tunnelRangeMs, setTunnelRangeMs] = useState(60 * 60 * 1000);
// --- Load tunnel list ---
const loadTunnels = useCallback(async (options?: { silent?: boolean }) => {
const silent = options?.silent ?? false;
if (!silent) setTunnelsLoading(true);
try {
const response = await getMonitorTunnels();
if (response.code === 0 && response.data) {
setAccessDenied(null);
setTunnelsError(null);
setTunnels(response.data);
return;
}
if (response.code === 403) {
setAccessDenied(response.msg || "暂无监控权限,请联系管理员授权");
setTunnelsError(null);
setTunnels([]);
return;
}
setTunnelsError(response.msg || "加载隧道列表失败");
if (!silent) toast.error(response.msg || "加载隧道列表失败");
} catch {
if (!silent) {
setTunnelsError("加载隧道列表失败");
toast.error("加载隧道列表失败");
}
} finally {
if (!silent) setTunnelsLoading(false);
}
}, []);
const loadMonitorTunnelQualityEnabled = useCallback(async () => {
try {
const response = await getConfigByName(
MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY,
);
setMonitorTunnelQualityEnabled(
typeof response.data?.value === "string"
? response.data.value === "true"
: true,
);
} catch {
setMonitorTunnelQualityEnabled(true);
}
}, []);
useEffect(() => {
void loadTunnels();
void loadMonitorTunnelQualityEnabled();
}, [loadMonitorTunnelQualityEnabled, loadTunnels]);
useEffect(() => {
const timer = window.setInterval(() => {
void loadTunnels({ silent: true });
}, 60_000);
return () => window.clearInterval(timer);
}, [loadTunnels]);
useEffect(() => {
const handleMonitorTunnelQualityEnabledChanged = (event: Event) => {
const enabled = (event as CustomEvent<{ enabled?: boolean }>).detail?.enabled;
if (typeof enabled === "boolean") {
setMonitorTunnelQualityEnabled(enabled);
if (!enabled) {
setQualityLoading(false);
}
} else {
void loadMonitorTunnelQualityEnabled();
}
};
window.addEventListener(
MONITOR_TUNNEL_QUALITY_ENABLED_EVENT,
handleMonitorTunnelQualityEnabledChanged as EventListener,
);
return () => {
window.removeEventListener(
MONITOR_TUNNEL_QUALITY_ENABLED_EVENT,
handleMonitorTunnelQualityEnabledChanged as EventListener,
);
};
}, [loadMonitorTunnelQualityEnabled]);
useEffect(() => {
if (tunnels.length > 0 && !initialHistoryFetched.current) {
initialHistoryFetched.current = true;
const fetchHistory = async () => {
const end = Date.now();
// Fetch last 5 minutes (about 30 points)
const start = end - 5 * 60 * 1000;
const promises = tunnels.map((t) =>
getMonitorTunnelQualityHistory(t.id, start, end).then(res => {
if (res.code === 0 && res.data) {
return { id: t.id, data: res.data };
}
return { id: t.id, data: [] };
}).catch(() => ({ id: t.id, data: [] }))
);
const results = await Promise.all(promises);
setQualityHistoryMap(prev => {
const nextMap = { ...prev };
results.forEach(r => {
const existing = nextMap[r.id] || [];
const merged = [...r.data, ...existing].sort((a,b) => a.timestamp - b.timestamp);
const unique: TunnelQualityApiItem[] = [];
for (const item of merged) {
if (unique.length === 0 || unique[unique.length-1].timestamp !== item.timestamp) {
unique.push(item);
}
}
nextMap[r.id] = unique.slice(-UPTIME_KUMA_BARS_COUNT);
});
return nextMap;
});
};
void fetchHistory();
}
}, [tunnels]);
// --- Load quality snapshots (auto-polling every 10s) ---
const loadQuality = useCallback(async (options?: { silent?: boolean }) => {
const silent = options?.silent ?? false;
if (!silent) setQualityLoading(true);
try {
const response = await getMonitorTunnelQuality();
if (response.code === 0 && Array.isArray(response.data)) {
const map: Record<number, TunnelQualityApiItem> = {};
for (const q of response.data) {
map[q.tunnelId] = q;
}
setQualityMap(map);
// Accumulate locally
setQualityHistoryMap(prev => {
const nextMap = { ...prev };
for (const q of response.data) {
const currentArr = nextMap[q.tunnelId] || [];
const lastItem = currentArr.length > 0 ? currentArr[currentArr.length - 1] : null;
if (!lastItem || lastItem.timestamp !== q.timestamp) {
nextMap[q.tunnelId] = [...currentArr, q].slice(-UPTIME_KUMA_BARS_COUNT);
}
}
return nextMap;
});
}
} catch {
// Silently ignore quality load failures
} finally {
if (!silent) setQualityLoading(false);
}
}, []);
useEffect(() => {
if (!monitorTunnelQualityEnabled) {
return;
}
void loadQuality();
}, [loadQuality, monitorTunnelQualityEnabled]);
useEffect(() => {
if (!monitorTunnelQualityEnabled) {
if (qualityTimerRef.current) {
window.clearInterval(qualityTimerRef.current);
qualityTimerRef.current = null;
}
return;
}
qualityTimerRef.current = window.setInterval(() => {
void loadQuality({ silent: true });
}, QUALITY_POLL_INTERVAL);
return () => {
if (qualityTimerRef.current) {
window.clearInterval(qualityTimerRef.current);
qualityTimerRef.current = null;
}
};
}, [loadQuality, monitorTunnelQualityEnabled]);
// --- Load quality history for detail chart ---
const loadQualityHistory = useCallback(
async (tunnelId: number, options?: { silent?: boolean }) => {
const silent = options?.silent ?? false;
if (!silent) setQualityHistoryLoading(true);
try {
const end = Date.now();
const start = end - qualityRangeMs;
const response = await getMonitorTunnelQualityHistory(tunnelId, start, end);
if (response.code === 0 && Array.isArray(response.data)) {
setQualityHistoryError(null);
setQualityHistory(response.data);
return;
}
if (response.code === 403) {
setAccessDenied(response.msg || "暂无监控权限");
return;
}
setQualityHistoryError(response.msg || "加载质量历史失败");
if (!silent) toast.error(response.msg || "加载质量历史失败");
} catch {
if (!silent) {
setQualityHistoryError("加载质量历史失败");
}
} finally {
if (!silent) setQualityHistoryLoading(false);
}
},
[qualityRangeMs],
);
// --- Load tunnel traffic metrics for detail chart ---
const loadTunnelMetrics = useCallback(
async (tunnelId: number, options?: { silent?: boolean }) => {
const silent = options?.silent ?? false;
if (!silent) setTunnelMetricsLoading(true);
try {
const end = Date.now();
const start = end - tunnelRangeMs;
const response = await getTunnelMetrics(tunnelId, start, end);
if (response.code === 0 && Array.isArray(response.data)) {
setTunnelMetricsError(null);
const ordered = [...response.data].sort(
(a, b) => a.timestamp - b.timestamp,
);
setTunnelMetrics(ordered);
return;
}
setTunnelMetricsError(response.msg || "加载流量数据失败");
} catch {
if (!silent) setTunnelMetricsError("加载流量数据失败");
} finally {
if (!silent) setTunnelMetricsLoading(false);
}
},
[tunnelRangeMs],
);
useEffect(() => {
if (detailTunnelId) {
void loadTunnelMetrics(detailTunnelId);
if (monitorTunnelQualityEnabled) {
void loadQualityHistory(detailTunnelId);
}
}
}, [
detailTunnelId,
loadQualityHistory,
loadTunnelMetrics,
monitorTunnelQualityEnabled,
]);
// Auto-refresh detail charts
useEffect(() => {
if (!detailTunnelId) return;
const timer = window.setInterval(() => {
if (monitorTunnelQualityEnabled) {
void loadQualityHistory(detailTunnelId, { silent: true });
}
void loadTunnelMetrics(detailTunnelId, { silent: true });
}, 30_000);
return () => window.clearInterval(timer);
}, [
detailTunnelId,
loadQualityHistory,
loadTunnelMetrics,
monitorTunnelQualityEnabled,
]);
// Memoize chart data so React.memo sub-components see stable references
const qualityChartData = useMemo(
() => qualityHistory.map((q) => ({
time: formatTimestamp(q.timestamp, qualityRangeMs),
entryToExit: q.entryToExitLatency >= 0 ? q.entryToExitLatency : null,
exitToBing: q.exitToBingLatency >= 0 ? q.exitToBingLatency : null,
entryToExitLoss: q.entryToExitLoss,
exitToBingLoss: q.exitToBingLoss,
})),
[qualityHistory, qualityRangeMs],
);
const tunnelChartData = useMemo(
() => tunnelMetrics.map((m) => ({
time: formatTimestamp(m.timestamp, tunnelRangeMs),
bytesIn: m.bytesIn,
bytesOut: m.bytesOut,
connections: m.connections,
})),
[tunnelMetrics, tunnelRangeMs],
);
const detailTunnel = detailTunnelId != null
? tunnels.find((t) => t.id === detailTunnelId)
: null;
// Aggregate stats
const tunnelStats = useMemo(() => {
const enabled = tunnels.filter((t) => t.status === 1).length;
return { total: tunnels.length, enabled };
}, [tunnels]);
// Last quality update timestamp
const lastQualityUpdate = useMemo(() => {
let latest = 0;
for (const q of Object.values(qualityMap)) {
if (q.timestamp > latest) latest = q.timestamp;
}
return latest > 0 ? new Date(latest).toLocaleTimeString("zh-CN") : null;
}, [qualityMap]);
if (accessDenied) {
return (
<Card>
<CardHeader className="flex flex-row items-center gap-2">
<Activity className="w-5 h-5 text-warning" />
<h3 className="text-lg font-semibold">监控权限</h3>
</CardHeader>
<CardBody>
<div className="text-sm text-default-600">{accessDenied}</div>
<div className="text-xs text-default-500 mt-2">
如需使用监控功能,请联系管理员在用户页面授予监控权限。
</div>
</CardBody>
</Card>
);
}
if (detailTunnelId && detailTunnel) {
const quality = qualityMap[detailTunnelId];
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3 flex-wrap">
<Button size="sm" variant="flat" onPress={() => {
setDetailTunnelId(null);
setQualityHistory([]);
setTunnelMetrics([]);
}}>
<ArrowLeft className="w-4 h-4 mr-1" />
返回隧道列表
</Button>
<div className="flex items-center gap-2">
<ArrowRightLeft className={`w-5 h-5 ${detailTunnel.status === 1 ? "text-success" : "text-default-400"}`} />
<h3 className="text-lg font-semibold">{detailTunnel.name}</h3>
<Chip size="sm" color={detailTunnel.status === 1 ? "success" : "danger"} variant="flat">
{detailTunnel.status === 1 ? "启用" : "禁用"}
</Chip>
</div>
</div>
{/* Quality KPI Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50">
<CardBody className="py-3 px-4 flex flex-col items-center justify-center min-h-[5rem]">
<span className="text-[11px] text-default-500 mb-1.5 flex items-center gap-1">
<Zap className="w-3 h-3" />
入口 → 出口 延迟
</span>
<LatencyDisplay value={quality?.entryToExitLatency} loading={qualityLoading} />
</CardBody>
</Card>
<Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50">
<CardBody className="py-3 px-4 flex flex-col items-center justify-center min-h-[5rem]">
<span className="text-[11px] text-default-500 mb-1.5 flex items-center gap-1">
<Globe className="w-3 h-3" />
出口 → Bing 延迟
</span>
<LatencyDisplay value={quality?.exitToBingLatency} loading={qualityLoading} />
</CardBody>
</Card>
<Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50">
<CardBody className="py-3 px-4 flex flex-col items-center justify-center min-h-[5rem]">
<span className="text-[11px] text-default-500 mb-1.5">入口 → 出口 丢包</span>
<span className={`text-sm font-semibold font-mono ${(quality?.entryToExitLoss ?? 0) > 0 ? "text-warning" : ""}`}>
{quality?.entryToExitLoss !== undefined ? `${quality.entryToExitLoss.toFixed(1)}%` : "-"}
</span>
</CardBody>
</Card>
<Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50">
<CardBody className="py-3 px-4 flex flex-col items-center justify-center min-h-[5rem]">
<span className="text-[11px] text-default-500 mb-1.5">出口 → Bing 丢包</span>
<span className={`text-sm font-semibold font-mono ${(quality?.exitToBingLoss ?? 0) > 0 ? "text-warning" : ""}`}>
{quality?.exitToBingLoss !== undefined ? `${quality.exitToBingLoss.toFixed(1)}%` : "-"}
</span>
</CardBody>
</Card>
</div>
{/* Auto-probe status */}
<div className="flex items-center gap-2 text-xs text-default-500">
{monitorTunnelQualityEnabled ? (
<>
<LiveDot />
<span>自动探测中(每秒测试,30秒上报)</span>
</>
) : (
<>
<WifiOff className="w-3.5 h-3.5 text-warning" />
<span>实时隧道质量检测已关闭</span>
</>
)}
{quality?.timestamp && (
<span className="text-default-400">
· 最近更新: {new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
</span>
)}
{quality?.errorMessage && (
<span className="text-danger ml-2">{quality.errorMessage}</span>
)}
</div>
{/* ====== Quality History Chart — isolated with React.memo ====== */}
<QualityChartCard
rangeMs={qualityRangeMs}
onRangeChange={setQualityRangeMs}
loading={qualityHistoryLoading}
error={qualityHistoryError}
data={qualityChartData}
tunnelId={detailTunnelId}
onRefresh={loadQualityHistory}
/>
{/* ====== Traffic Chart — isolated with React.memo ====== */}
<TrafficChartCard
rangeMs={tunnelRangeMs}
onRangeChange={setTunnelRangeMs}
loading={tunnelMetricsLoading}
error={tunnelMetricsError}
data={tunnelChartData}
tunnelId={detailTunnelId}
onRefresh={loadTunnelMetrics}
/>
</div>
);
}
// ===== LIST/GRID VIEW (unchanged) =====
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center gap-3 mb-1">
<Chip color="primary" size="sm" variant="flat">隧道 {tunnelStats.enabled}/{tunnelStats.total}</Chip>
{lastQualityUpdate ? (
<div className="flex items-center gap-1.5 text-xs text-default-500">
{monitorTunnelQualityEnabled ? (
<>
<LiveDot />
<span>每秒探测 · 更新于 {lastQualityUpdate}</span>
</>
) : (
<>
<WifiOff className="w-3.5 h-3.5 text-warning" />
<span>实时质量检测已关闭 · 最近更新于 {lastQualityUpdate}</span>
</>
)}
</div>
) : !monitorTunnelQualityEnabled ? (
<div className="flex items-center gap-1.5 text-xs text-default-500">
<WifiOff className="w-3.5 h-3.5 text-warning" />
<span>实时质量检测已关闭</span>
</div>
) : null}
<div className="ml-auto">
<Button isLoading={tunnelsLoading} size="sm" variant="flat" onPress={() => loadTunnels()}>
<RefreshCw className="w-4 h-4 mr-1" />
刷新
</Button>
</div>
</div>
{tunnelsError ? (
<Card>
<CardBody>
<div className="text-sm text-default-600">{tunnelsError}</div>
</CardBody>
</Card>
) : null}
{viewMode === "grid" ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{tunnels.map((tunnel) => {
const quality = qualityMap[tunnel.id];
const isEnabled = tunnel.status === 1;
return (
<Card
key={tunnel.id}
className="group relative overflow-hidden shadow-sm border border-divider dark:border-default-100 hover:-translate-y-1 hover:shadow-lg transition-all duration-300 h-full flex flex-col cursor-pointer bg-background"
onClick={() => setDetailTunnelId(tunnel.id)}
>
<div className={`absolute top-0 left-0 right-0 h-1 ${isEnabled ? "bg-success" : "bg-danger"}`} />
<div className={`absolute -right-8 -top-8 w-24 h-24 rounded-full blur-2xl opacity-10 transition-opacity group-hover:opacity-20 ${isEnabled ? "bg-success" : "bg-danger"}`} />
<CardHeader className="pb-2 pt-5 px-5 flex flex-row justify-between items-start gap-4">
<div className="flex items-center gap-3 min-w-0">
<div className="relative flex-shrink-0">
<div className="w-10 h-10 rounded-xl bg-default-100 dark:bg-default-50/10 flex items-center justify-center border border-divider">
<ArrowRightLeft className={`w-5 h-5 ${isEnabled ? "text-success" : "text-danger"}`} />
</div>
<span className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-background ${isEnabled ? "bg-success" : "bg-danger"}`} />
</div>
<div className="flex flex-col min-w-0">
<h3 className="font-semibold text-foreground text-sm truncate">{tunnel.name}</h3>
<div className="flex items-center gap-1.5 text-[11px] text-default-500 mt-0.5">
<span className="font-mono">{isEnabled ? "启用" : "禁用"}</span>
</div>
</div>
</div>
</CardHeader>
<CardBody className="py-3 px-5 flex-1 flex flex-col justify-end gap-3 z-10 w-full overflow-hidden">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<div className="text-[10px] text-default-500 flex items-center gap-1">
<Zap className="w-3 h-3" />
入口→出口
</div>
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="entryToExit" latestValue={quality?.entryToExitLatency} />
</div>
<div className="space-y-1">
<div className="text-[10px] text-default-500 flex items-center gap-1">
<Globe className="w-3 h-3" />
出口→Bing
</div>
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="exitToBing" latestValue={quality?.exitToBingLatency} />
</div>
</div>
<div className="flex justify-between items-center pt-2 border-t border-divider/50">
{quality?.errorMessage ? (
<span className="text-[11px] text-danger truncate">{quality.errorMessage}</span>
) : quality?.timestamp ? (
<span className="text-[11px] text-default-500 flex items-center gap-1">
{monitorTunnelQualityEnabled ? (
<LiveDot />
) : (
<WifiOff className="w-3 h-3 text-warning" />
)}
{new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
</span>
) : (
<span className="text-[11px] text-default-400">
{monitorTunnelQualityEnabled ? "等待探测..." : "实时检测已关闭"}
</span>
)}
</div>
</CardBody>
</Card>
);
})}
</div>
) : (
<Card className="w-full">
<Table
aria-label="隧道列表"
className="overflow-x-auto min-w-full"
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",
}}
>
<TableHeader>
<TableColumn>状态</TableColumn>
<TableColumn>名称</TableColumn>
<TableColumn>入口→出口</TableColumn>
<TableColumn>出口→Bing</TableColumn>
<TableColumn>更新时间</TableColumn>
</TableHeader>
<TableBody emptyContent="暂无隧道">
{tunnels.map((tunnel) => {
const quality = qualityMap[tunnel.id];
const isEnabled = tunnel.status === 1;
return (
<TableRow key={tunnel.id} className="cursor-pointer" onClick={() => setDetailTunnelId(tunnel.id)}>
<TableCell>
<div className="flex items-center gap-1.5">
{isEnabled ? (
<Wifi className="w-3.5 h-3.5 text-success" />
) : (
<WifiOff className="w-3.5 h-3.5 text-danger" />
)}
</div>
</TableCell>
<TableCell>
<span className="font-semibold text-sm whitespace-nowrap">{tunnel.name}</span>
</TableCell>
<TableCell>
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="entryToExit" latestValue={quality?.entryToExitLatency} />
</TableCell>
<TableCell>
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="exitToBing" latestValue={quality?.exitToBingLatency} />
</TableCell>
<TableCell>
{quality?.timestamp ? (
<span className="text-xs text-default-500 flex items-center gap-1 whitespace-nowrap">
{monitorTunnelQualityEnabled ? (
<LiveDot />
) : (
<WifiOff className="w-3.5 h-3.5 text-warning" />
)}
{new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
</span>
) : (
<span className="text-xs text-default-400">
{monitorTunnelQualityEnabled ? "-" : "实时检测已关闭"}
</span>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Card>
)}
</div>
);
}
+34 -30
View File
@@ -32,6 +32,9 @@ interface PanelAddress {
const FORWARD_COMPACT_MODE_CONFIG_KEY = "forward_compact_mode";
const parseBooleanConfig = (value: unknown, defaultValue: boolean) =>
typeof value === "string" ? value === "true" : defaultValue;
export const SettingsPage = () => {
const navigate = useNavigate();
const [panelAddresses, setPanelAddresses] = useState<PanelAddress[]>([]);
@@ -50,9 +53,16 @@ export const SettingsPage = () => {
setPanelAddresses(newAddress);
};
useEffect(() => {
(window as any).setPanelAddresses = setPanelAddressesFunc;
return () => {
delete (window as any).setPanelAddresses;
};
}, []);
// 加载面板地址列表
const loadPanelAddresses = async () => {
(window as any).setPanelAddresses = setPanelAddressesFunc;
getPanelAddresses();
};
@@ -72,7 +82,6 @@ export const SettingsPage = () => {
return;
}
(window as any).setPanelAddresses = setPanelAddressesFunc;
savePanelAddress(newName.trim(), newAddress.trim());
setNewName("");
setNewAddress("");
@@ -81,14 +90,12 @@ export const SettingsPage = () => {
// 设置当前面板地址
const setCurrentPanel = async (name: string) => {
(window as any).setPanelAddresses = setPanelAddressesFunc;
setCurrentPanelAddress(name);
reinitializeBaseURL();
};
// 删除面板地址
const handleDeletePanelAddress = async (name: string) => {
(window as any).setPanelAddresses = setPanelAddressesFunc;
deletePanelAddress(name);
reinitializeBaseURL();
toast.success("删除成功");
@@ -103,12 +110,7 @@ export const SettingsPage = () => {
const loadForwardCompactMode = async () => {
try {
const res = await getConfigByName(FORWARD_COMPACT_MODE_CONFIG_KEY);
const enabled =
res.code === 0 &&
typeof res.data?.value === "string" &&
res.data.value === "true";
setForwardCompactMode(enabled);
setForwardCompactMode(parseBooleanConfig(res.data?.value, false));
} catch {
setForwardCompactMode(false);
}
@@ -216,28 +218,30 @@ export const SettingsPage = () => {
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
显示设置
</h2>
<div className="rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
规则页面精简模式
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
开启后,规则页面列表使用 2.1.6-alpha8 样式。{" "}
</p>
<div className="space-y-3">
<div className="rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
规则页面精简模式
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
开启后,规则页面列表使用 2.1.6-alpha8 样式。{" "}
</p>
</div>
<Switch
color="primary"
isDisabled={!admin || forwardCompactModeSaving}
isSelected={forwardCompactMode}
onValueChange={handleForwardCompactModeChange}
/>
</div>
<Switch
color="primary"
isDisabled={!admin || forwardCompactModeSaving}
isSelected={forwardCompactMode}
onValueChange={handleForwardCompactModeChange}
/>
{!admin && (
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
仅管理员可修改该全局配置。
</p>
)}
</div>
{!admin && (
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
仅管理员可修改该全局配置。
</p>
)}
</div>
</CardBody>
</Card>
+148 -1
View File
@@ -45,6 +45,15 @@ 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 { LayoutGrid, List } from "lucide-react";
import {
Table,
TableHeader,
TableColumn,
TableBody,
TableRow,
TableCell,
} from "@/shadcn-bridge/heroui/table";
import {
createTunnel,
batchDeleteTunnelsWithForwards,
@@ -181,6 +190,10 @@ export default function TunnelPage() {
"",
);
const [isSearchVisible, setIsSearchVisible] = useState(false);
const [viewMode, setViewMode] = useLocalStorageState<"list" | "grid">(
"tunnel-view-mode",
"grid",
);
// 模态框状态
const [modalOpen, setModalOpen] = useState(false);
@@ -405,7 +418,7 @@ export default function TunnelPage() {
// 表单验证
const validateForm = (): boolean => {
const newErrors = validateTunnelForm(form, nodes);
const newErrors = validateTunnelForm(form, nodes, isEdit);
setErrors(newErrors);
@@ -1523,6 +1536,14 @@ export default function TunnelPage() {
>
批量
</Button>
<Button
isIconOnly
size="sm"
variant="flat"
onPress={() => setViewMode(viewMode === "list" ? "grid" : "list")}
>
{viewMode === "list" ? <LayoutGrid className="w-4 h-4" /> : <List className="w-4 h-4" />}
</Button>
<Button
color="primary"
size="sm"
@@ -1556,6 +1577,131 @@ export default function TunnelPage() {
{/* 隧道卡片网格 */}
{tunnels.length > 0 ? (
viewMode === "list" ? (
<Card>
<Table
aria-label="隧道列表"
className="overflow-x-auto min-w-full"
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",
}}
>
<TableHeader>
{selectMode ? <TableColumn className="w-12 px-4 whitespace-nowrap overflow-hidden">
<Checkbox
isSelected={selectedIds.size === sortedTunnels.length && sortedTunnels.length > 0}
onValueChange={(checked) => checked ? selectAll() : deselectAll()}
/>
</TableColumn> : <TableColumn className="w-0 p-0 overflow-hidden text-[0px]"></TableColumn>}
<TableColumn>隧道名称</TableColumn>
<TableColumn>类型</TableColumn>
<TableColumn>拓扑</TableColumn>
<TableColumn>流量统计</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody items={sortedTunnels}>
{(tunnel) => {
const typeDisplay = getTunnelTypeDisplay(tunnel.type);
const tunnelTypeChipClassName =
tunnel.type === 1
? "text-[10px] h-5 bg-primary-100 text-primary-800 border-primary-300 dark:bg-primary-900/45 dark:text-primary-200 dark:border-primary-700"
: "text-[10px] h-5 bg-success-100 text-success-800 border-success-300 dark:bg-success-900/35 dark:text-success-200 dark:border-success-700";
return (
<TableRow key={tunnel.id}>
{selectMode ? <TableCell className="px-4">
<Checkbox
isSelected={selectedIds.has(tunnel.id)}
onValueChange={() => toggleSelect(tunnel.id)}
/>
</TableCell> : <TableCell className="w-0 p-0 overflow-hidden text-[0px]"></TableCell>}
<TableCell>
<div className="flex items-center gap-2">
<div
className={`shrink-0 w-2 h-2 rounded-full ${
tunnel.status === 1 ? "bg-success" : "bg-danger"
}`}
/>
<span className="font-medium text-foreground text-sm">
{tunnel.name}
</span>
</div>
</TableCell>
<TableCell>
<Chip
className={tunnelTypeChipClassName}
color={typeDisplay.color as any}
size="sm"
variant="flat"
>
{typeDisplay.text}
</Chip>
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5 text-xs">
<span className="font-semibold text-primary-700 dark:text-primary-400">
{tunnel.inNodeId?.length || 0}入口
</span>
<span className="text-default-400">→</span>
<span className="font-semibold text-secondary-700 dark:text-secondary-400">
{tunnel.type === 2 ? tunnel.chainNodes?.length || 0 : 0}跳
</span>
<span className="text-default-400">→</span>
<span className="font-semibold text-success-700 dark:text-success-400">
{tunnel.type === 2 ? tunnel.outNodeId?.length || 0 : tunnel.inNodeId?.length || 0}出口
</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2 text-xs">
<span className="text-default-500">{getTunnelFlowDisplay(tunnel.flow)}</span>
<span className="text-default-300">|</span>
<span className="text-default-500">{tunnel.trafficRatio}x</span>
{tunnel.type === 2 && tunnel.ipPreference && (
<>
<span className="text-default-300">|</span>
<span className="text-default-500">{tunnel.ipPreference === "v4" ? "IPv4" : "IPv6"}</span>
</>
)}
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
<Button
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
size="sm"
variant="flat"
onPress={() => handleEdit(tunnel)}
>
编辑
</Button>
<Button
className="h-6 px-2 min-w-0 text-xs bg-amber-50 text-amber-600 hover:bg-amber-100 dark:bg-amber-950/30 dark:text-amber-400"
size="sm"
variant="flat"
onPress={() => handleDiagnose(tunnel)}
>
诊断
</Button>
<Button
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
size="sm"
variant="flat"
onPress={() => handleDelete(tunnel)}
>
删除
</Button>
</div>
</TableCell>
</TableRow>
);
}}
</TableBody>
</Table>
</Card>
) : (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<SortableContext
items={sortableTunnelIds}
@@ -1836,6 +1982,7 @@ export default function TunnelPage() {
</div>
</SortableContext>
</DndContext>
)
) : (
/* 空状态 */
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
+12 -7
View File
@@ -33,6 +33,7 @@ export const createTunnelFormDefaults = () => {
export const validateTunnelForm = (
form: TunnelFormInput,
nodes: TunnelNodeInput[],
isEdit = false,
): Record<string, string> => {
const errors: Record<string, string> = {};
@@ -44,7 +45,9 @@ export const validateTunnelForm = (
if (!form.inNodeId || form.inNodeId.length === 0) {
errors.inNodeId = "请至少选择一个入口节点";
} else {
} else if (!isEdit) {
// Only enforce online check for new tunnels. During edit the backend
// allows existing offline nodes (user may be removing them).
const offlineInNodes = form.inNodeId.filter((item) => {
const node = nodes.find((n) => n.id === item.nodeId);
@@ -64,14 +67,16 @@ export const validateTunnelForm = (
if (!form.outNodeId || form.outNodeId.length === 0) {
errors.outNodeId = "请至少选择一个出口节点";
} else {
const offlineOutNodes = form.outNodeId.filter((item) => {
const node = nodes.find((n) => n.id === item.nodeId);
if (!isEdit) {
const offlineOutNodes = form.outNodeId.filter((item) => {
const node = nodes.find((n) => n.id === item.nodeId);
return node && node.status !== 1;
});
return node && node.status !== 1;
});
if (offlineOutNodes.length > 0) {
errors.outNodeId = "所有出口节点必须在线";
if (offlineOutNodes.length > 0) {
errors.outNodeId = "所有出口节点必须在线";
}
}
const inNodeIds = form.inNodeId.map((item) => item.nodeId);
+365 -177
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
import toast from "react-hot-toast";
import { parseDate } from "@internationalized/date";
import { LayoutGrid, List } from "lucide-react";
import {
AnimatedPage,
@@ -190,6 +191,10 @@ export default function UserPage() {
"",
);
const [isSearchVisible, setIsSearchVisible] = useState(false);
const [viewMode, setViewMode] = useLocalStorageState<"list" | "grid">(
"user-view-mode",
"grid",
);
const [pagination, setPagination] = useState<PaginationType>({
current: 1,
size: 10,
@@ -245,7 +250,6 @@ export default function UserPage() {
// 分配新隧道权限相关状态
const [assignLoading, setAssignLoading] = useState(false);
const [isTunnelListExpanded, setIsTunnelListExpanded] = useState(false);
const [batchTunnelSelections, setBatchTunnelSelections] = useState<
Map<number, number | null>
>(new Map());
@@ -1041,9 +1045,23 @@ export default function UserPage() {
)}
</div>
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
新增
</Button>
<div className="flex items-center gap-2">
<Button
isIconOnly
size="sm"
variant="flat"
onPress={() => setViewMode(viewMode === "list" ? "grid" : "list")}
>
{viewMode === "list" ? (
<LayoutGrid className="w-4 h-4" />
) : (
<List className="w-4 h-4" />
)}
</Button>
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
新增
</Button>
</div>
</div>
{/* 用户列表 */}
@@ -1060,6 +1078,162 @@ export default function UserPage() {
</p>
</CardBody>
</Card>
) : viewMode === "list" ? (
<Card>
<Table
aria-label="用户列表"
className="overflow-x-auto min-w-full"
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",
}}
>
<TableHeader>
<TableColumn>用户名</TableColumn>
<TableColumn>流量统计</TableColumn>
<TableColumn>配额限制</TableColumn>
<TableColumn>规则数量</TableColumn>
<TableColumn>到期时间</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody items={users}>
{(user) => {
const userStatus = getUserStatus(user);
const expStatus = user.expTime
? getExpireStatus(user.expTime)
: null;
const usedFlow = calculateUserTotalUsedFlow(user);
return (
<TableRow key={user.id}>
<TableCell>
<div className="flex items-center gap-2">
<div
className={`shrink-0 w-2 h-2 rounded-full ${
userStatus.color === "success"
? "bg-success"
: "bg-danger"
}`}
/>
<div className="flex flex-col">
<span className="font-medium text-foreground text-sm">
{user.name || user.user}
</span>
<span className="text-xs text-default-500">
@{user.user}
</span>
</div>
{user.disabledByQuota && (
<Chip className="text-[10px] h-4 px-1 ml-1" color="danger" size="sm" variant="flat">
超额
</Chip>
)}
</div>
</TableCell>
<TableCell>
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-1 text-xs">
<span className="text-default-500">已用:</span>
<span className="text-danger font-medium whitespace-nowrap">
{formatFlow(usedFlow)}
</span>
</div>
<div className="flex items-center gap-1 text-xs">
<span className="text-default-500">限制:</span>
<span className="text-default-700 font-medium whitespace-nowrap">
{formatFlow(user.flow, "gb")}
</span>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex flex-col gap-0.5 whitespace-nowrap text-[11px]">
{((user.dailyQuotaGB ?? 0) > 0 || (user.monthlyQuotaGB ?? 0) > 0) ? (
<>
<div className="flex gap-1 justify-between">
<span className="text-default-500">日配额:</span>
<span>{formatFlow(user.dailyUsedBytes ?? 0)} / {formatQuotaLimit(user.dailyQuotaGB)}</span>
</div>
<div className="flex gap-1 justify-between">
<span className="text-default-500">月配额:</span>
<span>{formatFlow(user.monthlyUsedBytes ?? 0)} / {formatQuotaLimit(user.monthlyQuotaGB)}</span>
</div>
</>
) : (
<span className="text-default-400">无配额</span>
)}
</div>
</TableCell>
<TableCell>
<span className="text-sm font-medium">{user.num}</span>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1 items-start whitespace-nowrap">
{user.expTime ? (
<>
<span className="text-sm">
{new Date(user.expTime).toLocaleString()}
</span>
{expStatus && (
<Chip
color={expStatus.color}
size="sm"
variant="flat"
className="h-5 px-1 text-[10px]"
>
{expStatus.text}
</Chip>
)}
</>
) : (
<span className="text-default-400 text-sm">无限期</span>
)}
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
<Button
size="sm"
variant="flat"
className="h-6 px-2 min-w-0 text-xs bg-emerald-50 text-emerald-600 hover:bg-emerald-100 dark:bg-emerald-950/30 dark:text-emerald-400"
onPress={() => handleManageTunnels(user)}
>
权限
</Button>
<Button
size="sm"
variant="flat"
className="h-6 px-2 min-w-0 text-xs bg-amber-50 text-amber-600 hover:bg-amber-100 dark:bg-amber-950/30 dark:text-amber-400"
title="重置流量"
onPress={() => handleResetFlow(user)}
>
重置
</Button>
<Button
size="sm"
variant="flat"
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
onPress={() => handleEdit(user)}
>
编辑
</Button>
<Button
size="sm"
variant="flat"
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
onPress={() => handleDelete(user)}
>
删除
</Button>
</div>
</TableCell>
</TableRow>
);
}}
</TableBody>
</Table>
</Card>
) : (
<StaggerList className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{users.map((user) => {
@@ -1519,7 +1693,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}
@@ -1570,147 +1744,136 @@ export default function UserPage() {
<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 dark:bg-default-100 dark:ring-primary/20" : "border-default-200 bg-default-50 hover:border-primary-300 dark:hover:bg-default-100 dark:hover:border-primary-400"}`}
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 z-[999] w-[320px] overflow-hidden rounded-2xl border border-default-200 bg-white text-foreground shadow-2xl dark:bg-default-50"
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={`mb-1 flex items-center justify-between rounded-xl border px-4 py-2.5 transition-all ${isSelected ? "border-primary-200 bg-primary-50/60 dark:border-primary-500/40 dark:bg-primary-900/40" : "border-transparent bg-transparent hover:bg-default-100"} ${isAssigned ? "cursor-not-allowed opacity-40 grayscale" : "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={`truncate text-sm font-medium text-foreground ${isSelected ? "text-primary-700 dark:text-primary-300" : ""}`}
>
{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="flex justify-end border-t border-default-200 bg-default-50/80 p-2 dark:bg-default-100/80">
<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}
@@ -1732,19 +1895,21 @@ 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",
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",
}}
>
<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="暂无隧道权限"
@@ -1753,36 +1918,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)}
@@ -1791,12 +1981,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"
@@ -1810,7 +2003,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)}
@@ -1826,14 +2020,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>

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