mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 11051f5517 | |||
| ff2c7c4959 | |||
| 6364b96935 | |||
| 409f0a232a | |||
| f79994e0e0 | |||
| 9fdb16d035 | |||
| 53b632a6f7 | |||
| bf7b2a0740 | |||
| 8475bc27bb | |||
| 5d01572eff | |||
| a353faaa71 | |||
| e7b25004ba | |||
| 3826cb02c0 | |||
| a1fee8e432 | |||
| aafdb78482 | |||
| 16b545d8cd | |||
| 45065178b8 | |||
| 8ebde9dca9 | |||
| 0a1ec60750 | |||
| 9ec35d2f2f | |||
| c914040b7d | |||
| 822362c44c | |||
| 80f5935b76 | |||
| 433c8aab13 | |||
| 949dfcd42d | |||
| 1580e4ee10 | |||
| 32e4f0f514 | |||
| ca3a643ef7 | |||
| ce2b234843 | |||
| ac3506847c | |||
| 6e3d604618 | |||
| 4417ece7cd | |||
| bab4371ba7 | |||
| 960c97cee4 | |||
| 9d05d75fd6 | |||
| c137bdcc63 | |||
| b7065f6e99 | |||
| bd4e1f66cb | |||
| 9f0670f4d0 | |||
| 32e338d295 | |||
| f6a753baa3 | |||
| 322a10bb9d | |||
| 27c13d6c47 | |||
| f45f96063a | |||
| 1780be73b9 | |||
| addf8e2089 | |||
| 75cd60ea3e | |||
| fe42a77409 | |||
| ce9abf457f | |||
| 3c57a5ac84 | |||
| ff7c91d277 | |||
| 1498f3052d | |||
| 6b1264ae90 | |||
| 18445ec063 | |||
| 08bc91e5c9 | |||
| 2f424bea31 | |||
| bc75ed745d | |||
| 78fb9a31d6 | |||
| d3ed2e8856 | |||
| db21ce6bb4 | |||
| 76ad841231 | |||
| d0535707dc | |||
| 6458b5af00 | |||
| 555039e028 | |||
| 7134253b2c | |||
| 58d29b440a | |||
| 6a3a9add08 |
@@ -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)
|
||||
@@ -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 +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 .` 成功
|
||||
@@ -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
|
||||
- 后端完成: ✅
|
||||
- 前端完成: ✅
|
||||
@@ -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-rc6
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -42,6 +42,8 @@ type Handler struct {
|
||||
|
||||
upgradeMu sync.Mutex
|
||||
pendingUpgradeRedeploy map[int64]struct{}
|
||||
|
||||
qualityProber *tunnelQualityProber
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
@@ -93,6 +95,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 +232,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)
|
||||
@@ -1234,6 +1238,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 +1273,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)
|
||||
|
||||
@@ -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,13 @@ func (h *Handler) runHealthChecks(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) runTunnelQualityProber(ctx context.Context) {
|
||||
defer h.jobsWG.Done()
|
||||
if h.qualityProber != nil {
|
||||
h.qualityProber.Start(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) runHourlyStatsLoop(ctx context.Context) {
|
||||
defer h.jobsWG.Done()
|
||||
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -1107,6 +1107,15 @@ func (h *Handler) syncTunnelForwardsEntryPorts(tunnelID int64, entryNodeIDs []in
|
||||
continue
|
||||
}
|
||||
|
||||
// If the existing port is outside any entry node's allowed range,
|
||||
// pick a new random port that satisfies all entry nodes.
|
||||
if !h.isPortValidForAllEntryNodes(port, entryNodeIDs) {
|
||||
newPort := h.pickTunnelPort(tunnelID)
|
||||
if newPort > 0 {
|
||||
port = newPort
|
||||
}
|
||||
}
|
||||
|
||||
var entries []forwardPortReplaceEntry
|
||||
if allowInIP {
|
||||
entries = buildForwardPortEntriesWithPreservedInIP(entryNodeIDs, oldPorts, port)
|
||||
@@ -1120,6 +1129,25 @@ func (h *Handler) syncTunnelForwardsEntryPorts(tunnelID int64, entryNodeIDs []in
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) isPortValidForAllEntryNodes(port int, entryNodeIDs []int64) bool {
|
||||
if port <= 0 {
|
||||
return false
|
||||
}
|
||||
for _, nodeID := range entryNodeIDs {
|
||||
node, err := h.getNodeRecord(nodeID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if validateLocalNodePort(node, port) != nil {
|
||||
return false
|
||||
}
|
||||
if validateRemoteNodePort(node, port) != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *Handler) tunnelDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
|
||||
@@ -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,364 @@
|
||||
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 {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &tunnelQualityProber{
|
||||
handler: h,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
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() {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// maybePrune deletes old quality rows periodically (mirrors PruneServiceMonitorResults).
|
||||
func (p *tunnelQualityProber) maybePrune() {
|
||||
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() {
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -51,6 +51,7 @@ type NodeMetric = model.NodeMetric
|
||||
type TunnelMetric = model.TunnelMetric
|
||||
type ServiceMonitor = model.ServiceMonitor
|
||||
type ServiceMonitorResult = model.ServiceMonitorResult
|
||||
type TunnelQuality = model.TunnelQuality
|
||||
|
||||
// ─── Repository ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -191,6 +192,7 @@ func autoMigrateAll(db *gorm.DB) error {
|
||||
&model.TunnelMetric{},
|
||||
&model.ServiceMonitor{},
|
||||
&model.ServiceMonitorResult{},
|
||||
&model.TunnelQuality{},
|
||||
}
|
||||
|
||||
if db.Dialector.Name() != "sqlite" {
|
||||
@@ -675,9 +677,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
|
||||
@@ -3317,15 +3320,61 @@ 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(
|
||||
"? AS node_id, "+
|
||||
"CAST(%s AS INTEGER) 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 INTEGER) AS net_in_bytes, "+
|
||||
"CAST(AVG(net_out_bytes) AS INTEGER) AS net_out_bytes, "+
|
||||
"CAST(AVG(net_in_speed) AS INTEGER) AS net_in_speed, "+
|
||||
"CAST(AVG(net_out_speed) AS INTEGER) AS net_out_speed, "+
|
||||
"AVG(load1) AS load1, "+
|
||||
"AVG(load5) AS load5, "+
|
||||
"AVG(load15) AS load15, "+
|
||||
"CAST(AVG(tcp_conns) AS INTEGER) AS tcp_conns, "+
|
||||
"CAST(AVG(udp_conns) AS INTEGER) AS udp_conns, "+
|
||||
"CAST(MAX(uptime) AS INTEGER) AS uptime",
|
||||
bucketExpr,
|
||||
),
|
||||
nodeID,
|
||||
).
|
||||
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 +3468,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 +3615,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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 .`
|
||||
@@ -0,0 +1,19 @@
|
||||
# 013 - 隧道入口端口校验不严格修复
|
||||
|
||||
## Issue
|
||||
- GitHub Issue: [#373](https://github.com/Sagit-chu/flvx/issues/373)
|
||||
- 添加隧道入口时,端口校验不严格 - 默认端口可能不在新入口设置范围内
|
||||
|
||||
## 问题分析
|
||||
当已有隧道上添加新入口节点时,系统会使用既有转发的端口部署到新入口节点上,
|
||||
但该端口可能不在新入口节点设置的端口范围内,且没有校验提示。
|
||||
|
||||
### 根因
|
||||
`syncTunnelForwardsEntryPorts` 函数在同步入口端口时,直接复用了原有端口,
|
||||
没有检查该端口是否在新入口节点的允许范围内。
|
||||
|
||||
## 修复方案
|
||||
|
||||
- [x] 1. 新增 `isPortValidForAllEntryNodes` 辅助方法,校验端口是否在各节点范围内
|
||||
- [x] 2. 在 `syncTunnelForwardsEntryPorts` 中检测端口超范围时,通过 `pickTunnelPort` 自动随机分配新端口
|
||||
- [x] 3. 构建通过 + 全量测试通过
|
||||
@@ -1,15 +1,23 @@
|
||||
# Monitor Nezha Redesign
|
||||
|
||||
## Summary
|
||||
Redesign the monitor overview to resemble Nezha dashboard by introducing a Server Matrix (Grid) containing cards with metrics visualisations.
|
||||
Redesign the monitor overview to resemble Nezha dashboard. Phase 1 introduced ServerCard grid. Phase 2 hides node metrics from the overview and adds a detail view with charts and service monitor latency sparklines.
|
||||
|
||||
## Objectives
|
||||
- [x] Analyze `monitor-view.tsx` layout boundaries.
|
||||
- [x] Analyze `monitor-view.tsx` layout boundaries.
|
||||
- [x] Create `ServerCard` utilizing `Progress` component.
|
||||
- [x] Render metrics (CPU, RAM, Disk, System Load, Connections, Network speeeds).
|
||||
- [x] Render metrics (CPU, RAM, Disk, System Load, Connections, Network speeds).
|
||||
- [x] Implement robust real-time updates and seamless state linkage via existing hooks.
|
||||
- [x] Adjust layout positioning for impact at top-of-page.
|
||||
- [x] Hide node metrics from overview — click node card to enter detail view.
|
||||
- [x] Detail view: back button + node header + realtime KPI cards.
|
||||
- [x] Detail view: node metrics chart (CPU/Memory/Disk/Network/Load/Connections).
|
||||
- [x] Detail view: tunnel traffic chart.
|
||||
- [x] Detail view: service monitors rendered as latency sparkline charts (Nezha-style).
|
||||
- [x] Service monitor cards include status dot, type chip, target, interval, and dropdown actions.
|
||||
|
||||
## Technical Details
|
||||
Injected a `ServerCard` inline component mapping over unified node metric states. Used the tailwind and hero UI components matching visual designs like progress bars and small monospace typograhy. Extracted utility color scales.
|
||||
- Phase 1: Injected a `ServerCard` inline component with progress bars and real-time metrics.
|
||||
- Phase 2: Added `detailNodeId` state for drill-down navigation. Grid view shows only summary bar + clickable server cards. Detail view shows KPI summary cards, node metrics chart (reusing existing recharts setup), tunnel traffic chart, and service monitors as a responsive grid of cards each containing a latency-over-time sparkline chart. Reduced file from 2108 to 1799 lines by consolidating the old overview card into the summary bar.
|
||||
|
||||
## Status: Complete
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# 046 - Node Card Cleanup & Monitor Redesign
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Remove duplicate system metrics from node cards in `node.tsx` (CPU, memory, upload/download speed, upload/download traffic, disk, load)
|
||||
- [x] 2. Redesign monitor `ServerCard` in `monitor-view.tsx` to match the node card style from `node.tsx`
|
||||
- [x] 3. Make all charts in monitor view update incrementally (streaming) instead of full page reload
|
||||
@@ -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`).
|
||||
@@ -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`.
|
||||
@@ -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).
|
||||
@@ -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 编译通过验证
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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 编译通过
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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` 模式
|
||||
@@ -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秒上报"
|
||||
@@ -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`).
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 `v2.1.9-rc6`.
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"security-scan": {
|
||||
"source": "affaan-m/everything-claude-code",
|
||||
"sourceType": "github",
|
||||
"computedHash": "92cdcaddc554e318402f066ccc073c2e3dbcfda8c2730ec62ec373f805c41a57"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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[]>(
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 {
|
||||
@@ -979,6 +980,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">
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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,889 @@
|
||||
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,
|
||||
} 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 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",
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
/** Render a colored latency value with appropriate visual cue */
|
||||
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>;
|
||||
}
|
||||
|
||||
/** Animated pulse dot for live status */
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Module-level constants & memoized sub-components ────────────── */
|
||||
|
||||
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);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadTunnels();
|
||||
}, [loadTunnels]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
void loadTunnels({ silent: true });
|
||||
}, 60_000);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [loadTunnels]);
|
||||
|
||||
// --- Initial history load ---
|
||||
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(() => {
|
||||
void loadQuality();
|
||||
}, [loadQuality]);
|
||||
|
||||
useEffect(() => {
|
||||
qualityTimerRef.current = window.setInterval(() => {
|
||||
void loadQuality({ silent: true });
|
||||
}, QUALITY_POLL_INTERVAL);
|
||||
|
||||
return () => {
|
||||
if (qualityTimerRef.current) {
|
||||
window.clearInterval(qualityTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [loadQuality]);
|
||||
|
||||
// --- 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 loadQualityHistory(detailTunnelId);
|
||||
void loadTunnelMetrics(detailTunnelId);
|
||||
}
|
||||
}, [detailTunnelId, loadQualityHistory, loadTunnelMetrics]);
|
||||
|
||||
// Auto-refresh detail charts
|
||||
useEffect(() => {
|
||||
if (!detailTunnelId) return;
|
||||
const timer = window.setInterval(() => {
|
||||
void loadQualityHistory(detailTunnelId, { silent: true });
|
||||
void loadTunnelMetrics(detailTunnelId, { silent: true });
|
||||
}, 30_000);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [detailTunnelId, loadQualityHistory, loadTunnelMetrics]);
|
||||
|
||||
// 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]);
|
||||
|
||||
// =====================
|
||||
// RENDER
|
||||
// =====================
|
||||
|
||||
if (accessDenied) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center gap-2">
|
||||
<Activity className="w-5 h-5 text-warning" />
|
||||
<h3 className="text-lg font-semibold">监控权限</h3>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="text-sm text-default-600">{accessDenied}</div>
|
||||
<div className="text-xs text-default-500 mt-2">
|
||||
如需使用监控功能,请联系管理员在用户页面授予监控权限。
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== DETAIL VIEW =====
|
||||
if (detailTunnelId && detailTunnel) {
|
||||
const quality = 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">
|
||||
<LiveDot />
|
||||
<span>自动探测中(每秒测试,30秒上报)</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">
|
||||
<LiveDot />
|
||||
<span>每秒探测 · 更新于 {lastQualityUpdate}</span>
|
||||
</div>
|
||||
)}
|
||||
<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">
|
||||
<LiveDot />
|
||||
{new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-default-400">等待探测...</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">
|
||||
<LiveDot />
|
||||
{new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-default-400">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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">
|
||||
|
||||
+365
-177
@@ -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>
|
||||
|
||||
@@ -1,62 +1,43 @@
|
||||
import * as React from "react";
|
||||
/**
|
||||
* useTheme — backwards-compatible hook
|
||||
* =====================================
|
||||
* Wraps the new theme system's context to provide the same API that the
|
||||
* rest of the codebase already expects: `{ theme, setTheme }`.
|
||||
*
|
||||
* For full theme system access, use `useThemeContext` from "@/themes".
|
||||
*/
|
||||
|
||||
type ThemeMode = "light" | "dark";
|
||||
import { useSyncExternalStore, useCallback } from "react";
|
||||
|
||||
const STORAGE_KEY = "flvx:theme";
|
||||
import {
|
||||
subscribe,
|
||||
getSavedMode,
|
||||
getEffectiveMode,
|
||||
saveMode,
|
||||
reapplyActiveTheme,
|
||||
type ThemeMode,
|
||||
} from "@/themes/registry";
|
||||
|
||||
function resolveInitialTheme(): ThemeMode {
|
||||
if (typeof window === "undefined") {
|
||||
return "light";
|
||||
}
|
||||
|
||||
const fromStorage = window.localStorage.getItem(STORAGE_KEY);
|
||||
|
||||
if (fromStorage === "dark" || fromStorage === "light") {
|
||||
return fromStorage;
|
||||
}
|
||||
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
|
||||
let currentTheme: ThemeMode = resolveInitialTheme();
|
||||
const listeners = new Set<(theme: ThemeMode) => void>();
|
||||
|
||||
function broadcast(theme: ThemeMode) {
|
||||
currentTheme = theme;
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(STORAGE_KEY, theme);
|
||||
}
|
||||
listeners.forEach((listener) => {
|
||||
listener(theme);
|
||||
// Monotonic counter for snapshot identity
|
||||
let _rev = 0;
|
||||
const _sub = (cb: () => void) =>
|
||||
subscribe(() => {
|
||||
_rev++;
|
||||
cb();
|
||||
});
|
||||
}
|
||||
const _snap = () => _rev;
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setThemeState] = React.useState<ThemeMode>(currentTheme);
|
||||
useSyncExternalStore(_sub, _snap);
|
||||
|
||||
React.useEffect(() => {
|
||||
const listener = (nextTheme: ThemeMode) => {
|
||||
setThemeState(nextTheme);
|
||||
};
|
||||
const theme = getEffectiveMode();
|
||||
const mode = getSavedMode();
|
||||
|
||||
listeners.add(listener);
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
const setTheme = useCallback((next: string) => {
|
||||
if (next !== "dark" && next !== "light" && next !== "system") return;
|
||||
saveMode(next as ThemeMode);
|
||||
reapplyActiveTheme();
|
||||
}, []);
|
||||
|
||||
const setTheme = React.useCallback((nextTheme: string) => {
|
||||
if (nextTheme !== "dark" && nextTheme !== "light") {
|
||||
return;
|
||||
}
|
||||
broadcast(nextTheme);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
setTheme,
|
||||
theme,
|
||||
};
|
||||
return { theme, mode, setTheme };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
@theme inline {
|
||||
--font-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-border: var(--border);
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
# FLVX 主题开发指南
|
||||
|
||||
## 概述
|
||||
|
||||
FLVX 主题系统允许你完全自定义前端的外观和行为。一个主题可以覆盖:
|
||||
|
||||
| 覆盖层级 | 说明 | 难度 |
|
||||
|----------|------|------|
|
||||
| **CSS 变量** | 修改颜色、字体、圆角等设计 token | ⭐ 简单 |
|
||||
| **原始 CSS** | 注入自定义 CSS(动画、字体、阴影等) | ⭐⭐ 中等 |
|
||||
| **组件替换** | 替换任意 UI 组件(按钮、卡片、输入框等) | ⭐⭐⭐ 高级 |
|
||||
| **布局替换** | 替换整个页面布局结构 | ⭐⭐⭐ 高级 |
|
||||
| **页面替换** | 替换整个页面实现 | ⭐⭐⭐⭐ 专家 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 创建主题文件夹
|
||||
|
||||
```
|
||||
src/themes/my-theme/
|
||||
├── index.ts ← 必须:主题入口,导出 ThemePackage
|
||||
├── components/ ← 可选:组件覆盖
|
||||
│ ├── index.ts
|
||||
│ └── button.tsx
|
||||
├── layouts/ ← 可选:布局覆盖
|
||||
│ └── admin.tsx
|
||||
├── pages/ ← 可选:页面覆盖
|
||||
│ └── login.tsx
|
||||
├── assets/ ← 可选:图片、字体等资源
|
||||
└── styles.css ← 可选:额外样式文件
|
||||
```
|
||||
|
||||
### 2. 编写主题入口 `index.ts`
|
||||
|
||||
```typescript
|
||||
import type { ThemePackage } from "../types";
|
||||
|
||||
const myTheme: ThemePackage = {
|
||||
id: "my-theme", // 唯一标识(kebab-case)
|
||||
name: "我的主题", // 显示名称
|
||||
author: "Your Name", // 作者
|
||||
version: "1.0.0", // 版本号
|
||||
description: "一个自定义主题",
|
||||
|
||||
// CSS 变量覆盖
|
||||
tokens: {
|
||||
light: {
|
||||
"--primary": "#ff6600",
|
||||
"--primary-foreground": "#ffffff",
|
||||
"--background": "#fafafa",
|
||||
},
|
||||
dark: {
|
||||
"--primary": "#ff8833",
|
||||
"--background": "#1a1a2e",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default myTheme;
|
||||
```
|
||||
|
||||
### 3. 注册主题
|
||||
|
||||
打开 `src/themes/loader.ts`,添加两行:
|
||||
|
||||
```typescript
|
||||
import myTheme from "./my-theme";
|
||||
registerTheme(myTheme);
|
||||
```
|
||||
|
||||
完成!主题已可用。
|
||||
|
||||
---
|
||||
|
||||
## 详细指南
|
||||
|
||||
### CSS 变量覆盖
|
||||
|
||||
所有可用的 CSS 变量定义在 `src/themes/types.ts` 的 `ThemeTokens` 接口中。常用的:
|
||||
|
||||
```typescript
|
||||
tokens: {
|
||||
light: {
|
||||
// 基础色
|
||||
"--background": "#ffffff", // 页面背景
|
||||
"--foreground": "#000000", // 文字颜色
|
||||
"--border": "#e5e7eb", // 边框颜色
|
||||
"--content1": "#ffffff", // 卡片背景
|
||||
|
||||
// 品牌色
|
||||
"--primary": "#2563eb", // 主色
|
||||
"--primary-foreground": "#fff", // 主色上的文字
|
||||
"--secondary": "#6366f1", // 辅色
|
||||
|
||||
// 状态色
|
||||
"--danger": "#dc2626",
|
||||
"--success": "#16a34a",
|
||||
"--warning": "#d97706",
|
||||
|
||||
// 每种品牌色都有 50-900 共 10 级色阶
|
||||
"--primary-50": "#eff6ff", // 最浅
|
||||
"--primary-500": "#3b82f6", // 中间
|
||||
"--primary-900": "#1e3a8a", // 最深
|
||||
|
||||
// 字体
|
||||
"--font-sans": '"Inter", sans-serif',
|
||||
"--font-mono": '"Fira Code", monospace',
|
||||
|
||||
// 圆角
|
||||
"--radius": "0.5rem",
|
||||
},
|
||||
dark: {
|
||||
// 暗色模式下的覆盖...
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
> **提示**: 你不需要定义所有变量,只定义你想修改的,其余沿用默认值。
|
||||
|
||||
### 原始 CSS 注入
|
||||
|
||||
`css` 字段可以注入任意 CSS。主题激活时会插入一个 `<style>` 标签,停用时自动移除。
|
||||
|
||||
```typescript
|
||||
const theme: ThemePackage = {
|
||||
// ...
|
||||
css: `
|
||||
/* 自定义字体 */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC&display=swap');
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans SC', sans-serif;
|
||||
}
|
||||
|
||||
/* 自定义动画 */
|
||||
@keyframes my-fade-in {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* 给所有卡片加阴影 */
|
||||
.rounded-xl, .rounded-lg {
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
::-webkit-scrollbar { width: 8px; }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--primary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
`,
|
||||
};
|
||||
```
|
||||
|
||||
### 组件替换
|
||||
|
||||
可以替换任意 UI 组件。替换组件**必须接受与原组件相同的 props**。
|
||||
|
||||
#### 可替换的组件列表
|
||||
|
||||
| 组件键名 | 原始位置 | 说明 |
|
||||
|----------|----------|------|
|
||||
| `Button` | `shadcn-bridge/heroui/button` | 按钮 |
|
||||
| `Card`, `CardHeader`, `CardBody`, `CardFooter` | `shadcn-bridge/heroui/card` | 卡片 |
|
||||
| `Input` | `shadcn-bridge/heroui/input` | 输入框 |
|
||||
| `Select`, `SelectItem` | `shadcn-bridge/heroui/select` | 下拉选择 |
|
||||
| `Switch` | `shadcn-bridge/heroui/switch` | 开关 |
|
||||
| `Checkbox` | `shadcn-bridge/heroui/checkbox` | 复选框 |
|
||||
| `Chip` | `shadcn-bridge/heroui/chip` | 标签/芯片 |
|
||||
| `Modal`, `ModalContent`, `ModalHeader`, `ModalBody`, `ModalFooter` | `shadcn-bridge/heroui/modal` | 模态框 |
|
||||
| `Table`, `TableHeader`, `TableBody`, `TableRow`, `TableCell`, `TableColumn` | `shadcn-bridge/heroui/table` | 表格 |
|
||||
| `Tabs`, `Tab` | `shadcn-bridge/heroui/tabs` | 标签页 |
|
||||
| `Progress` | `shadcn-bridge/heroui/progress` | 进度条 |
|
||||
| `Spinner` | `shadcn-bridge/heroui/spinner` | 加载指示器 |
|
||||
| `Divider` | `shadcn-bridge/heroui/divider` | 分割线 |
|
||||
| `Link` | `shadcn-bridge/heroui/link` | 链接 |
|
||||
| `Dropdown`, `DropdownTrigger`, `DropdownMenu`, `DropdownItem` | `shadcn-bridge/heroui/dropdown` | 下拉菜单 |
|
||||
| `Navbar`, `NavbarContent`, `NavbarItem` | `shadcn-bridge/heroui/navbar` | 导航栏 |
|
||||
| `Radio`, `RadioGroup` | `shadcn-bridge/heroui/radio` | 单选框 |
|
||||
| `Accordion`, `AccordionItem` | `shadcn-bridge/heroui/accordion` | 手风琴 |
|
||||
| `DatePicker` | `shadcn-bridge/heroui/date-picker` | 日期选择器 |
|
||||
| `Alert` | `shadcn-bridge/heroui/alert` | 警告提示 |
|
||||
| `SearchBar` | `components/search-bar` | 搜索栏 |
|
||||
| `BrandLogo` | `components/brand-logo` | 品牌 Logo |
|
||||
| `VersionFooter` | `components/version-footer` | 版本页脚 |
|
||||
|
||||
#### 组件替换示例
|
||||
|
||||
**方式一:包装原组件**(推荐,保证兼容性)
|
||||
|
||||
```typescript
|
||||
// src/themes/my-theme/components/button.tsx
|
||||
import React from "react";
|
||||
import type { ButtonProps } from "@/shadcn-bridge/heroui/button";
|
||||
import { Button as OriginalButton } from "@/shadcn-bridge/heroui/button";
|
||||
|
||||
export const MyButton: React.FC<ButtonProps> = (props) => {
|
||||
return (
|
||||
<OriginalButton
|
||||
{...props}
|
||||
className={`${props.className || ""} my-custom-class`}
|
||||
style={{
|
||||
...props.style,
|
||||
borderRadius: "9999px", // 全圆角
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
**方式二:完全重写组件**
|
||||
|
||||
```typescript
|
||||
// src/themes/my-theme/components/button.tsx
|
||||
import React from "react";
|
||||
import type { ButtonProps } from "@/shadcn-bridge/heroui/button";
|
||||
|
||||
export const MyButton: React.FC<ButtonProps> = ({
|
||||
children,
|
||||
color = "default",
|
||||
variant = "solid",
|
||||
size = "md",
|
||||
isLoading,
|
||||
isDisabled,
|
||||
onPress,
|
||||
className,
|
||||
...rest
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
className={`my-totally-custom-button ${className || ""}`}
|
||||
disabled={isDisabled || isLoading}
|
||||
onClick={() => onPress?.()}
|
||||
{...rest}
|
||||
>
|
||||
{isLoading && <span className="spinner" />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
然后在主题入口中注册:
|
||||
|
||||
```typescript
|
||||
// src/themes/my-theme/index.ts
|
||||
import { MyButton } from "./components/button";
|
||||
|
||||
const theme: ThemePackage = {
|
||||
// ...
|
||||
components: {
|
||||
Button: MyButton,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 布局替换
|
||||
|
||||
可以替换 4 种布局:
|
||||
|
||||
| 布局键名 | 说明 |
|
||||
|----------|------|
|
||||
| `AdminLayout` | 管理后台主布局(侧边栏 + 顶栏) |
|
||||
| `H5Layout` | 移动端布局(底部导航) |
|
||||
| `H5SimpleLayout` | 移动端简洁布局(无底部导航) |
|
||||
| `DefaultLayout` | 默认布局(登录页等) |
|
||||
|
||||
```typescript
|
||||
// src/themes/my-theme/layouts/admin.tsx
|
||||
import React from "react";
|
||||
|
||||
const MyAdminLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
<div className="my-admin-layout">
|
||||
<header className="my-header">
|
||||
{/* 自定义顶栏 */}
|
||||
</header>
|
||||
<aside className="my-sidebar">
|
||||
{/* 自定义侧边栏 */}
|
||||
</aside>
|
||||
<main className="my-content">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyAdminLayout;
|
||||
```
|
||||
|
||||
### 页面替换
|
||||
|
||||
可以替换任意页面路由的实现:
|
||||
|
||||
| 页面键名 | 路由 |
|
||||
|----------|------|
|
||||
| `LoginPage` | `/` |
|
||||
| `DashboardPage` | `/dashboard` |
|
||||
| `MonitorPage` | `/monitor` |
|
||||
| `ForwardPage` | `/forward` |
|
||||
| `TunnelPage` | `/tunnel` |
|
||||
| `NodePage` | `/node` |
|
||||
| `UserPage` | `/user` |
|
||||
| `GroupPage` | `/group` |
|
||||
| `ProfilePage` | `/profile` |
|
||||
| `LimitPage` | `/limit` |
|
||||
| `ConfigPage` | `/config` |
|
||||
| `PanelSharingPage` | `/panel-sharing` |
|
||||
| `SettingsPage` | `/settings` |
|
||||
|
||||
### 生命周期钩子
|
||||
|
||||
```typescript
|
||||
const theme: ThemePackage = {
|
||||
// ...
|
||||
onActivate: () => {
|
||||
// 主题被激活时执行
|
||||
// 例如:加载外部字体、注入全局属性
|
||||
console.log("Theme activated!");
|
||||
},
|
||||
onDeactivate: () => {
|
||||
// 主题被停用时执行
|
||||
// 例如:清理全局属性
|
||||
console.log("Theme deactivated!");
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 主题提交流程
|
||||
|
||||
1. Fork 本仓库
|
||||
2. 在 `src/themes/` 下创建你的主题文件夹
|
||||
3. 在 `src/themes/loader.ts` 中注册
|
||||
4. 提交 Pull Request
|
||||
|
||||
### 命名规范
|
||||
|
||||
- 文件夹名:`kebab-case`(如 `my-awesome-theme`)
|
||||
- 主题 `id`:与文件夹名一致
|
||||
- 主题 `name`:简短中文名
|
||||
|
||||
### 代码规范
|
||||
|
||||
- TypeScript 严格模式
|
||||
- 组件替换必须保证 props 兼容性
|
||||
- 不得修改 `src/themes/types.ts`(影响其他主题)
|
||||
- 不得修改 `src/themes/registry.ts`(影响核心逻辑)
|
||||
- 仅修改你自己的主题文件夹 + `loader.ts` 中的注册
|
||||
|
||||
---
|
||||
|
||||
## 文件结构参考
|
||||
|
||||
```
|
||||
src/themes/
|
||||
├── types.ts # 主题接口定义 (勿改)
|
||||
├── registry.ts # 主题注册表 (勿改)
|
||||
├── context.tsx # React Context (勿改)
|
||||
├── index.ts # 公共 API (勿改)
|
||||
├── loader.ts # 主题加载器 (仅在此添加注册)
|
||||
│
|
||||
├── default/ # 默认主题 (参考实现)
|
||||
│ └── index.ts
|
||||
│
|
||||
├── example-cyberpunk/ # 示例主题 (可复制修改)
|
||||
│ ├── index.ts
|
||||
│ └── components/
|
||||
│ └── button.tsx
|
||||
│
|
||||
└── your-theme/ # 你的主题
|
||||
├── index.ts
|
||||
├── components/
|
||||
│ ├── button.tsx
|
||||
│ └── card.tsx
|
||||
├── layouts/
|
||||
│ └── admin.tsx
|
||||
└── assets/
|
||||
└── logo.svg
|
||||
```
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Theme Context + Provider (React integration)
|
||||
* =============================================
|
||||
* Wraps the registry in a React context so that the entire component tree
|
||||
* re-renders when the active theme changes.
|
||||
*/
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useSyncExternalStore,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from "react";
|
||||
|
||||
import type { ThemePackage, ComponentKey, LayoutKey, PageKey } from "./types";
|
||||
import {
|
||||
subscribe,
|
||||
getActiveTheme,
|
||||
getActiveThemeId,
|
||||
getRegisteredThemes,
|
||||
activateTheme,
|
||||
deactivateTheme,
|
||||
reapplyActiveTheme,
|
||||
resolveComponent,
|
||||
resolveLayout,
|
||||
resolvePage,
|
||||
getSavedMode,
|
||||
saveMode,
|
||||
getEffectiveMode,
|
||||
registerTheme,
|
||||
unregisterTheme,
|
||||
type ThemeMode,
|
||||
} from "./registry";
|
||||
|
||||
// ─── context value ───────────────────────────────────────────────────────────
|
||||
|
||||
interface ThemeContextValue {
|
||||
/** Currently active theme package (null = default). */
|
||||
activeTheme: ThemePackage | null;
|
||||
activeThemeId: string | null;
|
||||
/** All registered themes. */
|
||||
themes: ThemePackage[];
|
||||
/** Current mode preference. */
|
||||
mode: ThemeMode;
|
||||
/** Resolved effective mode (never "system"). */
|
||||
effectiveMode: "light" | "dark";
|
||||
|
||||
/** Switch the active theme. */
|
||||
switchTheme: (id: string) => void;
|
||||
/** Reset to no custom theme (use defaults). */
|
||||
resetTheme: () => void;
|
||||
/** Change mode preference. */
|
||||
setMode: (mode: ThemeMode) => void;
|
||||
/** Register a new theme at runtime. */
|
||||
register: (pkg: ThemePackage) => void;
|
||||
/** Unregister a theme by id. */
|
||||
unregister: (id: string) => void;
|
||||
|
||||
/** Resolve a component (returns themed override or fallback). */
|
||||
component: <P = any>(key: ComponentKey, fallback: React.ComponentType<P>) => React.ComponentType<P>;
|
||||
/** Resolve a layout. */
|
||||
layout: (key: LayoutKey, fallback: React.ComponentType<{ children: React.ReactNode }>) => React.ComponentType<{ children: React.ReactNode }>;
|
||||
/** Resolve a page. */
|
||||
page: <P = any>(key: PageKey, fallback: React.ComponentType<P>) => React.ComponentType<P>;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
// ─── snapshot for useSyncExternalStore ────────────────────────────────────────
|
||||
|
||||
// We use a monotonic counter to create new snapshot references when the
|
||||
// registry notifies.
|
||||
let snapshotCounter = 0;
|
||||
function getSnapshot() {
|
||||
return snapshotCounter;
|
||||
}
|
||||
const originalSubscribe = (onStoreChange: () => void) => {
|
||||
const unsub = subscribe(() => {
|
||||
snapshotCounter++;
|
||||
onStoreChange();
|
||||
});
|
||||
return unsub;
|
||||
};
|
||||
|
||||
// ─── provider ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
// Re-render whenever register changes
|
||||
useSyncExternalStore(originalSubscribe, getSnapshot);
|
||||
|
||||
// Listen for system mode changes
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handler = () => reapplyActiveTheme();
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
const switchTheme = useCallback((id: string) => activateTheme(id), []);
|
||||
const resetTheme = useCallback(() => {
|
||||
deactivateTheme();
|
||||
reapplyActiveTheme();
|
||||
localStorage.removeItem("flvx:active-theme");
|
||||
}, []);
|
||||
const setMode = useCallback((m: ThemeMode) => {
|
||||
saveMode(m);
|
||||
reapplyActiveTheme();
|
||||
}, []);
|
||||
const register = useCallback((pkg: ThemePackage) => registerTheme(pkg), []);
|
||||
const unregister = useCallback((id: string) => unregisterTheme(id), []);
|
||||
|
||||
const value = useMemo<ThemeContextValue>(() => ({
|
||||
activeTheme: getActiveTheme(),
|
||||
activeThemeId: getActiveThemeId(),
|
||||
themes: getRegisteredThemes(),
|
||||
mode: getSavedMode(),
|
||||
effectiveMode: getEffectiveMode(),
|
||||
switchTheme,
|
||||
resetTheme,
|
||||
setMode,
|
||||
register,
|
||||
unregister,
|
||||
component: resolveComponent,
|
||||
layout: resolveLayout,
|
||||
page: resolvePage,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}), [snapshotCounter, switchTheme, resetTheme, setMode, register, unregister]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={value}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── hooks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Access the full theme context. */
|
||||
export function useThemeContext(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error("useThemeContext must be used within <ThemeProvider>");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: resolve a single themed component.
|
||||
*
|
||||
* ```tsx
|
||||
* import { useThemedComponent } from "@/themes/context";
|
||||
* import { Button as DefaultButton } from "@/shadcn-bridge/heroui/button";
|
||||
*
|
||||
* function MyPage() {
|
||||
* const Button = useThemedComponent("Button", DefaultButton);
|
||||
* return <Button color="primary">Click</Button>;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useThemedComponent<P = any>(
|
||||
key: ComponentKey,
|
||||
fallback: React.ComponentType<P>,
|
||||
): React.ComponentType<P> {
|
||||
const ctx = useThemeContext();
|
||||
return ctx.component(key, fallback);
|
||||
}
|
||||
|
||||
/** Convenience: resolve a themed layout. */
|
||||
export function useThemedLayout(
|
||||
key: LayoutKey,
|
||||
fallback: React.ComponentType<{ children: React.ReactNode }>,
|
||||
): React.ComponentType<{ children: React.ReactNode }> {
|
||||
const ctx = useThemeContext();
|
||||
return ctx.layout(key, fallback);
|
||||
}
|
||||
|
||||
/** Convenience: resolve a themed page. */
|
||||
export function useThemedPage<P = any>(
|
||||
key: PageKey,
|
||||
fallback: React.ComponentType<P>,
|
||||
): React.ComponentType<P> {
|
||||
const ctx = useThemeContext();
|
||||
return ctx.page(key, fallback);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Default Theme
|
||||
* =============
|
||||
* This is the built-in "stock" FLVX theme. It doesn't override any
|
||||
* components — it only declares the CSS tokens that match the colours
|
||||
* already defined in `globals.css`. This serves as the **reference
|
||||
* implementation** that theme authors can copy and modify.
|
||||
*
|
||||
* When this theme is active, the frontend looks identical to an
|
||||
* unmodified FLVX install.
|
||||
*/
|
||||
|
||||
import type { ThemePackage } from "../types";
|
||||
|
||||
const defaultTheme: ThemePackage = {
|
||||
id: "default",
|
||||
name: "默认主题",
|
||||
author: "FLVX Team",
|
||||
version: "1.0.0",
|
||||
description: "FLVX 内置默认蓝色主题",
|
||||
|
||||
tokens: {
|
||||
light: {
|
||||
"--background": "#f6f7fb",
|
||||
"--foreground": "#111827",
|
||||
"--border": "#e5e7eb",
|
||||
"--input": "#d1d5db",
|
||||
"--ring": "#93c5fd",
|
||||
"--content1": "#ffffff",
|
||||
"--divider": "#e5e7eb",
|
||||
|
||||
"--default-50": "#f9fafb",
|
||||
"--default-100": "#f3f4f6",
|
||||
"--default-200": "#e5e7eb",
|
||||
"--default-300": "#d1d5db",
|
||||
"--default-400": "#9ca3af",
|
||||
"--default-500": "#6b7280",
|
||||
"--default-600": "#4b5563",
|
||||
"--default-700": "#374151",
|
||||
"--default-800": "#1f2937",
|
||||
"--default-900": "#111827",
|
||||
|
||||
"--primary": "#2563eb",
|
||||
"--primary-foreground": "#ffffff",
|
||||
"--primary-50": "#eff6ff",
|
||||
"--primary-100": "#dbeafe",
|
||||
"--primary-200": "#bfdbfe",
|
||||
"--primary-300": "#93c5fd",
|
||||
"--primary-400": "#60a5fa",
|
||||
"--primary-500": "#3b82f6",
|
||||
"--primary-600": "#2563eb",
|
||||
"--primary-700": "#1d4ed8",
|
||||
"--primary-800": "#1e40af",
|
||||
"--primary-900": "#1e3a8a",
|
||||
|
||||
"--secondary": "#6366f1",
|
||||
"--secondary-foreground": "#ffffff",
|
||||
"--secondary-50": "#eef2ff",
|
||||
"--secondary-100": "#e0e7ff",
|
||||
"--secondary-200": "#c7d2fe",
|
||||
"--secondary-300": "#a5b4fc",
|
||||
"--secondary-400": "#818cf8",
|
||||
"--secondary-500": "#6366f1",
|
||||
"--secondary-600": "#4f46e5",
|
||||
"--secondary-700": "#4338ca",
|
||||
"--secondary-800": "#3730a3",
|
||||
"--secondary-900": "#312e81",
|
||||
|
||||
"--danger": "#dc2626",
|
||||
"--danger-50": "#fef2f2",
|
||||
"--danger-100": "#fee2e2",
|
||||
"--danger-200": "#fecaca",
|
||||
"--danger-300": "#fca5a5",
|
||||
"--danger-400": "#f87171",
|
||||
"--danger-500": "#ef4444",
|
||||
"--danger-600": "#dc2626",
|
||||
"--danger-700": "#b91c1c",
|
||||
"--danger-800": "#991b1b",
|
||||
"--danger-900": "#7f1d1d",
|
||||
|
||||
"--success": "#16a34a",
|
||||
"--success-50": "#f0fdf4",
|
||||
"--success-100": "#dcfce7",
|
||||
"--success-200": "#bbf7d0",
|
||||
"--success-300": "#86efac",
|
||||
"--success-400": "#4ade80",
|
||||
"--success-500": "#22c55e",
|
||||
"--success-600": "#16a34a",
|
||||
"--success-700": "#15803d",
|
||||
"--success-800": "#166534",
|
||||
"--success-900": "#14532d",
|
||||
|
||||
"--warning": "#d97706",
|
||||
"--warning-50": "#fffbeb",
|
||||
"--warning-100": "#fef3c7",
|
||||
"--warning-200": "#fde68a",
|
||||
"--warning-300": "#fcd34d",
|
||||
"--warning-400": "#fbbf24",
|
||||
"--warning-500": "#f59e0b",
|
||||
"--warning-600": "#d97706",
|
||||
"--warning-700": "#b45309",
|
||||
"--warning-800": "#92400e",
|
||||
"--warning-900": "#78350f",
|
||||
},
|
||||
|
||||
dark: {
|
||||
"--background": "#0b1020",
|
||||
"--foreground": "#f3f4f6",
|
||||
"--border": "#334155",
|
||||
"--input": "#475569",
|
||||
"--ring": "#60a5fa",
|
||||
"--content1": "#111827",
|
||||
"--divider": "#334155",
|
||||
|
||||
"--default-50": "#0f172a",
|
||||
"--default-100": "#1e293b",
|
||||
"--default-200": "#334155",
|
||||
"--default-300": "#475569",
|
||||
"--default-400": "#64748b",
|
||||
"--default-500": "#94a3b8",
|
||||
"--default-600": "#cbd5e1",
|
||||
"--default-700": "#e2e8f0",
|
||||
"--default-800": "#f1f5f9",
|
||||
"--default-900": "#f8fafc",
|
||||
|
||||
"--primary": "#3b82f6",
|
||||
"--secondary": "#818cf8",
|
||||
"--danger": "#ef4444",
|
||||
"--success": "#22c55e",
|
||||
"--warning": "#f59e0b",
|
||||
},
|
||||
},
|
||||
|
||||
// No component/layout/page overrides — uses all defaults.
|
||||
};
|
||||
|
||||
export default defaultTheme;
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Cyberpunk Button — Component Override Example
|
||||
* ==============================================
|
||||
* Demonstrates how to override a built-in component.
|
||||
*
|
||||
* Rules:
|
||||
* 1. Accept the SAME props as the original component.
|
||||
* 2. Import the original's props type for compatibility.
|
||||
* 3. You CAN wrap the original component and add extra behaviour,
|
||||
* or you can build a completely new component from scratch.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
// Import the original Button's props interface for compatibility
|
||||
import type { ButtonProps } from "@/shadcn-bridge/heroui/button";
|
||||
// Optionally import the original to wrap it
|
||||
import { Button as OriginalButton } from "@/shadcn-bridge/heroui/button";
|
||||
|
||||
/**
|
||||
* CyberpunkButton wraps the original Button and adds a neon glow effect.
|
||||
* It passes all props through, so it's a full drop-in replacement.
|
||||
*/
|
||||
export const CyberpunkButton: React.FC<ButtonProps> = (props) => {
|
||||
const { className = "", style, color, ...rest } = props;
|
||||
|
||||
// Add neon glow based on color
|
||||
const glowColor =
|
||||
color === "danger"
|
||||
? "rgba(255, 51, 102, 0.5)"
|
||||
: color === "success"
|
||||
? "rgba(0, 255, 136, 0.5)"
|
||||
: color === "warning"
|
||||
? "rgba(255, 170, 0, 0.5)"
|
||||
: color === "secondary"
|
||||
? "rgba(0, 255, 255, 0.5)"
|
||||
: "rgba(255, 0, 255, 0.5)";
|
||||
|
||||
const glowStyle: React.CSSProperties = {
|
||||
...style,
|
||||
boxShadow: `0 0 8px ${glowColor}, 0 0 16px ${glowColor}`,
|
||||
transition: "box-shadow 0.3s ease, transform 0.15s ease",
|
||||
textTransform: "uppercase" as const,
|
||||
letterSpacing: "0.05em",
|
||||
};
|
||||
|
||||
return (
|
||||
<OriginalButton
|
||||
className={`${className} cyberpunk-btn`}
|
||||
color={color}
|
||||
style={glowStyle}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Example Theme: Cyberpunk
|
||||
* ========================
|
||||
* A neon-cyberpunk dark theme that demonstrates ALL override capabilities:
|
||||
* ✅ CSS tokens (full dark palette)
|
||||
* ✅ Raw CSS (neon glow effects, custom animations, font-face)
|
||||
* ✅ Component override (custom Button with glow)
|
||||
* ✅ Lifecycle hooks
|
||||
*
|
||||
* Theme authors: copy this entire folder and modify it to create your own
|
||||
* theme. See README.md for the full guide.
|
||||
*/
|
||||
|
||||
import type { ThemePackage } from "../types";
|
||||
import { CyberpunkButton } from "./components/button";
|
||||
|
||||
const cyberpunkTheme: ThemePackage = {
|
||||
id: "cyberpunk",
|
||||
name: "赛博朋克",
|
||||
author: "FLVX Community",
|
||||
version: "1.0.0",
|
||||
description: "霓虹灯风格的赛博朋克暗色主题",
|
||||
|
||||
// ── CSS Tokens ────────────────────────────────────────────────────────────
|
||||
tokens: {
|
||||
light: {
|
||||
// This theme is dark-only, so the light tokens just fall through to dark
|
||||
"--background": "#0a0a1a",
|
||||
"--foreground": "#e0e0ff",
|
||||
"--border": "#2a2a4a",
|
||||
"--input": "#1a1a3a",
|
||||
"--ring": "#ff00ff",
|
||||
"--content1": "#12122a",
|
||||
"--divider": "#2a2a4a",
|
||||
|
||||
"--primary": "#ff00ff",
|
||||
"--primary-foreground": "#ffffff",
|
||||
"--secondary": "#00ffff",
|
||||
"--secondary-foreground": "#000000",
|
||||
"--danger": "#ff3366",
|
||||
"--success": "#00ff88",
|
||||
"--warning": "#ffaa00",
|
||||
},
|
||||
dark: {
|
||||
"--background": "#0a0a1a",
|
||||
"--foreground": "#e0e0ff",
|
||||
"--border": "#2a2a4a",
|
||||
"--input": "#1a1a3a",
|
||||
"--ring": "#ff00ff",
|
||||
"--content1": "#12122a",
|
||||
"--divider": "#2a2a4a",
|
||||
|
||||
"--default-50": "#0d0d20",
|
||||
"--default-100": "#14142e",
|
||||
"--default-200": "#1e1e3c",
|
||||
"--default-300": "#2a2a4a",
|
||||
"--default-400": "#4a4a6a",
|
||||
"--default-500": "#7a7a9a",
|
||||
"--default-600": "#9a9aba",
|
||||
"--default-700": "#babada",
|
||||
"--default-800": "#dadaf0",
|
||||
"--default-900": "#f0f0ff",
|
||||
|
||||
"--primary": "#ff00ff",
|
||||
"--primary-foreground": "#ffffff",
|
||||
"--primary-50": "#1a001a",
|
||||
"--primary-100": "#330033",
|
||||
"--primary-200": "#660066",
|
||||
"--primary-300": "#990099",
|
||||
"--primary-400": "#cc00cc",
|
||||
"--primary-500": "#ff00ff",
|
||||
"--primary-600": "#ff33ff",
|
||||
"--primary-700": "#ff66ff",
|
||||
"--primary-800": "#ff99ff",
|
||||
"--primary-900": "#ffccff",
|
||||
|
||||
"--secondary": "#00ffff",
|
||||
"--secondary-foreground": "#000000",
|
||||
|
||||
"--danger": "#ff3366",
|
||||
"--success": "#00ff88",
|
||||
"--warning": "#ffaa00",
|
||||
},
|
||||
},
|
||||
|
||||
// ── Raw CSS (glow effects, animations, fonts) ─────────────────────────────
|
||||
css: `
|
||||
/* Cyberpunk neon glow on primary buttons */
|
||||
[data-flvx-theme="cyberpunk"] .bg-primary,
|
||||
.bg-primary {
|
||||
box-shadow: 0 0 12px rgba(255, 0, 255, 0.4),
|
||||
0 0 24px rgba(255, 0, 255, 0.15);
|
||||
}
|
||||
|
||||
/* Neon border glow on cards */
|
||||
[data-flvx-theme="cyberpunk"] [class*="border"] {
|
||||
border-color: rgba(255, 0, 255, 0.15);
|
||||
}
|
||||
|
||||
/* Scanline overlay animation */
|
||||
@keyframes flvx-scanline {
|
||||
0% { transform: translateY(-100%); }
|
||||
100% { transform: translateY(100vh); }
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: #0a0a1a; }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, #ff00ff, #00ffff);
|
||||
border-radius: 3px;
|
||||
}
|
||||
`,
|
||||
|
||||
// ── Component Overrides ───────────────────────────────────────────────────
|
||||
components: {
|
||||
Button: CyberpunkButton,
|
||||
},
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
onActivate: () => {
|
||||
// Force dark mode for this theme
|
||||
document.documentElement.classList.add("dark");
|
||||
document.documentElement.style.colorScheme = "dark";
|
||||
// Mark the body for theme-specific CSS selectors
|
||||
document.body.setAttribute("data-flvx-theme", "cyberpunk");
|
||||
},
|
||||
onDeactivate: () => {
|
||||
document.body.removeAttribute("data-flvx-theme");
|
||||
},
|
||||
};
|
||||
|
||||
export default cyberpunkTheme;
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @module @/themes
|
||||
* Public API for the FLVX theme system.
|
||||
*
|
||||
* Usage:
|
||||
* import { ThemeProvider, useThemeContext, registerTheme } from "@/themes";
|
||||
*/
|
||||
|
||||
export type {
|
||||
ThemePackage,
|
||||
ThemeTokens,
|
||||
ComponentKey,
|
||||
LayoutKey,
|
||||
PageKey,
|
||||
} from "./types";
|
||||
|
||||
export {
|
||||
registerTheme,
|
||||
unregisterTheme,
|
||||
getRegisteredThemes,
|
||||
getTheme,
|
||||
getActiveThemeId,
|
||||
getActiveTheme,
|
||||
activateTheme,
|
||||
deactivateTheme,
|
||||
reapplyActiveTheme,
|
||||
initThemeSystem,
|
||||
resolveComponent,
|
||||
resolveLayout,
|
||||
resolvePage,
|
||||
getSavedMode,
|
||||
saveMode,
|
||||
getEffectiveMode,
|
||||
subscribe,
|
||||
} from "./registry";
|
||||
|
||||
export type { ThemeMode } from "./registry";
|
||||
|
||||
export {
|
||||
ThemeProvider,
|
||||
useThemeContext,
|
||||
useThemedComponent,
|
||||
useThemedLayout,
|
||||
useThemedPage,
|
||||
} from "./context";
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Theme Loader — auto-registers all built-in themes
|
||||
* ==================================================
|
||||
* Import this module once at app startup (in provider.tsx or App.tsx).
|
||||
*
|
||||
* To add a new theme:
|
||||
* 1. Create a folder under `src/themes/` (e.g. `src/themes/my-theme/`)
|
||||
* 2. Export a `ThemePackage` as the default export from `index.ts`
|
||||
* 3. Import and register it below
|
||||
*/
|
||||
|
||||
import { registerTheme } from "./registry";
|
||||
|
||||
// ── Built-in themes ──────────────────────────────────────────────────────────
|
||||
import defaultTheme from "./default";
|
||||
import cyberpunkTheme from "./example-cyberpunk";
|
||||
|
||||
// Register all themes
|
||||
registerTheme(defaultTheme);
|
||||
registerTheme(cyberpunkTheme);
|
||||
|
||||
/*
|
||||
* ── ADDING YOUR OWN THEME ──────────────────────────────────────────────────
|
||||
*
|
||||
* 1. Create your theme folder:
|
||||
* src/themes/my-awesome-theme/
|
||||
* ├── index.ts ← exports ThemePackage
|
||||
* ├── components/ ← optional component overrides
|
||||
* └── ...
|
||||
*
|
||||
* 2. Import and register here:
|
||||
* import myTheme from "./my-awesome-theme";
|
||||
* registerTheme(myTheme);
|
||||
*
|
||||
* That's it! The theme will appear in the theme picker.
|
||||
*/
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Theme Registry
|
||||
* ==============
|
||||
* Manages the set of installed themes and the currently active theme.
|
||||
* Handles CSS variable injection, `<style>` element management, and
|
||||
* lifecycle callbacks.
|
||||
*
|
||||
* This module is framework-agnostic (no React dependency). The React
|
||||
* integration lives in `./context.tsx`.
|
||||
*/
|
||||
|
||||
import type { ThemePackage, ThemeTokens, ComponentKey, LayoutKey, PageKey } from "./types";
|
||||
|
||||
// ─── internal state ──────────────────────────────────────────────────────────
|
||||
|
||||
const installed = new Map<string, ThemePackage>();
|
||||
let activeId: string | null = null;
|
||||
let injectedStyleEl: HTMLStyleElement | null = null;
|
||||
|
||||
const STORAGE_KEY = "flvx:active-theme";
|
||||
const MODE_KEY = "flvx:theme"; // backwards-compat with old use-theme
|
||||
|
||||
type ChangeListener = () => void;
|
||||
const changeListeners = new Set<ChangeListener>();
|
||||
|
||||
function notify() {
|
||||
changeListeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
// ─── public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Register a theme package. Call this for every theme you want available.
|
||||
* Registering a theme with an existing id replaces the previous one.
|
||||
*/
|
||||
export function registerTheme(pkg: ThemePackage): void {
|
||||
installed.set(pkg.id, pkg);
|
||||
notify();
|
||||
}
|
||||
|
||||
/** Unregister a theme by id. */
|
||||
export function unregisterTheme(id: string): void {
|
||||
if (activeId === id) deactivateTheme();
|
||||
installed.delete(id);
|
||||
notify();
|
||||
}
|
||||
|
||||
/** Get all registered themes. */
|
||||
export function getRegisteredThemes(): ThemePackage[] {
|
||||
return Array.from(installed.values());
|
||||
}
|
||||
|
||||
/** Get a specific theme by id. */
|
||||
export function getTheme(id: string): ThemePackage | undefined {
|
||||
return installed.get(id);
|
||||
}
|
||||
|
||||
/** Get the id of the currently active theme (or null). */
|
||||
export function getActiveThemeId(): string | null {
|
||||
return activeId;
|
||||
}
|
||||
|
||||
/** Get the currently active ThemePackage (or null). */
|
||||
export function getActiveTheme(): ThemePackage | null {
|
||||
return activeId ? installed.get(activeId) ?? null : null;
|
||||
}
|
||||
|
||||
// ─── theme mode ──────────────────────────────────────────────────────────────
|
||||
|
||||
export type ThemeMode = "light" | "dark" | "system";
|
||||
|
||||
export function resolveSystemMode(): "light" | "dark" {
|
||||
if (typeof window === "undefined") return "light";
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
export function getSavedMode(): ThemeMode {
|
||||
if (typeof window === "undefined") return "system";
|
||||
const raw = localStorage.getItem(MODE_KEY);
|
||||
if (raw === "light" || raw === "dark" || raw === "system") return raw;
|
||||
return "system";
|
||||
}
|
||||
|
||||
export function saveMode(mode: ThemeMode): void {
|
||||
localStorage.setItem(MODE_KEY, mode);
|
||||
notify();
|
||||
}
|
||||
|
||||
export function getEffectiveMode(): "light" | "dark" {
|
||||
const mode = getSavedMode();
|
||||
return mode === "system" ? resolveSystemMode() : mode;
|
||||
}
|
||||
|
||||
// ─── activation ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Activate a theme by id. This:
|
||||
* 1. Calls `onDeactivate` on the previous theme.
|
||||
* 2. Injects CSS tokens onto `document.documentElement`.
|
||||
* 3. Injects the theme's `css` string into a `<style>` element.
|
||||
* 4. Calls `onActivate` on the new theme.
|
||||
* 5. Persists the choice to localStorage.
|
||||
*/
|
||||
export function activateTheme(id: string): void {
|
||||
const pkg = installed.get(id);
|
||||
if (!pkg) {
|
||||
console.warn(`[FLVX themes] Theme "${id}" is not registered.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Deactivate previous
|
||||
deactivateTheme();
|
||||
|
||||
activeId = id;
|
||||
|
||||
// Inject tokens
|
||||
const mode = getEffectiveMode();
|
||||
const tokens = mode === "dark" ? pkg.tokens?.dark : pkg.tokens?.light;
|
||||
if (tokens) injectTokens(tokens);
|
||||
|
||||
// Inject custom CSS
|
||||
if (pkg.css) {
|
||||
injectedStyleEl = document.createElement("style");
|
||||
injectedStyleEl.setAttribute("data-flvx-theme", id);
|
||||
injectedStyleEl.textContent = pkg.css;
|
||||
document.head.appendChild(injectedStyleEl);
|
||||
}
|
||||
|
||||
// Update dark class
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle("dark", mode === "dark");
|
||||
root.style.colorScheme = mode;
|
||||
|
||||
// Lifecycle
|
||||
pkg.onActivate?.();
|
||||
|
||||
// Persist
|
||||
localStorage.setItem(STORAGE_KEY, id);
|
||||
|
||||
notify();
|
||||
}
|
||||
|
||||
/** Deactivate the current theme, reverting all overrides. */
|
||||
export function deactivateTheme(): void {
|
||||
const prev = activeId ? installed.get(activeId) : null;
|
||||
prev?.onDeactivate?.();
|
||||
|
||||
// Remove injected tokens
|
||||
clearInjectedTokens();
|
||||
|
||||
// Remove injected style element
|
||||
if (injectedStyleEl) {
|
||||
injectedStyleEl.remove();
|
||||
injectedStyleEl = null;
|
||||
}
|
||||
|
||||
activeId = null;
|
||||
}
|
||||
|
||||
/** Re-apply the active theme (e.g. after mode changes). */
|
||||
export function reapplyActiveTheme(): void {
|
||||
if (activeId) {
|
||||
const id = activeId;
|
||||
// quick re-inject without full lifecycle
|
||||
const pkg = installed.get(id);
|
||||
if (!pkg) return;
|
||||
|
||||
clearInjectedTokens();
|
||||
const mode = getEffectiveMode();
|
||||
const tokens = mode === "dark" ? pkg.tokens?.dark : pkg.tokens?.light;
|
||||
if (tokens) injectTokens(tokens);
|
||||
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle("dark", mode === "dark");
|
||||
root.style.colorScheme = mode;
|
||||
} else {
|
||||
// No theme active — just set dark class based on mode
|
||||
const mode = getEffectiveMode();
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle("dark", mode === "dark");
|
||||
root.style.colorScheme = mode;
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
// ─── component resolution ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve a component: returns the themed override if present, otherwise
|
||||
* returns the fallback (default implementation).
|
||||
*/
|
||||
export function resolveComponent<P = any>(
|
||||
key: ComponentKey,
|
||||
fallback: React.ComponentType<P>,
|
||||
): React.ComponentType<P> {
|
||||
const pkg = activeId ? installed.get(activeId) : null;
|
||||
const override = pkg?.components?.[key];
|
||||
return (override as React.ComponentType<P>) ?? fallback;
|
||||
}
|
||||
|
||||
/** Resolve a layout override. */
|
||||
export function resolveLayout(
|
||||
key: LayoutKey,
|
||||
fallback: React.ComponentType<{ children: React.ReactNode }>,
|
||||
): React.ComponentType<{ children: React.ReactNode }> {
|
||||
const pkg = activeId ? installed.get(activeId) : null;
|
||||
return pkg?.layouts?.[key] ?? fallback;
|
||||
}
|
||||
|
||||
/** Resolve a page override. */
|
||||
export function resolvePage<P = any>(
|
||||
key: PageKey,
|
||||
fallback: React.ComponentType<P>,
|
||||
): React.ComponentType<P> {
|
||||
const pkg = activeId ? installed.get(activeId) : null;
|
||||
const override = pkg?.pages?.[key];
|
||||
return (override as React.ComponentType<P>) ?? fallback;
|
||||
}
|
||||
|
||||
// ─── subscriber API (for React) ──────────────────────────────────────────────
|
||||
|
||||
export function subscribe(listener: ChangeListener): () => void {
|
||||
changeListeners.add(listener);
|
||||
return () => changeListeners.delete(listener);
|
||||
}
|
||||
|
||||
// ─── initialisation ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Call once at app boot. Restores the previously active theme from
|
||||
* localStorage (if the theme is registered).
|
||||
*/
|
||||
export function initThemeSystem(): void {
|
||||
const savedId = localStorage.getItem(STORAGE_KEY);
|
||||
if (savedId && installed.has(savedId)) {
|
||||
activateTheme(savedId);
|
||||
} else {
|
||||
// Just apply mode
|
||||
reapplyActiveTheme();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── internal helpers ────────────────────────────────────────────────────────
|
||||
|
||||
const injectedVars: string[] = [];
|
||||
|
||||
function injectTokens(tokens: ThemeTokens): void {
|
||||
const root = document.documentElement;
|
||||
for (const [varName, value] of Object.entries(tokens)) {
|
||||
if (value !== undefined) {
|
||||
root.style.setProperty(varName, value);
|
||||
injectedVars.push(varName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearInjectedTokens(): void {
|
||||
const root = document.documentElement;
|
||||
for (const varName of injectedVars) {
|
||||
root.style.removeProperty(varName);
|
||||
}
|
||||
injectedVars.length = 0;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user