introduce uptime monitor for easytier public nodes (#1250)

This commit is contained in:
Sijie.Sun
2025-08-20 22:59:44 +08:00
committed by GitHub
parent 8f37d4ef7c
commit e6ec7f405c
61 changed files with 12122 additions and 17 deletions

305
Cargo.lock generated
View File

@@ -527,8 +527,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "504e3947307ac8326a5437504c517c4b56716c9d98fac0028c2acc7ca47d70ae"
dependencies = [
"async-trait",
"axum-core",
"axum-macros",
"axum-core 0.4.5",
"axum-macros 0.4.2",
"bytes",
"futures-util",
"http",
@@ -537,7 +537,42 @@ dependencies = [
"hyper",
"hyper-util",
"itoa",
"matchit",
"matchit 0.7.3",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"rustversion",
"serde",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower 0.5.2",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5"
dependencies = [
"axum-core 0.5.2",
"axum-macros 0.5.0",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit 0.8.4",
"memchr",
"mime",
"percent-encoding",
@@ -576,13 +611,33 @@ dependencies = [
"tracing",
]
[[package]]
name = "axum-core"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-embed"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "077959a7f8cf438676af90b483304528eb7e16eadadb7f44e9ada4f9dceb9e62"
dependencies = [
"axum-core",
"axum-core 0.4.5",
"chrono",
"http",
"mime_guess",
@@ -597,7 +652,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5260ed0ecc8ace8e7e61a7406672faba598c8a86b8f4742fcdde0ddc979a318f"
dependencies = [
"async-trait",
"axum",
"axum 0.7.7",
"form_urlencoded",
"serde",
"subtle",
@@ -621,6 +676,17 @@ dependencies = [
"syn 2.0.87",
]
[[package]]
name = "axum-macros"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.87",
]
[[package]]
name = "axum-messages"
version = "0.7.0"
@@ -628,7 +694,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40e85c86a8bd84f54833bca296a0204bd865958ade62bacadeae92dda34cfb8a"
dependencies = [
"async-trait",
"axum-core",
"axum-core 0.4.5",
"http",
"parking_lot",
"serde",
@@ -1935,6 +2001,12 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "downcast"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1"
[[package]]
name = "downcast-rs"
version = "1.2.1"
@@ -2161,13 +2233,50 @@ dependencies = [
"prost-build",
]
[[package]]
name = "easytier-uptime"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"axum 0.8.4",
"chrono",
"clap",
"dashmap",
"easytier",
"futures",
"jsonwebtoken",
"mockall",
"once_cell",
"parking_lot",
"reqwest",
"sea-orm",
"sea-orm-migration",
"serde",
"serde_json",
"serde_yaml",
"sqlx",
"tempfile",
"thiserror 1.0.63",
"tokio",
"tokio-test",
"tokio-util",
"toml 0.8.19",
"tower 0.5.2",
"tower-http",
"tracing",
"tracing-subscriber",
"uuid",
"validator",
]
[[package]]
name = "easytier-web"
version = "2.4.2"
dependencies = [
"anyhow",
"async-trait",
"axum",
"axum 0.7.7",
"axum-embed",
"axum-login",
"axum-messages",
@@ -2570,6 +2679,12 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fragile"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619"
[[package]]
name = "funty"
version = "2.0.0"
@@ -3264,7 +3379,7 @@ dependencies = [
"futures-channel",
"futures-io",
"futures-util",
"idna",
"idna 1.0.3",
"ipnet",
"once_cell",
"rand 0.9.1",
@@ -3679,6 +3794,16 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "idna"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6"
dependencies = [
"unicode-bidi",
"unicode-normalization",
]
[[package]]
name = "idna"
version = "1.0.3"
@@ -4013,6 +4138,21 @@ dependencies = [
"serde_json",
]
[[package]]
name = "jsonwebtoken"
version = "9.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
dependencies = [
"base64 0.22.1",
"js-sys",
"pem",
"ring",
"serde",
"serde_json",
"simple_asn1",
]
[[package]]
name = "kcp-sys"
version = "0.1.0"
@@ -4340,6 +4480,12 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "matrixmultiply"
version = "0.3.9"
@@ -4455,6 +4601,33 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "mockall"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43766c2b5203b10de348ffe19f7e54564b64f3d6018ff7648d1e2d6d3a0f0a48"
dependencies = [
"cfg-if",
"downcast",
"fragile",
"lazy_static",
"mockall_derive",
"predicates",
"predicates-tree",
]
[[package]]
name = "mockall_derive"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af7cbce79ec385a1d4f54baa90a76401eb15d9cab93685f62e7e9f942aa00ae2"
dependencies = [
"cfg-if",
"proc-macro2",
"quote",
"syn 2.0.87",
]
[[package]]
name = "moka"
version = "0.12.10"
@@ -5868,6 +6041,32 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "predicates"
version = "3.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573"
dependencies = [
"anstyle",
"predicates-core",
]
[[package]]
name = "predicates-core"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa"
[[package]]
name = "predicates-tree"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c"
dependencies = [
"predicates-core",
"termtree",
]
[[package]]
name = "prefix-trie"
version = "0.7.0"
@@ -7332,6 +7531,19 @@ dependencies = [
"syn 2.0.87",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap 2.7.1",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "serde_yml"
version = "0.0.11"
@@ -7511,6 +7723,18 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "simple_asn1"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb"
dependencies = [
"num-bigint",
"num-traits",
"thiserror 2.0.11",
"time",
]
[[package]]
name = "siphasher"
version = "0.3.11"
@@ -8523,6 +8747,12 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "termtree"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
[[package]]
name = "thiserror"
version = "1.0.63"
@@ -8757,6 +8987,19 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-test"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7"
dependencies = [
"async-stream",
"bytes",
"futures-core",
"tokio",
"tokio-stream",
]
[[package]]
name = "tokio-util"
version = "0.7.13"
@@ -8765,8 +9008,12 @@ checksum = "d7fcaa8d55a2bdd6b83ace262b016eca0d79ee02818c5c1bcdf0305114081078"
dependencies = [
"bytes",
"futures-core",
"futures-io",
"futures-sink",
"futures-util",
"hashbrown 0.14.5",
"pin-project-lite",
"slab",
"tokio",
]
@@ -8947,7 +9194,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fd0118512cf0b3768f7fcccf0bef1ae41d68f2b45edc1e77432b36c97c56c6d"
dependencies = [
"async-trait",
"axum-core",
"axum-core 0.4.5",
"cookie",
"futures-util",
"http",
@@ -9013,7 +9260,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb6abbfcaf6436ec5a772cd9f965401da12db793e404ae6134eac066fa5a04f3"
dependencies = [
"async-trait",
"axum-core",
"axum-core 0.4.5",
"base64 0.22.1",
"futures",
"http",
@@ -9366,6 +9613,12 @@ dependencies = [
"subtle",
]
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -9379,7 +9632,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60"
dependencies = [
"form_urlencoded",
"idna",
"idna 1.0.3",
"percent-encoding",
"serde",
]
@@ -9451,6 +9704,36 @@ dependencies = [
"syn 2.0.87",
]
[[package]]
name = "validator"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db79c75af171630a3148bd3e6d7c4f42b6a9a014c2945bc5ed0020cbb8d9478e"
dependencies = [
"idna 0.5.0",
"once_cell",
"regex",
"serde",
"serde_derive",
"serde_json",
"url",
"validator_derive",
]
[[package]]
name = "validator_derive"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df0bcf92720c40105ac4b2dda2a4ea3aa717d4d6a862cc217da653a4bd5c6b10"
dependencies = [
"darling",
"once_cell",
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.87",
]
[[package]]
name = "valuable"
version = "0.1.0"

View File

@@ -6,6 +6,7 @@ members = [
"easytier-rpc-build",
"easytier-web",
"easytier-contrib/easytier-ffi",
"easytier-contrib/easytier-uptime",
]
default-members = ["easytier", "easytier-web"]
exclude = [
@@ -14,6 +15,7 @@ exclude = [
[profile.dev]
panic = "unwind"
debug = 2
[profile.release]
panic = "abort"

View File

@@ -27,6 +27,10 @@
"name": "openharmony",
"path": "easytier-contrib/easytier-ohrs"
},
{
"name": "uptime",
"path": "easytier-contrib/easytier-uptime"
},
{
"name": "vpnservice",
"path": "tauri-plugin-vpnservice"

View File

@@ -0,0 +1,17 @@
# Development Environment Configuration
SERVER_HOST=127.0.0.1
SERVER_PORT=8080
DATABASE_PATH=uptime.db
DATABASE_MAX_CONNECTIONS=5
HEALTH_CHECK_INTERVAL=60
HEALTH_CHECK_TIMEOUT=15
HEALTH_CHECK_RETRIES=2
RUST_LOG=debug
LOG_LEVEL=debug
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080
CORS_ALLOWED_METHODS=GET,POST,PUT,DELETE,OPTIONS
CORS_ALLOWED_HEADERS=content-type,authorization
NODE_ENV=development
API_BASE_URL=/api
ENABLE_COMPRESSION=true
ENABLE_CORS=true

View File

@@ -0,0 +1,29 @@
# Server Configuration
SERVER_HOST=127.0.0.1
SERVER_PORT=8080
# Database Configuration
DATABASE_PATH=uptime.db
DATABASE_MAX_CONNECTIONS=10
# Health Check Configuration
HEALTH_CHECK_INTERVAL=30
HEALTH_CHECK_TIMEOUT=10
HEALTH_CHECK_RETRIES=3
# Logging Configuration
RUST_LOG=info
LOG_LEVEL=info
# CORS Configuration
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080
CORS_ALLOWED_METHODS=GET,POST,PUT,DELETE,OPTIONS
CORS_ALLOWED_HEADERS=content-type,authorization
# Production Configuration
NODE_ENV=development
API_BASE_URL=/api
# Security Configuration
ENABLE_COMPRESSION=true
ENABLE_CORS=true

View File

@@ -0,0 +1,21 @@
# Production Environment Configuration
SERVER_HOST=0.0.0.0
SERVER_PORT=8080
DATABASE_PATH=/var/lib/easytier-uptime/uptime.db
DATABASE_MAX_CONNECTIONS=20
HEALTH_CHECK_INTERVAL=30
HEALTH_CHECK_TIMEOUT=10
HEALTH_CHECK_RETRIES=3
RUST_LOG=info
LOG_LEVEL=info
CORS_ALLOWED_ORIGINS=https://yourdomain.com
CORS_ALLOWED_METHODS=GET,POST,PUT,DELETE,OPTIONS
CORS_ALLOWED_HEADERS=content-type,authorization
NODE_ENV=production
API_BASE_URL=/api
ENABLE_COMPRESSION=true
ENABLE_CORS=true
# Security
SECRET_KEY=your-secret-key-here
JWT_SECRET=your-jwt-secret-here

View File

@@ -0,0 +1,3 @@
*.db
*.db-shm
*.db-wal

View File

@@ -0,0 +1,63 @@
[package]
name = "easytier-uptime"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1.0", features = ["full"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
anyhow = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1.0", features = ["v4", "serde"] }
# Axum web framework
axum = { version = "0.8.4", features = ["macros"] }
tower-http = { version = "0.6", features = ["cors", "compression-full"] }
tower = "0.5"
# SeaORM dependencies
sea-orm = { version = "1.1", features = [
"sqlx-sqlite",
"runtime-tokio-rustls",
"macros",
"with-chrono",
"with-uuid",
"with-json"
] }
sea-orm-migration = { version = "1.1" }
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio-rustls", "chrono", "uuid"] }
# Validation
validator = { version = "0.18", features = ["derive"] }
thiserror = "1.0"
jsonwebtoken = "9.0"
# Configuration and serialization
serde_yaml = "0.9"
toml = "0.8"
# Network and async
async-trait = "0.1"
futures = "0.3"
tokio-util = { version = "0.7", features = ["full"] }
# Filesystem operations
tempfile = "3.8"
# Additional utilities
dashmap = "6.1.0"
clap = { version = "4.0", features = ["derive"] }
parking_lot = "0.12"
once_cell = "1.19"
# EasyTier core
easytier = { path = "../../easytier" }
# Testing
[dev-dependencies]
mockall = "0.12"
tokio-test = "0.4"
reqwest = "0.12"

View File

@@ -0,0 +1,272 @@
# EasyTier Uptime Monitor
一个用于监控 EasyTier 实例健康状态和运行时间的系统。
## 功能特性
- 🏥 **健康监控**: 实时监控 EasyTier 节点的健康状态
- 📊 **数据统计**: 提供详细的运行时间和响应时间统计
- 🔧 **实例管理**: 管理多个 EasyTier 实例
- 🌐 **Web界面**: 直观的 Web 管理界面
- 🚨 **告警系统**: 支持健康状态异常告警
- 📈 **图表展示**: 可视化展示监控数据
## 系统架构
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Frontend │ │ Backend │ │ Database │
│ (Vue.js) │◄──►│ (Rust/Axum) │◄──►│ (SQLite) │
│ │ │ │ │ │
│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │
│ │ Dashboard │ │ │ │ API Routes │ │ │ │ Nodes │ │
│ │ Health View │ │ │ │ Health │ │ │ │ Health │ │
│ │ Node Mgmt │ │ │ │ Instances │ │ │ │ Instances │ │
│ │ Charts │ │ │ │ Scheduler │ │ │ │ Stats │ │
│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
## 快速开始
### 环境要求
- **Rust**: 1.70+
- **Node.js**: 16+
- **npm**: 8+
### 开发环境
1. **克隆项目**
```bash
git clone <repository-url>
cd easytier-uptime
```
2. **启动开发环境**
```bash
./start-dev.sh
```
3. **访问应用**
- 前端界面: http://localhost:3000
- 后端API: http://localhost:8080
- 健康检查: http://localhost:8080/health
### 生产环境
1. **启动生产环境**
```bash
./start-prod.sh
```
2. **停止生产环境**
```bash
./stop-prod.sh
```
## 配置说明
### 环境变量
#### 后端配置 (.env)
| 变量名 | 默认值 | 说明 |
|--------|--------|------|
| `SERVER_HOST` | `127.0.0.1` | 服务器监听地址 |
| `SERVER_PORT` | `8080` | 服务器端口 |
| `DATABASE_PATH` | `uptime.db` | 数据库文件路径 |
| `DATABASE_MAX_CONNECTIONS` | `10` | 数据库最大连接数 |
| `HEALTH_CHECK_INTERVAL` | `30` | 健康检查间隔(秒) |
| `HEALTH_CHECK_TIMEOUT` | `10` | 健康检查超时(秒) |
| `HEALTH_CHECK_RETRIES` | `3` | 健康检查重试次数 |
| `RUST_LOG` | `info` | 日志级别 |
| `CORS_ALLOWED_ORIGINS` | `http://localhost:3000` | 允许的跨域来源 |
| `ENABLE_CORS` | `true` | 是否启用CORS |
| `ENABLE_COMPRESSION` | `true` | 是否启用压缩 |
#### 前端配置 (frontend/.env)
| 变量名 | 默认值 | 说明 |
|--------|--------|------|
| `VITE_APP_TITLE` | `EasyTier Uptime Monitor` | 应用标题 |
| `VITE_API_BASE_URL` | `/api` | API基础URL |
| `VITE_APP_ENV` | `development` | 应用环境 |
| `VITE_ENABLE_DEV_TOOLS` | `true` | 是否启用开发工具 |
| `VITE_API_TIMEOUT` | `10000` | API超时时间(毫秒) |
## API 文档
### 健康检查
```http
GET /health
```
### 节点管理
```http
# 获取节点列表
GET /api/nodes
# 创建节点
POST /api/nodes
# 获取节点详情
GET /api/nodes/{id}
# 更新节点
PUT /api/nodes/{id}
# 删除节点
DELETE /api/nodes/{id}
```
### 健康记录
```http
# 获取节点健康历史
GET /api/nodes/{id}/health
# 获取节点健康统计
GET /api/nodes/{id}/health/stats
```
### 实例管理
```http
# 获取实例列表
GET /api/instances
# 创建实例
POST /api/instances
# 停止实例
DELETE /api/instances/{id}
```
## 测试
### 运行集成测试
```bash
./test-integration.sh
```
### 运行单元测试
```bash
cargo test
```
### 测试覆盖率
```bash
cargo tarpaulin
```
## 部署
### Docker 部署
```bash
# 构建镜像
docker build -t easytier-uptime .
# 运行容器
docker run -d -p 8080:8080 easytier-uptime
```
### 手动部署
1. **构建后端**
```bash
cargo build --release
```
2. **构建前端**
```bash
cd frontend
npm install
npm run build
cd ..
```
3. **配置环境**
```bash
cp .env.production .env
# 编辑 .env 文件
```
4. **启动服务**
```bash
./start-prod.sh
```
## 监控和日志
### 日志文件
- **后端日志**: `logs/backend.log`
- **前端日志**: `logs/frontend.log`
- **测试日志**: `test-results/`
### 健康检查
系统提供以下健康检查端点:
- `/health` - 基本健康检查
- `/api/health/stats` - 健康统计信息
- `/api/health/scheduler/status` - 调度器状态
## 故障排除
### 常见问题
1. **后端启动失败**
- 检查端口是否被占用
- 确认数据库文件权限
- 查看日志文件 `logs/backend.log`
2. **前端连接失败**
- 检查后端服务是否运行
- 确认API地址配置
- 检查CORS配置
3. **健康检查失败**
- 确认目标节点可访问
- 检查防火墙设置
- 验证健康检查配置
### 性能优化
1. **数据库优化**
- 定期清理过期数据
- 配置适当的连接池大小
- 使用索引优化查询
2. **前端优化**
- 启用代码分割
- 配置缓存策略
- 优化图片和资源
3. **网络优化**
- 启用压缩
- 配置CDN
- 优化API响应时间
## 贡献指南
1. Fork 项目
2. 创建特性分支
3. 提交更改
4. 推送到分支
5. 创建 Pull Request
## 许可证
MIT License
## 支持
如有问题或建议,请提交 Issue 或联系开发团队。

View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
{
"name": "easytier-uptime-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.9",
"dayjs": "^1.11.13",
"easytier-uptime-frontend": "link:",
"element-plus": "^2.8.8",
"vue": "^3.5.18",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"unplugin-auto-import": "^0.18.6",
"unplugin-vue-components": "^0.27.4",
"vite": "^7.1.2"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,326 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { healthApi } from './api'
import {
Monitor,
Plus,
CircleCheck,
CircleClose,
Loading,
Link
} from '@element-plus/icons-vue'
const router = useRouter()
const route = useRoute()
const healthStatus = ref(null)
const loading = ref(false)
// 安全地打开外部链接
const openExternalLink = (url) => {
try {
if (typeof window !== 'undefined' && window.open) {
window.open(url, '_blank')
} else {
// 备用方案:创建一个临时链接元素
const link = document.createElement('a')
link.href = url
link.target = '_blank'
link.rel = 'noopener noreferrer'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
} catch (error) {
console.error('Failed to open external link:', error)
// 最后的备用方案:直接跳转
if (typeof window !== 'undefined') {
window.location.href = url
}
}
}
// 检查后端健康状态
const checkHealth = async () => {
try {
loading.value = true
const response = await healthApi.check()
healthStatus.value = response.success
} catch (error) {
healthStatus.value = false
console.error('Health check failed:', error)
} finally {
loading.value = false
}
}
// 导航菜单项
const menuItems = [
{
path: '/',
name: 'dashboard',
title: '节点监控',
icon: 'Monitor'
},
{
path: '/submit',
name: 'submit',
title: '提交节点',
icon: 'Plus'
}
]
onMounted(() => {
checkHealth()
// 定期检查健康状态
setInterval(checkHealth, 60000) // 每分钟检查一次
})
</script>
<template>
<div id="app">
<!-- 顶部导航栏 -->
<el-header class="app-header">
<div class="header-content">
<div class="logo-section">
<el-icon size="32" color="#409EFF">
<Monitor />
</el-icon>
<h1 class="app-title">EasyTier Uptime</h1>
</div>
<el-menu :default-active="route.name" mode="horizontal" class="nav-menu"
@select="(key) => router.push(menuItems.find(item => item.name === key)?.path || '/')">
<el-menu-item v-for="item in menuItems" :key="item.name" :index="item.name">
<el-icon>
<component :is="item.icon" />
</el-icon>
<span>{{ item.title }}</span>
</el-menu-item>
</el-menu>
<div class="header-actions">
<!-- 健康状态指示器 -->
<el-tooltip :content="healthStatus === null ? '检查中...' : healthStatus ? '服务正常' : '服务异常'" placement="bottom">
<div class="health-indicator">
<el-icon :color="healthStatus === null ? '#909399' : healthStatus ? '#67C23A' : '#F56C6C'"
:class="{ 'loading': loading }">
<CircleCheck v-if="healthStatus === true" />
<CircleClose v-else-if="healthStatus === false" />
<Loading v-else />
</el-icon>
</div>
</el-tooltip>
<!-- 管理员入口 -->
<el-button type="warning" link @click="() => router.push('/admin/login')">
管理员
</el-button>
<!-- GitHub链接 -->
<el-button type="primary" link @click="() => openExternalLink('https://github.com/EasyTier/EasyTier')">
<el-icon>
<Link />
</el-icon>
GitHub
</el-button>
</div>
</div>
</el-header>
<!-- 主要内容区域 -->
<el-main class="app-main">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</el-main>
<!-- 底部信息 -->
<el-footer class="app-footer">
<div class="footer-content">
<p>
© 2024 EasyTier Community |
<el-button type="primary" link size="small"
@click="() => openExternalLink('https://github.com/EasyTier/EasyTier')">
开源项目
</el-button>
|
<el-button type="primary" link size="small"
@click="() => openExternalLink('https://github.com/EasyTier/EasyTier/blob/main/README.md')">
使用文档
</el-button>
</p>
</div>
</el-footer>
</div>
</template>
<style>
/* 全局样式重置 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
background-color: #f5f7fa;
}
#app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* 顶部导航栏 */
.app-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
padding: 0;
height: 60px;
line-height: 60px;
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
height: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
.logo-section {
display: flex;
align-items: center;
gap: 12px;
}
.app-title {
color: white;
font-size: 20px;
font-weight: 600;
margin: 0;
}
.nav-menu {
background: transparent;
border: none;
flex: 1;
justify-content: center;
}
.nav-menu .el-menu-item {
color: rgba(255, 255, 255, 0.8);
border-bottom: 2px solid transparent;
transition: all 0.3s;
}
.nav-menu .el-menu-item:hover,
.nav-menu .el-menu-item.is-active {
color: white;
background: rgba(255, 255, 255, 0.1);
border-bottom-color: white;
}
.header-actions {
display: flex;
align-items: center;
gap: 15px;
}
.health-indicator {
display: flex;
align-items: center;
cursor: pointer;
}
.health-indicator .loading {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* 主要内容区域 */
.app-main {
flex: 1;
padding: 0;
background-color: #f5f7fa;
}
/* 页面切换动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* 底部信息 */
.app-footer {
background: white;
border-top: 1px solid #e4e7ed;
text-align: center;
height: 50px;
line-height: 50px;
}
.footer-content p {
color: #909399;
font-size: 14px;
margin: 0;
}
/* 响应式设计 */
@media (max-width: 768px) {
.header-content {
padding: 0 10px;
}
.app-title {
font-size: 16px;
}
.nav-menu {
display: none;
}
.header-actions {
gap: 10px;
}
}
/* Element Plus 组件样式覆盖 */
.el-card {
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.el-button {
border-radius: 6px;
}
.el-input {
border-radius: 6px;
}
.el-select {
border-radius: 6px;
}
</style>

View File

@@ -0,0 +1,155 @@
import axios from 'axios'
// 创建axios实例
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '',
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器
api.interceptors.request.use(
config => {
// 只在管理员相关的API请求中添加token
if (config.url && config.url.includes('/api/admin/')) {
const token = localStorage.getItem('admin_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
}
return config
},
error => {
return Promise.reject(error)
}
)
// 响应拦截器
api.interceptors.response.use(
response => {
// 直接返回完整的response对象让各个API方法自己处理数据格式
return response
},
error => {
console.error('API Error Details:', {
message: error.message,
status: error.response?.status,
statusText: error.response?.statusText,
data: error.response?.data,
config: {
url: error.config?.url,
method: error.config?.method,
headers: error.config?.headers
}
})
return Promise.reject(error)
}
)
// 节点相关API
export const nodeApi = {
// 获取节点列表
async getNodes(params = {}) {
const response = await api.get('/api/nodes', { params })
return response.data
},
// 创建节点
async createNode(data) {
const response = await api.post('/api/nodes', data)
return response.data
},
// 获取单个节点
async getNode(id) {
const response = await api.get(`/api/nodes/${id}`)
return response.data
},
// 更新节点
async updateNode(id, data) {
const response = await api.put(`/api/nodes/${id}`, data)
return response.data
},
// 删除节点
async deleteNode(id) {
const response = await api.delete(`/api/nodes/${id}`)
return response.data
},
// 获取节点健康记录
async getNodeHealth(id, params = {}) {
const response = await api.get(`/api/nodes/${id}/health`, { params })
return response.data
},
// 获取节点健康统计
async getNodeHealthStats(id, params = {}) {
const response = await api.get(`/api/nodes/${id}/health/stats`, { params })
return response.data
},
// 测试节点连接
async testConnection(data) {
const response = await api.post('/api/test_connection', data)
return response.data
}
}
// 健康检查API
export const healthApi = {
async check() {
const response = await api.get('/health')
return response.data
}
}
// 管理员API
export const adminApi = {
// 管理员登录
async login(password) {
const response = await api.post('/api/admin/login', { password })
return response.data
},
// 验证token有效性
async verifyToken() {
const response = await api.get('/api/admin/verify')
return response.data
},
// 获取所有节点(包括未审批的)
async getNodes(params = {}) {
const response = await api.get('/api/admin/nodes', { params })
return response.data
},
// 审批节点
async approveNode(id) {
const response = await api.put(`/api/admin/nodes/${id}/approve`)
return response.data
},
// 撤销审批节点
async revokeApproval(id) {
const response = await api.put(`/api/admin/nodes/${id}/revoke`)
return response.data
},
// 删除节点
async deleteNode(id) {
const response = await api.delete(`/api/admin/nodes/${id}`)
return response.data
},
// 更新节点
async updateNode(id, data) {
const response = await api.put(`/api/admin/nodes/${id}`, data)
return response.data
}
}
export default api

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,405 @@
<template>
<div class="health-timeline" :class="{ 'compact': compact }">
<div class="timeline-header">
<span class="timeline-title">最近24小时健康状态</span>
<div class="timeline-legend">
<span class="legend-item">
<span class="legend-dot perfect"></span>
<span class="legend-text">100%</span>
</span>
<span class="legend-item">
<span class="legend-dot excellent"></span>
<span class="legend-text">90-99%</span>
</span>
<span class="legend-item">
<span class="legend-dot good"></span>
<span class="legend-text">80-89%</span>
</span>
<span class="legend-item">
<span class="legend-dot fair"></span>
<span class="legend-text">60-79%</span>
</span>
<span class="legend-item">
<span class="legend-dot poor"></span>
<span class="legend-text">1-59%</span>
</span>
<span class="legend-item">
<span class="legend-dot unknown"></span>
<span class="legend-text">未知</span>
</span>
</div>
</div>
<div class="timeline-container" v-loading="loading">
<div class="timeline-grid">
<!-- 时间刻度 -->
<div class="time-labels">
<span v-for="(hour, idx) in timeLabels" :key="idx" class="time-label">
{{ hour }}
</span>
</div>
<!-- 健康状态条 -->
<div class="health-bars">
<div v-for="(segment, index) in healthSegments" :key="index" class="health-segment" :class="segment.status"
:style="{ width: segment.width + '%', backgroundColor: segment.color }" :title="getSegmentTooltip(segment)">
</div>
</div>
</div>
<!-- 统计信息 -->
<div class="health-summary">
<div class="summary-item">
<span class="summary-value">{{ uptimePercentage }}%</span>
<span class="summary-label">在线率</span>
</div>
<div class="summary-item">
<span class="summary-value">{{ avgResponseTime }}ms</span>
<span class="summary-label">平均响应</span>
</div>
<div class="summary-item">
<span class="summary-value">{{ totalChecks }}</span>
<span class="summary-label">检查次数</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { nodeApi } from '../api'
import dayjs from 'dayjs'
const props = defineProps({
nodeInfo: {
type: Object,
required: true
},
compact: {
type: Boolean,
default: true
}
})
const loading = ref(false)
const avg_response_time = ref(0)
// 时间标签24小时每4小时一个标签
const timeLabels = computed(() => {
const nodeInfo = props.nodeInfo
const granularity = nodeInfo.ring_granularity
const total_ring = nodeInfo.health_record_total_counter_ring
const totalDuration = granularity * total_ring.length
const now = dayjs(nodeInfo.last_check_time)
const startTime = now.subtract(totalDuration, 'second')
const labelCount = 6
const labelIntervalDuration = totalDuration / (labelCount - 1)
let labels = []
for (let i = 0; i < labelCount; i++) {
const time = startTime.add(i * labelIntervalDuration, 'second')
labels.push(time.format('HH:mm'))
}
return labels
})
const total_checks = computed(() => {
let total = 0
for (let i = 0; i < props.nodeInfo.health_record_total_counter_ring.length; i++) {
total += props.nodeInfo.health_record_total_counter_ring[i]
}
return total
})
const healthy_checks = computed(() => {
let total = 0
for (let i = 0; i < props.nodeInfo.health_record_healthy_counter_ring.length; i++) {
total += props.nodeInfo.health_record_healthy_counter_ring[i]
}
return total
})
const uptime_percentage = computed(() => {
return (healthy_checks.value / total_checks.value) * 100
})
// 根据成功率获取颜色
const getColorBySuccessRate = (rate) => {
if (rate === 1) {
return '#67c23a' // 100% 绿色
} else if (rate >= 0.9) {
return '#85ce61' // 90-99% 浅绿色
} else if (rate >= 0.8) {
return '#e6a23c' // 80-89% 橙色
} else if (rate >= 0.6) {
return '#f78989' // 60-79% 浅红色
} else if (rate > 0) {
return '#f56c6c' // 1-59% 红色
} else {
return '#c0c4cc' // 0% 或未知 灰色
}
}
// 健康状态分段
const healthSegments = computed(() => {
const nodeInfo = props.nodeInfo
const total_ring = nodeInfo.health_record_total_counter_ring
const healthy_ring = nodeInfo.health_record_healthy_counter_ring
const granularity = nodeInfo.ring_granularity
const totalDuration = granularity * total_ring.length
const segments = []
const now = dayjs(nodeInfo.last_check_time)
const startTime = now.subtract(totalDuration, 'second')
for (let i = total_ring.length - 1; i >= 0; i--) {
const total_counter = total_ring[i]
const healthy_counter = healthy_ring[i]
const currentTime = startTime.subtract((i + 1) * granularity, 'second')
const currentEndTime = currentTime.add(granularity, 'second')
let successRate = 0
let currentStatus = 'unknown'
if (total_counter !== 0) {
successRate = healthy_counter / total_counter
if (successRate === 1) {
currentStatus = 'perfect'
} else if (successRate >= 0.9) {
currentStatus = 'excellent'
} else if (successRate >= 0.8) {
currentStatus = 'good'
} else if (successRate >= 0.6) {
currentStatus = 'fair'
} else if (successRate > 0) {
currentStatus = 'poor'
} else {
currentStatus = 'failed'
}
}
segments.push({
status: currentStatus,
successRate: successRate,
color: getColorBySuccessRate(successRate),
width: (granularity / totalDuration) * 100,
duration: granularity / 60.0,
startTime: currentTime.format('HH:mm'),
endTime: currentEndTime.format('HH:mm'),
})
}
return segments
})
// 统计数据
const uptimePercentage = computed(() => {
return uptime_percentage.value.toFixed(1) || '0.0'
})
const avgResponseTime = computed(() => {
return (props.nodeInfo.last_response_time / 1000).toFixed(1) || '0.0'
})
const totalChecks = computed(() => {
return total_checks.value || 0
})
// 获取分段提示信息
const getSegmentTooltip = (segment) => {
const statusText = {
perfect: '完美',
excellent: '优秀',
good: '良好',
fair: '一般',
poor: '较差',
failed: '失败',
unknown: '未知'
}[segment.status] || '未知'
const successRateText = segment.successRate > 0 ? `${(segment.successRate * 100).toFixed(1)}%` : '0%'
return `${segment.startTime} - ${segment.endTime}: ${statusText} (${successRateText}) - ${Math.round(segment.duration)}分钟`
}
</script>
<style scoped>
.health-timeline {
background: #f8f9fa;
border-radius: 8px;
padding: 12px;
margin-top: 8px;
border: 1px solid #e4e7ed;
}
.timeline-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.timeline-title {
font-size: 13px;
font-weight: 500;
color: #606266;
}
.timeline-legend {
display: flex;
gap: 12px;
}
.legend-item {
display: flex;
align-items: center;
gap: 4px;
}
.legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.legend-dot.perfect {
background-color: #67c23a;
}
.legend-dot.excellent {
background-color: #85ce61;
}
.legend-dot.good {
background-color: #e6a23c;
}
.legend-dot.fair {
background-color: #f78989;
}
.legend-dot.poor {
background-color: #f56c6c;
}
.legend-dot.unknown {
background-color: #c0c4cc;
}
.legend-text {
font-size: 11px;
color: #909399;
}
.timeline-container {
position: relative;
min-height: 60px;
}
.timeline-grid {
position: relative;
}
.time-labels {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
}
.time-label {
font-size: 10px;
color: #c0c4cc;
font-family: monospace;
}
.health-bars {
display: flex;
height: 12px;
border-radius: 6px;
overflow: hidden;
background-color: #f0f0f0;
margin-bottom: 8px;
}
.health-segment {
height: 100%;
transition: all 0.3s ease;
cursor: pointer;
}
/* 颜色现在通过动态样式设置不再需要这些CSS类 */
.health-segment:hover {
opacity: 0.8;
transform: scaleY(1.2);
}
.response-time-chart {
height: 30px;
margin-bottom: 8px;
}
.response-chart {
width: 100%;
height: 100%;
}
.health-summary {
display: flex;
justify-content: space-around;
padding-top: 8px;
border-top: 1px solid #e4e7ed;
}
.summary-item {
text-align: center;
}
.summary-value {
display: block;
font-size: 14px;
font-weight: 600;
color: #409eff;
line-height: 1;
}
.summary-label {
font-size: 10px;
color: #909399;
margin-top: 2px;
}
/* 紧凑模式 */
.health-timeline.compact {
padding: 8px;
}
.health-timeline.compact .timeline-header {
margin-bottom: 8px;
}
.health-timeline.compact .timeline-title {
font-size: 12px;
}
.health-timeline.compact .health-bars {
height: 8px;
margin-bottom: 6px;
}
.health-timeline.compact .health-summary {
padding-top: 6px;
}
.health-timeline.compact .summary-value {
font-size: 12px;
}
.health-timeline.compact .summary-label {
font-size: 9px;
}
</style>

View File

@@ -0,0 +1,507 @@
<template>
<div>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" label-position="left"
@submit.prevent="handleSubmit">
<el-form-item label="节点名称" prop="name" required>
<el-input v-model="form.name" placeholder="请输入节点名称,如:北京-联通-01" maxlength="100" show-word-limit clearable>
<template #prefix>
<el-icon>
<Monitor />
</el-icon>
</template>
</el-input>
<div class="form-tip">建议使用地区-运营商-编号的格式命名</div>
</el-form-item>
<el-row :gutter="20">
<el-col :span="16">
<el-form-item label="主机地址" prop="host" required>
<el-input v-model="form.host" placeholder="请输入IP地址或域名" clearable>
<template #prefix>
<el-icon>
<Location />
</el-icon>
</template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="端口" prop="port" required>
<el-input-number v-model="form.port" :min="1" :max="65535" placeholder="端口号" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="协议类型" prop="protocol" required>
<el-radio-group v-model="form.protocol">
<el-radio value="tcp">TCP</el-radio>
<el-radio value="udp">UDP</el-radio>
<el-radio value="ws">WebSocket</el-radio>
<el-radio value="wss">WebSocket Secure</el-radio>
</el-radio-group>
<div class="form-tip">选择节点支持的连接协议</div>
</el-form-item>
<el-form-item label="允许中转" prop="allow_relay" required>
<el-radio-group v-model="form.allow_relay">
<el-radio :value="true">允许中转数据</el-radio>
<el-radio :value="false">仅用于打洞</el-radio>
</el-radio-group>
<div class="form-tip">选择节点是否允许中转其他用户的数据流量</div>
</el-form-item>
<el-form-item label="网络名称" prop="network_name" required>
<el-input v-model="form.network_name" placeholder="请输入EasyTier网络名称" maxlength="100" clearable>
<template #prefix>
<el-icon>
<Connection />
</el-icon>
</template>
</el-input>
<div class="form-tip"> EasyTier network name 一致用于后端探活</div>
</el-form-item>
<el-form-item label="网络密码" prop="network_secret" required>
<el-input v-model="form.network_secret" type="password" placeholder="请输入网络密码" maxlength="100" clearable
show-password>
<template #prefix>
<el-icon>
<Lock />
</el-icon>
</template>
</el-input>
<div class="form-tip"> EasyTier network secret 一致</div>
</el-form-item>
<el-form-item label="最大网络数" prop="max_connections" required>
<el-input-number v-model="form.max_connections" :min="1" :max="10000" placeholder="最大网络数量"
style="width: 200px" />
<div class="form-tip">节点能够承载的最大网络数量</div>
</el-form-item>
<el-form-item label="节点描述" prop="description">
<el-input v-model="form.description" type="textarea" :rows="4" placeholder="请描述您的节点特点,如:地理位置、网络质量、使用限制等"
maxlength="500" show-word-limit />
<div class="form-tip">详细描述有助于用户选择合适的节点</div>
</el-form-item>
<!-- 联系方式 -->
<el-form-item label="联系方式" prop="contact_info">
<div class="contact-section">
<el-form-item label="微信" prop="wechat">
<el-input v-model="form.wechat" placeholder="请输入微信号" maxlength="50" clearable>
<template #prefix>
<el-icon>
<ChatDotRound />
</el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="QQ" prop="qq_number">
<el-input v-model="form.qq_number" placeholder="请输入QQ号" maxlength="20" clearable>
<template #prefix>
<el-icon>
<User />
</el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="邮箱" prop="mail">
<el-input v-model="form.mail" placeholder="请输入邮箱地址" maxlength="100" clearable>
<template #prefix>
<el-icon>
<Message />
</el-icon>
</template>
</el-input>
</el-form-item>
<div class="form-tip">请至少填写一种联系方式便于节点问题时联系您仅管理员可见</div>
</div>
</el-form-item>
<!-- 连接测试 -->
<el-form-item label="连接测试">
<div class="test-section">
<el-button type="warning" @click="testConnection" :loading="testing" :disabled="!canTest">
<el-icon>
<Connection />
</el-icon>
测试连接
</el-button>
<div v-if="testResult" class="test-result">
<el-tag :type="testResult.success ? 'success' : 'danger'" size="large">
{{ testResult.success ? '连接成功' : '连接失败' }}
</el-tag>
<span v-if="testResult.message" class="test-message">
{{ testResult.message }}
</span>
</div>
</div>
<div class="form-tip">建议在提交前测试连接以确保节点可用</div>
</el-form-item>
<!-- 使用条款 -->
<el-form-item prop="agreed" v-if="props.showAgreement">
<el-checkbox v-model="form.agreed">
我已阅读并同意
<el-button type="primary" link @click="showTerms = true">
节点共享协议
</el-button>
</el-checkbox>
</el-form-item>
<!-- 提交按钮 -->
<el-form-item>
<div class="submit-section">
<el-button type="primary" size="large" @click="handleSubmit" :loading="submitting"
:disabled="!form.agreed && props.showAgreement">
<el-icon>
<Upload />
</el-icon>
提交节点
</el-button>
<el-button size="large" @click="resetFields">
<el-icon>
<RefreshLeft />
</el-icon>
重置表单
</el-button>
</div>
</el-form-item>
</el-form> <!-- 使用条款对话框 -->
<el-dialog v-model="showTerms" title="节点共享协议" width="600px">
<div class="terms-content">
<h3>1. 节点共享原则</h3>
<p> 节点提供者应确保节点的稳定性和可用性</p>
<p> 不得利用共享节点进行违法违规活动</p>
<p> 尊重其他用户的使用权益</p>
<h3>2. 服务质量要求</h3>
<p> 节点应保持7x24小时稳定运行</p>
<p> 网络延迟应控制在合理范围内</p>
<p> 及时处理连接问题和故障</p>
<h3>3. 数据安全</h3>
<p> 不得记录或泄露用户传输数据</p>
<p> 保护用户隐私和数据安全</p>
<p> 遵守相关法律法规</p>
<h3>4. 免责声明</h3>
<p> 平台不对节点服务质量承担责任</p>
<p> 用户使用节点服务的风险自担</p>
<p> 平台有权移除不符合要求的节点</p>
</div>
<template #footer>
<el-button @click="showTerms = false">关闭</el-button>
<el-button type="primary" @click="acceptTerms">同意并关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, watch } from 'vue'
import {
Monitor,
Location,
PriceTag,
Connection,
Upload,
Edit,
RefreshLeft,
ChatDotRound,
User,
Message
} from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { nodeApi } from '../api'
const props = defineProps({
modelValue: {
type: Object,
default: () => ({
name: '',
host: '',
port: 11010,
protocol: 'tcp',
allow_relay: true,
network_name: '',
network_secret: '',
max_connections: 100,
description: '',
wechat: '',
qq_number: '',
mail: '',
agreed: false
})
},
submitting: {
type: Boolean,
default: false
},
submitText: {
type: String,
default: '提交节点'
},
submitIcon: {
type: String,
default: 'Upload'
},
showConnectionTest: {
type: Boolean,
default: true
},
showAgreement: {
type: Boolean,
default: true
},
showCancel: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue', 'submit', 'reset', 'cancel', 'show-terms'])
const formRef = ref()
const testing = ref(false)
const testResult = ref(null)
const showTerms = ref(false)
// 表单数据
const form = reactive({ ...props.modelValue })
// 监听props变化更新表单数据
watch(() => props.modelValue, (newValue) => {
Object.assign(form, newValue)
}, { deep: true })
// 监听表单变化,向上传递
watch(form, (newValue) => {
emit('update:modelValue', { ...newValue })
}, { deep: true })
// 表单验证规则
const rules = {
name: [
{ required: true, message: '请输入节点名称', trigger: 'blur' },
{ min: 1, max: 100, message: '节点名称长度应在1-100个字符之间', trigger: 'blur' }
],
host: [
{ required: true, message: '请输入主机地址', trigger: 'blur' },
{ min: 1, max: 255, message: '主机地址长度应在1-255个字符之间', trigger: 'blur' },
{
pattern: /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$/,
message: '请输入有效的IP地址或域名',
trigger: 'blur'
}
],
port: [
{ required: true, message: '请输入端口号', trigger: 'blur' },
{ type: 'number', min: 1, max: 65535, message: '端口号应在1-65535之间', trigger: 'blur' }
],
protocol: [
{ required: true, message: '请选择协议类型', trigger: 'change' }
],
max_connections: [
{ required: true, message: '请输入最大连接数', trigger: 'blur' },
{ type: 'number', min: 1, max: 10000, message: '最大连接数应在1-10000之间', trigger: 'blur' }
],
version: [
{ max: 50, message: '版本信息长度不能超过50个字符', trigger: 'blur' }
],
description: [
{ max: 500, message: '描述长度不能超过500个字符', trigger: 'blur' }
],
wechat: [
{ max: 50, message: '微信号长度不能超过50个字符', trigger: 'blur' }
],
qq_number: [
{ max: 20, message: 'QQ号长度不能超过20个字符', trigger: 'blur' },
{ pattern: /^[1-9][0-9]{4,19}$/, message: '请输入有效的QQ号', trigger: 'blur' }
],
mail: [
{ max: 100, message: '邮箱地址长度不能超过100个字符', trigger: 'blur' },
{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' }
],
contact_info: [
{
validator: (rule, value, callback) => {
if (!form.wechat && !form.qq_number && !form.mail) {
callback(new Error('请至少填写一种联系方式'))
} else {
callback()
}
},
trigger: 'blur'
}
],
agreed: [
{
validator: (rule, value, callback) => {
if (!value) {
callback(new Error('请阅读并同意节点共享协议'))
} else {
callback()
}
},
trigger: 'change'
}
]
}
// 是否可以测试连接
const canTest = computed(() => {
return form.host && form.port && form.protocol && form.network_name && form.network_secret
})
const buildDataFromForm = () => {
return {
name: form.name || 'Test Node',
host: form.host,
port: form.port,
protocol: form.protocol,
description: form.description || null,
max_connections: form.max_connections || 100,
allow_relay: form.allow_relay,
network_name: form.network_name || null,
network_secret: form.network_secret || null,
wechat: form.wechat || null,
qq_number: form.qq_number || null,
mail: form.mail || null
}
}
// 测试连接
const testConnection = async () => {
if (!canTest.value) {
ElMessage.warning('请先填写主机地址、端口、协议、网络名称和网络密码')
return
}
testing.value = true
testResult.value = null
try {
// 构建测试数据
const testData = buildDataFromForm()
// 调用实际的连接测试API
const response = await nodeApi.testConnection(testData)
if (response.success) {
testResult.value = {
success: true,
message: '连接测试成功,节点可正常访问'
}
ElMessage.success('连接测试成功')
} else {
testResult.value = {
success: false,
message: response.error || '连接测试失败'
}
ElMessage.error('连接测试失败')
}
} catch (error) {
console.error('连接测试失败:', error)
testResult.value = {
success: false,
message: error.response?.data?.error || '测试过程中发生错误,请检查网络连接'
}
ElMessage.error('连接测试失败')
} finally {
testing.value = false
}
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
try {
const valid = await formRef.value.validate()
if (!valid) return
const submitData = buildDataFromForm()
emit('submit', submitData)
} catch (error) {
console.error('表单验证失败:', error)
}
}
// 重置表单
const resetFields = () => {
if (formRef.value) {
formRef.value.resetFields()
}
testResult.value = null
emit('reset')
}
const acceptTerms = () => {
form.agreed = true
showTerms.value = false
ElMessage.success('已同意节点共享协议')
}
// 暴露方法给父组件
defineExpose({
validate: () => formRef.value?.validate(),
resetFields: () => formRef.value?.resetFields()
})
</script>
<style scoped>
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
.test-section {
display: flex;
align-items: center;
gap: 12px;
}
.test-result {
display: flex;
align-items: center;
gap: 8px;
}
.test-message {
font-size: 12px;
color: #606266;
}
.submit-section {
display: flex;
gap: 12px;
}
.contact-section {
width: 100%;
}
.contact-section .el-form-item {
margin-bottom: 16px;
}
.contact-section .el-form-item:last-of-type {
margin-bottom: 8px;
}
.contact-section .el-form-item__label {
font-size: 14px;
color: #606266;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,22 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import router from './router'
import App from './App.vue'
import './style.css'
const app = createApp(App)
// 注册Element Plus
app.use(ElementPlus)
// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
// 注册路由
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,78 @@
import { createRouter, createWebHistory } from 'vue-router'
import NodeDashboard from '../views/NodeDashboard.vue'
import SubmitNode from '../views/SubmitNode.vue'
import AdminLogin from '../views/AdminLogin.vue'
import AdminDashboard from '../views/AdminDashboard.vue'
const routes = [
{
path: '/',
name: 'Dashboard',
component: NodeDashboard,
meta: {
title: '节点状态监控'
}
},
{
path: '/submit',
name: 'Submit',
component: SubmitNode,
meta: {
title: '提交共享节点'
}
},
{
path: '/admin/login',
name: 'AdminLogin',
component: AdminLogin,
meta: {
title: '管理员登录'
}
},
{
path: '/admin',
name: 'AdminDashboard',
component: AdminDashboard,
meta: {
title: '管理员面板',
requiresAuth: true
}
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// 路由守卫
router.beforeEach(async (to, from, next) => {
// 设置页面标题
if (to.meta.title) {
document.title = `${to.meta.title} - EasyTier Uptime`
}
// 检查管理员权限
if (to.meta.requiresAuth) {
const token = localStorage.getItem('admin_token')
if (!token) {
next('/admin/login')
return
}
// 验证token有效性
try {
const { adminApi } = await import('../api')
await adminApi.verifyToken()
} catch (error) {
console.error('Token verification failed:', error)
localStorage.removeItem('admin_token')
next('/admin/login')
return
}
}
next()
})
export default router

View File

@@ -0,0 +1,243 @@
/* 自定义样式 */
:root {
--primary-color: #409EFF;
--success-color: #67C23A;
--warning-color: #E6A23C;
--danger-color: #F56C6C;
--info-color: #909399;
--text-primary: #303133;
--text-regular: #606266;
--text-secondary: #909399;
--text-placeholder: #C0C4CC;
--border-base: #DCDFE6;
--border-light: #E4E7ED;
--border-lighter: #EBEEF5;
--border-extra-light: #F2F6FC;
--background-base: #F5F7FA;
--background-light: #FAFAFA;
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 工具类 */
.text-center {
text-align: center;
}
.text-left {
text-align: left;
}
.text-right {
text-align: right;
}
.flex {
display: flex;
}
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.flex-column {
flex-direction: column;
}
.flex-1 {
flex: 1;
}
.mb-10 {
margin-bottom: 10px;
}
.mb-20 {
margin-bottom: 20px;
}
.mt-10 {
margin-top: 10px;
}
.mt-20 {
margin-top: 20px;
}
.p-10 {
padding: 10px;
}
.p-20 {
padding: 20px;
}
/* 动画效果 */
.fade-in {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.slide-up {
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 响应式断点 */
@media (max-width: 768px) {
.mobile-hidden {
display: none !important;
}
}
@media (min-width: 769px) {
.desktop-hidden {
display: none !important;
}
}
/* 状态指示器 */
.status-online {
color: var(--success-color);
}
.status-offline {
color: var(--danger-color);
}
.status-warning {
color: var(--warning-color);
}
/* 卡片阴影效果 */
.card-shadow {
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
transition: box-shadow 0.3s;
}
.card-shadow:hover {
box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.15);
}
/* 加载状态 */
.loading-overlay {
position: relative;
}
.loading-overlay::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
/* 表格样式增强 */
.el-table .el-table__row:hover {
cursor: pointer;
}
/* 按钮组样式 */
.button-group {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.button-group .el-button {
margin: 0;
}
/* 统计卡片样式 */
.stat-card {
text-align: center;
padding: 10px;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
border-radius: 8px;
transition: transform 0.3s;
}
.stat-card:hover {
transform: translateY(-2px);
}
/* 标签样式 */
.tag-group {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
/* 描述列表样式 */
.description-list {
display: grid;
grid-template-columns: auto 1fr;
gap: 10px 20px;
align-items: center;
}
.description-list .label {
font-weight: 600;
color: var(--text-regular);
}
.description-list .value {
color: var(--text-primary);
}

View File

@@ -0,0 +1,579 @@
<template>
<div>
<el-container class="admin-dashboard">
<!-- 头部导航 -->
<el-header class="admin-header">
<div class="header-content">
<div class="flex">
<h1 class="header-title">管理员面板</h1>
</div>
<div class="header-actions">
<router-link to="/" class="nav-link">
返回首页
</router-link>
<el-button type="danger" @click="logout">
退出登录
</el-button>
</div>
</div>
</el-header>
<!-- 主要内容 -->
<el-main class="main-content">
<!-- 统计卡片 -->
<el-row :gutter="20" class="mb-20">
<el-col :xs="24" :sm="12" :md="6">
<el-card class="stat-card">
<div class="stat-content">
<div class="stat-icon success">
<el-icon>
<Check />
</el-icon>
</div>
<div class="stat-info">
<div class="stat-label">已审批节点</div>
<div class="stat-value">{{ stats.approved }}</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<el-card class="stat-card">
<div class="stat-content">
<div class="stat-icon warning">
<el-icon>
<Clock />
</el-icon>
</div>
<div class="stat-info">
<div class="stat-label">待审批节点</div>
<div class="stat-value">{{ stats.pending }}</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<el-card class="stat-card">
<div class="stat-content">
<div class="stat-icon info">
<el-icon>
<DataAnalysis />
</el-icon>
</div>
<div class="stat-info">
<div class="stat-label">总节点数</div>
<div class="stat-value">{{ stats.total }}</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<el-card class="stat-card">
<div class="stat-content">
<div class="stat-icon success">
<el-icon>
<CircleCheck />
</el-icon>
</div>
<div class="stat-info">
<div class="stat-label">在线节点</div>
<div class="stat-value">{{ stats.active }}</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 筛选器 -->
<el-card class="mb-20">
<template #header>
<span>筛选条件</span>
</template>
<el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="6">
<el-form-item label="审批状态">
<el-select v-model="filters.approved" @change="loadNodes" placeholder="全部" clearable>
<el-option label="全部" value="" />
<el-option label="已审批" value="true" />
<el-option label="待审批" value="false" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<el-form-item label="在线状态">
<el-select v-model="filters.active" @change="loadNodes" placeholder="全部" clearable>
<el-option label="全部" value="" />
<el-option label="在线" value="true" />
<el-option label="离线" value="false" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<el-form-item label="协议">
<el-select v-model="filters.protocol" @change="loadNodes" placeholder="全部" clearable>
<el-option label="全部" value="" />
<el-option label="TCP" value="tcp" />
<el-option label="UDP" value="udp" />
<el-option label="WireGuard" value="wg" />
<el-option label="WebSocket" value="ws" />
<el-option label="WebSocket Secure" value="wss" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<el-form-item label="搜索">
<el-input v-model="filters.search" @input="debounceSearch" placeholder="搜索节点名称或主机" clearable />
</el-form-item>
</el-col>
</el-row>
</el-card>
<!-- 节点列表 -->
<el-card>
<template #header>
<div class="flex-between">
<div>
<h3>节点列表</h3>
<p class="text-secondary">管理所有共享节点</p>
</div>
</div>
</template>
<div v-if="loading" class="text-center p-20">
<el-icon class="is-loading" size="32">
<Loading />
</el-icon>
<p class="mt-10">加载中...</p>
</div>
<el-table v-else-if="nodes.length > 0" :data="nodes" stripe>
<el-table-column prop="name" label="节点名称" min-width="120">
<template #default="{ row }">
<div class="flex items-center">
<el-icon class="mr-2"
:color="row.is_active && row.is_approved ? '#67C23A' : !row.is_approved ? '#E6A23C' : '#F56C6C'">
<CircleCheck v-if="row.is_active && row.is_approved" />
<Clock v-else-if="!row.is_approved" />
<el-icon v-else></el-icon>
</el-icon>
<span>{{ row.name }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="host" label="主机地址" min-width="150">
<template #default="{ row }">
{{ row.host }}:{{ row.port }}
</template>
</el-table-column>
<el-table-column prop="protocol" label="协议" width="80">
<template #default="{ row }">
<el-tag :type="getProtocolType(row.protocol)" size="small">
{{ row.protocol.toUpperCase() }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="is_approved" label="审批状态" width="100">
<template #default="{ row }">
<el-tag :type="row.is_approved ? 'success' : 'warning'" size="small">
{{ row.is_approved ? '已审批' : '待审批' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="is_active" label="在线状态" width="100">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'danger'" size="small">
{{ row.is_active ? '在线' : '离线' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="150" show-overflow-tooltip />
<el-table-column prop="created_at" label="创建时间" width="160">
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" @click="editNode(row)">
编辑
</el-button>
<el-button v-if="!row.is_approved" type="success" size="small" @click="approveNode(row.id)">
审批
</el-button>
<el-button v-if="row.is_approved" type="warning" size="small" @click="revokeApproval(row.id)">
撤销
</el-button>
<el-button type="danger" size="small" @click="deleteNode(row.id)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无节点数据" />
</el-card>
</el-main>
</el-container>
<!-- 编辑节点对话框 -->
<el-dialog v-model="editDialogVisible" title="编辑节点" width="800px" destroy-on-close>
<NodeForm v-if="editDialogVisible" v-model="editForm" :submitting="updating" submit-text="更新节点" submit-icon="Edit"
:show-connection-test="false" :show-agreement="false" :show-cancel="true" @submit="handleUpdateNode"
@cancel="editDialogVisible = false" @reset="resetEditForm" />
</el-dialog>
</div>
</template>
<script>
import { adminApi } from '../api'
import dayjs from 'dayjs'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Check, Clock, DataAnalysis, CircleCheck, Loading } from '@element-plus/icons-vue'
import NodeForm from '../components/NodeForm.vue'
export default {
name: 'AdminDashboard',
components: {
Check,
Clock,
DataAnalysis,
CircleCheck,
Loading,
NodeForm
},
data() {
return {
loading: false,
nodes: [],
filters: {
approved: '',
active: '',
protocol: '',
search: ''
},
searchTimeout: null,
editDialogVisible: false,
editForm: {
name: '',
host: '',
port: 11010,
protocol: 'tcp',
version: '',
max_connections: 100,
description: ''
},
editingNodeId: null,
updating: false
}
},
computed: {
stats() {
const total = this.nodes.length
const approved = this.nodes.filter(node => node.is_approved).length
const pending = this.nodes.filter(node => !node.is_approved).length
const active = this.nodes.filter(node => node.is_active).length
return {
total,
approved,
pending,
active
}
}
},
async mounted() {
// 先验证token有效性
try {
await adminApi.verifyToken()
await this.loadNodes()
} catch (error) {
console.error('Token verification failed in mounted:', error)
this.logout()
}
},
methods: {
async loadNodes() {
try {
this.loading = true
const params = {}
if (this.filters.approved !== '') {
params.approved = this.filters.approved
}
if (this.filters.active !== '') {
params.active = this.filters.active
}
if (this.filters.protocol) {
params.protocol = this.filters.protocol
}
if (this.filters.search) {
params.search = this.filters.search
}
const response = await adminApi.getNodes(params)
this.nodes = response.data?.items || []
} catch (error) {
console.error('加载节点失败:', error)
if (error.response?.status === 401) {
this.logout()
} else {
ElMessage.error('加载节点失败')
}
} finally {
this.loading = false
}
},
async approveNode(nodeId) {
try {
await ElMessageBox.confirm('确定要审批通过这个节点吗?', '确认审批', {
type: 'warning'
})
await adminApi.approveNode(nodeId)
ElMessage.success('审批成功')
await this.loadNodes()
} catch (error) {
if (error !== 'cancel') {
console.error('审批失败:', error)
ElMessage.error('审批失败')
}
}
},
async revokeApproval(nodeId) {
try {
await ElMessageBox.confirm('确定要撤销这个节点的审批吗?撤销后节点将变为待审批状态。', '确认撤销审批', {
type: 'warning'
})
await adminApi.revokeApproval(nodeId)
ElMessage.success('撤销审批成功')
await this.loadNodes()
} catch (error) {
if (error !== 'cancel') {
console.error('撤销审批失败:', error)
ElMessage.error('撤销审批失败')
}
}
},
async deleteNode(nodeId) {
try {
await ElMessageBox.confirm('确定要删除这个节点吗?此操作不可恢复!', '确认删除', {
type: 'warning'
})
await adminApi.deleteNode(nodeId)
ElMessage.success('删除成功')
await this.loadNodes()
} catch (error) {
if (error !== 'cancel') {
console.error('删除失败:', error)
ElMessage.error('删除失败')
}
}
},
editNode(node) {
this.editingNodeId = node.id
this.editForm = node
this.editDialogVisible = true
},
async handleUpdateNode(formData) {
try {
this.updating = true
await adminApi.updateNode(this.editingNodeId, formData)
ElMessage.success('节点更新成功')
this.editDialogVisible = false
await this.loadNodes()
} catch (error) {
console.error('更新节点失败:', error)
ElMessage.error('更新节点失败')
} finally {
this.updating = false
}
},
resetEditForm() {
this.editForm = {
name: '',
host: '',
port: 11010,
protocol: 'tcp',
version: '',
max_connections: 100,
description: ''
}
},
debounceSearch() {
if (this.searchTimeout) {
clearTimeout(this.searchTimeout)
}
this.searchTimeout = setTimeout(() => {
this.loadNodes()
}, 500)
},
formatDate(dateString) {
return dayjs(dateString).format('YYYY-MM-DD HH:mm:ss')
},
getProtocolType(protocol) {
const typeMap = {
tcp: 'primary',
udp: 'success',
wg: 'warning',
ws: 'info',
wss: 'danger'
}
return typeMap[protocol] || 'info'
},
async logout() {
try {
await ElMessageBox.confirm('确定要退出登录吗?', '确认退出', {
type: 'warning'
})
localStorage.removeItem('admin_token')
this.$router.push('/admin/login')
} catch (error) {
// 用户取消
}
}
}
}
</script>
<style scoped>
.admin-dashboard {
min-height: 100vh;
}
.admin-header {
background: white;
border-bottom: 1px solid #e4e7ed;
padding: 0;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20px;
height: 100%;
}
.header-title {
margin: 0;
color: #303133;
}
.header-actions {
display: flex;
align-items: center;
gap: 16px;
}
.nav-link {
color: #409eff;
text-decoration: none;
}
.nav-link:hover {
color: #66b1ff;
}
.main-content {
background: #f5f7fa;
padding: 20px;
}
.mb-20 {
margin-bottom: 20px;
}
.stat-card {
position: relative;
overflow: hidden;
height: 100px;
}
.stat-content {
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
}
.stat-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.stat-label {
font-size: 12px;
color: #909399;
margin: 0 0 4px 0;
}
.stat-value {
font-size: 24px;
font-weight: bold;
color: #303133;
line-height: 1;
margin: 0;
}
.stat-icon {
font-size: 28px;
opacity: 0.3;
margin-left: 16px;
}
.stat-icon.success {
color: #67c23a;
}
.stat-icon.warning {
color: #e6a23c;
}
.stat-icon.info {
color: #409eff;
}
.flex {
display: flex;
}
.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
.items-center {
align-items: center;
}
.mr-2 {
margin-right: 8px;
}
.mt-10 {
margin-top: 10px;
}
.p-20 {
padding: 20px;
}
.text-center {
text-align: center;
}
.text-secondary {
color: #909399;
}
</style>

View File

@@ -0,0 +1,251 @@
<template>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<div class="login-icon">
<el-icon :size="48" color="#409EFF">
<Lock />
</el-icon>
</div>
<h2 class="login-title">管理员登录</h2>
<p class="login-subtitle">请输入管理员密码以访问管理面板</p>
</div>
<div class="login-form">
<el-form @submit.prevent="handleLogin" :model="form" :rules="rules" ref="loginForm">
<el-form-item prop="password">
<el-input v-model="form.password" type="password" placeholder="请输入管理员密码" size="large" show-password
:prefix-icon="Lock" @keyup.enter="handleLogin" />
</el-form-item>
<el-form-item v-if="error">
<el-alert :title="error" type="error" :closable="false" show-icon />
</el-form-item>
<el-form-item>
<el-button type="primary" size="large" :loading="loading" @click="handleLogin" class="login-button">
{{ loading ? '登录中...' : '登录' }}
</el-button>
</el-form-item>
</el-form>
<div class="login-divider">
<el-divider></el-divider>
</div>
<div class="login-actions">
<el-button size="large" @click="$router.push('/')" class="back-button">
<el-icon class="mr-2">
<ArrowLeft />
</el-icon>
返回首页
</el-button>
</div>
</div>
</div>
</div>
</template>
<script>
import { adminApi } from '../api'
import { Lock, ArrowLeft } from '@element-plus/icons-vue'
export default {
name: 'AdminLogin',
components: {
Lock,
ArrowLeft
},
data() {
return {
loading: false,
error: '',
form: {
password: ''
},
rules: {
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 1, message: '密码不能为空', trigger: 'blur' }
]
}
}
},
methods: {
async handleLogin() {
if (!this.form.password) {
this.error = '请输入密码'
return
}
this.loading = true
this.error = ''
try {
const response = await adminApi.login(this.form.password)
// 保存token
const token = response.data?.token || response.token
if (token) {
localStorage.setItem('admin_token', token)
// 跳转到管理面板
this.$router.push('/admin')
} else {
throw new Error('No token received from server')
}
} catch (error) {
console.error('Login error:', error)
console.error('Error details:', {
message: error.message,
status: error.response?.status,
statusText: error.response?.statusText,
data: error.response?.data
})
if (error.response?.status === 401) {
this.error = '密码错误,请重新输入'
} else if (error.response?.data?.message) {
this.error = error.response.data.message
} else if (error.message) {
this.error = error.message
} else {
this.error = '登录失败,请检查网络连接'
}
} finally {
this.loading = false
}
}
},
mounted() {
// 如果已经登录,直接跳转到管理面板
const token = localStorage.getItem('admin_token')
if (token) {
this.$router.push('/admin')
}
}
}
</script>
<style scoped>
.login-container {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-card {
background: white;
border-radius: 16px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
padding: 40px;
width: 100%;
max-width: 400px;
backdrop-filter: blur(10px);
}
.login-header {
text-align: center;
margin-bottom: 32px;
}
.login-icon {
margin-bottom: 16px;
}
.login-title {
font-size: 28px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 8px 0;
}
.login-subtitle {
font-size: 14px;
color: var(--text-secondary);
margin: 0;
}
.login-form {
width: 100%;
}
.login-button {
width: 100%;
height: 48px;
font-size: 16px;
font-weight: 500;
border-radius: 8px;
}
.login-divider {
margin: 24px 0;
}
.login-actions {
width: 100%;
}
.back-button {
width: 100%;
height: 48px;
font-size: 16px;
border-radius: 8px;
}
.mr-2 {
margin-right: 8px;
}
/* 响应式设计 */
@media (max-width: 480px) {
.login-card {
padding: 24px;
margin: 16px;
}
.login-title {
font-size: 24px;
}
}
/* 动画效果 */
.login-card {
animation: fadeInUp 0.6s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Element Plus 组件样式覆盖 */
:deep(.el-input__wrapper) {
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
:deep(.el-input__wrapper:hover) {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
:deep(.el-button) {
transition: all 0.3s ease;
}
:deep(.el-button:hover) {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(64, 158, 255, 0.3);
}
</style>

View File

@@ -0,0 +1,573 @@
<template>
<div class="node-dashboard">
<!-- 页面头部 -->
<div class="dashboard-header">
<h1>EasyTier 节点状态监控</h1>
<p class="subtitle">实时监控所有共享节点的健康状态和连接信息</p>
</div>
<!-- 统计卡片 -->
<el-row :gutter="20" class="stats-row">
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-content">
<div class="stat-number">{{ totalNodes }}</div>
<div class="stat-label">总节点数</div>
</div>
<el-icon class="stat-icon" color="#409EFF">
<Monitor />
</el-icon>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-content">
<div class="stat-number">{{ activeNodes }}</div>
<div class="stat-label">在线节点</div>
</div>
<el-icon class="stat-icon" color="#67C23A">
<CircleCheck />
</el-icon>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-content">
<div class="stat-number">{{ averageLoad }} %</div>
<div class="stat-label">平均负载</div>
</div>
<el-icon class="stat-icon" color="#E6A23C">
<Link />
</el-icon>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-content">
<div class="stat-number">{{ averageUptime }}%</div>
<div class="stat-label">平均在线率</div>
</div>
<el-icon class="stat-icon" color="#F56C6C">
<TrendCharts />
</el-icon>
</el-card>
</el-col>
</el-row>
<!-- 搜索和筛选 -->
<el-card class="filter-card">
<el-row :gutter="20">
<el-col :span="8">
<el-input v-model="searchText" placeholder="搜索节点名称、主机地址或描述" prefix-icon="Search" clearable
@input="handleSearch" />
</el-col>
<el-col :span="4">
<el-select v-model="statusFilter" placeholder="状态筛选" clearable @change="handleFilter">
<el-option label="全部" value="" />
<el-option label="在线" value="true" />
<el-option label="离线" value="false" />
</el-select>
</el-col>
<el-col :span="4">
<el-select v-model="protocolFilter" placeholder="协议筛选" clearable @change="handleFilter">
<el-option label="全部" value="" />
<el-option label="TCP" value="tcp" />
<el-option label="UDP" value="udp" />
<el-option label="WS" value="ws" />
<el-option label="WSS" value="wss" />
</el-select>
</el-col>
<el-col :span="4">
<el-button type="primary" @click="refreshData" :loading="loading">
<el-icon>
<Refresh />
</el-icon>
刷新
</el-button>
</el-col>
<el-col :span="4">
<el-button type="success" @click="$router.push('/submit')">
<el-icon>
<Plus />
</el-icon>
提交节点
</el-button>
</el-col>
</el-row>
</el-card>
<!-- 节点列表 -->
<el-card class="nodes-card">
<template #header>
<div class="card-header">
<span>节点列表</span>
<el-tag :type="loading ? 'info' : 'success'">
{{ loading ? '加载中...' : `${pagination.total} 个节点` }}
</el-tag>
</div>
</template>
<el-table :data="nodes" v-loading="loading" stripe style="width: 100%" row-key="id">
<!-- 展开列 -->
<el-table-column type="expand" width="50">
<template #default="{ row }">
<div class="expanded-content">
<HealthTimeline :node-info="row" :compact="true" />
</div>
</template>
</el-table-column>
<el-table-column prop="name" label="节点名称" width="150">
<template #default="{ row }">
<div class="node-name">
<el-icon :color="row.is_active ? '#67C23A' : '#F56C6C'">
<CircleCheck v-if="row.is_active" />
<CircleClose v-else />
</el-icon>
<span>{{ row.name }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="address" label="节点连接地址" width="250">
<template #header>
<span>节点连接地址</span>
<el-tooltip content="可以将节点链接填入命令行的 -p 参数,或者图形界面的节点地址字段(公共服务器或手动皆可)" placement="top" effect="light">
<el-icon class="help-icon">
<QuestionFilled />
</el-icon>
</el-tooltip>
</template>
<template #default="{ row }">
<el-tag type="primary" size="" style="margin-bottom: 0.2rem;"
@click="copyAddress(apiUrl + 'node/' + row.id)"> {{
apiUrl
}}node/{{ row.id }}</el-tag>
<el-tag type="info" size="" @click="copyAddress(row.address)">{{ row.address }}</el-tag>
</template>
</el-table-column>
<el-table-column label="版本" width="90">
<template #default="{ row }">
<div style="display: flex; flex-direction: column; gap: 1px; align-items: flex-start;">
<el-tag v-if="row.version" size="small" style="font-size: 11px; padding: 1px 4px;">{{ row.version
}}</el-tag>
<span v-else class="text-muted" style="font-size: 11px;">未知</span>
<el-tag :type="row.allow_relay ? 'success' : 'info'" size="small"
style="font-size: 9px; padding: 1px 3px;">
{{ row.allow_relay ? '可中转' : '禁中转' }}
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="连接状态" width="150">
<template #default="{ row }">
<div class="connection-info">
<span>{{ row.current_connections }}/{{ row.max_connections }}</span>
<el-progress :percentage="row.usage_percentage" :color="getProgressColor(row.usage_percentage)"
:stroke-width="6" :show-text="false" />
</div>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="200">
<template #default="{ row }">
<span class="description">{{ row.description || '暂无描述' }}</span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" @click.stop="viewNodeDetails(row)">
详情
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page"
:page-sizes="[10, 20, 50, 100]" :total="pagination.total" layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange" @current-change="handleCurrentChange" />
</div>
</el-card>
<!-- 节点详情对话框 -->
<el-dialog v-model="detailDialogVisible" :title="selectedNode?.name + ' - 详细信息'" width="800px" destroy-on-close>
<div v-if="selectedNode" class="node-details">
<el-descriptions :column="2" border>
<el-descriptions-item label="节点名称">{{ selectedNode.name }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="selectedNode.is_active ? 'success' : 'danger'">
{{ selectedNode.is_active ? '在线' : '离线' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="主机地址">{{ selectedNode.host }}</el-descriptions-item>
<el-descriptions-item label="端口">{{ selectedNode.port }}</el-descriptions-item>
<el-descriptions-item label="协议">{{ selectedNode.protocol.toUpperCase() }}</el-descriptions-item>
<el-descriptions-item label="版本">{{ selectedNode.version || '未知' }}</el-descriptions-item>
<el-descriptions-item label="允许中转">
<el-tag :type="selectedNode.allow_relay ? 'success' : 'info'" size="small">
{{ selectedNode.allow_relay ? '是' : '否' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="使用率">{{ selectedNode.usage_percentage.toFixed(1) }}%</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDate(selectedNode.created_at) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDate(selectedNode.updated_at) }}</el-descriptions-item>
<el-descriptions-item label="描述" :span="2">{{ selectedNode.description || '暂无描述' }}</el-descriptions-item>
</el-descriptions>
<!-- 健康状态统计 -->
<div class="health-stats" v-if="healthStats">
<h3>健康状态统计 (最近24小时)</h3>
<el-row :gutter="20">
<el-col :span="6">
<div class="health-stat-item">
<div class="stat-value">{{ healthStats.uptime_percentage?.toFixed(1) || 0 }}%</div>
<div class="stat-label">在线率</div>
</div>
</el-col>
<el-col :span="6">
<div class="health-stat-item">
<div class="stat-value">{{ (selectedNode.last_response_time / 1000) || 0 }}ms</div>
<div class="stat-label">平均响应时间</div>
</div>
</el-col>
<el-col :span="6">
<div class="health-stat-item">
<div class="stat-value">{{ healthStats.total_checks || 0 }}</div>
<div class="stat-label">检查次数</div>
</div>
</el-col>
<el-col :span="6">
<div class="health-stat-item">
<div class="stat-value">{{ healthStats.failed_checks || 0 }}</div>
<div class="stat-label">失败次数</div>
</div>
</el-col>
</el-row>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { nodeApi } from '../api'
import dayjs from 'dayjs'
import HealthTimeline from '../components/HealthTimeline.vue'
import {
Monitor,
CircleCheck,
CircleClose,
Link,
TrendCharts,
Search,
Refresh,
Plus
} from '@element-plus/icons-vue'
// 响应式数据
const loading = ref(false)
const nodes = ref([])
const searchText = ref('')
const statusFilter = ref('true')
const protocolFilter = ref('')
const detailDialogVisible = ref(false)
const selectedNode = ref(null)
const healthStats = ref(null)
const expandedRows = ref([])
const apiUrl = ref(window.location.href)
// 分页数据
const pagination = reactive({
page: 1,
per_page: 20,
total: 0
})
// 计算属性
const totalNodes = computed(() => nodes.value.length)
const activeNodes = computed(() => nodes.value.filter(node => node.is_active).length)
const averageLoad = computed(() =>
(nodes.value.reduce((sum, node) => sum + node.current_connections, 0) / (nodes.value.length)).toFixed(2)
)
const averageUptime = computed(() => {
if (nodes.value.length === 0) return 0
const activeCount = nodes.value.filter(node => node.is_active).length
return ((activeCount / nodes.value.length) * 100).toFixed(1)
})
// 方法
const fetchNodes = async (with_loading = true) => {
try {
if (with_loading) {
loading.value = true
}
const params = {
page: pagination.page,
per_page: pagination.per_page
}
if (searchText.value) {
params.search = searchText.value
}
if (statusFilter.value !== '') {
params.is_active = statusFilter.value === 'true'
}
if (protocolFilter.value) {
params.protocol = protocolFilter.value
}
const response = await nodeApi.getNodes(params)
if (response.success && response.data) {
nodes.value = response.data.items
pagination.total = response.data.total
}
} catch (error) {
console.error('获取节点列表失败:', error)
ElMessage.error('获取节点列表失败')
} finally {
if (with_loading) {
loading.value = false
}
}
}
const refreshData = () => {
fetchNodes()
}
const handleSearch = () => {
pagination.page = 1
fetchNodes()
}
const handleFilter = () => {
pagination.page = 1
fetchNodes()
}
const handleSizeChange = (size) => {
pagination.per_page = size
pagination.page = 1
fetchNodes()
}
const handleCurrentChange = (page) => {
pagination.page = page
fetchNodes()
}
const viewNodeDetails = async (node) => {
selectedNode.value = node
detailDialogVisible.value = true
// 获取健康状态统计
try {
const response = await nodeApi.getNodeHealthStats(node.id, { hours: 24 })
if (response.success && response.data) {
healthStats.value = response.data
}
} catch (error) {
console.error('获取健康状态统计失败:', error)
}
}
const formatDate = (dateString) => {
return dayjs(dateString).format('YYYY-MM-DD HH:mm:ss')
}
const getProgressColor = (percentage) => {
if (percentage < 50) return '#67C23A'
if (percentage < 80) return '#E6A23C'
return '#F56C6C'
}
const copyAddress = (address) => {
try {
navigator.clipboard.writeText(address).then(() => {
ElMessage.success(`地址已复制, ${address}`)
}).catch(() => {
ElMessage.error(`复制失败, ${address}`)
})
} catch (error) {
ElMessage.error(`复制失败, ${address}`)
}
}
// 生命周期
onMounted(() => {
fetchNodes()
// 设置定时刷新
setInterval(() => {
fetchNodes(false)
}, 3000) // 每30秒刷新一次
})
</script>
<style scoped>
.node-dashboard {
padding: 20px;
background-color: #f5f7fa;
min-height: 100vh;
}
.dashboard-header {
text-align: center;
margin-bottom: 30px;
}
.dashboard-header h1 {
color: #303133;
margin-bottom: 10px;
font-size: 32px;
font-weight: 600;
}
.subtitle {
color: #606266;
font-size: 16px;
margin: 0;
}
.stats-row {
margin-bottom: 16px;
}
.stat-card {
position: relative;
overflow: hidden;
height: 100px;
}
.stat-content {
padding: 0 16px;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.stat-number {
font-size: 24px;
font-weight: bold;
color: #303133;
line-height: 1;
margin-bottom: 4px;
}
.stat-label {
font-size: 12px;
color: #909399;
margin: 0;
}
.stat-icon {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
font-size: 28px;
opacity: 0.3;
}
.filter-card {
margin-bottom: 20px;
}
.nodes-card {
background: white;
border-radius: 8px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.node-name {
display: flex;
align-items: center;
gap: 8px;
}
.address {
margin-left: 8px;
font-family: monospace;
}
.connection-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.description {
color: #606266;
font-size: 13px;
}
.text-muted {
color: #C0C4CC;
}
.pagination-wrapper {
display: flex;
justify-content: center;
margin-top: 20px;
}
.node-details {
padding: 10px 0;
}
.health-stats {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #EBEEF5;
}
.health-stats h3 {
margin-bottom: 20px;
color: #303133;
}
.health-stat-item {
text-align: center;
padding: 15px;
background: #f8f9fa;
border-radius: 6px;
}
.health-stat-item .stat-value {
font-size: 24px;
font-weight: bold;
color: #409EFF;
margin-bottom: 5px;
}
.health-stat-item .stat-label {
font-size: 12px;
color: #909399;
}
.expanded-content {
padding: 16px 24px;
background-color: #fafafa;
border-top: 1px solid #ebeef5;
}
</style>

View File

@@ -0,0 +1,351 @@
<template>
<div class="submit-node">
<!-- 页面头部 -->
<div class="page-header">
<el-button type="primary" @click="$router.back()" class="back-btn">
<el-icon>
<ArrowLeft />
</el-icon>
返回
</el-button>
<h1>提交共享节点</h1>
<p class="subtitle">分享您的EasyTier节点为社区贡献力量</p>
</div>
<el-row :gutter="20" justify="center">
<el-col :span="16">
<!-- 提交表单 -->
<el-card class="form-card">
<template #header>
<div class="card-header">
<el-icon>
<Plus />
</el-icon>
<span>节点信息</span>
</div>
</template>
<NodeForm ref="formRef" @submit="handleSubmit" :submitting="submitting" />
</el-card>
</el-col>
<!-- 侧边栏信息 -->
<el-col :span="8">
<el-card class="info-card">
<template #header>
<div class="card-header">
<el-icon>
<InfoFilled />
</el-icon>
<span>提交须知</span>
</div>
</template>
<div class="info-content">
<div class="info-item">
<el-icon color="#409EFF">
<CircleCheck />
</el-icon>
<div>
<h4>节点要求</h4>
<p>确保您的节点稳定运行具有良好的网络连接</p>
</div>
</div>
<div class="info-item">
<el-icon color="#67C23A">
<Lock />
</el-icon>
<div>
<h4>隐私保护</h4>
<p>关键信息仅社区管理员可见</p>
</div>
</div>
<div class="info-item">
<el-icon color="#E6A23C">
<Warning />
</el-icon>
<div>
<h4>注意事项</h4>
<p>请确保节点信息准确避免提交虚假信息</p>
</div>
</div>
<div class="info-item">
<el-icon color="#F56C6C">
<Delete />
</el-icon>
<div>
<h4>移除条件</h4>
<p>长期离线或不稳定的节点将被自动移除</p>
</div>
</div>
<div class="info-item">
<el-icon color="#F56C6C">
<DocumentChecked />
</el-icon>
<div>
<h4>审核机制</h4>
<p>所有节点提交均需要审核审核通过后才会展示在节点列表中</p>
</div>
</div>
</div>
</el-card>
<!-- 统计信息 -->
<el-card class="stats-card">
<template #header>
<div class="card-header">
<el-icon>
<DataAnalysis />
</el-icon>
<span>社区统计</span>
</div>
</template>
<div class="stats-content">
<div class="stat-item">
<div class="stat-number">{{ communityStats.totalNodes }}</div>
<div class="stat-label">总节点数</div>
</div>
<div class="stat-item">
<div class="stat-number">{{ communityStats.activeNodes }}</div>
<div class="stat-label">在线节点</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { nodeApi } from '../api'
import {
ArrowLeft,
Plus,
InfoFilled,
CircleCheck,
Lock,
Warning,
DocumentChecked,
Delete,
DataAnalysis
} from '@element-plus/icons-vue'
import NodeForm from '../components/NodeForm.vue'
const formRef = ref()
const router = useRouter()
const submitting = ref(false)
// 社区统计数据
const communityStats = reactive({
totalNodes: 0,
activeNodes: 0,
})
const handleSubmit = async (submitData) => {
try {
const response = await nodeApi.createNode(submitData)
if (response.success) {
ElMessage.success('节点提交成功!')
ElMessageBox.confirm(
'节点已成功提交,等待管理员审核后将会展示在节点列表中。如果信息填写错误请重新提交或者联系管理员更改。',
'提交成功',
{
confirmButtonText: '查看列表',
cancelButtonText: '继续提交',
type: 'success'
}
).then(() => {
router.push('/')
}).catch(() => {
})
} else {
ElMessage.error(response.error || '提交失败,请重试')
}
} catch (error) {
console.error('提交节点失败:', error)
ElMessage.error('提交失败,请检查网络连接')
} finally {
submitting.value = false
}
}
const fetchCommunityStats = async () => {
try {
const response = await nodeApi.getNodes({ page: 1, per_page: 1 })
if (response.success && response.data) {
communityStats.totalNodes = response.data.total
// 获取活跃节点数
const activeResponse = await nodeApi.getNodes({ page: 1, per_page: 1, is_active: true })
if (activeResponse.success && activeResponse.data) {
communityStats.activeNodes = activeResponse.data.total
}
}
} catch (error) {
console.error('获取社区统计失败:', error)
}
}
// 生命周期
onMounted(() => {
fetchCommunityStats()
})
</script>
<style scoped>
.submit-node {
padding: 20px;
background-color: #f5f7fa;
min-height: 100vh;
}
.page-header {
text-align: center;
margin-bottom: 30px;
position: relative;
}
.back-btn {
position: absolute;
left: 0;
top: 0;
}
.page-header h1 {
color: #303133;
margin-bottom: 10px;
font-size: 28px;
font-weight: 600;
}
.subtitle {
color: #606266;
font-size: 16px;
margin: 0;
}
.info-card {
margin-bottom: 20px;
}
.info-content {
padding: 10px 0;
}
.info-item {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 20px;
}
.info-item:last-child {
margin-bottom: 0;
}
.info-item h4 {
margin: 0 0 5px 0;
font-size: 14px;
color: #303133;
}
.info-item p {
margin: 0;
font-size: 13px;
color: #606266;
line-height: 1.4;
}
.stats-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.stats-card :deep(.el-card__header) {
border-bottom-color: rgba(255, 255, 255, 0.2);
}
.stats-card :deep(.card-header) {
color: white;
}
.stats-content {
display: flex;
flex-direction: column;
gap: 15px;
}
.stat-item {
text-align: center;
padding: 15px;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
backdrop-filter: blur(10px);
}
.stat-number {
font-size: 24px;
font-weight: bold;
margin-bottom: 5px;
}
.stat-label {
font-size: 12px;
opacity: 0.8;
}
.terms-content {
max-height: 400px;
overflow-y: auto;
padding: 10px;
}
.terms-content h3 {
color: #303133;
margin: 20px 0 10px 0;
font-size: 16px;
}
.terms-content h3:first-child {
margin-top: 0;
}
.terms-content p {
margin: 5px 0;
color: #606266;
line-height: 1.6;
}
/* 响应式设计 */
@media (max-width: 768px) {
.submit-node {
padding: 10px;
}
.page-header {
margin-bottom: 20px;
}
.back-btn {
position: static;
margin-bottom: 10px;
}
.submit-section {
flex-direction: column;
align-items: center;
}
}
</style>

View File

@@ -0,0 +1,30 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver()],
}),
],
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/health': {
target: 'http://localhost:8080',
changeOrigin: true,
}
}
}
})

View File

@@ -0,0 +1,80 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde_json::json;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ApiError {
#[error("Database error: {0}")]
Database(#[from] sea_orm::DbErr),
#[error("Validation error: {0}")]
Validation(String),
#[error("Not found: {0}")]
NotFound(String),
#[error("Bad request: {0}")]
BadRequest(String),
#[error("Internal server error: {0}")]
Internal(String),
#[error("Unauthorized: {0}")]
Unauthorized(String),
#[error("Forbidden: {0}")]
Forbidden(String),
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, error_message) = match self {
ApiError::Database(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", err),
),
ApiError::Validation(msg) => (StatusCode::BAD_REQUEST, msg),
ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
ApiError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
ApiError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg),
ApiError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg),
};
let body = json!({
"error": {
"code": status.as_u16(),
"message": error_message
}
});
(status, axum::Json(body)).into_response()
}
}
pub type ApiResult<T> = Result<T, ApiError>;
impl From<validator::ValidationErrors> for ApiError {
fn from(err: validator::ValidationErrors) -> Self {
let errors: Vec<String> = err
.field_errors()
.iter()
.map(|(field, errors)| {
let error_msgs: Vec<String> = errors
.iter()
.map(|error| {
if let Some(msg) = &error.message {
msg.to_string()
} else {
format!("Validation failed for field: {}", field)
}
})
.collect();
error_msgs.join(", ")
})
.collect();
ApiError::Validation(errors.join("; "))
}
}

View File

@@ -0,0 +1,507 @@
use std::ops::{Div, Mul};
use axum::extract::{Path, Query, State};
use axum::Json;
use sea_orm::{
ColumnTrait, Condition, EntityTrait, IntoActiveModel, ModelTrait, Order, PaginatorTrait,
QueryFilter, QueryOrder, QuerySelect, Set, TryIntoModel,
};
use serde::Deserialize;
use validator::Validate;
use crate::api::{
error::{ApiError, ApiResult},
models::*,
};
use crate::db::entity::{self, health_records, shared_nodes};
use crate::db::{operations::*, Db};
use crate::health_checker_manager::HealthCheckerManager;
use std::sync::Arc;
#[derive(Clone)]
pub struct AppState {
pub db: Db,
pub health_checker_manager: Arc<HealthCheckerManager>,
}
pub async fn health_check() -> Json<ApiResponse<String>> {
Json(ApiResponse::message("Service is healthy".to_string()))
}
pub async fn get_nodes(
State(app_state): State<AppState>,
Query(pagination): Query<PaginationParams>,
Query(filters): Query<NodeFilterParams>,
) -> ApiResult<Json<ApiResponse<PaginatedResponse<NodeResponse>>>> {
let page = pagination.page.unwrap_or(1);
let per_page = pagination.per_page.unwrap_or(20);
let offset = (page - 1) * per_page;
let mut query = entity::shared_nodes::Entity::find();
// 普通用户只能看到已审核的节点
query = query.filter(entity::shared_nodes::Column::IsApproved.eq(true));
if let Some(is_active) = filters.is_active {
query = query.filter(entity::shared_nodes::Column::IsActive.eq(is_active));
}
if let Some(protocol) = filters.protocol {
query = query.filter(entity::shared_nodes::Column::Protocol.eq(protocol));
}
if let Some(search) = filters.search {
query = query.filter(
sea_orm::Condition::any()
.add(entity::shared_nodes::Column::Name.contains(&search))
.add(entity::shared_nodes::Column::Host.contains(&search))
.add(entity::shared_nodes::Column::Description.contains(&search)),
);
}
let total = query.clone().count(app_state.db.orm_db()).await?;
let nodes = query
.order_by_asc(entity::shared_nodes::Column::Id)
.limit(Some(per_page as u64))
.offset(Some(offset as u64))
.all(app_state.db.orm_db())
.await?;
let mut node_responses: Vec<NodeResponse> = nodes.into_iter().map(NodeResponse::from).collect();
let total_pages = total.div_ceil(per_page as u64);
// 为每个节点添加健康状态信息
for node_response in &mut node_responses {
if let Some(mut health_record) = app_state
.health_checker_manager
.get_node_memory_record(node_response.id)
{
node_response.current_health_status =
Some(health_record.get_current_health_status().to_string());
node_response.last_check_time = Some(health_record.get_last_check_time());
node_response.last_response_time = health_record.get_last_response_time();
// 获取24小时健康统计
if let Some(stats) = app_state
.health_checker_manager
.get_node_health_stats(node_response.id, 24)
{
node_response.health_percentage_24h = Some(stats.health_percentage);
}
let (total_ring, healthy_ring) = health_record.get_counter_ring();
node_response.health_record_total_counter_ring = total_ring;
node_response.health_record_healthy_counter_ring = healthy_ring;
node_response.ring_granularity = health_record.get_ring_granularity();
}
}
// remove sensitive information
node_responses.iter_mut().for_each(|node| {
tracing::info!("node: {:?}", node);
node.network_name = None;
node.network_secret = None;
// make cur connection and max conn round to percentage
if node.max_connections != 0 {
node.current_connections = node.current_connections.mul(100).div(node.max_connections);
node.max_connections = 100;
} else {
node.current_connections = 0;
node.max_connections = 0;
}
node.wechat = None;
node.qq_number = None;
node.mail = None;
});
Ok(Json(ApiResponse::success(PaginatedResponse {
items: node_responses,
total,
page,
per_page,
total_pages: total_pages as u32,
})))
}
pub async fn create_node(
State(app_state): State<AppState>,
Json(request): Json<CreateNodeRequest>,
) -> ApiResult<Json<ApiResponse<NodeResponse>>> {
request.validate()?;
let node = NodeOperations::create_node(&app_state.db, request).await?;
Ok(Json(ApiResponse::success(NodeResponse::from(node))))
}
pub async fn test_connection(
State(app_state): State<AppState>,
Json(request): Json<CreateNodeRequest>,
) -> ApiResult<Json<ApiResponse<NodeResponse>>> {
let mut node = NodeOperations::create_node_model(request);
node.id = Set(0);
let node = node.try_into_model()?;
app_state
.health_checker_manager
.test_connection(&node, std::time::Duration::from_secs(5))
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Json(ApiResponse::success(NodeResponse::from(node))))
}
pub async fn get_node(
State(app_state): State<AppState>,
Path(id): Path<i32>,
) -> ApiResult<Json<ApiResponse<NodeResponse>>> {
let node = NodeOperations::get_node_by_id(&app_state.db, id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Node with id {} not found", id)))?;
Ok(Json(ApiResponse::success(NodeResponse::from(node))))
}
pub async fn get_node_health(
State(app_state): State<AppState>,
Path(node_id): Path<i32>,
Query(pagination): Query<PaginationParams>,
Query(filters): Query<HealthFilterParams>,
) -> ApiResult<Json<ApiResponse<PaginatedResponse<HealthRecordResponse>>>> {
let page = pagination.page.unwrap_or(1);
let per_page = pagination.per_page.unwrap_or(20);
let offset = (page - 1) * per_page;
let mut query = entity::health_records::Entity::find()
.filter(entity::health_records::Column::NodeId.eq(node_id));
if let Some(status) = filters.status {
query = query.filter(entity::health_records::Column::Status.eq(status));
}
if let Some(since) = filters.since {
query = query.filter(entity::health_records::Column::CheckedAt.gte(since.naive_utc()));
}
let total = query.clone().count(app_state.db.orm_db()).await?;
let records = query
.order_by_desc(entity::health_records::Column::CheckedAt)
.limit(Some(per_page as u64))
.offset(Some(offset as u64))
.all(app_state.db.orm_db())
.await?;
let record_responses: Vec<HealthRecordResponse> = records
.into_iter()
.map(HealthRecordResponse::from)
.collect();
let total_pages = total.div_ceil(per_page as u64);
Ok(Json(ApiResponse::success(PaginatedResponse {
items: record_responses,
total,
page,
per_page,
total_pages: total_pages as u32,
})))
}
pub async fn get_node_health_stats(
State(app_state): State<AppState>,
Path(node_id): Path<i32>,
Query(params): Query<HealthStatsParams>,
) -> ApiResult<Json<ApiResponse<HealthStatsResponse>>> {
let hours = params.hours.unwrap_or(24);
let stats = HealthOperations::get_health_stats(&app_state.db, node_id, hours).await?;
Ok(Json(ApiResponse::success(HealthStatsResponse::from(stats))))
}
#[derive(Debug, Deserialize)]
pub struct HealthStatsParams {
pub hours: Option<i64>,
}
#[derive(Debug, Deserialize)]
pub struct InstanceFilterParams {
pub node_id: Option<i32>,
pub status: Option<String>,
}
// 管理员相关处理器
use crate::config::AppConfig;
use axum::http::{HeaderMap, StatusCode};
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::Serialize;
#[derive(Debug, Serialize, Deserialize)]
struct AdminClaims {
sub: String,
exp: usize,
iat: usize,
}
pub async fn get_node_connect_url(
State(app_state): State<AppState>,
Path(id): Path<i32>,
) -> ApiResult<String> {
let node = NodeOperations::get_node_by_id(&app_state.db, id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Node with id {} not found", id)))?;
let connect_url = format!("{}://{}:{}", node.protocol, node.host, node.port);
Ok(connect_url)
}
pub async fn admin_login(
Json(request): Json<AdminLoginRequest>,
) -> ApiResult<Json<ApiResponse<AdminLoginResponse>>> {
request
.validate()
.map_err(|e| ApiError::Validation(e.to_string()))?;
let config = AppConfig::default();
if request.password != config.security.admin_password {
return Err(ApiError::Unauthorized("Invalid password".to_string()));
}
let now = Utc::now();
let expires_at = now + Duration::hours(24);
let claims = AdminClaims {
sub: "admin".to_string(),
exp: expires_at.timestamp() as usize,
iat: now.timestamp() as usize,
};
let token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(config.security.jwt_secret.as_ref()),
)
.map_err(|e| ApiError::Internal(format!("Token generation failed: {}", e)))?;
Ok(Json(ApiResponse::success(AdminLoginResponse {
token,
expires_at,
})))
}
pub async fn admin_get_nodes(
State(app_state): State<AppState>,
Query(pagination): Query<PaginationParams>,
Query(filters): Query<AdminNodeFilterParams>,
headers: HeaderMap,
) -> ApiResult<Json<ApiResponse<PaginatedResponse<NodeResponse>>>> {
verify_admin_token(&headers)?;
let page = pagination.page.unwrap_or(1);
let per_page = pagination.per_page.unwrap_or(20);
let offset = (page - 1) * per_page;
let mut query = entity::shared_nodes::Entity::find();
if let Some(is_active) = filters.is_active {
query = query.filter(entity::shared_nodes::Column::IsActive.eq(is_active));
}
if let Some(is_approved) = filters.is_approved {
query = query.filter(entity::shared_nodes::Column::IsApproved.eq(is_approved));
}
if let Some(protocol) = filters.protocol {
query = query.filter(entity::shared_nodes::Column::Protocol.eq(protocol));
}
if let Some(search) = filters.search {
query = query.filter(
sea_orm::Condition::any()
.add(entity::shared_nodes::Column::Name.contains(&search))
.add(entity::shared_nodes::Column::Host.contains(&search))
.add(entity::shared_nodes::Column::Description.contains(&search)),
);
}
let total = query.clone().count(app_state.db.orm_db()).await?;
let nodes = query
.order_by(entity::shared_nodes::Column::CreatedAt, Order::Desc)
.offset(offset as u64)
.limit(per_page as u64)
.all(app_state.db.orm_db())
.await?;
let node_responses: Vec<NodeResponse> = nodes.into_iter().map(NodeResponse::from).collect();
let total_pages = (total as f64 / per_page as f64).ceil() as u32;
Ok(Json(ApiResponse::success(PaginatedResponse {
items: node_responses,
total,
page,
per_page,
total_pages,
})))
}
pub async fn admin_approve_node(
State(app_state): State<AppState>,
Path(id): Path<i32>,
headers: HeaderMap,
) -> ApiResult<Json<ApiResponse<NodeResponse>>> {
verify_admin_token(&headers)?;
let node = entity::shared_nodes::Entity::find_by_id(id)
.one(app_state.db.orm_db())
.await?
.ok_or_else(|| ApiError::NotFound("Node not found".to_string()))?;
let mut active_model = node.into_active_model();
active_model.is_approved = sea_orm::Set(true);
let updated_node = entity::shared_nodes::Entity::update(active_model)
.exec(app_state.db.orm_db())
.await?;
Ok(Json(ApiResponse::success(NodeResponse::from(updated_node))))
}
pub async fn admin_update_node(
State(app_state): State<AppState>,
Path(id): Path<i32>,
headers: HeaderMap,
Json(request): Json<UpdateNodeRequest>,
) -> ApiResult<Json<ApiResponse<NodeResponse>>> {
verify_admin_token(&headers)?;
request.validate()?;
let mut node = NodeOperations::get_node_by_id(&app_state.db, id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Node with id {} not found", id)))?;
let mut node = node.into_active_model();
if let Some(name) = request.name {
node.name = Set(name);
}
if let Some(host) = request.host {
node.host = Set(host);
}
if let Some(port) = request.port {
node.port = Set(port);
}
if let Some(protocol) = request.protocol {
node.protocol = Set(protocol);
}
if let Some(description) = request.description {
node.description = Set(description);
}
if let Some(max_connections) = request.max_connections {
node.max_connections = Set(max_connections);
}
if let Some(is_active) = request.is_active {
node.is_active = Set(is_active);
}
if let Some(allow_relay) = request.allow_relay {
node.allow_relay = Set(allow_relay);
}
if let Some(network_name) = request.network_name {
node.network_name = Set(network_name);
}
if let Some(network_secret) = request.network_secret {
node.network_secret = Set(network_secret);
}
if let Some(wechat) = request.wechat {
node.wechat = Set(wechat);
}
if let Some(mail) = request.mail {
node.mail = Set(mail);
}
if let Some(qq_number) = request.qq_number {
node.qq_number = Set(qq_number);
}
node.updated_at = Set(chrono::Utc::now().fixed_offset());
tracing::info!("updated node: {:?}", node);
let updated_node = entity::shared_nodes::Entity::update(node)
.exec(app_state.db.orm_db())
.await?;
Ok(Json(ApiResponse::success(NodeResponse::from(updated_node))))
}
pub async fn admin_revoke_approval(
State(app_state): State<AppState>,
Path(id): Path<i32>,
headers: HeaderMap,
) -> ApiResult<Json<ApiResponse<NodeResponse>>> {
verify_admin_token(&headers)?;
let node = entity::shared_nodes::Entity::find_by_id(id)
.one(app_state.db.orm_db())
.await?
.ok_or_else(|| ApiError::NotFound("Node not found".to_string()))?;
let mut active_model = node.into_active_model();
active_model.is_approved = sea_orm::Set(false);
let updated_node = entity::shared_nodes::Entity::update(active_model)
.exec(app_state.db.orm_db())
.await?;
Ok(Json(ApiResponse::success(NodeResponse::from(updated_node))))
}
pub async fn admin_delete_node(
State(app_state): State<AppState>,
Path(id): Path<i32>,
headers: HeaderMap,
) -> ApiResult<Json<ApiResponse<String>>> {
verify_admin_token(&headers)?;
let node = entity::shared_nodes::Entity::find_by_id(id)
.one(app_state.db.orm_db())
.await?
.ok_or_else(|| ApiError::NotFound("Node not found".to_string()))?;
node.delete(app_state.db.orm_db()).await?;
Ok(Json(ApiResponse::message(
"Node deleted successfully".to_string(),
)))
}
pub async fn admin_verify_token(headers: HeaderMap) -> ApiResult<Json<ApiResponse<String>>> {
verify_admin_token(&headers)?;
Ok(Json(ApiResponse::message("Token is valid".to_string())))
}
fn verify_admin_token(headers: &HeaderMap) -> ApiResult<()> {
let config = AppConfig::default();
let auth_header = headers
.get("authorization")
.ok_or_else(|| ApiError::Unauthorized("Missing authorization header".to_string()))?;
let auth_str = auth_header
.to_str()
.map_err(|_| ApiError::Unauthorized("Invalid authorization header".to_string()))?;
let token = auth_str
.strip_prefix("Bearer ")
.ok_or_else(|| ApiError::Unauthorized("Invalid authorization format".to_string()))?;
let _claims = decode::<AdminClaims>(
token,
&DecodingKey::from_secret(config.security.jwt_secret.as_ref()),
&Validation::default(),
)
.map_err(|_| ApiError::Unauthorized("Invalid token".to_string()))?;
Ok(())
}

View File

@@ -0,0 +1,8 @@
pub mod error;
pub mod handlers;
pub mod models;
pub mod routes;
pub use error::{ApiError, ApiResult};
pub use handlers::*;
pub use models::*;

View File

@@ -0,0 +1,316 @@
use crate::db::entity;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use validator::Validate;
#[derive(Debug, Serialize, Deserialize)]
pub struct ApiResponse<T> {
pub success: bool,
pub data: Option<T>,
pub error: Option<String>,
pub message: Option<String>,
}
impl<T> ApiResponse<T> {
pub fn success(data: T) -> Self {
Self {
success: true,
data: Some(data),
error: None,
message: None,
}
}
pub fn error(error: String) -> Self {
Self {
success: false,
data: None,
error: Some(error),
message: None,
}
}
pub fn message(message: String) -> Self {
Self {
success: true,
data: None,
error: None,
message: Some(message),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PaginatedResponse<T> {
pub items: Vec<T>,
pub total: u64,
pub page: u32,
pub per_page: u32,
pub total_pages: u32,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PaginationParams {
pub page: Option<u32>,
pub per_page: Option<u32>,
}
impl Default for PaginationParams {
fn default() -> Self {
Self {
page: Some(1),
per_page: Some(20),
}
}
}
#[derive(Debug, Serialize, Deserialize, Validate)]
#[validate(schema(function = "validate_contact_info", skip_on_field_errors = false))]
pub struct CreateNodeRequest {
#[validate(length(min = 1, max = 100))]
pub name: String,
#[validate(length(min = 1, max = 255))]
pub host: String,
#[validate(range(min = 1, max = 65535))]
pub port: i32,
#[validate(length(min = 1, max = 20))]
pub protocol: String,
#[validate(length(max = 500))]
pub description: Option<String>,
#[validate(range(min = 1, max = 10000))]
pub max_connections: i32,
pub allow_relay: bool,
#[validate(length(min = 1, max = 100))]
pub network_name: String,
#[validate(length(max = 100))]
pub network_secret: Option<String>,
// 联系方式字段
#[validate(length(max = 20))]
pub qq_number: Option<String>,
#[validate(length(max = 50))]
pub wechat: Option<String>,
#[validate(email)]
pub mail: Option<String>,
}
// 自定义验证函数:确保至少填写一种联系方式
fn validate_contact_info(request: &CreateNodeRequest) -> Result<(), validator::ValidationError> {
let has_qq = request
.qq_number
.as_ref()
.is_some_and(|s| !s.trim().is_empty());
let has_wechat = request
.wechat
.as_ref()
.is_some_and(|s| !s.trim().is_empty());
let has_mail = request.mail.as_ref().is_some_and(|s| !s.trim().is_empty());
if !has_qq && !has_wechat && !has_mail {
return Err(validator::ValidationError::new("contact_required"));
}
Ok(())
}
#[derive(Debug, Serialize, Deserialize, Validate)]
pub struct UpdateNodeRequest {
#[validate(length(min = 1, max = 100))]
pub name: Option<String>,
#[validate(length(min = 1, max = 255))]
pub host: Option<String>,
#[validate(range(min = 1, max = 65535))]
pub port: Option<i32>,
#[validate(length(min = 1, max = 20))]
pub protocol: Option<String>,
#[validate(length(max = 500))]
pub description: Option<String>,
#[validate(range(min = 1, max = 10000))]
pub max_connections: Option<i32>,
pub is_active: Option<bool>,
pub allow_relay: Option<bool>,
#[validate(length(min = 1, max = 100))]
pub network_name: Option<String>,
#[validate(length(max = 100))]
pub network_secret: Option<String>,
// 联系方式字段
#[validate(length(max = 20))]
pub qq_number: Option<String>,
#[validate(length(max = 50))]
pub wechat: Option<String>,
#[validate(email)]
pub mail: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct NodeResponse {
pub id: i32,
pub name: String,
pub host: String,
pub port: i32,
pub protocol: String,
pub version: Option<String>,
pub description: Option<String>,
pub max_connections: i32,
pub current_connections: i32,
pub is_active: bool,
pub is_approved: bool,
pub allow_relay: bool,
pub network_name: Option<String>,
pub network_secret: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub address: String,
pub usage_percentage: f64,
// 健康状态相关字段
pub current_health_status: Option<String>,
pub last_check_time: Option<chrono::DateTime<chrono::Utc>>,
pub last_response_time: Option<i32>,
pub health_percentage_24h: Option<f64>,
pub health_record_total_counter_ring: Vec<u64>,
pub health_record_healthy_counter_ring: Vec<u64>,
pub ring_granularity: u32,
// 联系方式字段
pub qq_number: Option<String>,
pub wechat: Option<String>,
pub mail: Option<String>,
}
impl From<entity::shared_nodes::Model> for NodeResponse {
fn from(node: entity::shared_nodes::Model) -> Self {
Self {
id: node.id,
name: node.name.clone(),
host: node.host.clone(),
port: node.port,
protocol: node.protocol.clone(),
version: Some(node.version.clone()),
description: Some(node.description.clone()),
max_connections: node.max_connections,
current_connections: node.current_connections,
is_active: node.is_active,
is_approved: node.is_approved,
allow_relay: node.allow_relay,
network_name: Some(node.network_name.clone()),
network_secret: Some(node.network_secret.clone()),
created_at: node.created_at.into(),
updated_at: node.updated_at.into(),
address: format!("{}://{}:{}", node.protocol, node.host, node.port),
usage_percentage: node.current_connections as f64 / node.max_connections as f64 * 100.0,
// 健康状态字段初始化为 None将在 handlers 中填充
current_health_status: None,
last_check_time: None,
last_response_time: None,
health_percentage_24h: None,
health_record_healthy_counter_ring: Vec::new(),
health_record_total_counter_ring: Vec::new(),
ring_granularity: 0,
// 联系方式字段
qq_number: if node.qq_number.is_empty() {
None
} else {
Some(node.qq_number)
},
wechat: if node.wechat.is_empty() {
None
} else {
Some(node.wechat)
},
mail: if node.mail.is_empty() {
None
} else {
Some(node.mail)
},
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct HealthRecordResponse {
pub id: i32,
pub node_id: i32,
pub status: String,
pub response_time: Option<i32>,
pub error_message: Option<String>,
pub checked_at: chrono::DateTime<chrono::Utc>,
}
impl From<entity::health_records::Model> for HealthRecordResponse {
fn from(record: entity::health_records::Model) -> Self {
Self {
id: record.id,
node_id: record.node_id,
status: record.status.to_string(),
response_time: Some(record.response_time),
error_message: Some(record.error_message),
checked_at: record.checked_at.into(),
}
}
}
pub type HealthStatsResponse = crate::db::HealthStats;
#[derive(Debug, Serialize, Deserialize)]
pub struct NodeFilterParams {
pub is_active: Option<bool>,
pub protocol: Option<String>,
pub search: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct HealthFilterParams {
pub status: Option<String>,
pub since: Option<DateTime<Utc>>,
}
// 管理员相关模型
#[derive(Debug, Serialize, Deserialize, Validate)]
pub struct AdminLoginRequest {
#[validate(length(min = 1))]
pub password: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AdminLoginResponse {
pub token: String,
pub expires_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ApproveNodeRequest {
pub approved: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AdminNodeFilterParams {
pub is_active: Option<bool>,
pub is_approved: Option<bool>,
pub protocol: Option<String>,
pub search: Option<String>,
}

View File

@@ -0,0 +1,64 @@
use axum::routing::{delete, get, post, put};
use axum::Router;
use tower_http::compression::CompressionLayer;
use tower_http::cors::CorsLayer;
use super::handlers::AppState;
use super::handlers::{
admin_approve_node, admin_delete_node, admin_get_nodes, admin_login, admin_revoke_approval,
admin_update_node, admin_verify_token, create_node, get_node, get_node_health,
get_node_health_stats, get_nodes, health_check,
};
use crate::api::{get_node_connect_url, test_connection};
use crate::config::AppConfig;
use crate::db::Db;
pub fn create_routes() -> Router<AppState> {
let config = AppConfig::default();
let compression_layer = if config.security.enable_compression {
Some(
CompressionLayer::new()
.br(true)
.deflate(true)
.gzip(true)
.zstd(true),
)
} else {
None
};
let cors_layer = if config.cors.enabled {
Some(CorsLayer::very_permissive())
} else {
None
};
let mut router = Router::new()
.route("/node/{id}", get(get_node_connect_url))
.route("/health", get(health_check))
.route("/api/nodes", get(get_nodes).post(create_node))
.route("/api/test_connection", post(test_connection))
.route("/api/nodes/{id}/health", get(get_node_health))
.route("/api/nodes/{id}/health/stats", get(get_node_health_stats))
// 管理员路由
.route("/api/admin/login", post(admin_login))
.route("/api/admin/verify", get(admin_verify_token))
.route("/api/admin/nodes", get(admin_get_nodes))
.route("/api/admin/nodes/{id}/approve", put(admin_approve_node))
.route("/api/admin/nodes/{id}/revoke", put(admin_revoke_approval))
.route(
"/api/admin/nodes/{id}",
put(admin_update_node).delete(admin_delete_node),
);
if let Some(layer) = compression_layer {
router = router.layer(layer);
}
if let Some(layer) = cors_layer {
router = router.layer(layer);
}
router
}

View File

@@ -0,0 +1,198 @@
use std::env;
use std::net::{IpAddr, SocketAddr};
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct AppConfig {
pub server: ServerConfig,
pub database: DatabaseConfig,
pub health_check: HealthCheckConfig,
pub logging: LoggingConfig,
pub cors: CorsConfig,
pub security: SecurityConfig,
}
#[derive(Debug, Clone)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
pub addr: SocketAddr,
}
#[derive(Debug, Clone)]
pub struct DatabaseConfig {
pub path: PathBuf,
pub max_connections: u32,
}
#[derive(Debug, Clone)]
pub struct HealthCheckConfig {
pub interval_seconds: u64,
pub timeout_seconds: u64,
pub max_retries: u32,
}
#[derive(Debug, Clone)]
pub struct LoggingConfig {
pub level: String,
pub rust_log: String,
}
#[derive(Debug, Clone)]
pub struct CorsConfig {
pub allowed_origins: Vec<String>,
pub allowed_methods: Vec<String>,
pub allowed_headers: Vec<String>,
pub enabled: bool,
}
#[derive(Debug, Clone)]
pub struct SecurityConfig {
pub enable_compression: bool,
pub secret_key: String,
pub jwt_secret: String,
pub admin_password: String,
}
impl Default for AppConfig {
fn default() -> Self {
Self::from_env().unwrap_or_else(|_| Self::default_config())
}
}
impl AppConfig {
pub fn from_env() -> Result<Self, env::VarError> {
let server_config = ServerConfig {
host: env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()),
port: env::var("SERVER_PORT")
.map(|s| s.parse().unwrap_or(8080))
.unwrap_or(8080),
addr: SocketAddr::from((
env::var("SERVER_HOST")
.unwrap_or_else(|_| "127.0.0.1".to_string())
.parse::<IpAddr>()
.unwrap(),
env::var("SERVER_PORT")
.map(|s| s.parse().unwrap_or(8080))
.unwrap_or(8080),
)),
};
let database_config = DatabaseConfig {
path: PathBuf::from(
env::var("DATABASE_PATH").unwrap_or_else(|_| "uptime.db".to_string()),
),
max_connections: env::var("DATABASE_MAX_CONNECTIONS")
.map(|s| s.parse().unwrap_or(10))
.unwrap_or(10),
};
let health_check_config = HealthCheckConfig {
interval_seconds: env::var("HEALTH_CHECK_INTERVAL")
.map(|s| s.parse().unwrap_or(30))
.unwrap_or(30),
timeout_seconds: env::var("HEALTH_CHECK_TIMEOUT")
.map(|s| s.parse().unwrap_or(10))
.unwrap_or(10),
max_retries: env::var("HEALTH_CHECK_RETRIES")
.map(|s| s.parse().unwrap_or(3))
.unwrap_or(3),
};
let logging_config = LoggingConfig {
level: env::var("LOG_LEVEL").unwrap_or_else(|_| "info".to_string()),
rust_log: env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()),
};
let cors_config = CorsConfig {
allowed_origins: env::var("CORS_ALLOWED_ORIGINS")
.unwrap_or_else(|_| "http://localhost:3000,http://localhost:8080".to_string())
.split(',')
.map(|s| s.trim().to_string())
.collect(),
allowed_methods: env::var("CORS_ALLOWED_METHODS")
.unwrap_or_else(|_| "GET,POST,PUT,DELETE,OPTIONS".to_string())
.split(',')
.map(|s| s.trim().to_string())
.collect(),
allowed_headers: env::var("CORS_ALLOWED_HEADERS")
.unwrap_or_else(|_| "content-type,authorization".to_string())
.split(',')
.map(|s| s.trim().to_string())
.collect(),
enabled: env::var("ENABLE_CORS")
.map(|s| s.parse().unwrap_or(true))
.unwrap_or(true),
};
let security_config = SecurityConfig {
enable_compression: env::var("ENABLE_COMPRESSION")
.map(|s| s.parse().unwrap_or(true))
.unwrap_or(true),
secret_key: env::var("SECRET_KEY").unwrap_or_else(|_| "default-secret-key".to_string()),
jwt_secret: env::var("JWT_SECRET").unwrap_or_else(|_| "default-jwt-secret".to_string()),
admin_password: env::var("ADMIN_PASSWORD").unwrap_or_else(|_| "admin123".to_string()),
};
Ok(AppConfig {
server: server_config,
database: database_config,
health_check: health_check_config,
logging: logging_config,
cors: cors_config,
security: security_config,
})
}
pub fn default_config() -> Self {
Self {
server: ServerConfig {
host: "127.0.0.1".to_string(),
port: 8080,
addr: SocketAddr::from(([127, 0, 0, 1], 8080)),
},
database: DatabaseConfig {
path: PathBuf::from("uptime.db"),
max_connections: 10,
},
health_check: HealthCheckConfig {
interval_seconds: 30,
timeout_seconds: 10,
max_retries: 3,
},
logging: LoggingConfig {
level: "info".to_string(),
rust_log: "info".to_string(),
},
cors: CorsConfig {
allowed_origins: vec![
"http://localhost:3000".to_string(),
"http://localhost:8080".to_string(),
],
allowed_methods: vec![
"GET".to_string(),
"POST".to_string(),
"PUT".to_string(),
"DELETE".to_string(),
"OPTIONS".to_string(),
],
allowed_headers: vec!["content-type".to_string(), "authorization".to_string()],
enabled: true,
},
security: SecurityConfig {
enable_compression: true,
secret_key: "default-secret-key".to_string(),
jwt_secret: "default-jwt-secret".to_string(),
admin_password: "admin123".to_string(),
},
}
}
pub fn is_development(&self) -> bool {
env::var("NODE_ENV").unwrap_or_else(|_| "development".to_string()) == "development"
}
pub fn is_production(&self) -> bool {
env::var("NODE_ENV").unwrap_or_else(|_| "development".to_string()) == "production"
}
}

View File

@@ -0,0 +1,360 @@
use crate::db::entity::*;
use crate::db::Db;
use sea_orm::*;
use tokio::time::{sleep, Duration};
use tracing::{error, info, warn};
/// 数据清理策略配置
#[derive(Debug, Clone)]
pub struct CleanupConfig {
/// 健康记录保留天数
pub health_record_retention_days: i64,
/// 每个节点保留的健康记录最大数量
pub max_health_records_per_node: u64,
/// 清理任务运行间隔(秒)
pub cleanup_interval_seconds: u64,
/// 是否启用自动清理
pub auto_cleanup_enabled: bool,
}
impl Default for CleanupConfig {
fn default() -> Self {
Self {
health_record_retention_days: 30,
max_health_records_per_node: 70000,
cleanup_interval_seconds: 1200, // 20分钟
auto_cleanup_enabled: true,
}
}
}
/// 数据清理管理器
pub struct CleanupManager {
db: Db,
config: CleanupConfig,
running: std::sync::Arc<std::sync::atomic::AtomicBool>,
}
impl CleanupManager {
/// 创建新的清理管理器
pub fn new(db: Db, config: CleanupConfig) -> Self {
Self {
db,
config,
running: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
}
}
/// 使用默认配置创建清理管理器
pub fn with_default_config(db: Db) -> Self {
Self::new(db, CleanupConfig::default())
}
/// 启动自动清理任务
pub async fn start_auto_cleanup(&self) -> anyhow::Result<()> {
if self.config.auto_cleanup_enabled {
let running = self.running.clone();
let db = self.db.clone();
let config = self.config.clone();
running.store(true, std::sync::atomic::Ordering::SeqCst);
tokio::spawn(async move {
info!("Auto cleanup task started");
while running.load(std::sync::atomic::Ordering::SeqCst) {
if let Err(e) = Self::perform_cleanup(&db, &config).await {
error!("Auto cleanup failed: {}", e);
}
sleep(Duration::from_secs(config.cleanup_interval_seconds)).await;
}
info!("Auto cleanup task stopped");
});
}
Ok(())
}
/// 停止自动清理任务
pub fn stop_auto_cleanup(&self) {
self.running
.store(false, std::sync::atomic::Ordering::SeqCst);
}
/// 执行一次完整的清理操作
pub async fn perform_cleanup(db: &Db, config: &CleanupConfig) -> anyhow::Result<CleanupResult> {
let mut result = CleanupResult::default();
// 清理旧的健康记录
let health_cleanup_result =
Self::cleanup_old_health_records(db, config.health_record_retention_days).await?;
result.old_health_records_cleaned = health_cleanup_result.records_removed;
// 清理过量的健康记录
let excess_cleanup_result =
Self::cleanup_excess_health_records(db, config.max_health_records_per_node).await?;
result.excess_health_records_cleaned = excess_cleanup_result.records_removed;
// 数据库维护
let maintenance_result = Self::perform_database_maintenance(db).await?;
result.vacuum_performed = maintenance_result.vacuum_performed;
result.analyze_performed = maintenance_result.analyze_performed;
info!("Cleanup completed: {:?}", result);
Ok(result)
}
/// 清理旧的健康记录
async fn cleanup_old_health_records(
db: &Db,
days: i64,
) -> anyhow::Result<CleanupHealthRecordsResult> {
let cutoff = chrono::Local::now().fixed_offset() - chrono::Duration::days(days);
let result = health_records::Entity::delete_many()
.filter(health_records::Column::CheckedAt.lt(cutoff))
.exec(db.orm_db())
.await?;
let records_removed = result.rows_affected;
if records_removed > 0 {
info!(
"Cleaned {} old health records (older than {} days)",
records_removed, days
);
}
Ok(CleanupHealthRecordsResult { records_removed })
}
/// 清理过量的健康记录
async fn cleanup_excess_health_records(
db: &Db,
max_records: u64,
) -> anyhow::Result<CleanupExcessRecordsResult> {
// 获取所有节点
let nodes = shared_nodes::Entity::find().all(db.orm_db()).await?;
let mut total_removed = 0;
for node in nodes {
// 计算需要删除的记录数量
let total_count = health_records::Entity::find()
.filter(health_records::Column::NodeId.eq(node.id))
.count(db.orm_db())
.await?;
if total_count > max_records {
let to_remove = total_count - max_records;
// 获取需要保留的最小ID
let keep_id = health_records::Entity::find()
.filter(health_records::Column::NodeId.eq(node.id))
.order_by_desc(health_records::Column::CheckedAt)
.offset(max_records)
.limit(1)
.into_model::<health_records::Model>()
.one(db.orm_db())
.await?;
info!(
"Node {}: total count: {}, to remove: {}, last keep record: {:?}",
node.id, total_count, to_remove, keep_id
);
if let Some(keep_record) = keep_id {
// 删除比保留记录更早的记录
let result = health_records::Entity::delete_many()
.filter(health_records::Column::NodeId.eq(node.id))
.filter(health_records::Column::Id.lt(keep_record.id))
.exec(db.orm_db())
.await?;
total_removed += result.rows_affected;
}
}
}
if total_removed > 0 {
info!(
"Cleaned {} excess health records (max {} per node)",
total_removed, max_records
);
}
Ok(CleanupExcessRecordsResult {
records_removed: total_removed,
})
}
/// 执行数据库维护操作
async fn perform_database_maintenance(db: &Db) -> anyhow::Result<DatabaseMaintenanceResult> {
let mut vacuum_performed = false;
let mut analyze_performed = false;
// 执行 ANALYZE
match db
.orm_db()
.execute(Statement::from_string(
DatabaseBackend::Sqlite,
"ANALYZE".to_string(),
))
.await
{
Ok(_) => {
analyze_performed = true;
info!("Database ANALYZE completed");
}
Err(e) => {
warn!("Database ANALYZE failed: {}", e);
}
}
// 执行 VACUUM仅在需要时
if vacuum_performed || analyze_performed {
match db
.orm_db()
.execute(Statement::from_string(
DatabaseBackend::Sqlite,
"VACUUM".to_string(),
))
.await
{
Ok(_) => {
vacuum_performed = true;
info!("Database VACUUM completed");
}
Err(e) => {
warn!("Database VACUUM failed: {}", e);
}
}
}
Ok(DatabaseMaintenanceResult {
vacuum_performed,
analyze_performed,
})
}
/// 获取数据库统计信息
pub async fn get_database_stats(db: &Db) -> anyhow::Result<DatabaseStats> {
let total_nodes = shared_nodes::Entity::find().count(db.orm_db()).await?;
let total_health_records = health_records::Entity::find().count(db.orm_db()).await?;
let active_nodes = shared_nodes::Entity::find()
.filter(shared_nodes::Column::IsActive.eq(true))
.count(db.orm_db())
.await?;
Ok(DatabaseStats {
total_nodes,
active_nodes,
total_health_records,
})
}
/// 获取清理配置
pub fn get_config(&self) -> &CleanupConfig {
&self.config
}
/// 更新清理配置
pub fn update_config(&mut self, config: CleanupConfig) {
self.config = config;
}
}
/// 清理结果
#[derive(Default, Debug, Clone, serde::Serialize)]
pub struct CleanupResult {
pub old_health_records_cleaned: u64,
pub old_instances_cleaned: u64,
pub excess_health_records_cleaned: u64,
pub vacuum_performed: bool,
pub analyze_performed: bool,
}
/// 健康记录清理结果
#[derive(Debug, Clone, serde::Serialize)]
pub struct CleanupHealthRecordsResult {
pub records_removed: u64,
}
/// 停止实例清理结果
#[derive(Debug, Clone, serde::Serialize)]
pub struct CleanupStoppedInstancesResult {
pub instances_removed: u64,
}
/// 过量记录清理结果
#[derive(Debug, Clone, serde::Serialize)]
pub struct CleanupExcessRecordsResult {
pub records_removed: u64,
}
/// 数据库维护结果
#[derive(Debug, Clone, serde::Serialize)]
pub struct DatabaseMaintenanceResult {
pub vacuum_performed: bool,
pub analyze_performed: bool,
}
/// 数据库统计信息
#[derive(Debug, Clone, serde::Serialize)]
pub struct DatabaseStats {
pub total_nodes: u64,
pub active_nodes: u64,
pub total_health_records: u64,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Db;
#[tokio::test]
async fn test_cleanup_manager() {
let db = Db::memory_db().await;
let cleanup_manager = CleanupManager::with_default_config(db.clone());
// 测试获取配置
let config = cleanup_manager.get_config();
assert_eq!(config.health_record_retention_days, 30);
// 测试清理操作
let result = CleanupManager::perform_cleanup(&db, config).await.unwrap();
println!("Cleanup result: {:?}", result);
// 测试获取统计信息
let stats = CleanupManager::get_database_stats(&db).await.unwrap();
println!("Database stats: {:?}", stats);
}
#[tokio::test]
async fn test_cleanup_config() {
let config = CleanupConfig {
health_record_retention_days: 7,
max_health_records_per_node: 500,
cleanup_interval_seconds: 1800,
auto_cleanup_enabled: false,
};
let db = Db::memory_db().await;
let mut cleanup_manager = CleanupManager::new(db, config.clone());
assert_eq!(cleanup_manager.get_config().health_record_retention_days, 7);
// 测试更新配置
let new_config = CleanupConfig::default();
cleanup_manager.update_config(new_config);
assert_eq!(
cleanup_manager.get_config().health_record_retention_days,
30
);
}
}

View File

@@ -0,0 +1,39 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "connection_instances")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub node_id: i32,
#[sea_orm(unique)]
pub instance_id: String,
pub status: String,
#[sea_orm(column_type = "Text")]
pub config: String,
pub started_at: DateTimeWithTimeZone,
pub stopped_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::shared_nodes::Entity",
from = "Column::NodeId",
to = "super::shared_nodes::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
SharedNodes,
}
impl Related<super::shared_nodes::Entity> for Entity {
fn to() -> RelationDef {
Relation::SharedNodes.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,37 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "health_records")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub node_id: i32,
pub status: String,
pub response_time: i32,
#[sea_orm(column_type = "Text")]
pub error_message: String,
pub checked_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::shared_nodes::Entity",
from = "Column::NodeId",
to = "super::shared_nodes::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
SharedNodes,
}
impl Related<super::shared_nodes::Entity> for Entity {
fn to() -> RelationDef {
Relation::SharedNodes.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,6 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
pub mod prelude;
pub mod health_records;
pub mod shared_nodes;

View File

@@ -0,0 +1,4 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
pub use super::health_records::Entity as HealthRecords;
pub use super::shared_nodes::Entity as SharedNodes;

View File

@@ -0,0 +1,44 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "shared_nodes")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
pub host: String,
pub port: i32,
pub protocol: String,
pub version: String,
pub allow_relay: bool,
pub network_name: String,
pub network_secret: String,
#[sea_orm(column_type = "Text")]
pub description: String,
pub max_connections: i32,
pub current_connections: i32,
pub is_active: bool,
pub is_approved: bool,
pub qq_number: String,
pub wechat: String,
pub mail: String,
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::health_records::Entity")]
HealthRecords,
}
impl Related<super::health_records::Entity> for Entity {
fn to() -> RelationDef {
Relation::HealthRecords.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,351 @@
pub mod cleanup;
pub mod entity;
pub mod operations;
use std::fmt;
use sea_orm::{
prelude::*, sea_query::OnConflict, ColumnTrait as _, DatabaseConnection, DbErr, EntityTrait,
QueryFilter as _, Set, SqlxSqliteConnector, Statement, TransactionTrait as _,
};
use sea_orm_migration::MigratorTrait as _;
use serde::{Deserialize, Serialize};
use sqlx::{migrate::MigrateDatabase as _, Sqlite, SqlitePool};
use crate::migrator;
#[derive(Debug, Clone)]
pub struct Db {
db_path: String,
db: SqlitePool,
orm_db: DatabaseConnection,
}
impl Db {
pub async fn new<T: ToString>(db_path: T) -> anyhow::Result<Self> {
let db = Self::prepare_db(db_path.to_string().as_str()).await?;
let orm_db = SqlxSqliteConnector::from_sqlx_sqlite_pool(db.clone());
// 运行数据库迁移
migrator::Migrator::up(&orm_db, None).await?;
// 优化 SQLite 性能
Self::optimize_sqlite(&orm_db).await?;
Ok(Self {
db_path: db_path.to_string(),
db,
orm_db,
})
}
pub async fn memory_db() -> Self {
Self::new(":memory:").await.unwrap()
}
#[tracing::instrument(ret)]
async fn prepare_db(db_path: &str) -> anyhow::Result<SqlitePool> {
if !Sqlite::database_exists(db_path).await.unwrap_or(false) {
tracing::info!("Database not found, creating a new one");
Sqlite::create_database(db_path).await?;
}
let db = sqlx::pool::PoolOptions::new()
.max_lifetime(None)
.idle_timeout(None)
.connect(db_path)
.await?;
Ok(db)
}
async fn optimize_sqlite(db: &DatabaseConnection) -> Result<(), DbErr> {
// 优化 SQLite 性能
let pragmas = vec![
"PRAGMA journal_mode = WAL", // 使用 WAL 模式提高并发性能
"PRAGMA synchronous = NORMAL", // 平衡性能和数据安全
"PRAGMA cache_size = 10000", // 增加缓存大小
"PRAGMA temp_store = memory", // 临时存储使用内存
"PRAGMA mmap_size = 268435456", // 内存映射大小 256MB
"PRAGMA foreign_keys = ON", // 启用外键约束
];
for pragma in pragmas {
db.execute(sea_orm::Statement::from_string(
sea_orm::DatabaseBackend::Sqlite,
pragma.to_string(),
))
.await?;
}
Ok(())
}
pub fn inner(&self) -> SqlitePool {
self.db.clone()
}
pub fn orm_db(&self) -> &DatabaseConnection {
&self.orm_db
}
/// 清理旧的健康度记录删除30天前的记录
pub async fn cleanup_old_health_records(&self) -> Result<u64, DbErr> {
use chrono::Duration;
use entity::health_records;
let cutoff_date = chrono::Utc::now().naive_utc() - Duration::days(30);
let result = health_records::Entity::delete_many()
.filter(health_records::Column::CheckedAt.lt(cutoff_date))
.exec(self.orm_db())
.await?;
Ok(result.rows_affected)
}
/// 获取数据库统计信息
pub async fn get_database_stats(&self) -> anyhow::Result<DatabaseStats> {
use entity::{health_records, shared_nodes};
let node_count = shared_nodes::Entity::find().count(self.orm_db()).await?;
let health_record_count = health_records::Entity::find().count(self.orm_db()).await?;
let active_nodes_count = shared_nodes::Entity::find()
.filter(shared_nodes::Column::IsActive.eq(true))
.count(self.orm_db())
.await?;
Ok(DatabaseStats {
total_nodes: node_count,
active_nodes: active_nodes_count,
total_health_records: health_record_count,
})
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct DatabaseStats {
pub total_nodes: u64,
pub active_nodes: u64,
pub total_health_records: u64,
}
/// 健康状态枚举
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum HealthStatus {
/// 健康状态
Healthy,
/// 不健康状态
Unhealthy,
/// 超时状态
Timeout,
/// 连接错误
ConnectionError,
/// 未知错误
Unknown,
}
impl fmt::Display for HealthStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
HealthStatus::Healthy => write!(f, "healthy"),
HealthStatus::Unhealthy => write!(f, "unhealthy"),
HealthStatus::Timeout => write!(f, "timeout"),
HealthStatus::ConnectionError => write!(f, "connection_error"),
HealthStatus::Unknown => write!(f, "unknown"),
}
}
}
impl From<String> for HealthStatus {
fn from(s: String) -> Self {
match s.to_lowercase().as_str() {
"healthy" => HealthStatus::Healthy,
"unhealthy" => HealthStatus::Unhealthy,
"timeout" => HealthStatus::Timeout,
"connection_error" => HealthStatus::ConnectionError,
_ => HealthStatus::Unknown,
}
}
}
impl From<&str> for HealthStatus {
fn from(s: &str) -> Self {
HealthStatus::from(s.to_string())
}
}
/// 健康统计信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthStats {
/// 总检查次数
pub total_checks: u64,
/// 健康检查次数
pub healthy_count: u64,
/// 不健康检查次数
pub unhealthy_count: u64,
/// 健康百分比
pub health_percentage: f64,
/// 平均响应时间(毫秒)
pub average_response_time: Option<f64>,
/// 正常运行时间百分比
pub uptime_percentage: f64,
/// 最后检查时间
pub last_check_time: Option<chrono::DateTime<chrono::Utc>>,
/// 最后健康状态
pub last_status: Option<HealthStatus>,
}
impl Default for HealthStats {
fn default() -> Self {
Self {
total_checks: 0,
healthy_count: 0,
unhealthy_count: 0,
health_percentage: 0.0,
average_response_time: None,
uptime_percentage: 0.0,
last_check_time: None,
last_status: None,
}
}
}
impl HealthStats {
/// 从健康记录列表创建统计信息
pub fn from_records(records: &[self::entity::health_records::Model]) -> Self {
if records.is_empty() {
return Self::default();
}
let total_checks = records.len() as u64;
let healthy_count = records.iter().filter(|r| r.is_healthy()).count() as u64;
let unhealthy_count = total_checks - healthy_count;
let health_percentage = if total_checks > 0 {
(healthy_count as f64 / total_checks as f64) * 100.0
} else {
0.0
};
// 计算平均响应时间(只计算健康状态的记录)
let healthy_records: Vec<_> = records
.iter()
.filter(|r| r.is_healthy() && r.response_time > 0)
.collect();
let average_response_time = if !healthy_records.is_empty() {
let total_time: i32 = healthy_records.iter().map(|r| r.response_time).sum();
Some(total_time as f64 / healthy_records.len() as f64)
} else {
None
};
// 正常运行时间百分比(基于健康状态)
let uptime_percentage = health_percentage;
// 获取最后的检查信息
let last_record = records.first(); // records 应该按时间倒序排列
let last_check_time = last_record.map(|r| r.checked_at.into());
let last_status = last_record.map(|r| HealthStatus::from(r.status.clone()));
Self {
total_checks,
healthy_count,
unhealthy_count,
health_percentage,
average_response_time,
uptime_percentage,
last_check_time,
last_status,
}
}
}
/// Model 的扩展方法
impl entity::health_records::Model {
/// 检查记录是否为健康状态
pub fn is_healthy(&self) -> bool {
let status = HealthStatus::from(self.status.clone());
matches!(status, HealthStatus::Healthy)
}
/// 创建新的活动模型
pub fn new_active_model(
node_id: i32,
status: HealthStatus,
response_time: Option<i32>,
error_message: Option<String>,
) -> entity::health_records::ActiveModel {
entity::health_records::ActiveModel {
node_id: Set(node_id),
status: Set(status.to_string()),
response_time: Set(response_time.unwrap_or(0)),
error_message: Set(error_message.unwrap_or_default()),
checked_at: Set(chrono::Utc::now().fixed_offset()),
..Default::default()
}
}
/// 获取健康状态
pub fn get_status(&self) -> HealthStatus {
HealthStatus::from(self.status.clone())
}
}
/// Model 的扩展方法
impl entity::shared_nodes::Model {
/// 创建新的活动模型
#[allow(clippy::too_many_arguments)]
pub fn new_active_model(
name: String,
host: String,
port: i32,
protocol: String,
version: Option<String>,
description: Option<String>,
max_connections: i32,
allow_relay: bool,
network_name: String,
network_secret: Option<String>,
) -> entity::shared_nodes::ActiveModel {
let now = chrono::Utc::now().fixed_offset();
entity::shared_nodes::ActiveModel {
name: Set(name),
host: Set(host),
port: Set(port),
protocol: Set(protocol),
version: Set(version.unwrap_or_default()),
description: Set(description.unwrap_or_default()),
max_connections: Set(max_connections),
current_connections: Set(0),
is_active: Set(true),
is_approved: Set(false),
allow_relay: Set(allow_relay),
network_name: Set(network_name),
network_secret: Set(network_secret.unwrap_or_default()),
created_at: Set(now),
updated_at: Set(now),
..Default::default()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter as _};
#[tokio::test]
async fn test_database_creation() {
let db = Db::memory_db().await;
let stats = db.get_database_stats().await.unwrap();
// 初始状态下应该没有记录
assert_eq!(stats.total_nodes, 0);
assert_eq!(stats.active_nodes, 0);
assert_eq!(stats.total_health_records, 0);
}
}

View File

@@ -0,0 +1,343 @@
use crate::api::CreateNodeRequest;
use crate::db::entity::*;
use crate::db::Db;
use crate::db::HealthStats;
use crate::db::HealthStatus;
use sea_orm::*;
/// 节点管理操作
pub struct NodeOperations;
impl NodeOperations {
pub fn create_node_model(req: CreateNodeRequest) -> shared_nodes::ActiveModel {
shared_nodes::ActiveModel {
id: NotSet,
name: Set(req.name),
host: Set(req.host),
port: Set(req.port),
protocol: Set(req.protocol),
version: Set("".to_string()),
description: Set(req.description.unwrap_or_default()),
max_connections: Set(req.max_connections),
current_connections: Set(0),
is_active: Set(false),
is_approved: Set(false),
allow_relay: Set(req.allow_relay),
network_name: Set(req.network_name),
network_secret: Set(req.network_secret.unwrap_or_default()),
qq_number: Set(req.qq_number.unwrap_or_default()),
wechat: Set(req.wechat.unwrap_or_default()),
mail: Set(req.mail.unwrap_or_default()),
created_at: Set(chrono::Utc::now().fixed_offset()),
updated_at: Set(chrono::Utc::now().fixed_offset()),
}
}
/// 创建新节点
pub async fn create_node(
db: &Db,
req: CreateNodeRequest,
) -> Result<shared_nodes::Model, DbErr> {
let node = Self::create_node_model(req);
let insert_result = shared_nodes::Entity::insert(node).exec(db.orm_db()).await?;
shared_nodes::Entity::find_by_id(insert_result.last_insert_id)
.one(db.orm_db())
.await?
.ok_or(DbErr::RecordNotFound(
"Failed to retrieve created node".to_string(),
))
}
/// 获取所有节点
pub async fn get_all_nodes(db: &Db) -> Result<Vec<shared_nodes::Model>, DbErr> {
shared_nodes::Entity::find()
.order_by_asc(shared_nodes::Column::Id)
.all(db.orm_db())
.await
}
/// 根据ID获取节点
pub async fn get_node_by_id(db: &Db, id: i32) -> Result<Option<shared_nodes::Model>, DbErr> {
shared_nodes::Entity::find_by_id(id).one(db.orm_db()).await
}
/// 更新节点状态
pub async fn update_node_status(
db: &Db,
id: i32,
is_active: bool,
current_connections: Option<i32>,
) -> Result<shared_nodes::Model, DbErr> {
let mut node = shared_nodes::Entity::find_by_id(id)
.one(db.orm_db())
.await?
.ok_or(DbErr::RecordNotFound("Node not found".to_string()))?;
let mut node = node.into_active_model();
node.is_active = Set(is_active);
if let Some(connections) = current_connections {
node.current_connections = Set(connections);
}
node.updated_at = Set(chrono::Utc::now().fixed_offset());
let updated_node = shared_nodes::Entity::update(node).exec(db.orm_db()).await?;
Ok(updated_node)
}
/// 删除节点
pub async fn delete_node(db: &Db, id: i32) -> Result<u64, DbErr> {
let result = shared_nodes::Entity::delete_by_id(id)
.exec(db.orm_db())
.await?;
Ok(result.rows_affected)
}
/// 获取活跃节点
pub async fn get_active_nodes(db: &Db) -> Result<Vec<shared_nodes::Model>, DbErr> {
shared_nodes::Entity::find()
.filter(shared_nodes::Column::IsActive.eq(true))
.order_by_asc(shared_nodes::Column::Id)
.all(db.orm_db())
.await
}
/// 检查节点是否存在根据host、port、protocol
pub async fn node_exists(
db: &Db,
host: &str,
port: i32,
protocol: &str,
) -> Result<bool, DbErr> {
let count = shared_nodes::Entity::find()
.filter(shared_nodes::Column::Host.eq(host))
.filter(shared_nodes::Column::Port.eq(port))
.filter(shared_nodes::Column::Protocol.eq(protocol))
.count(db.orm_db())
.await?;
Ok(count > 0)
}
pub async fn update_node_version(
db: &Db,
node_id: i32,
version: String,
) -> Result<shared_nodes::Model, DbErr> {
let mut node = shared_nodes::Entity::find_by_id(node_id)
.one(db.orm_db())
.await?
.ok_or(DbErr::RecordNotFound("Node not found".to_string()))?;
let mut node = node.into_active_model();
node.version = Set(version);
node.updated_at = Set(chrono::Utc::now().fixed_offset());
let updated_node = shared_nodes::Entity::update(node).exec(db.orm_db()).await?;
Ok(updated_node)
}
}
/// 健康记录操作
pub struct HealthOperations;
impl HealthOperations {
/// 创建健康记录
pub async fn create_health_record(
db: &Db,
node_id: i32,
status: HealthStatus,
response_time: Option<i32>,
error_message: Option<String>,
) -> Result<health_records::Model, DbErr> {
let record =
health_records::Model::new_active_model(node_id, status, response_time, error_message);
let insert_result = health_records::Entity::insert(record)
.exec(db.orm_db())
.await?;
health_records::Entity::find_by_id(insert_result.last_insert_id)
.one(db.orm_db())
.await?
.ok_or(DbErr::RecordNotFound(
"Failed to retrieve created health record".to_string(),
))
}
/// 获取节点的健康记录
pub async fn get_node_health_records(
db: &Db,
node_id: i32,
from_date: Option<chrono::NaiveDateTime>,
limit: Option<u64>,
) -> Result<Vec<health_records::Model>, DbErr> {
let mut query = health_records::Entity::find()
.filter(health_records::Column::NodeId.eq(node_id))
.order_by_desc(health_records::Column::CheckedAt);
if let Some(from_date) = from_date {
query = query.filter(health_records::Column::CheckedAt.gte(from_date));
}
if let Some(limit) = limit {
query = query.limit(Some(limit));
}
query.all(db.orm_db()).await
}
/// 获取节点最近的健康状态
pub async fn get_latest_health_status(
db: &Db,
node_id: i32,
) -> Result<Option<health_records::Model>, DbErr> {
health_records::Entity::find()
.filter(health_records::Column::NodeId.eq(node_id))
.order_by_desc(health_records::Column::CheckedAt)
.one(db.orm_db())
.await
}
/// 获取健康统计信息
pub async fn get_health_stats(db: &Db, node_id: i32, hours: i64) -> Result<HealthStats, DbErr> {
let since = chrono::Utc::now().naive_utc() - chrono::Duration::hours(hours);
let records = health_records::Entity::find()
.filter(health_records::Column::NodeId.eq(node_id))
.filter(health_records::Column::CheckedAt.gte(since))
.order_by_desc(health_records::Column::CheckedAt)
.all(db.orm_db())
.await?;
Ok(HealthStats::from_records(&records))
}
/// 清理旧的健康记录
pub async fn cleanup_old_records(db: &Db, days: i64) -> Result<u64, DbErr> {
let cutoff = chrono::Utc::now().naive_utc() - chrono::Duration::days(days);
let result = health_records::Entity::delete_many()
.filter(health_records::Column::CheckedAt.lt(cutoff))
.exec(db.orm_db())
.await?;
Ok(result.rows_affected)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Db;
#[tokio::test]
async fn test_node_operations() {
let db = Db::memory_db().await;
let req = CreateNodeRequest {
name: "Test Node".to_string(),
host: "test.example.com".to_string(),
port: 11010,
protocol: "tcp".to_string(),
description: Some("Test node".to_string()),
max_connections: 100,
allow_relay: false,
network_name: "test-network".to_string(),
network_secret: Some("test-secret".to_string()),
qq_number: Some("123456789".to_string()),
wechat: Some("test_wechat".to_string()),
mail: Some("test@example.com".to_string()),
};
// 测试创建节点
let node = NodeOperations::create_node(&db, req).await.unwrap();
assert_eq!(node.name, "Test Node");
assert_eq!(node.host, "test.example.com");
assert_eq!(node.port, 11010);
assert!(node.is_active);
// 测试获取节点
let found_node = NodeOperations::get_node_by_id(&db, node.id).await.unwrap();
assert!(found_node.is_some());
assert_eq!(found_node.unwrap().id, node.id);
// 测试获取所有节点
let all_nodes = NodeOperations::get_all_nodes(&db).await.unwrap();
assert_eq!(all_nodes.len(), 1);
// 测试节点存在性检查
let exists = NodeOperations::node_exists(&db, "test.example.com", 11010, "tcp")
.await
.unwrap();
assert!(exists);
let not_exists = NodeOperations::node_exists(&db, "nonexistent.com", 8080, "tcp")
.await
.unwrap();
assert!(!not_exists);
}
#[tokio::test]
async fn test_health_operations() {
let db = Db::memory_db().await;
let req = CreateNodeRequest {
name: "Test Node".to_string(),
host: "test.example.com".to_string(),
port: 11010,
protocol: "tcp".to_string(),
description: Some("Test node".to_string()),
max_connections: 100,
allow_relay: false,
network_name: "test-network".to_string(),
network_secret: Some("test-secret".to_string()),
qq_number: Some("123456789".to_string()),
wechat: Some("test_wechat".to_string()),
mail: Some("test@example.com".to_string()),
};
// 创建测试节点
let node = NodeOperations::create_node(&db, req).await.unwrap();
// 测试创建健康记录
let record = HealthOperations::create_health_record(
&db,
node.id,
HealthStatus::Healthy,
Some(100),
None,
)
.await
.unwrap();
assert_eq!(record.node_id, node.id);
assert!(record.is_healthy());
assert_eq!(record.response_time, 100);
// 测试获取健康记录
let records = HealthOperations::get_node_health_records(&db, node.id, None, None)
.await
.unwrap();
assert_eq!(records.len(), 1);
// 测试获取最新状态
let latest = HealthOperations::get_latest_health_status(&db, node.id)
.await
.unwrap();
assert!(latest.is_some());
assert_eq!(latest.unwrap().id, record.id);
// 测试健康统计
let stats = HealthOperations::get_health_stats(&db, node.id, 24)
.await
.unwrap();
assert_eq!(stats.total_checks, 1);
assert_eq!(stats.healthy_count, 1);
assert_eq!(stats.health_percentage, 100.0);
}
}

View File

@@ -0,0 +1,660 @@
use std::{
ops::{DerefMut, Div},
sync::Arc,
time::{Duration, Instant},
};
use anyhow::Context as _;
use dashmap::DashMap;
use easytier::{
common::{
config::{ConfigLoader, NetworkIdentity, PeerConfig, TomlConfigLoader},
scoped_task::ScopedTask,
},
defer,
instance_manager::NetworkInstanceManager,
launcher::ConfigSource,
};
use serde::{Deserialize, Serialize};
use sqlx::any;
use tracing::{debug, error, info, instrument, warn};
use crate::db::{
entity::shared_nodes,
operations::{HealthOperations, NodeOperations},
Db, HealthStatus,
};
pub struct HealthCheckOneNode {
node_id: String,
}
const HEALTH_CHECK_RING_GRANULARITY_SEC: usize = 60 * 15; // 15分钟
const HEALTH_CHECK_RING_MAX_DURATION_SEC: usize = 60 * 60 * 24; // 最多一天
// const HEALTH_CHECK_RING_GRANULARITY_SEC: usize = 10;
// const HEALTH_CHECK_RING_MAX_DURATION_SEC: usize = 60;
const HEALTH_CHECK_RING_SIZE: usize =
HEALTH_CHECK_RING_MAX_DURATION_SEC / HEALTH_CHECK_RING_GRANULARITY_SEC;
#[derive(Debug, Default, Clone)]
struct RingItem {
counter: u64,
round: u64,
}
impl RingItem {
fn try_update_round(&mut self, timestamp: u64) {
let cur_round =
timestamp.div((HEALTH_CHECK_RING_GRANULARITY_SEC * HEALTH_CHECK_RING_SIZE) as u64);
if self.round != cur_round {
self.round = cur_round;
self.counter = 0;
}
}
fn inc(&mut self, timestamp: u64) {
self.try_update_round(timestamp);
self.counter += 1;
}
fn get(&mut self, timestamp: u64) -> u64 {
self.try_update_round(timestamp);
self.counter
}
}
#[derive(Debug, Clone)]
pub struct HealthyMemRecord {
node_id: i32,
current_health_status: HealthStatus,
last_error_info: Option<String>,
last_check_time: chrono::DateTime<chrono::Utc>,
last_response_time: Option<i32>,
// the current time is corresponding to the index by modulo with UNIX-timestamp.
total_check_counter_ring: Vec<RingItem>,
healthy_counter_ring: Vec<RingItem>,
}
impl HealthyMemRecord {
pub fn new(node_id: i32) -> Self {
Self {
node_id,
current_health_status: HealthStatus::Unknown,
last_error_info: None,
last_check_time: chrono::Utc::now(),
last_response_time: None,
total_check_counter_ring: vec![Default::default(); HEALTH_CHECK_RING_SIZE],
healthy_counter_ring: vec![Default::default(); HEALTH_CHECK_RING_SIZE],
}
}
/// 从数据库记录初始化内存记录
pub fn from_db_records(
node_id: i32,
records: &[crate::db::entity::health_records::Model],
) -> Self {
let mut mem_record = Self::new(node_id);
if let Some(latest) = records.first() {
mem_record.current_health_status = latest.get_status();
mem_record.last_check_time = latest.checked_at.to_utc();
mem_record.last_response_time = if latest.response_time == 0 {
None
} else {
Some(latest.response_time)
};
mem_record.last_error_info = if latest.error_message.is_empty() {
None
} else {
Some(latest.error_message.clone())
};
}
// 填充环形缓冲区
mem_record.populate_ring_from_records(records);
mem_record
}
/// 从历史记录填充环形缓冲区
fn populate_ring_from_records(&mut self, records: &[crate::db::entity::health_records::Model]) {
let now = chrono::Utc::now().timestamp() as usize;
for record in records {
let record_time = record.checked_at.to_utc().timestamp() as usize;
let time_diff = now.saturating_sub(record_time);
// 只处理在环形缓冲区时间范围内的记录
if time_diff < HEALTH_CHECK_RING_MAX_DURATION_SEC {
let ring_index =
(record_time / HEALTH_CHECK_RING_GRANULARITY_SEC) % HEALTH_CHECK_RING_SIZE;
self.total_check_counter_ring[ring_index].inc(record_time as u64);
if record.get_status() == HealthStatus::Healthy {
self.healthy_counter_ring[ring_index].inc(record_time as u64);
}
}
}
}
/// 更新健康状态并记录到环形缓冲区
pub fn update_health_status(
&mut self,
status: HealthStatus,
response_time: Option<i32>,
error_message: Option<String>,
) {
self.current_health_status = status.clone();
self.last_check_time = chrono::Utc::now();
self.last_response_time = response_time;
self.last_error_info = error_message;
// 更新环形缓冲区
let now = chrono::Utc::now().timestamp() as usize;
let ring_index = (now / HEALTH_CHECK_RING_GRANULARITY_SEC) % HEALTH_CHECK_RING_SIZE;
self.total_check_counter_ring[ring_index].inc(now as u64);
self.healthy_counter_ring[ring_index].try_update_round(now as u64);
if status == HealthStatus::Healthy {
self.healthy_counter_ring[ring_index].inc(now as u64);
}
}
/// 获取健康统计信息
pub fn get_health_stats(&self, hours: u64) -> crate::db::HealthStats {
let now = chrono::Utc::now().timestamp() as usize;
let mut total_checks = 0;
let mut healthy_count = 0;
for ring_index in 0..HEALTH_CHECK_RING_SIZE {
total_checks += self.total_check_counter_ring[ring_index].counter;
healthy_count += self.healthy_counter_ring[ring_index].counter;
}
let health_percentage = if total_checks > 0 {
(healthy_count as f64 / total_checks as f64) * 100.0
} else {
0.0
};
crate::db::HealthStats {
total_checks,
healthy_count,
unhealthy_count: total_checks - healthy_count,
health_percentage,
average_response_time: self.last_response_time.map(|rt| rt as f64),
uptime_percentage: health_percentage,
last_check_time: Some(self.last_check_time),
last_status: Some(self.current_health_status.clone()),
}
}
/// 获取当前健康状态
pub fn get_current_health_status(&self) -> &HealthStatus {
&self.current_health_status
}
/// 获取最后检查时间
pub fn get_last_check_time(&self) -> chrono::DateTime<chrono::Utc> {
self.last_check_time
}
/// 获取最后响应时间
pub fn get_last_response_time(&self) -> Option<i32> {
self.last_response_time
}
/// 获取最后错误信息
pub fn get_last_error_info(&self) -> &Option<String> {
&self.last_error_info
}
pub fn get_counter_ring(&mut self) -> (Vec<u64>, Vec<u64>) {
let now = self.last_check_time.timestamp() as usize;
let mut total_ring = vec![0; HEALTH_CHECK_RING_SIZE];
let mut healthy_ring = vec![0; HEALTH_CHECK_RING_SIZE];
let mut total_checks = 0;
let mut healthy_count = 0;
for i in 0..HEALTH_CHECK_RING_SIZE {
let ring_time = now - (i * HEALTH_CHECK_RING_GRANULARITY_SEC);
let ring_index =
ring_time.div_euclid(HEALTH_CHECK_RING_GRANULARITY_SEC) % HEALTH_CHECK_RING_SIZE;
total_ring[i] = self.total_check_counter_ring[ring_index].get(ring_time as u64);
healthy_ring[i] = self.healthy_counter_ring[ring_index].counter;
}
(total_ring, healthy_ring)
}
pub fn get_ring_granularity(&self) -> u32 {
HEALTH_CHECK_RING_GRANULARITY_SEC as u32
}
}
pub struct HealthChecker {
db: Db,
instance_mgr: Arc<NetworkInstanceManager>,
inst_id_map: DashMap<i32, uuid::Uuid>,
node_tasks: DashMap<i32, ScopedTask<()>>,
node_records: Arc<DashMap<i32, HealthyMemRecord>>,
node_cfg: Arc<DashMap<i32, TomlConfigLoader>>,
}
impl HealthChecker {
pub fn new(db: Db) -> Self {
let instance_mgr = Arc::new(NetworkInstanceManager::new());
Self {
db,
instance_mgr,
inst_id_map: DashMap::new(),
node_tasks: DashMap::new(),
node_records: Arc::new(DashMap::new()),
node_cfg: Arc::new(DashMap::new()),
}
}
/// 启动时从数据库加载所有节点的健康记录到内存
pub async fn load_health_records_from_db(&self) -> anyhow::Result<()> {
info!("Loading health records from database...");
// 获取所有活跃节点
let nodes = NodeOperations::get_all_nodes(&self.db)
.await
.with_context(|| "Failed to get all nodes from database")?;
let from_date = chrono::Utc::now().naive_utc()
- chrono::Duration::seconds(HEALTH_CHECK_RING_MAX_DURATION_SEC as i64);
for node in nodes {
// 获取每个节点最近的健康记录(用于初始化环形缓冲区)
let records =
HealthOperations::get_node_health_records(&self.db, node.id, Some(from_date), None)
.await
.with_context(|| {
format!("Failed to get health records for node {}", node.id)
})?;
// 创建内存记录
let mem_record = HealthyMemRecord::from_db_records(node.id, &records);
self.node_records.insert(node.id, mem_record);
debug!(
"Loaded {} health records for node {} ({})",
records.len(),
node.id,
node.name
);
}
info!(
"Loaded health records for {} nodes",
self.node_records.len()
);
Ok(())
}
/// 获取节点的内存健康记录
pub fn get_node_memory_record(&self, node_id: i32) -> Option<HealthyMemRecord> {
self.node_records.get(&node_id).map(|entry| entry.clone())
}
/// 获取节点的健康统计信息(从内存)
pub fn get_node_health_stats(
&self,
node_id: i32,
hours: u64,
) -> Option<crate::db::HealthStats> {
self.node_records
.get(&node_id)
.map(|record| record.get_health_stats(hours))
}
/// 获取所有节点的当前健康状态(从内存)
pub fn get_all_nodes_health_status(&self) -> Vec<(i32, HealthStatus, Option<String>)> {
self.node_records
.iter()
.map(|entry| {
let record = entry.value();
(
record.node_id,
record.current_health_status.clone(),
record.last_error_info.clone(),
)
})
.collect()
}
pub async fn try_update_node(&self, node_id: i32) -> anyhow::Result<()> {
let old_cfg = self
.node_cfg
.get(&node_id)
.ok_or_else(|| anyhow::anyhow!("old node cfg not found, node_id: {}", node_id))?
.clone();
let new_cfg = self.get_node_cfg(node_id, Some(old_cfg.get_id())).await?;
if new_cfg.dump() != old_cfg.dump() {
self.remove_node(node_id).await?;
self.add_node(node_id).await?;
info!("node {} cfg updated", node_id);
}
Ok(())
}
async fn get_node_cfg_with_model(
&self,
node_info: &shared_nodes::Model,
inst_id: Option<uuid::Uuid>,
) -> anyhow::Result<TomlConfigLoader> {
let cfg = TomlConfigLoader::default();
cfg.set_peers(vec![PeerConfig {
uri: format!(
"{}://{}:{}",
node_info.protocol, node_info.host, node_info.port
)
.parse()
.with_context(|| "failed to parse peer uri")?,
}]);
let inst_id = inst_id.unwrap_or(uuid::Uuid::new_v4());
cfg.set_id(inst_id);
cfg.set_network_identity(NetworkIdentity::new(
node_info.network_name.clone(),
node_info.network_secret.clone(),
));
cfg.set_hostname(Some("HealthCheckNode".to_string()));
let mut flags = cfg.get_flags();
flags.no_tun = true;
flags.disable_p2p = true;
flags.disable_udp_hole_punching = true;
cfg.set_flags(flags);
Ok(cfg)
}
pub async fn test_connection(
&self,
node_info: &shared_nodes::Model,
max_time: Duration,
) -> anyhow::Result<()> {
let cfg = self.get_node_cfg_with_model(node_info, None).await?;
defer!({
let _ = self
.instance_mgr
.delete_network_instance(vec![cfg.get_id()]);
});
self.instance_mgr
.run_network_instance(cfg.clone(), ConfigSource::FFI)
.with_context(|| "failed to run network instance")?;
let now = Instant::now();
let mut err = None;
while now.elapsed() < max_time {
match Self::test_node_healthy(cfg.get_id(), self.instance_mgr.clone()).await {
Ok(_) => {
return Ok(());
}
Err(e) => {
warn!(
"test node healthy failed, node_info: {:?}, err: {}",
node_info, e
);
err = Some(e);
}
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
Err(anyhow::anyhow!("test node healthy failed, err: {:?}", err))
}
async fn get_node_cfg(
&self,
node_id: i32,
inst_id: Option<uuid::Uuid>,
) -> anyhow::Result<TomlConfigLoader> {
let node_info = NodeOperations::get_node_by_id(&self.db, node_id)
.await
.with_context(|| format!("failed to get node by id: {}", node_id))?
.ok_or_else(|| anyhow::anyhow!("node not found"))?;
self.get_node_cfg_with_model(&node_info, inst_id).await
}
pub async fn add_node(&self, node_id: i32) -> anyhow::Result<()> {
let cfg = self.get_node_cfg(node_id, None).await?;
info!(
"Add node {} to health checker, cfg: {}",
node_id,
cfg.dump()
);
self.instance_mgr
.run_network_instance(cfg.clone(), ConfigSource::FFI)
.with_context(|| "failed to run network instance")?;
self.inst_id_map.insert(node_id, cfg.get_id());
// 初始化内存记录(如果不存在)
if !self.node_records.contains_key(&node_id) {
// 从数据库加载历史记录
let from_date = chrono::Utc::now().naive_utc()
- chrono::Duration::seconds(HEALTH_CHECK_RING_MAX_DURATION_SEC as i64);
if let Ok(records) =
HealthOperations::get_node_health_records(&self.db, node_id, Some(from_date), None)
.await
{
let mem_record = HealthyMemRecord::from_db_records(node_id, &records);
self.node_records.insert(node_id, mem_record);
info!(
"Initialized memory record for node {} with {} historical records",
node_id,
records.len()
);
} else {
self.node_records
.insert(node_id, HealthyMemRecord::new(node_id));
info!("Initialized new memory record for node {}", node_id);
}
}
// 启动健康检查任务
let task = ScopedTask::from(tokio::spawn(Self::node_health_check_task(
node_id,
cfg.get_id(),
Arc::clone(&self.instance_mgr),
self.db.clone(),
Arc::clone(&self.node_records),
)));
self.node_tasks.insert(node_id, task);
self.node_cfg.insert(node_id, cfg.clone());
Ok(())
}
pub async fn remove_node(&self, node_id: i32) -> anyhow::Result<()> {
self.node_tasks.remove(&node_id);
if let Some(inst_id) = self.inst_id_map.remove(&node_id) {
let _ = self.instance_mgr.delete_network_instance(vec![inst_id.1]);
}
self.node_cfg.remove(&node_id);
// 保留内存记录,不删除,以便后续查询历史数据
info!(
"Removed health check task for node {}, memory record retained",
node_id
);
Ok(())
}
#[instrument(err, ret, skip(instance_mgr))]
async fn test_node_healthy(
inst_id: uuid::Uuid,
instance_mgr: Arc<NetworkInstanceManager>,
// return version, response time on healthy, conn_count
) -> anyhow::Result<(String, u64, u32)> {
let Some(instance) = instance_mgr.get_network_info(&inst_id) else {
anyhow::bail!("healthy check node is not started");
};
let running = instance.running;
// health check node is not running, update db
if !running {
anyhow::bail!("healthy check node is not running");
}
if let Some(err) = instance.error_msg {
anyhow::bail!("healthy check node has error: {}", err);
}
let p = instance.peer_route_pairs;
// dst node is not online
let Some(dst_node) = p.iter().find(|x| {
// we disable p2p, so we only check direct connected peer
x.route.as_ref().is_some_and(|route| {
!route.feature_flag.unwrap().is_public_server && route.hostname != "HealthCheckNode"
}) && x.peer.as_ref().is_some_and(|p| !p.conns.is_empty())
}) else {
anyhow::bail!("dst node is not online");
};
let Some(route_info) = &dst_node.route else {
anyhow::bail!("dst node route is not found");
};
let Some(peer_info) = &dst_node.peer else {
anyhow::bail!("dst node peer is not found");
};
let version = route_info
.version
.clone()
.split("-")
.next()
.unwrap_or("")
.to_string();
// 计算响应时间(这里可以根据实际需要实现)
let response_time = peer_info
.conns
.iter()
.filter_map(|x| x.stats)
.map(|x| x.latency_us)
.min()
.unwrap_or(0);
let peer_id = peer_info.peer_id;
let conn_count = if let Some(summary) = instance.foreign_network_summary {
summary
.info_map
.get(&peer_id)
.map(|x| x.network_count)
.unwrap_or(0)
} else {
0
};
Ok((version, response_time, conn_count))
}
async fn node_health_check_task(
node_id: i32,
inst_id: uuid::Uuid,
instance_mgr: Arc<NetworkInstanceManager>,
db: Db,
node_records: Arc<DashMap<i32, HealthyMemRecord>>,
) {
/// 记录健康状态到数据库和内存
async fn record_health_status(
db: &Db,
node_records: &Arc<DashMap<i32, HealthyMemRecord>>,
node_id: i32,
status: HealthStatus,
response_time: Option<i32>,
error_message: Option<String>,
) {
// 写入数据库
if let Err(e) = HealthOperations::create_health_record(
db,
node_id,
status.clone(),
response_time,
error_message.clone(),
)
.await
{
error!("Failed to create health record for node {}: {}", node_id, e);
}
// 更新内存记录
if let Some(mut record) = node_records.get_mut(&node_id) {
record.update_health_status(status, response_time, error_message);
} else {
let mut new_record = HealthyMemRecord::new(node_id);
new_record.update_health_status(status, response_time, error_message);
node_records.insert(node_id, new_record);
}
}
let mut tick = tokio::time::interval(Duration::from_secs(5));
let mut counter: u64 = 0;
loop {
if counter != 0 {
tick.tick().await;
}
counter += 1;
match Self::test_node_healthy(inst_id, instance_mgr.clone()).await {
Ok((version, response_time, conn_count)) => {
if let Err(e) = NodeOperations::update_node_status(
&db,
node_id,
true,
Some(conn_count as i32),
)
.await
{
error!("Failed to update node status for node {}: {}", node_id, e);
}
record_health_status(
&db,
&node_records,
node_id,
HealthStatus::Healthy,
Some(response_time as i32),
None,
)
.await;
// update node version
if let Err(e) = NodeOperations::update_node_version(&db, node_id, version).await
{
error!("Failed to update node version for node {}: {}", node_id, e);
}
}
Err(e) => {
if let Err(e) =
NodeOperations::update_node_status(&db, node_id, false, None).await
{
error!("Failed to update node status for node {}: {}", node_id, e);
}
record_health_status(
&db,
&node_records,
node_id,
HealthStatus::Unhealthy,
None,
Some(e.to_string()),
)
.await;
}
}
}
}
}

View File

@@ -0,0 +1,160 @@
use std::{collections::HashSet, sync::Arc, time::Duration};
use anyhow::Context as _;
use tokio::time::{interval, Interval};
use tracing::{error, info};
use crate::{
db::{entity::shared_nodes, operations::NodeOperations, Db},
health_checker::HealthChecker,
};
/// HealthChecker的封装器用于监控数据库中节点的添加和删除
pub struct HealthCheckerManager {
health_checker: Arc<HealthChecker>,
db: Db,
current_nodes: Arc<tokio::sync::RwLock<HashSet<i32>>>,
monitor_interval: Duration,
}
impl HealthCheckerManager {
/// 创建新的HealthCheckerManager实例
pub fn new(health_checker: Arc<HealthChecker>, db: Db) -> Self {
Self {
health_checker,
db,
current_nodes: Arc::new(tokio::sync::RwLock::new(HashSet::new())),
monitor_interval: Duration::from_secs(1), // 默认每1秒检查一次
}
}
/// 设置监控间隔
pub fn with_monitor_interval(mut self, interval: Duration) -> Self {
self.monitor_interval = interval;
self
}
/// 启动监控任务
pub async fn start_monitoring(&self) -> anyhow::Result<()> {
// 启动定期检查任务
let health_checker = Arc::clone(&self.health_checker);
let db = self.db.clone();
let current_nodes = Arc::clone(&self.current_nodes);
let monitor_interval = self.monitor_interval;
tokio::spawn(async move {
let mut ticker = interval(monitor_interval);
loop {
if let Err(e) = Self::check_node_changes(&health_checker, &db, &current_nodes).await
{
tracing::error!("Error checking node changes: {}", e);
}
ticker.tick().await;
}
});
Ok(())
}
/// 检查节点变化并更新监控
async fn check_node_changes(
health_checker: &Arc<HealthChecker>,
db: &Db,
current_nodes: &Arc<tokio::sync::RwLock<HashSet<i32>>>,
) -> anyhow::Result<()> {
// 获取数据库中当前的所有节点
let db_nodes = NodeOperations::get_all_nodes(db)
.await
.with_context(|| "Failed to get all nodes from database")?;
let db_node_ids: HashSet<i32> = db_nodes.iter().map(|node| node.id).collect();
let mut current_nodes_guard = current_nodes.write().await;
// 检查新增的节点
for &node_id in &db_node_ids {
if !current_nodes_guard.contains(&node_id) {
// 新节点,添加到监控
if let Err(e) = health_checker.add_node(node_id).await {
error!("Failed to add node {} to health checker: {}", node_id, e);
continue;
}
current_nodes_guard.insert(node_id);
info!("Added new node {} to health monitoring", node_id);
} else if let Err(e) = health_checker.try_update_node(node_id).await {
error!("Failed to add node {} to health checker: {}", node_id, e);
}
}
// 检查删除的节点
let nodes_to_remove: Vec<i32> = current_nodes_guard
.iter()
.filter(|&&node_id| !db_node_ids.contains(&node_id))
.copied()
.collect();
for node_id in nodes_to_remove {
// 节点已删除,从监控中移除
if let Err(e) = health_checker.remove_node(node_id).await {
error!(
"Failed to remove node {} from health checker: {}",
node_id, e
);
continue;
}
current_nodes_guard.remove(&node_id);
info!("Removed node {} from health monitoring", node_id);
}
Ok(())
}
/// 手动触发节点变化检查
pub async fn refresh_nodes(&self) -> anyhow::Result<()> {
Self::check_node_changes(&self.health_checker, &self.db, &self.current_nodes).await
}
/// 获取当前监控的节点数量
pub async fn get_monitored_node_count(&self) -> usize {
self.current_nodes.read().await.len()
}
/// 获取当前监控的节点ID列表
pub async fn get_monitored_nodes(&self) -> Vec<i32> {
self.current_nodes.read().await.iter().copied().collect()
}
/// 获取节点的内存健康记录
pub fn get_node_memory_record(
&self,
node_id: i32,
) -> Option<crate::health_checker::HealthyMemRecord> {
self.health_checker.get_node_memory_record(node_id)
}
/// 获取节点的健康统计信息
pub fn get_node_health_stats(
&self,
node_id: i32,
hours: u64,
) -> Option<crate::db::HealthStats> {
self.health_checker.get_node_health_stats(node_id, hours)
}
/// 获取所有节点的当前健康状态
pub fn get_all_nodes_health_status(
&self,
) -> Vec<(i32, crate::db::HealthStatus, Option<String>)> {
self.health_checker.get_all_nodes_health_status()
}
pub async fn test_connection(
&self,
node_info: &shared_nodes::Model,
max_time: Duration,
) -> anyhow::Result<()> {
self.health_checker
.test_connection(node_info, max_time)
.await
}
}

View File

@@ -0,0 +1,153 @@
#![allow(unused)]
mod api;
mod config;
mod db;
mod health_checker;
mod health_checker_manager;
mod migrator;
use api::routes::create_routes;
use clap::Parser;
use config::AppConfig;
use db::{operations::NodeOperations, Db};
use health_checker::HealthChecker;
use health_checker_manager::HealthCheckerManager;
use std::env;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tracing_subscriber::EnvFilter;
use crate::db::cleanup::{CleanupConfig, CleanupManager};
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
/// Admin password for management access
#[arg(long, env = "ADMIN_PASSWORD")]
admin_password: Option<String>,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// 加载配置
let config = AppConfig::default();
// 初始化日志
tracing_subscriber::fmt()
.with_max_level(match config.logging.level.as_str() {
"debug" => tracing::Level::DEBUG,
"info" => tracing::Level::INFO,
"warn" => tracing::Level::WARN,
"error" => tracing::Level::ERROR,
_ => tracing::Level::INFO,
})
.with_target(false)
.with_thread_ids(true)
.with_env_filter(EnvFilter::new("easytier_uptime"))
.init();
// 解析命令行参数
let args = Args::parse();
// 如果提供了管理员密码,设置环境变量
if let Some(password) = args.admin_password {
env::set_var("ADMIN_PASSWORD", password);
}
tracing::info!(
"Admin password configured: {}",
!config.security.admin_password.is_empty()
);
// 创建数据库连接
let db = Db::new(&config.database.path.to_string_lossy()).await?;
// 获取数据库统计信息
let stats = db.get_database_stats().await?;
tracing::info!("Database initialized successfully!");
tracing::info!("Database stats: {:?}", stats);
// 创建配置目录
let config_dir = PathBuf::from("./configs");
tokio::fs::create_dir_all(&config_dir).await?;
// 创建健康检查器和管理器
let health_checker = Arc::new(HealthChecker::new(db.clone()));
let health_checker_manager = HealthCheckerManager::new(health_checker, db.clone())
.with_monitor_interval(Duration::from_secs(1)); // 每30秒检查一次节点变化
let cleanup_manager = CleanupManager::new(db.clone(), CleanupConfig::default());
cleanup_manager.start_auto_cleanup().await?;
// 启动节点监控
health_checker_manager.start_monitoring().await?;
tracing::info!("Health checker manager started successfully!");
let monitored_count = health_checker_manager.get_monitored_node_count().await;
tracing::info!("Currently monitoring {} nodes", monitored_count);
// 创建应用状态
let app_state = crate::api::handlers::AppState {
db: db.clone(),
health_checker_manager: Arc::new(health_checker_manager),
};
// 创建 API 路由
let app = create_routes().with_state(app_state);
// 配置服务器地址
let addr = config.server.addr;
tracing::info!("Starting server on http://{}", addr);
tracing::info!("Available endpoints:");
tracing::info!(" GET /health - Health check");
tracing::info!(" GET /api/nodes - Get nodes (paginated, approved only)");
tracing::info!(" POST /api/nodes - Create node (pending approval)");
tracing::info!(" GET /api/nodes/:id - Get node by ID");
tracing::info!(" PUT /api/nodes/:id - Update node");
tracing::info!(" DELETE /api/nodes/:id - Delete node");
tracing::info!(" GET /api/nodes/:id/health - Get node health history");
tracing::info!(" GET /api/nodes/:id/health/stats - Get node health stats");
tracing::info!("Admin endpoints:");
tracing::info!(" POST /api/admin/login - Admin login");
tracing::info!(" GET /api/admin/nodes - Get all nodes (including pending)");
tracing::info!(" PUT /api/admin/nodes/:id/approve - Approve/reject node");
tracing::info!(" DELETE /api/admin/nodes/:id - Delete node (admin only)");
// 启动服务器
let listener = tokio::net::TcpListener::bind(addr).await?;
// 设置优雅关闭
let shutdown_signal = Arc::new(tokio::sync::Notify::new());
let server_shutdown_signal = shutdown_signal.clone();
// 启动服务器任务
let server_handle = tokio::spawn(async move {
axum::serve(listener, app)
.with_graceful_shutdown(async move {
server_shutdown_signal.notified().await;
})
.await
.unwrap();
});
// 等待 Ctrl+C 信号
tokio::select! {
_ = tokio::signal::ctrl_c() => {
tracing::info!("Received shutdown signal");
}
_ = server_handle => {
tracing::info!("Server task completed");
}
}
// 优雅关闭
tracing::info!("Shutting down gracefully...");
shutdown_signal.notify_waiters();
tracing::info!("Shutdown complete");
Ok(())
}

View File

@@ -0,0 +1,181 @@
use sea_orm_migration::{prelude::*, schema::*};
pub struct Migration;
impl MigrationName for Migration {
fn name(&self) -> &str {
"m20250101_000001_create_tables"
}
}
#[derive(DeriveIden)]
pub enum SharedNodes {
Table,
Id,
Name,
Host,
Port,
Protocol,
Version,
AllowRelay,
NetworkName,
NetworkSecret,
Description,
MaxConnections,
CurrentConnections,
IsActive,
IsApproved,
QQNumber,
Wechat,
Mail,
CreatedAt,
UpdatedAt,
}
#[derive(DeriveIden)]
pub enum HealthRecords {
Table,
Id,
NodeId,
Status,
ResponseTime,
ErrorMessage,
CheckedAt,
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// 创建共享节点表
manager
.create_table(
Table::create()
.if_not_exists()
.table(SharedNodes::Table)
.col(pk_auto(SharedNodes::Id).not_null())
.col(string(SharedNodes::Name).not_null())
.col(string(SharedNodes::Host).not_null())
.col(integer(SharedNodes::Port).not_null())
.col(string(SharedNodes::Protocol).not_null().default("tcp"))
.col(string(SharedNodes::Version))
.col(boolean(SharedNodes::AllowRelay).default(false))
.col(string(SharedNodes::NetworkName))
.col(string(SharedNodes::NetworkSecret))
.col(text(SharedNodes::Description))
.col(integer(SharedNodes::MaxConnections).default(100))
.col(integer(SharedNodes::CurrentConnections).default(0))
.col(boolean(SharedNodes::IsActive).default(true))
.col(boolean(SharedNodes::IsApproved).default(false))
.col(string(SharedNodes::QQNumber))
.col(string(SharedNodes::Wechat))
.col(string(SharedNodes::Mail))
.col(
timestamp_with_time_zone(SharedNodes::CreatedAt)
.default(Expr::current_timestamp()),
)
.col(
timestamp_with_time_zone(SharedNodes::UpdatedAt)
.default(Expr::current_timestamp()),
)
.to_owned(),
)
.await?;
// 创建唯一约束
manager
.create_index(
Index::create()
.name("idx_shared_nodes_host_port_protocol")
.table(SharedNodes::Table)
.col(SharedNodes::Host)
.col(SharedNodes::Port)
.col(SharedNodes::Protocol)
.unique()
.to_owned(),
)
.await?;
// 创建健康度记录表
manager
.create_table(
Table::create()
.if_not_exists()
.table(HealthRecords::Table)
.col(pk_auto(HealthRecords::Id).not_null())
.col(integer(HealthRecords::NodeId).not_null())
.col(string(HealthRecords::Status).not_null())
.col(integer(HealthRecords::ResponseTime))
.col(text(HealthRecords::ErrorMessage).null())
.col(
timestamp_with_time_zone(HealthRecords::CheckedAt)
.default(Expr::current_timestamp()),
)
.foreign_key(
ForeignKey::create()
.name("fk_health_records_node_id_to_shared_nodes_id")
.from(HealthRecords::Table, HealthRecords::NodeId)
.to(SharedNodes::Table, SharedNodes::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
// 创建健康度记录索引
manager
.create_index(
Index::create()
.name("idx_health_records_node_id")
.table(HealthRecords::Table)
.col(HealthRecords::NodeId)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_health_records_checked_at")
.table(HealthRecords::Table)
.col(HealthRecords::CheckedAt)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_health_records_node_time")
.table(HealthRecords::Table)
.col(HealthRecords::NodeId)
.col(HealthRecords::CheckedAt)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_health_records_status")
.table(HealthRecords::Table)
.col(HealthRecords::Status)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(HealthRecords::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(SharedNodes::Table).to_owned())
.await?;
Ok(())
}
}

View File

@@ -0,0 +1,12 @@
use sea_orm_migration::prelude::*;
mod m20250101_000001_create_tables;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![Box::new(m20250101_000001_create_tables::Migration)]
}
}

View File

@@ -0,0 +1,95 @@
#!/bin/bash
# EasyTier Uptime Monitor 开发环境启动脚本
set -e
echo "🚀 Starting EasyTier Uptime Monitor Development Environment..."
# 检查依赖
echo "📦 Checking dependencies..."
# 检查 Rust
if ! command -v cargo &> /dev/null; then
echo "❌ Rust is not installed. Please install Rust first."
exit 1
fi
# 检查 Node.js
if ! command -v node &> /dev/null; then
echo "❌ Node.js is not installed. Please install Node.js first."
exit 1
fi
# 检查 npm
if ! command -v npm &> /dev/null; then
echo "❌ npm is not installed. Please install npm first."
exit 1
fi
# 设置环境变量
export RUST_LOG=debug
export NODE_ENV=development
# 创建必要的目录
echo "📁 Creating directories..."
mkdir -p logs
mkdir -p configs
mkdir -p frontend/dist
# 复制环境配置文件
if [ ! -f .env ]; then
echo "📝 Creating environment configuration..."
cp .env.development .env
fi
# 安装前端依赖
echo "📦 Installing frontend dependencies..."
cd frontend
if [ ! -d "node_modules" ]; then
npm install
fi
cd ..
# 启动后端服务
echo "🔧 Starting backend server..."
cargo run &
BACKEND_PID=$!
# 等待后端服务启动
echo "⏳ Waiting for backend server to start..."
sleep 5
# 启动前端开发服务器
echo "🎨 Starting frontend development server..."
cd frontend
npm run dev &
FRONTEND_PID=$!
cd ..
# 等待前端服务启动
echo "⏳ Waiting for frontend server to start..."
sleep 3
echo "✅ Development environment started successfully!"
echo "🌐 Frontend: http://localhost:3000"
echo "🔧 Backend API: http://localhost:8080"
echo "📊 API Health Check: http://localhost:8080/health"
echo ""
echo "Press Ctrl+C to stop all services"
# 清理函数
cleanup() {
echo ""
echo "🛑 Stopping services..."
kill $BACKEND_PID 2>/dev/null || true
kill $FRONTEND_PID 2>/dev/null || true
echo "✅ All services stopped"
exit 0
}
# 设置信号处理
trap cleanup SIGINT SIGTERM
# 等待用户中断
wait

View File

@@ -0,0 +1,98 @@
#!/bin/bash
# EasyTier Uptime Monitor 生产环境启动脚本
set -e
echo "🚀 Starting EasyTier Uptime Monitor Production Environment..."
# 检查依赖
echo "📦 Checking dependencies..."
# 检查 Rust
if ! command -v cargo &> /dev/null; then
echo "❌ Rust is not installed. Please install Rust first."
exit 1
fi
# 检查 Node.js
if ! command -v node &> /dev/null; then
echo "❌ Node.js is not installed. Please install Node.js first."
exit 1
fi
# 检查 npm
if ! command -v npm &> /dev/null; then
echo "❌ npm is not installed. Please install npm first."
exit 1
fi
# 设置环境变量
export RUST_LOG=info
export NODE_ENV=production
# 创建必要的目录
echo "📁 Creating directories..."
mkdir -p logs
mkdir -p configs
mkdir -p /var/lib/easytier-uptime
mkdir -p frontend/dist
# 复制环境配置文件
if [ ! -f .env ]; then
echo "📝 Creating environment configuration..."
cp .env.production .env
fi
# 构建后端
echo "🔧 Building backend..."
cargo build --release
# 构建前端
echo "🎨 Building frontend..."
cd frontend
if [ ! -d "node_modules" ]; then
npm install
fi
npm run build
cd ..
# 启动后端服务
echo "🔧 Starting backend server..."
nohup ./target/release/easytier-uptime > logs/backend.log 2>&1 &
BACKEND_PID=$!
# 等待后端服务启动
echo "⏳ Waiting for backend server to start..."
sleep 5
# 设置静态文件服务
echo "🌐 Setting up static file server..."
cd frontend/dist
python3 -m http.server 8081 > ../../logs/frontend.log 2>&1 &
FRONTEND_PID=$!
cd ../..
# 等待前端服务启动
echo "⏳ Waiting for frontend server to start..."
sleep 3
echo "✅ Production environment started successfully!"
echo "🌐 Frontend: http://localhost:8081"
echo "🔧 Backend API: http://localhost:8080"
echo "📊 API Health Check: http://localhost:8080/health"
echo ""
echo "Backend PID: $BACKEND_PID"
echo "Frontend PID: $FRONTEND_PID"
echo ""
echo "To stop services:"
echo " kill $BACKEND_PID"
echo " kill $FRONTEND_PID"
echo ""
echo "Or use the stop script: ./stop-prod.sh"
# 保存PID到文件
echo $BACKEND_PID > logs/backend.pid
echo $FRONTEND_PID > logs/frontend.pid
echo "✅ PIDs saved to logs/"

View File

@@ -0,0 +1,46 @@
#!/bin/bash
# EasyTier Uptime Monitor 停止服务脚本
set -e
echo "🛑 Stopping EasyTier Uptime Monitor services..."
# 检查PID文件
if [ -f "logs/backend.pid" ]; then
BACKEND_PID=$(cat logs/backend.pid)
echo "🔧 Stopping backend server (PID: $BACKEND_PID)..."
kill $BACKEND_PID 2>/dev/null || true
rm logs/backend.pid
echo "✅ Backend server stopped"
else
echo "⚠️ Backend PID file not found"
fi
if [ -f "logs/frontend.pid" ]; then
FRONTEND_PID=$(cat logs/frontend.pid)
echo "🌐 Stopping frontend server (PID: $FRONTEND_PID)..."
kill $FRONTEND_PID 2>/dev/null || true
rm logs/frontend.pid
echo "✅ Frontend server stopped"
else
echo "⚠️ Frontend PID file not found"
fi
# 强制杀死可能残留的进程
echo "🔍 Checking for remaining processes..."
REMAINING_BACKEND=$(ps aux | grep 'easytier-uptime' | grep -v grep | awk '{print $2}' || true)
if [ ! -z "$REMAINING_BACKEND" ]; then
echo "🔧 Killing remaining backend processes..."
echo $REMAINING_BACKEND | xargs kill -9 2>/dev/null || true
echo "✅ Remaining backend processes killed"
fi
REMAINING_FRONTEND=$(ps aux | grep 'python3 -m http.server' | grep -v grep | awk '{print $2}' || true)
if [ ! -z "$REMAINING_FRONTEND" ]; then
echo "🌐 Killing remaining frontend processes..."
echo $REMAINING_FRONTEND | xargs kill -9 2>/dev/null || true
echo "✅ Remaining frontend processes killed"
fi
echo "✅ All services stopped successfully!"

View File

@@ -0,0 +1,144 @@
#!/bin/bash
# EasyTier Uptime Monitor 集成测试脚本
set -e
echo "🧪 Running EasyTier Uptime Monitor Integration Tests..."
# 检查依赖
echo "📦 Checking dependencies..."
# 检查 Rust
if ! command -v cargo &> /dev/null; then
echo "❌ Rust is not installed. Please install Rust first."
exit 1
fi
# 检查 curl
if ! command -v curl &> /dev/null; then
echo "❌ curl is not installed. Please install curl first."
exit 1
fi
# 设置环境变量
export RUST_LOG=info
export NODE_ENV=test
# 创建测试目录
echo "📁 Creating test directories..."
mkdir -p test-results
mkdir -p test-logs
# 复制测试环境配置
if [ ! -f .env ]; then
echo "📝 Creating test environment configuration..."
cp .env.development .env
fi
# 构建项目
echo "🔧 Building project..."
cargo build
# 启动后端服务进行测试
echo "🚀 Starting backend server for testing..."
cargo run &
BACKEND_PID=$!
# 等待后端服务启动
echo "⏳ Waiting for backend server to start..."
sleep 5
# 检查服务是否运行
echo "🔍 Checking if server is running..."
if curl -f http://localhost:8080/health > /dev/null 2>&1; then
echo "✅ Backend server is running"
else
echo "❌ Backend server failed to start"
kill $BACKEND_PID 2>/dev/null || true
exit 1
fi
# 运行API测试
echo "🧪 Running API tests..."
if cargo test api_test --lib -- --nocapture > test-results/api-test.log 2>&1; then
echo "✅ API tests passed"
else
echo "❌ API tests failed"
echo "Check test-results/api-test.log for details"
fi
# 运行健康检查测试
echo "🏥 Running health check tests..."
curl -s http://localhost:8080/health | jq . > test-results/health-check.json
if [ $? -eq 0 ]; then
echo "✅ Health check test passed"
else
echo "❌ Health check test failed"
fi
# 运行节点管理测试
echo "🔧 Running node management tests..."
# 创建测试节点
curl -s -X POST http://localhost:8080/api/nodes \
-H "Content-Type: application/json" \
-d '{
"name": "Test Node",
"host": "127.0.0.1",
"port": 11010,
"protocol": "tcp",
"version": "1.0.0",
"description": "Test node for integration testing",
"max_connections": 100
}' > test-results/create-node.json
# 获取节点列表
curl -s http://localhost:8080/api/nodes > test-results/get-nodes.json
echo "✅ Node management tests completed"
# 停止后端服务
echo "🛑 Stopping backend server..."
kill $BACKEND_PID 2>/dev/null || true
sleep 2
# 强制杀死可能残留的进程
pkill -f easytier-uptime 2>/dev/null || true
echo "✅ Integration tests completed!"
echo "📊 Test results saved to test-results/"
echo "📋 Test logs saved to test-logs/"
# 生成测试报告
echo "📝 Generating test report..."
cat > test-results/test-report.md << EOF
# EasyTier Uptime Monitor Integration Test Report
## Test Summary
- **Test Date**: $(date)
- **Test Environment**: Integration
- **Backend PID**: $BACKEND_PID
## Test Results
### API Tests
- Status: $(grep -q "test result: ok" test-results/api-test.log && echo "PASSED" || echo "FAILED")
- Log: [api-test.log](api-test.log)
### Health Check
- Status: $(jq -r '.success' test-results/health-check.json 2>/dev/null || echo "FAILED")
- Response: $(cat test-results/health-check.json 2>/dev/null || echo "No response")
### Node Management
- Status: COMPLETED
- Create Node: [create-node.json](create-node.json)
- Get Nodes: [get-nodes.json](get-nodes.json)
## System Information
- **Rust Version**: $(rustc --version)
- **Cargo Version**: $(cargo --version)
- **System**: $(uname -a)
EOF
echo "✅ Test report generated: test-results/test-report.md"

View File

@@ -0,0 +1,66 @@
//! 测试 HealthyStats 功能的示例代码
use easytier_uptime::db::entity::health_records::{HealthStatus, HealthStats, Model};
use sea_orm::prelude::*;
fn main() {
// 创建一些模拟的健康记录
let records = vec![
Model {
id: 1,
node_id: 1,
status: HealthStatus::Healthy.to_string(),
response_time: 100,
error_message: String::new(),
checked_at: chrono::Utc::now().fixed_offset(),
},
Model {
id: 2,
node_id: 1,
status: HealthStatus::Healthy.to_string(),
response_time: 150,
error_message: String::new(),
checked_at: chrono::Utc::now().fixed_offset(),
},
Model {
id: 3,
node_id: 1,
status: HealthStatus::Unhealthy.to_string(),
response_time: 0,
error_message: "Connection failed".to_string(),
checked_at: chrono::Utc::now().fixed_offset(),
},
];
// 从记录创建统计信息
let stats = HealthStats::from_records(&records);
println!("健康统计信息:");
println!("总检查次数: {}", stats.total_checks);
println!("健康检查次数: {}", stats.healthy_count);
println!("不健康检查次数: {}", stats.unhealthy_count);
println!("健康百分比: {:.2}%", stats.health_percentage);
println!("平均响应时间: {:?} ms", stats.average_response_time);
println!("正常运行时间百分比: {:.2}%", stats.uptime_percentage);
println!("最后检查时间: {:?}", stats.last_check_time);
println!("最后状态: {:?}", stats.last_status);
// 测试健康状态转换
println!("\n健康状态测试:");
let status_healthy = HealthStatus::from("healthy");
let status_unhealthy = HealthStatus::from("unhealthy");
let status_timeout = HealthStatus::from("timeout");
let status_unknown = HealthStatus::from("invalid_status");
println!("healthy -> {:?}", status_healthy);
println!("unhealthy -> {:?}", status_unhealthy);
println!("timeout -> {:?}", status_timeout);
println!("invalid_status -> {:?}", status_unknown);
// 测试记录的健康状态检查
println!("\n记录健康状态检查:");
for record in &records {
println!("记录 {} 是否健康: {}", record.id, record.is_healthy());
println!("记录 {} 状态: {:?}", record.id, record.get_status());
}
}

View File

@@ -169,6 +169,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.type_attribute("peer_rpc.DirectConnectedPeerInfo", "#[derive(Hash)]")
.type_attribute("peer_rpc.PeerInfoForGlobalMap", "#[derive(Hash)]")
.type_attribute("peer_rpc.ForeignNetworkRouteInfoKey", "#[derive(Hash, Eq)]")
.type_attribute(
"peer_rpc.RouteForeignNetworkSummary.Info",
"#[derive(Hash, Eq, serde::Serialize, serde::Deserialize)]",
)
.type_attribute(
"peer_rpc.RouteForeignNetworkSummary",
"#[derive(Hash, Eq, serde::Serialize, serde::Deserialize)]",
)
.type_attribute("common.RpcDescriptor", "#[derive(Hash, Eq)]")
.field_attribute(".web.NetworkConfig", "#[serde(default)]")
.service_generator(Box::new(rpc_build::ServiceGenerator::new()))

View File

@@ -134,6 +134,12 @@ impl NetworkInstanceManager {
Ok(ret)
}
pub fn get_network_info(&self, instance_id: &uuid::Uuid) -> Option<NetworkInstanceRunningInfo> {
self.instance_map
.get(instance_id)
.and_then(|instance| instance.value().get_running_info())
}
pub fn list_network_instance_ids(&self) -> Vec<uuid::Uuid> {
self.instance_map.iter().map(|item| *item.key()).collect()
}

View File

@@ -1,4 +1,5 @@
use crate::common::config::PortForwardConfig;
use crate::proto::peer_rpc::RouteForeignNetworkSummary;
use crate::proto::web;
use crate::{
common::{
@@ -36,6 +37,7 @@ struct EasyTierData {
my_node_info: RwLock<MyNodeInfo>,
routes: RwLock<Vec<Route>>,
peers: RwLock<Vec<PeerInfo>>,
foreign_network_summary: RwLock<RouteForeignNetworkSummary>,
tun_fd: Arc<RwLock<Option<i32>>>,
tun_dev_name: RwLock<String>,
event_subscriber: RwLock<broadcast::Sender<GlobalCtxEvent>>,
@@ -51,6 +53,7 @@ impl Default for EasyTierData {
my_node_info: RwLock::new(MyNodeInfo::default()),
routes: RwLock::new(Vec::new()),
peers: RwLock::new(Vec::new()),
foreign_network_summary: RwLock::new(RouteForeignNetworkSummary::default()),
tun_fd: Arc::new(RwLock::new(None)),
tun_dev_name: RwLock::new(String::new()),
instance_stop_notifier: Arc::new(tokio::sync::Notify::new()),
@@ -195,6 +198,8 @@ impl EasyTierLauncher {
*data_c.routes.write().unwrap() = peer_mgr_c.list_routes().await;
*data_c.peers.write().unwrap() =
PeerManagerRpcService::list_peers(&peer_mgr_c).await;
*data_c.foreign_network_summary.write().unwrap() =
peer_mgr_c.get_foreign_network_summary().await;
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
});
@@ -323,6 +328,10 @@ impl EasyTierLauncher {
pub fn get_peers(&self) -> Vec<PeerInfo> {
self.data.peers.read().unwrap().clone()
}
pub fn get_foreign_network_summary(&self) -> RouteForeignNetworkSummary {
self.data.foreign_network_summary.read().unwrap().clone()
}
}
impl Drop for EasyTierLauncher {
@@ -401,6 +410,7 @@ impl NetworkInstance {
peer_route_pairs,
running: launcher.running(),
error_msg: launcher.error_msg(),
foreign_network_summary: Some(launcher.get_foreign_network_summary()),
})
}

View File

@@ -40,7 +40,9 @@ use crate::{
self, list_global_foreign_network_response::OneForeignNetwork,
ListGlobalForeignNetworkResponse,
},
peer_rpc::{ForeignNetworkRouteInfoEntry, ForeignNetworkRouteInfoKey},
peer_rpc::{
ForeignNetworkRouteInfoEntry, ForeignNetworkRouteInfoKey, RouteForeignNetworkSummary,
},
},
tunnel::{
self,
@@ -953,6 +955,10 @@ impl PeerManager {
resp
}
pub async fn get_foreign_network_summary(&self) -> RouteForeignNetworkSummary {
self.get_route().get_foreign_network_summary().await
}
async fn run_nic_packet_process_pipeline(&self, data: &mut ZCPacket) {
if !self
.global_ctx

View File

@@ -1,5 +1,5 @@
use std::{
collections::BTreeSet,
collections::{BTreeMap, BTreeSet},
fmt::Debug,
net::{Ipv4Addr, Ipv6Addr},
sync::{
@@ -35,9 +35,10 @@ use crate::{
proto::{
common::{Ipv4Inet, NatType, StunInfo},
peer_rpc::{
route_foreign_network_infos, ForeignNetworkRouteInfoEntry, ForeignNetworkRouteInfoKey,
OspfRouteRpc, OspfRouteRpcClientFactory, OspfRouteRpcServer, PeerIdVersion,
RouteForeignNetworkInfos, RoutePeerInfo, RoutePeerInfos, SyncRouteInfoError,
route_foreign_network_infos, route_foreign_network_summary,
ForeignNetworkRouteInfoEntry, ForeignNetworkRouteInfoKey, OspfRouteRpc,
OspfRouteRpcClientFactory, OspfRouteRpcServer, PeerIdVersion, RouteForeignNetworkInfos,
RouteForeignNetworkSummary, RoutePeerInfo, RoutePeerInfos, SyncRouteInfoError,
SyncRouteInfoRequest, SyncRouteInfoResponse,
},
rpc_types::{
@@ -2320,6 +2321,16 @@ impl Route for PeerRoute {
foreign_networks
}
async fn get_foreign_network_summary(&self) -> RouteForeignNetworkSummary {
let mut info_map: BTreeMap<PeerId, route_foreign_network_summary::Info> = BTreeMap::new();
for item in self.service_impl.synced_route_info.foreign_network.iter() {
let entry = info_map.entry(item.key().peer_id).or_default();
entry.network_count += 1;
entry.peer_count += item.value().foreign_peer_ids.len() as u32;
}
RouteForeignNetworkSummary { info_map }
}
async fn list_peers_own_foreign_network(
&self,
network_identity: &NetworkIdentity,

View File

@@ -9,7 +9,7 @@ use crate::{
common::{global_ctx::NetworkIdentity, PeerId},
proto::peer_rpc::{
ForeignNetworkRouteInfoEntry, ForeignNetworkRouteInfoKey, RouteForeignNetworkInfos,
RoutePeerInfo,
RouteForeignNetworkSummary, RoutePeerInfo,
},
};
@@ -102,6 +102,10 @@ pub trait Route {
Default::default()
}
async fn get_foreign_network_summary(&self) -> RouteForeignNetworkSummary {
Default::default()
}
// my peer id in foreign network is different from the one in local network
// this function is used to get the peer id in local network
async fn get_origin_my_peer_id(

View File

@@ -60,6 +60,16 @@ message RouteForeignNetworkInfos {
repeated Info infos = 1;
}
message RouteForeignNetworkSummary {
message Info {
uint32 peer_id = 1;
uint32 network_count = 2;
uint32 peer_count = 3;
}
map<uint32, Info> info_map = 1;
}
message SyncRouteInfoRequest {
uint32 my_peer_id = 1;
uint64 my_session_id = 2;

View File

@@ -102,6 +102,7 @@ message NetworkInstanceRunningInfo {
repeated cli.PeerRoutePair peer_route_pairs = 6;
bool running = 7;
optional string error_msg = 8;
peer_rpc.RouteForeignNetworkSummary foreign_network_summary = 9;
}
message NetworkInstanceRunningInfoMap {

1236
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff