mirror of
https://github.com/EasyTier/EasyTier.git
synced 2025-11-01 04:22:55 +08:00
Compare commits
102 Commits
v2.4.1
...
make_ospf_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
936790be8b | ||
|
|
71679e889a | ||
|
|
7485f5f64e | ||
|
|
bbe8f9f810 | ||
|
|
eba9504fc2 | ||
|
|
67ac9b00ff | ||
|
|
3ffa6214ca | ||
|
|
6f278ab167 | ||
|
|
f10b45a67c | ||
|
|
cc8f35787e | ||
|
|
8f1786fa23 | ||
|
|
70dddeace3 | ||
|
|
8cc9da9d6d | ||
|
|
5292b87275 | ||
|
|
87b7b7ed7c | ||
|
|
999a486928 | ||
|
|
627e989faa | ||
|
|
af95312949 | ||
|
|
a452c34390 | ||
|
|
4d5330fa0a | ||
|
|
5e48626cb9 | ||
|
|
ad7dc3a129 | ||
|
|
92fab5aafa | ||
|
|
841d525913 | ||
|
|
d2efbbef04 | ||
|
|
971ef82679 | ||
|
|
020bf04ec4 | ||
|
|
4d91582fd8 | ||
|
|
e9b4dbce6e | ||
|
|
00fd02c739 | ||
|
|
c0d2045e52 | ||
|
|
835cd407bf | ||
|
|
f5ba5bb146 | ||
|
|
7a694257d9 | ||
|
|
67abf4446d | ||
|
|
7035a3fef4 | ||
|
|
4445916ba7 | ||
|
|
a102a8bfc7 | ||
|
|
c9e8c35e77 | ||
|
|
1a1be8138a | ||
|
|
e06e8a9e8a | ||
|
|
56fd6e4ab6 | ||
|
|
215db09925 | ||
|
|
9fff5e4fec | ||
|
|
802d3f78d7 | ||
|
|
3593035eb9 | ||
|
|
757d76c9da | ||
|
|
445e68ddd1 | ||
|
|
b540ec3f46 | ||
|
|
5c90431876 | ||
|
|
793889c3b7 | ||
|
|
eb42086f9c | ||
|
|
d0efc40efb | ||
|
|
ae704d1d5f | ||
|
|
525dfd9fc1 | ||
|
|
18bd178bbd | ||
|
|
088155f6f3 | ||
|
|
b750faa66f | ||
|
|
ef3309814d | ||
|
|
b87a05b457 | ||
|
|
754439f03c | ||
|
|
2145ef40b9 | ||
|
|
a3806e0190 | ||
|
|
0ceb58586b | ||
|
|
719a1fe7cf | ||
|
|
671b8d5a0c | ||
|
|
e29206aef9 | ||
|
|
3299a77da3 | ||
|
|
0804fd6632 | ||
|
|
ea76114d50 | ||
|
|
9304d3b227 | ||
|
|
78004de5e5 | ||
|
|
5b7384fddd | ||
|
|
08a92a53c3 | ||
|
|
34560af141 | ||
|
|
2e7e0088dd | ||
|
|
d23366ea84 | ||
|
|
df7eb47593 | ||
|
|
839a28a3d5 | ||
|
|
9c6d1dabdf | ||
|
|
e6ec7f405c | ||
|
|
8f37d4ef7c | ||
|
|
c37af8c1be | ||
|
|
489661a2ce | ||
|
|
fa3e208668 | ||
|
|
4d240efde9 | ||
|
|
d9bcbd9b31 | ||
|
|
35ff9b82fc | ||
|
|
a511abb613 | ||
|
|
1eec27b5ff | ||
|
|
1de7777a71 | ||
|
|
975ca8bd9c | ||
|
|
e43537939a | ||
|
|
0087ac3ffc | ||
|
|
7de4b33dd1 | ||
|
|
8ffc2f12e4 | ||
|
|
37b24164b6 | ||
|
|
8cdb27d43d | ||
|
|
efa17a7c10 | ||
|
|
6d14e9e441 | ||
|
|
e3e406dcde | ||
|
|
d0a6c93c2c |
3
.envrc
3
.envrc
@@ -1 +1,2 @@
|
||||
use flake
|
||||
PROFILE=$(cat .flake-profile 2>/dev/null)
|
||||
use flake .#${PROFILE}
|
||||
|
||||
8
.github/workflows/Dockerfile
vendored
8
.github/workflows/Dockerfile
vendored
@@ -8,10 +8,16 @@ WORKDIR /tmp/output
|
||||
RUN ARTIFACT_ARCH=""; \
|
||||
if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
|
||||
ARTIFACT_ARCH="x86_64"; \
|
||||
elif [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then \
|
||||
ARTIFACT_ARCH="armhf"; \
|
||||
elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \
|
||||
ARTIFACT_ARCH="armv7hf"; \
|
||||
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
|
||||
ARTIFACT_ARCH="aarch64"; \
|
||||
elif [ "$TARGETPLATFORM" = "linux/riscv64" ]; then \
|
||||
ARTIFACT_ARCH="riscv64"; \
|
||||
else \
|
||||
echo "Unsupported architecture: $TARGETARCH"; \
|
||||
echo "Unsupported architecture: $TARGETPLATFORM"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
cp /tmp/artifacts/easytier-linux-${ARTIFACT_ARCH}/* /tmp/output;
|
||||
|
||||
19
.github/workflows/core.yml
vendored
19
.github/workflows/core.yml
vendored
@@ -154,14 +154,13 @@ jobs:
|
||||
name: easytier-web-dashboard
|
||||
path: easytier-web/frontend/dist/
|
||||
|
||||
- name: Cargo cache
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: ${{ ! endsWith(matrix.TARGET, 'freebsd') }}
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo
|
||||
./target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
# The prefix cache key, this can be changed to start a new cache manually.
|
||||
# default: "v0-rust"
|
||||
prefix-key: ""
|
||||
|
||||
|
||||
- name: Setup protoc
|
||||
uses: arduino/setup-protoc@v3
|
||||
@@ -187,12 +186,12 @@ jobs:
|
||||
fi
|
||||
|
||||
if [[ $OS =~ ^ubuntu.*$ && $TARGET =~ ^mips.*$ ]]; then
|
||||
cargo +nightly build -r --target $TARGET -Z build-std=std,panic_abort --package=easytier --features=jemalloc
|
||||
cargo +nightly-2025-09-01 build -r --target $TARGET -Z build-std=std,panic_abort --package=easytier --features=jemalloc
|
||||
else
|
||||
if [[ $OS =~ ^windows.*$ ]]; then
|
||||
SUFFIX=.exe
|
||||
CORE_FEATURES="--features=mimalloc"
|
||||
elif [[ $TARGET =~ ^riscv64.*$ ]]; then
|
||||
elif [[ $TARGET =~ ^riscv64.*$ || $TARGET =~ ^loongarch64.*$ || $TARGET =~ ^aarch64.*$ ]]; then
|
||||
CORE_FEATURES="--features=mimalloc"
|
||||
else
|
||||
CORE_FEATURES="--features=jemalloc"
|
||||
@@ -229,8 +228,8 @@ jobs:
|
||||
|
||||
rustup set auto-self-update disable
|
||||
|
||||
rustup install 1.87
|
||||
rustup default 1.87
|
||||
rustup install 1.89
|
||||
rustup default 1.89
|
||||
|
||||
export CC=clang
|
||||
export CXX=clang++
|
||||
|
||||
44
.github/workflows/docker.yml
vendored
44
.github/workflows/docker.yml
vendored
@@ -11,13 +11,18 @@ on:
|
||||
image_tag:
|
||||
description: 'Tag for this image build'
|
||||
type: string
|
||||
default: 'v2.4.1'
|
||||
default: 'v2.4.5'
|
||||
required: true
|
||||
mark_latest:
|
||||
description: 'Mark this image as latest'
|
||||
type: boolean
|
||||
default: false
|
||||
required: true
|
||||
mark_unstable:
|
||||
description: 'Mark this image as unstable'
|
||||
type: boolean
|
||||
default: false
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
@@ -27,6 +32,13 @@ jobs:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Validate inputs
|
||||
run: |
|
||||
if [[ "${{ inputs.mark_latest }}" == "true" && "${{ inputs.mark_unstable }}" == "true" ]]; then
|
||||
echo "Error: mark_latest and mark_unstable cannot both be true"
|
||||
exit 1
|
||||
fi
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -56,14 +68,36 @@ jobs:
|
||||
- name: List files
|
||||
run: |
|
||||
ls -l -R .
|
||||
- name: Prepare Docker tags
|
||||
id: tags
|
||||
run: |
|
||||
# Base tags with version
|
||||
DOCKERHUB_TAGS="easytier/easytier:${{ inputs.image_tag }}"
|
||||
GHCR_TAGS="ghcr.io/easytier/easytier:${{ inputs.image_tag }}"
|
||||
|
||||
# Add latest tags if requested
|
||||
if [[ "${{ inputs.mark_latest }}" == "true" ]]; then
|
||||
DOCKERHUB_TAGS="${DOCKERHUB_TAGS},easytier/easytier:latest"
|
||||
GHCR_TAGS="${GHCR_TAGS},ghcr.io/easytier/easytier:latest"
|
||||
fi
|
||||
|
||||
# Add unstable tags if requested
|
||||
if [[ "${{ inputs.mark_unstable }}" == "true" ]]; then
|
||||
DOCKERHUB_TAGS="${DOCKERHUB_TAGS},easytier/easytier:unstable"
|
||||
GHCR_TAGS="${GHCR_TAGS},ghcr.io/easytier/easytier:unstable"
|
||||
fi
|
||||
|
||||
# Combine all tags
|
||||
ALL_TAGS="${DOCKERHUB_TAGS},${GHCR_TAGS}"
|
||||
|
||||
echo "tags=${ALL_TAGS}" >> $GITHUB_OUTPUT
|
||||
echo "Generated tags: ${ALL_TAGS}"
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./docker_context
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/riscv64
|
||||
push: true
|
||||
file: .github/workflows/Dockerfile
|
||||
tags: |
|
||||
easytier/easytier:${{ inputs.image_tag }}${{ inputs.mark_latest && ',easytier/easytier:latest' || '' }},
|
||||
ghcr.io/easytier/easytier:${{ inputs.image_tag }}${{ inputs.mark_latest && ',easytier/easytier:latest' || '' }},
|
||||
tags: ${{ steps.tags.outputs.tags }}
|
||||
|
||||
31
.github/workflows/gui.yml
vendored
31
.github/workflows/gui.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
concurrent_skipping: 'same_content_newer'
|
||||
skip_after_successful_duplicate: 'true'
|
||||
cancel_others: 'true'
|
||||
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-gui/**", ".github/workflows/gui.yml", ".github/workflows/install_rust.sh"]'
|
||||
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-gui/**", ".github/workflows/gui.yml", ".github/workflows/install_rust.sh", ".github/workflows/install_gui_dep.sh"]'
|
||||
build-gui:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -78,20 +78,11 @@ jobs:
|
||||
needs: pre_job
|
||||
if: needs.pre_job.outputs.should_skip != 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Install GUI dependencies (x86 only)
|
||||
if: ${{ matrix.TARGET == 'x86_64-unknown-linux-musl' }}
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt install -qq libwebkit2gtk-4.1-dev \
|
||||
build-essential \
|
||||
curl \
|
||||
wget \
|
||||
file \
|
||||
libgtk-3-dev \
|
||||
librsvg2-dev \
|
||||
libxdo-dev \
|
||||
libssl-dev \
|
||||
patchelf
|
||||
run: bash ./.github/workflows/install_gui_dep.sh
|
||||
|
||||
- name: Install GUI cross compile (aarch64 only)
|
||||
if: ${{ matrix.TARGET == 'aarch64-unknown-linux-musl' }}
|
||||
@@ -124,12 +115,10 @@ jobs:
|
||||
sudo apt install aptitude
|
||||
sudo aptitude install -y libgstreamer1.0-0:arm64 gstreamer1.0-plugins-base:arm64 gstreamer1.0-plugins-good:arm64 \
|
||||
libgstreamer-gl1.0-0:arm64 libgstreamer-plugins-base1.0-0:arm64 libgstreamer-plugins-good1.0-0:arm64 libwebkit2gtk-4.1-0:arm64 \
|
||||
libwebkit2gtk-4.1-dev:arm64 libssl-dev:arm64 gcc-aarch64-linux-gnu
|
||||
libwebkit2gtk-4.1-dev:arm64 libssl-dev:arm64 gcc-aarch64-linux-gnu libsoup-3.0-dev:arm64 libjavascriptcoregtk-4.1-dev:arm64
|
||||
echo "PKG_CONFIG_SYSROOT_DIR=/usr/aarch64-linux-gnu/" >> "$GITHUB_ENV"
|
||||
echo "PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig/" >> "$GITHUB_ENV"
|
||||
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set current ref as env variable
|
||||
run: |
|
||||
echo "GIT_DESC=$(git log -1 --format=%cd.%h --date=format:%Y-%m-%d_%H:%M:%S)" >> $GITHUB_ENV
|
||||
@@ -162,13 +151,11 @@ jobs:
|
||||
pnpm -r install
|
||||
pnpm -r build
|
||||
|
||||
- name: Cargo cache
|
||||
uses: actions/cache@v4
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
path: |
|
||||
~/.cargo
|
||||
./target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
# The prefix cache key, this can be changed to start a new cache manually.
|
||||
# default: "v0-rust"
|
||||
prefix-key: ""
|
||||
|
||||
- name: Install rust target
|
||||
run: bash ./.github/workflows/install_rust.sh
|
||||
|
||||
11
.github/workflows/install_gui_dep.sh
vendored
Normal file
11
.github/workflows/install_gui_dep.sh
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
sudo apt update
|
||||
sudo apt install -qq libwebkit2gtk-4.1-dev \
|
||||
build-essential \
|
||||
curl \
|
||||
wget \
|
||||
file \
|
||||
libgtk-3-dev \
|
||||
librsvg2-dev \
|
||||
libxdo-dev \
|
||||
libssl-dev \
|
||||
patchelf
|
||||
8
.github/workflows/install_rust.sh
vendored
8
.github/workflows/install_rust.sh
vendored
@@ -31,8 +31,8 @@ fi
|
||||
|
||||
# see https://github.com/rust-lang/rustup/issues/3709
|
||||
rustup set auto-self-update disable
|
||||
rustup install 1.87
|
||||
rustup default 1.87
|
||||
rustup install 1.89
|
||||
rustup default 1.89
|
||||
|
||||
# mips/mipsel cannot add target from rustup, need compile by ourselves
|
||||
if [[ $OS =~ ^ubuntu.*$ && $TARGET =~ ^mips.*$ ]]; then
|
||||
@@ -44,8 +44,8 @@ if [[ $OS =~ ^ubuntu.*$ && $TARGET =~ ^mips.*$ ]]; then
|
||||
ar x libgcc.a _ctzsi2.o _clz.o _bswapsi2.o
|
||||
ar rcs libctz.a _ctzsi2.o _clz.o _bswapsi2.o
|
||||
|
||||
rustup toolchain install nightly-x86_64-unknown-linux-gnu
|
||||
rustup component add rust-src --toolchain nightly-x86_64-unknown-linux-gnu
|
||||
rustup toolchain install nightly-2025-09-01-x86_64-unknown-linux-gnu
|
||||
rustup component add rust-src --toolchain nightly-2025-09-01-x86_64-unknown-linux-gnu
|
||||
|
||||
# https://github.com/rust-lang/rust/issues/128808
|
||||
# remove it after Cargo or rustc fix this.
|
||||
|
||||
10
.github/workflows/mobile.yml
vendored
10
.github/workflows/mobile.yml
vendored
@@ -98,13 +98,11 @@ jobs:
|
||||
pnpm -r install
|
||||
pnpm -r build
|
||||
|
||||
- name: Cargo cache
|
||||
uses: actions/cache@v4
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
path: |
|
||||
~/.cargo
|
||||
./target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
# The prefix cache key, this can be changed to start a new cache manually.
|
||||
# default: "v0-rust"
|
||||
prefix-key: ""
|
||||
|
||||
- name: Install rust target
|
||||
run: |
|
||||
|
||||
21
.github/workflows/ohos.yml
vendored
21
.github/workflows/ohos.yml
vendored
@@ -5,6 +5,7 @@ on:
|
||||
branches: ["develop", "main", "releases/**"]
|
||||
pull_request:
|
||||
branches: ["develop", "main"]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
@@ -15,6 +16,16 @@ defaults:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
cargo_fmt_check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: fmt check
|
||||
working-directory: ./easytier-contrib/easytier-ohrs
|
||||
run: |
|
||||
bash ../../.github/workflows/install_rust.sh
|
||||
rustup component add rustfmt
|
||||
cargo fmt --all -- --check
|
||||
pre_job:
|
||||
# continue-on-error: true # Uncomment once integration is finished
|
||||
runs-on: ubuntu-latest
|
||||
@@ -27,9 +38,9 @@ jobs:
|
||||
uses: fkirc/skip-duplicate-actions@v5
|
||||
with:
|
||||
# All of these options are optional, so you can remove them if you are happy with the defaults
|
||||
concurrent_skipping: 'same_content_newer'
|
||||
skip_after_successful_duplicate: 'true'
|
||||
cancel_others: 'true'
|
||||
concurrent_skipping: "same_content_newer"
|
||||
skip_after_successful_duplicate: "true"
|
||||
cancel_others: "true"
|
||||
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-contrib/easytier-ohrs/**", ".github/workflows/ohos.yml", ".github/workflows/install_rust.sh"]'
|
||||
build-ohos:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -104,11 +115,13 @@ jobs:
|
||||
cargo update easytier
|
||||
ohrs doctor
|
||||
ohrs build --release --arch aarch
|
||||
ohrs artifact
|
||||
mv package.har easytier-ohrs.har
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: easytier-ohos
|
||||
path: ./easytier-contrib/easytier-ohrs/dist/arm64-v8a/libeasytier_ohrs.so
|
||||
path: ./easytier-contrib/easytier-ohrs/easytier-ohrs.har
|
||||
retention-days: 5
|
||||
if-no-files-found: error
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -21,7 +21,7 @@ on:
|
||||
version:
|
||||
description: 'Version for this release'
|
||||
type: string
|
||||
default: 'v2.4.1'
|
||||
default: 'v2.4.5'
|
||||
required: true
|
||||
make_latest:
|
||||
description: 'Mark this release as latest'
|
||||
|
||||
20
.github/workflows/test.yml
vendored
20
.github/workflows/test.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
# All of these options are optional, so you can remove them if you are happy with the defaults
|
||||
concurrent_skipping: 'never'
|
||||
skip_after_successful_duplicate: 'true'
|
||||
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", ".github/workflows/test.yml"]'
|
||||
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", ".github/workflows/test.yml", ".github/workflows/install_gui_dep.sh", ".github/workflows/install_rust.sh"]'
|
||||
test:
|
||||
runs-on: ubuntu-22.04
|
||||
needs: pre_job
|
||||
@@ -89,6 +89,24 @@ jobs:
|
||||
./target
|
||||
key: ${{ runner.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Install GUI dependencies (Used by clippy)
|
||||
run: |
|
||||
bash ./.github/workflows/install_gui_dep.sh
|
||||
bash ./.github/workflows/install_rust.sh
|
||||
rustup component add rustfmt
|
||||
rustup component add clippy
|
||||
|
||||
- name: Check formatting
|
||||
if: ${{ !cancelled() }}
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Check Clippy
|
||||
if: ${{ !cancelled() }}
|
||||
# NOTE: tauri need `dist` dir in build.rs
|
||||
run: |
|
||||
mkdir -p easytier-gui/dist
|
||||
cargo clippy --all-targets --all-features --all -- -D warnings
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
sudo prlimit --pid $$ --nofile=1048576:1048576
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -41,3 +41,4 @@ easytier-gui/src-tauri/*.dll
|
||||
/easytier-contrib/easytier-ohrs/dist/
|
||||
|
||||
.direnv
|
||||
.flake-profile
|
||||
|
||||
@@ -26,7 +26,7 @@ Thank you for your interest in contributing to EasyTier! This document provides
|
||||
#### Required Tools
|
||||
- Node.js v21 or higher
|
||||
- pnpm v9 or higher
|
||||
- Rust toolchain (version 1.87)
|
||||
- Rust toolchain (version 1.89)
|
||||
- LLVM and Clang
|
||||
- Protoc (Protocol Buffers compiler)
|
||||
|
||||
@@ -79,8 +79,8 @@ sudo apt install -y bridge-utils
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
# Install Rust toolchain
|
||||
rustup install 1.87
|
||||
rustup default 1.87
|
||||
rustup install 1.89
|
||||
rustup default 1.89
|
||||
|
||||
# Install project dependencies
|
||||
pnpm -r install
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
#### 必需工具
|
||||
- Node.js v21 或更高版本
|
||||
- pnpm v9 或更高版本
|
||||
- Rust 工具链(版本 1.87)
|
||||
- Rust 工具链(版本 1.89)
|
||||
- LLVM 和 Clang
|
||||
- Protoc(Protocol Buffers 编译器)
|
||||
|
||||
@@ -87,8 +87,8 @@ sudo apt install -y bridge-utils
|
||||
2. 安装依赖:
|
||||
```bash
|
||||
# 安装 Rust 工具链
|
||||
rustup install 1.87
|
||||
rustup default 1.87
|
||||
rustup install 1.89
|
||||
rustup default 1.89
|
||||
|
||||
# 安装项目依赖
|
||||
pnpm -r install
|
||||
|
||||
697
Cargo.lock
generated
697
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,8 @@ members = [
|
||||
"easytier-rpc-build",
|
||||
"easytier-web",
|
||||
"easytier-contrib/easytier-ffi",
|
||||
"easytier-contrib/easytier-uptime",
|
||||
"easytier-contrib/easytier-android-jni",
|
||||
]
|
||||
default-members = ["easytier", "easytier-web"]
|
||||
exclude = [
|
||||
@@ -14,6 +16,7 @@ exclude = [
|
||||
|
||||
[profile.dev]
|
||||
panic = "unwind"
|
||||
debug = 2
|
||||
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
"name": "openharmony",
|
||||
"path": "easytier-contrib/easytier-ohrs"
|
||||
},
|
||||
{
|
||||
"name": "uptime",
|
||||
"path": "easytier-contrib/easytier-uptime"
|
||||
},
|
||||
{
|
||||
"name": "vpnservice",
|
||||
"path": "tauri-plugin-vpnservice"
|
||||
@@ -44,5 +48,10 @@
|
||||
"prettier.enable": false,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnSaveMode": "modifications",
|
||||
"editor.formatOnPaste": false,
|
||||
"editor.formatOnType": true,
|
||||
"[nix]": {
|
||||
"editor.formatOnSave": false,
|
||||
},
|
||||
}
|
||||
}
|
||||
13
README.md
13
README.md
@@ -59,7 +59,7 @@ cargo install --git https://github.com/EasyTier/EasyTier.git easytier
|
||||
# See https://easytier.cn/en/guide/installation.html#installation-methods
|
||||
|
||||
# 4. Linux Quick Install
|
||||
wget -O- https://raw.githubusercontent.com/EasyTier/EasyTier/main/script/install.sh | sudo bash
|
||||
wget -O- https://raw.githubusercontent.com/EasyTier/EasyTier/main/script/install.sh | sudo bash -s install
|
||||
|
||||
# 5. MacOS via Homebrew
|
||||
brew tap brewforge/chinese
|
||||
@@ -105,9 +105,9 @@ After successful execution, you can check the network status using `easytier-cli
|
||||
```text
|
||||
| ipv4 | hostname | cost | lat_ms | loss_rate | rx_bytes | tx_bytes | tunnel_proto | nat_type | id | version |
|
||||
| ------------ | -------------- | ----- | ------ | --------- | -------- | -------- | ------------ | -------- | ---------- | --------------- |
|
||||
| 10.126.126.1 | abc-1 | Local | * | * | * | * | udp | FullCone | 439804259 | 2.4.1-70e69a38~ |
|
||||
| 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.4.1-70e69a38~ |
|
||||
| | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.4.1-70e69a38~ |
|
||||
| 10.126.126.1 | abc-1 | Local | * | * | * | * | udp | FullCone | 439804259 | 2.4.5-70e69a38~ |
|
||||
| 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.4.5-70e69a38~ |
|
||||
| | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.4.5-70e69a38~ |
|
||||
```
|
||||
|
||||
You can test connectivity between nodes:
|
||||
@@ -286,7 +286,10 @@ sudo easytier-core --network-name mysharednode --network-secret mysharednode
|
||||
### Contact Us
|
||||
|
||||
- 💬 **[Telegram Group](https://t.me/easytier)**
|
||||
- 👥 **[QQ Group: 949700262](https://qm.qq.com/cgi-bin/qm/qr?k=kC8YJ6Jb8vWJIDbZrZJB8pB5YZgPJA5-)**
|
||||
- 👥 **[QQ Group]**
|
||||
- No.1 [949700262](https://qm.qq.com/q/wFoTUChqZW)
|
||||
- No.2 [837676408](https://qm.qq.com/q/4V33DrfgHe)
|
||||
- No.3 [957189589](https://qm.qq.com/q/YNyTQjwlai)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
13
README_CN.md
13
README_CN.md
@@ -59,7 +59,7 @@ cargo install --git https://github.com/EasyTier/EasyTier.git easytier
|
||||
# 参见 https://easytier.cn/guide/installation.html#%E5%AE%89%E8%A3%85%E6%96%B9%E5%BC%8F
|
||||
|
||||
# 4. Linux 快速安装
|
||||
wget -O- https://raw.githubusercontent.com/EasyTier/EasyTier/main/script/install.sh | sudo bash
|
||||
wget -O- https://raw.githubusercontent.com/EasyTier/EasyTier/main/script/install.sh | sudo bash -s install
|
||||
|
||||
# 5. MacOS 通过 Homebrew 安装
|
||||
brew tap brewforge/chinese
|
||||
@@ -106,9 +106,9 @@ sudo easytier-core -d --network-name abc --network-secret abc -p tcp://public.ea
|
||||
```text
|
||||
| ipv4 | hostname | cost | lat_ms | loss_rate | rx_bytes | tx_bytes | tunnel_proto | nat_type | id | version |
|
||||
| ------------ | -------------- | ----- | ------ | --------- | -------- | -------- | ------------ | -------- | ---------- | --------------- |
|
||||
| 10.126.126.1 | abc-1 | Local | * | * | * | * | udp | FullCone | 439804259 | 2.4.1-70e69a38~ |
|
||||
| 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.4.1-70e69a38~ |
|
||||
| | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.4.1-70e69a38~ |
|
||||
| 10.126.126.1 | abc-1 | Local | * | * | * | * | udp | FullCone | 439804259 | 2.4.5-70e69a38~ |
|
||||
| 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.4.5-70e69a38~ |
|
||||
| | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.4.5-70e69a38~ |
|
||||
```
|
||||
|
||||
您可以测试节点之间的连通性:
|
||||
@@ -287,7 +287,10 @@ sudo easytier-core --network-name mysharednode --network-secret mysharednode
|
||||
### 联系我们
|
||||
|
||||
- 💬 **[Telegram 群组](https://t.me/easytier)**
|
||||
- 👥 **[QQ 群:949700262](https://qm.qq.com/cgi-bin/qm/qr?k=kC8YJ6Jb8vWJIDbZrZJB8pB5YZgPJA5-)**
|
||||
- 👥 **QQ 群**
|
||||
- 一群 [949700262](https://qm.qq.com/q/wFoTUChqZW)
|
||||
- 二群 [837676408](https://qm.qq.com/q/4V33DrfgHe)
|
||||
- 三群 [957189589](https://qm.qq.com/q/YNyTQjwlai)
|
||||
|
||||
## 许可证
|
||||
|
||||
|
||||
116
android.nix
Normal file
116
android.nix
Normal file
@@ -0,0 +1,116 @@
|
||||
# Android build environment
|
||||
{
|
||||
pkgs,
|
||||
nixpkgs,
|
||||
system,
|
||||
}:
|
||||
|
||||
let
|
||||
androidEnv = pkgs.callPackage "${nixpkgs}/pkgs/development/mobile/androidenv" {
|
||||
inherit pkgs;
|
||||
licenseAccepted = true;
|
||||
};
|
||||
|
||||
includeAuto = pkgs.stdenv.hostPlatform.isx86_64 || pkgs.stdenv.hostPlatform.isDarwin;
|
||||
ndkVersion = "26.1.10909125";
|
||||
ndkVersions = [ ndkVersion ];
|
||||
|
||||
sdkArgs = {
|
||||
includeNDK = true;
|
||||
includeSources = true;
|
||||
includeSystemImages = false;
|
||||
includeEmulator = false;
|
||||
inherit ndkVersions;
|
||||
useGoogleAPIs = true;
|
||||
useGoogleTVAddOns = true;
|
||||
buildToolsVersions = [ "34.0.0" ];
|
||||
numLatestPlatformVersions = 10;
|
||||
includeExtras = [
|
||||
"extras;google;gcm"
|
||||
]
|
||||
++ pkgs.lib.optionals includeAuto [
|
||||
"extras;google;auto"
|
||||
];
|
||||
extraLicenses = [
|
||||
"android-sdk-preview-license"
|
||||
"android-googletv-license"
|
||||
"android-sdk-arm-dbt-license"
|
||||
"google-gdk-license"
|
||||
"intel-android-extra-license"
|
||||
"intel-android-sysimage-license"
|
||||
"mips-android-sysimage-license"
|
||||
];
|
||||
};
|
||||
|
||||
androidComposition = androidEnv.composeAndroidPackages sdkArgs;
|
||||
androidSdk = androidComposition.androidsdk;
|
||||
platformTools = androidComposition.platform-tools;
|
||||
cmake = androidComposition.cmake;
|
||||
ndkHostTag =
|
||||
if pkgs.stdenv.isLinux then
|
||||
"linux-x86_64"
|
||||
else if pkgs.stdenv.isDarwin then
|
||||
"darwin-x86_64"
|
||||
else
|
||||
"";
|
||||
ndkToolchain = "${androidSdk}/libexec/android-sdk/ndk/${ndkVersion}/toolchains/llvm/prebuilt/${ndkHostTag}";
|
||||
in
|
||||
{
|
||||
inherit
|
||||
androidSdk
|
||||
platformTools
|
||||
cmake
|
||||
ndkToolchain
|
||||
ndkVersion
|
||||
;
|
||||
|
||||
# List of packages required for Android development
|
||||
packages = [
|
||||
pkgs.jdk # openjdk 21
|
||||
androidSdk
|
||||
platformTools
|
||||
cmake
|
||||
pkgs.glibc_multi.dev
|
||||
];
|
||||
|
||||
# Provide Rust extensions/targets for use by the upper-level flake
|
||||
rust = {
|
||||
extensions = [ "rust-std" ];
|
||||
targets = [
|
||||
"aarch64-linux-android"
|
||||
"armv7-linux-androideabi"
|
||||
"i686-linux-android"
|
||||
"x86_64-linux-android"
|
||||
];
|
||||
};
|
||||
|
||||
# Android environment variables and shellHook
|
||||
envVars = {
|
||||
LANG = "C.UTF-8";
|
||||
LC_ALL = "C.UTF-8";
|
||||
JAVA_HOME = "${pkgs.jdk}/lib/openjdk";
|
||||
ANDROID_SDK_ROOT = "${androidSdk}/libexec/android-sdk";
|
||||
ANDROID_NDK_ROOT = "\${ANDROID_SDK_ROOT}/ndk-bundle";
|
||||
NDK_HOME = "${androidSdk}/libexec/android-sdk/ndk/${ndkVersion}";
|
||||
LIBCLANG_PATH = "${ndkToolchain}/lib";
|
||||
KCP_SYS_EXTRA_HEADER_PATH = "${ndkToolchain}/lib/clang/19/include:${pkgs.glibc_multi.dev}/include";
|
||||
ZSTD_SYS_STATIC = "1";
|
||||
BINDGEN_EXTRA_CLANG_ARGS = "--sysroot=${ndkToolchain}/sysroot -I${ndkToolchain}/lib/clang/17/include ";
|
||||
|
||||
shellHook = ''
|
||||
echo "Android environment activated"
|
||||
export GRADLE_OPTS="-Dorg.gradle.project.android.aapt2FromMavenOverride=$(echo "$ANDROID_SDK_ROOT/build-tools/"*"/aapt2")"
|
||||
cmake_root="$(echo "$ANDROID_SDK_ROOT/cmake/"*/)"
|
||||
export PATH="$cmake_root/bin:$PATH"
|
||||
|
||||
unset NIX_CFLAGS_COMPILE
|
||||
unset NIX_CFLAGS_COMPILE_FOR_BUILD
|
||||
|
||||
cat <<EOF > easytier-gui/local.properties
|
||||
sdk.dir=$ANDROID_SDK_ROOT
|
||||
ndk.dir=$ANDROID_NDK_ROOT
|
||||
cmake.dir=$cmake_root
|
||||
EOF
|
||||
'';
|
||||
};
|
||||
}
|
||||
16
easytier-contrib/easytier-android-jni/Cargo.toml
Normal file
16
easytier-contrib/easytier-android-jni/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "easytier-android-jni"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
jni = "0.21"
|
||||
once_cell = "1.18.0"
|
||||
log = "0.4"
|
||||
android_logger = "0.13"
|
||||
serde = { version = "1.0.220", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
easytier = { path = "../../easytier" }
|
||||
267
easytier-contrib/easytier-android-jni/README.md
Normal file
267
easytier-contrib/easytier-android-jni/README.md
Normal file
@@ -0,0 +1,267 @@
|
||||
# EasyTier Android JNI
|
||||
|
||||
这是 EasyTier 的 Android JNI 绑定库,允许 Android 应用程序调用 EasyTier 的网络功能。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 🚀 完整的 EasyTier FFI 接口封装
|
||||
- 📱 原生 Android JNI 支持
|
||||
- 🔧 支持多种 Android 架构 (arm64-v8a, armeabi-v7a, x86, x86_64)
|
||||
- 🛡️ 类型安全的 Java 接口
|
||||
- 📝 详细的错误处理和日志记录
|
||||
|
||||
## 支持的架构
|
||||
|
||||
- `arm64-v8a` (aarch64-linux-android)
|
||||
- `armeabi-v7a` (armv7-linux-androideabi)
|
||||
- `x86` (i686-linux-android)
|
||||
- `x86_64` (x86_64-linux-android)
|
||||
|
||||
## 构建要求
|
||||
|
||||
### 系统要求
|
||||
|
||||
- Rust 1.70+
|
||||
- Android NDK r21+
|
||||
- Linux/macOS 开发环境
|
||||
|
||||
### 环境设置
|
||||
|
||||
1. **安装 Rust**
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
source ~/.cargo/env
|
||||
```
|
||||
|
||||
2. **安装 Android NDK**
|
||||
- 下载 Android NDK: https://developer.android.com/ndk/downloads
|
||||
- 解压到合适的目录
|
||||
- 设置环境变量:
|
||||
```bash
|
||||
export ANDROID_NDK_ROOT=/path/to/android-ndk
|
||||
```
|
||||
|
||||
3. **添加 Android 目标**
|
||||
```bash
|
||||
rustup target add aarch64-linux-android
|
||||
rustup target add armv7-linux-androideabi
|
||||
rustup target add i686-linux-android
|
||||
rustup target add x86_64-linux-android
|
||||
```
|
||||
|
||||
## 构建步骤
|
||||
|
||||
1. **克隆项目并进入目录**
|
||||
```bash
|
||||
cd /path/to/EasyTier/easytier-contrib/easytier-android-jni
|
||||
```
|
||||
|
||||
2. **运行构建脚本**
|
||||
```bash
|
||||
./build.sh
|
||||
```
|
||||
|
||||
3. **构建完成后,库文件将生成在 `target/android/` 目录下**
|
||||
```
|
||||
target/android/
|
||||
├── arm64-v8a/
|
||||
│ └── libeasytier_android_jni.so
|
||||
├── armeabi-v7a/
|
||||
│ └── libeasytier_android_jni.so
|
||||
├── x86/
|
||||
│ └── libeasytier_android_jni.so
|
||||
└── x86_64/
|
||||
└── libeasytier_android_jni.so
|
||||
```
|
||||
|
||||
## Android 项目集成
|
||||
|
||||
### 1. 复制库文件
|
||||
|
||||
将生成的 `.so` 文件复制到您的 Android 项目中:
|
||||
|
||||
```
|
||||
your-android-project/
|
||||
└── src/main/
|
||||
├── jniLibs/
|
||||
│ ├── arm64-v8a/
|
||||
│ │ └── libeasytier_android_jni.so
|
||||
│ ├── armeabi-v7a/
|
||||
│ │ └── libeasytier_android_jni.so
|
||||
│ ├── x86/
|
||||
│ │ └── libeasytier_android_jni.so
|
||||
│ └── x86_64/
|
||||
│ └── libeasytier_android_jni.so
|
||||
└── java/
|
||||
└── com/easytier/jni/
|
||||
└── EasyTierJNI.java
|
||||
```
|
||||
|
||||
### 2. 复制 Java 接口
|
||||
|
||||
将 `java/com/easytier/jni/EasyTierJNI.java` 复制到您的 Android 项目的相应包路径下。
|
||||
|
||||
### 3. 添加权限
|
||||
|
||||
在 `AndroidManifest.xml` 中添加必要的权限:
|
||||
|
||||
```xml
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 基本使用
|
||||
|
||||
```java
|
||||
import com.easytier.jni.EasyTierJNI;
|
||||
import java.util.Map;
|
||||
|
||||
public class EasyTierManager {
|
||||
|
||||
// 初始化网络实例
|
||||
public void startNetwork() {
|
||||
String config = """
|
||||
inst_name = "my_instance"
|
||||
network = "my_network"
|
||||
""";
|
||||
|
||||
try {
|
||||
// 解析配置
|
||||
int result = EasyTierJNI.parseConfig(config);
|
||||
if (result != 0) {
|
||||
String error = EasyTierJNI.getLastError();
|
||||
throw new RuntimeException("配置解析失败: " + error);
|
||||
}
|
||||
|
||||
// 启动网络实例
|
||||
result = EasyTierJNI.runNetworkInstance(config);
|
||||
if (result != 0) {
|
||||
String error = EasyTierJNI.getLastError();
|
||||
throw new RuntimeException("网络实例启动失败: " + error);
|
||||
}
|
||||
|
||||
System.out.println("EasyTier 网络实例启动成功");
|
||||
|
||||
} catch (RuntimeException e) {
|
||||
System.err.println("启动失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 获取网络信息
|
||||
public void getNetworkInfo() {
|
||||
try {
|
||||
Map<String, String> infos = EasyTierJNI.collectNetworkInfosAsMap(10);
|
||||
for (Map.Entry<String, String> entry : infos.entrySet()) {
|
||||
System.out.println(entry.getKey() + ": " + entry.getValue());
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
System.err.println("获取网络信息失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 停止所有实例
|
||||
public void stopNetwork() {
|
||||
try {
|
||||
int result = EasyTierJNI.stopAllInstances();
|
||||
if (result == 0) {
|
||||
System.out.println("所有网络实例已停止");
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
System.err.println("停止网络失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### VPN 服务集成
|
||||
|
||||
如果您要在 Android VPN 服务中使用:
|
||||
|
||||
```java
|
||||
public class EasyTierVpnService extends VpnService {
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
// 建立 VPN 连接
|
||||
ParcelFileDescriptor vpnInterface = establishVpnInterface();
|
||||
|
||||
if (vpnInterface != null) {
|
||||
int fd = vpnInterface.getFd();
|
||||
|
||||
// 设置 TUN 文件描述符
|
||||
try {
|
||||
EasyTierJNI.setTunFd("my_instance", fd);
|
||||
} catch (RuntimeException e) {
|
||||
Log.e("EasyTier", "设置 TUN FD 失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
private ParcelFileDescriptor establishVpnInterface() {
|
||||
Builder builder = new Builder();
|
||||
builder.setMtu(1500);
|
||||
builder.addAddress("10.0.0.2", 24);
|
||||
builder.addRoute("0.0.0.0", 0);
|
||||
builder.setSession("EasyTier VPN");
|
||||
|
||||
return builder.establish();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API 参考
|
||||
|
||||
### EasyTierJNI 类方法
|
||||
|
||||
| 方法 | 描述 | 参数 | 返回值 |
|
||||
|------|------|------|--------|
|
||||
| `parseConfig(String config)` | 解析 TOML 配置 | config: 配置字符串 | 0=成功, -1=失败 |
|
||||
| `runNetworkInstance(String config)` | 启动网络实例 | config: 配置字符串 | 0=成功, -1=失败 |
|
||||
| `setTunFd(String instanceName, int fd)` | 设置 TUN 文件描述符 | instanceName: 实例名, fd: 文件描述符 | 0=成功, -1=失败 |
|
||||
| `retainNetworkInstance(String[] names)` | 保留指定实例 | names: 实例名数组 | 0=成功, -1=失败 |
|
||||
| `collectNetworkInfos(int maxLength)` | 收集网络信息 | maxLength: 最大条目数 | 信息字符串数组 |
|
||||
| `collectNetworkInfosAsMap(int maxLength)` | 收集网络信息为 Map | maxLength: 最大条目数 | Map<String, String> |
|
||||
| `getLastError()` | 获取最后错误 | 无 | 错误消息字符串 |
|
||||
| `stopAllInstances()` | 停止所有实例 | 无 | 0=成功, -1=失败 |
|
||||
| `retainSingleInstance(String name)` | 保留单个实例 | name: 实例名 | 0=成功, -1=失败 |
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
1. **构建失败: "Android NDK not found"**
|
||||
- 确保设置了 `ANDROID_NDK_ROOT` 环境变量
|
||||
- 检查 NDK 路径是否正确
|
||||
|
||||
2. **运行时错误: "java.lang.UnsatisfiedLinkError"**
|
||||
- 确保 `.so` 文件放在正确的 `jniLibs` 目录下
|
||||
- 检查目标架构是否匹配
|
||||
|
||||
3. **配置解析失败**
|
||||
- 检查 TOML 配置格式是否正确
|
||||
- 使用 `getLastError()` 获取详细错误信息
|
||||
|
||||
### 调试技巧
|
||||
|
||||
- 启用 Android 日志查看 JNI 层的日志输出
|
||||
- 使用 `adb logcat -s EasyTier-JNI` 查看相关日志
|
||||
- 检查 `getLastError()` 返回的错误信息
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目遵循与 EasyTier 主项目相同的许可证。
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request 来改进这个项目。
|
||||
|
||||
## 相关链接
|
||||
|
||||
- [EasyTier 主项目](https://github.com/EasyTier/EasyTier)
|
||||
- [Android NDK 文档](https://developer.android.com/ndk)
|
||||
- [Rust JNI 文档](https://docs.rs/jni/)
|
||||
129
easytier-contrib/easytier-android-jni/build.sh
Executable file
129
easytier-contrib/easytier-android-jni/build.sh
Executable file
@@ -0,0 +1,129 @@
|
||||
#!/bin/bash
|
||||
|
||||
# EasyTier Android JNI 构建脚本
|
||||
# 用于编译适用于 Android 平台的 JNI 库
|
||||
# 使用 cargo-ndk 工具简化 Android 编译过程
|
||||
|
||||
set -e
|
||||
|
||||
# 颜色输出
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
|
||||
echo -e "${GREEN}EasyTier Android JNI 构建脚本 (使用 cargo-ndk)${NC}"
|
||||
echo "=============================================="
|
||||
|
||||
# 检查 Rust 是否安装
|
||||
if ! command -v rustc &> /dev/null; then
|
||||
echo -e "${RED}错误: 未找到 Rust 编译器,请先安装 Rust${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查 cargo 是否安装
|
||||
if ! command -v cargo &> /dev/null; then
|
||||
echo -e "${RED}错误: 未找到 Cargo,请先安装 Rust 工具链${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查 cargo-ndk 是否安装
|
||||
if ! cargo ndk --version &> /dev/null; then
|
||||
echo -e "${YELLOW}cargo-ndk 未安装,正在安装...${NC}"
|
||||
cargo install cargo-ndk
|
||||
if ! cargo ndk --version &> /dev/null; then
|
||||
echo -e "${RED}错误: cargo-ndk 安装失败${NC}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}cargo-ndk 版本: $(cargo ndk --version)${NC}"
|
||||
|
||||
# Android 目标架构映射 (cargo-ndk 使用的架构名称)
|
||||
# ANDROID_TARGETS=("arm64-v8a" "armeabi-v7a" "x86" "x86_64")
|
||||
ANDROID_TARGETS=("arm64-v8a")
|
||||
|
||||
# Android 架构到 Rust target 的映射
|
||||
declare -A TARGET_MAP
|
||||
TARGET_MAP["arm64-v8a"]="aarch64-linux-android"
|
||||
TARGET_MAP["armeabi-v7a"]="armv7-linux-androideabi"
|
||||
TARGET_MAP["x86"]="i686-linux-android"
|
||||
TARGET_MAP["x86_64"]="x86_64-linux-android"
|
||||
|
||||
# 检查并安装所需的 Rust target
|
||||
echo -e "${YELLOW}检查并安装 Android 目标架构...${NC}"
|
||||
for android_target in "${ANDROID_TARGETS[@]}"; do
|
||||
rust_target="${TARGET_MAP[$android_target]}"
|
||||
if ! rustup target list --installed | grep -q "$rust_target"; then
|
||||
echo -e "${YELLOW}安装目标架构: $rust_target (for $android_target)${NC}"
|
||||
rustup target add "$rust_target"
|
||||
else
|
||||
echo -e "${GREEN}目标架构已安装: $rust_target (for $android_target)${NC}"
|
||||
fi
|
||||
done
|
||||
|
||||
# 创建输出目录
|
||||
OUTPUT_DIR="./target/android"
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
# 构建函数
|
||||
build_for_target() {
|
||||
local android_target=$1
|
||||
echo -e "${YELLOW}构建目标: $android_target${NC}"
|
||||
|
||||
# 首先构建 easytier-ffi
|
||||
echo -e "${YELLOW}构建 easytier-ffi for $android_target${NC}"
|
||||
(cd $REPO_ROOT/easytier-contrib/easytier-ffi && cargo ndk -t $android_target build --release)
|
||||
|
||||
# 构建 JNI 库
|
||||
cargo ndk -t $android_target build --release
|
||||
|
||||
# 复制库文件到输出目录
|
||||
# cargo-ndk 使用 Rust target 名称作为目录名,而不是 Android 架构名称
|
||||
rust_target="${TARGET_MAP[$android_target]}"
|
||||
mkdir -p "$OUTPUT_DIR/$android_target"
|
||||
cp "$REPO_ROOT/target/$rust_target/release/libeasytier_android_jni.so" "$OUTPUT_DIR/$android_target/"
|
||||
cp "$REPO_ROOT/target/$rust_target/release/libeasytier_ffi.so" "$OUTPUT_DIR/$android_target/"
|
||||
echo -e "${GREEN}库文件已复制到: $OUTPUT_DIR/$android_target/${NC}"
|
||||
}
|
||||
|
||||
# 检查 Android NDK (cargo-ndk 会自动处理 NDK 路径)
|
||||
if [ -z "$ANDROID_NDK_ROOT" ] && [ -z "$ANDROID_NDK_HOME" ] && [ -z "$NDK_HOME" ]; then
|
||||
echo -e "${YELLOW}警告: 未设置 Android NDK 环境变量${NC}"
|
||||
echo "cargo-ndk 将尝试自动检测 NDK 路径"
|
||||
echo "如果构建失败,请设置以下环境变量之一:"
|
||||
echo " - ANDROID_NDK_ROOT"
|
||||
echo " - ANDROID_NDK_HOME"
|
||||
echo " - NDK_HOME"
|
||||
else
|
||||
if [ -n "$ANDROID_NDK_ROOT" ]; then
|
||||
echo -e "${GREEN}使用 Android NDK: $ANDROID_NDK_ROOT${NC}"
|
||||
elif [ -n "$ANDROID_NDK_HOME" ]; then
|
||||
echo -e "${GREEN}使用 Android NDK: $ANDROID_NDK_HOME${NC}"
|
||||
elif [ -n "$NDK_HOME" ]; then
|
||||
echo -e "${GREEN}使用 Android NDK: $NDK_HOME${NC}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 构建所有目标
|
||||
echo -e "${YELLOW}开始构建所有目标架构...${NC}"
|
||||
for target in "${ANDROID_TARGETS[@]}"; do
|
||||
build_for_target "$target"
|
||||
done
|
||||
|
||||
echo -e "${GREEN}构建完成!${NC}"
|
||||
echo -e "${GREEN}所有库文件已生成到: $OUTPUT_DIR${NC}"
|
||||
echo ""
|
||||
echo "目录结构:"
|
||||
ls -la "$OUTPUT_DIR"/*/
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}使用说明:${NC}"
|
||||
echo "1. 将生成的 .so 文件复制到您的 Android 项目的 src/main/jniLibs/ 目录下"
|
||||
echo "2. 将 java/com/easytier/jni/EasyTierJNI.java 复制到您的 Android 项目中"
|
||||
echo "3. 在您的 Android 代码中调用 EasyTierJNI 类的方法"
|
||||
echo ""
|
||||
echo -e "${GREEN}注意: 此脚本使用 cargo-ndk 工具,无需手动设置复杂的环境变量${NC}"
|
||||
echo -e "${GREEN}cargo-ndk 会自动处理交叉编译所需的工具链配置${NC}"
|
||||
56
easytier-contrib/easytier-android-jni/example_config.toml
Normal file
56
easytier-contrib/easytier-android-jni/example_config.toml
Normal file
@@ -0,0 +1,56 @@
|
||||
# EasyTier Android JNI 示例配置文件
|
||||
# 这是一个基本的配置示例,展示如何配置 EasyTier 网络实例
|
||||
|
||||
# 实例名称 (必需)
|
||||
inst_name = "android_instance"
|
||||
|
||||
# 网络名称 (必需)
|
||||
network = "my_easytier_network"
|
||||
|
||||
# 网络密钥 (可选,用于网络加密)
|
||||
# network_secret = "your_secret_key_here"
|
||||
|
||||
# 监听地址 (可选)
|
||||
# listeners = ["tcp://0.0.0.0:11010", "udp://0.0.0.0:11010"]
|
||||
|
||||
# 对等节点地址 (可选)
|
||||
# peers = ["tcp://peer1.example.com:11010", "udp://peer2.example.com:11010"]
|
||||
|
||||
# 虚拟 IP 地址 (可选)
|
||||
# ipv4 = "10.144.144.1"
|
||||
|
||||
# 主机名 (可选)
|
||||
# hostname = "android-device"
|
||||
|
||||
# 启用 IPv6 (可选)
|
||||
# ipv6 = "fd00::1"
|
||||
|
||||
# 代理网络 (可选)
|
||||
# proxy_networks = ["192.168.1.0/24"]
|
||||
|
||||
# 退出节点 (可选)
|
||||
# exit_nodes = ["peer1"]
|
||||
|
||||
# 启用加密 (可选)
|
||||
# enable_encryption = true
|
||||
|
||||
# 启用 IPv4 转发 (可选)
|
||||
# enable_ipv4 = true
|
||||
|
||||
# 启用 IPv6 转发 (可选)
|
||||
# enable_ipv6 = false
|
||||
|
||||
# MTU 设置 (可选)
|
||||
# mtu = 1420
|
||||
|
||||
# 日志级别 (可选: error, warn, info, debug, trace)
|
||||
# log_level = "info"
|
||||
|
||||
# 禁用 P2P (可选)
|
||||
# disable_p2p = false
|
||||
|
||||
# 使用多路径 (可选)
|
||||
# use_multi_path = true
|
||||
|
||||
# 延迟优先 (可选)
|
||||
# latency_first = false
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.easytier.jni
|
||||
|
||||
/** EasyTier JNI 接口类 提供 Android 应用调用 EasyTier 网络功能的接口 */
|
||||
object EasyTierJNI {
|
||||
|
||||
init {
|
||||
// 加载本地库
|
||||
System.loadLibrary("easytier_android_jni")
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 TUN 文件描述符
|
||||
* @param instanceName 实例名称
|
||||
* @param fd TUN 文件描述符
|
||||
* @return 0 表示成功,-1 表示失败
|
||||
* @throws RuntimeException 当操作失败时抛出异常
|
||||
*/
|
||||
@JvmStatic external fun setTunFd(instanceName: String, fd: Int): Int
|
||||
|
||||
/**
|
||||
* 解析配置字符串
|
||||
* @param config TOML 格式的配置字符串
|
||||
* @return 0 表示成功,-1 表示失败
|
||||
* @throws RuntimeException 当配置解析失败时抛出异常
|
||||
*/
|
||||
@JvmStatic external fun parseConfig(config: String): Int
|
||||
|
||||
/**
|
||||
* 运行网络实例
|
||||
* @param config TOML 格式的配置字符串
|
||||
* @return 0 表示成功,-1 表示失败
|
||||
* @throws RuntimeException 当实例启动失败时抛出异常
|
||||
*/
|
||||
@JvmStatic external fun runNetworkInstance(config: String): Int
|
||||
|
||||
/**
|
||||
* 保留指定的网络实例,停止其他实例
|
||||
* @param instanceNames 要保留的实例名称数组,传入 null 或空数组将停止所有实例
|
||||
* @return 0 表示成功,-1 表示失败
|
||||
* @throws RuntimeException 当操作失败时抛出异常
|
||||
*/
|
||||
@JvmStatic external fun retainNetworkInstance(instanceNames: Array<String>?): Int
|
||||
|
||||
/**
|
||||
* 收集网络信息
|
||||
* @param maxLength 最大返回条目数
|
||||
* @return 包含网络信息的字符串数组,每个元素格式为 "key=value"
|
||||
* @throws RuntimeException 当操作失败时抛出异常
|
||||
*/
|
||||
@JvmStatic external fun collectNetworkInfos(maxLength: Int): String?
|
||||
|
||||
/**
|
||||
* 获取最后的错误消息
|
||||
* @return 错误消息字符串,如果没有错误则返回 null
|
||||
*/
|
||||
@JvmStatic external fun getLastError(): String?
|
||||
|
||||
/**
|
||||
* 便利方法:停止所有网络实例
|
||||
* @return 0 表示成功,-1 表示失败
|
||||
* @throws RuntimeException 当操作失败时抛出异常
|
||||
*/
|
||||
@JvmStatic
|
||||
fun stopAllInstances(): Int {
|
||||
return retainNetworkInstance(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* 便利方法:停止指定实例外的所有实例
|
||||
* @param instanceName 要保留的实例名称
|
||||
* @return 0 表示成功,-1 表示失败
|
||||
* @throws RuntimeException 当操作失败时抛出异常
|
||||
*/
|
||||
@JvmStatic
|
||||
fun retainSingleInstance(instanceName: String): Int {
|
||||
return retainNetworkInstance(arrayOf(instanceName))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package com.easytier.jni
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.wire.WireJsonAdapterFactory
|
||||
import common.Ipv4Inet
|
||||
import web.NetworkInstanceRunningInfoMap
|
||||
|
||||
fun parseIpv4InetToString(inet: Ipv4Inet?): String? {
|
||||
val addr = inet?.address?.addr ?: return null
|
||||
val networkLength = inet.network_length
|
||||
|
||||
// 将 int32 转换为 IPv4 字符串
|
||||
val ip =
|
||||
String.format(
|
||||
"%d.%d.%d.%d",
|
||||
(addr shr 24) and 0xFF,
|
||||
(addr shr 16) and 0xFF,
|
||||
(addr shr 8) and 0xFF,
|
||||
addr and 0xFF
|
||||
)
|
||||
|
||||
return "$ip/$networkLength"
|
||||
}
|
||||
|
||||
/** EasyTier 管理类 负责管理 EasyTier 实例的生命周期、监控网络状态变化、控制 VpnService */
|
||||
class EasyTierManager(
|
||||
private val activity: Activity,
|
||||
private val instanceName: String,
|
||||
private val networkConfig: String
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "EasyTierManager"
|
||||
private const val MONITOR_INTERVAL = 3000L // 3秒监控间隔
|
||||
}
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var isRunning = false
|
||||
private var currentIpv4: String? = null
|
||||
private var currentProxyCidrs: List<String> = emptyList()
|
||||
private var vpnServiceIntent: Intent? = null
|
||||
|
||||
// JSON 解析器
|
||||
private val moshi = Moshi.Builder().add(WireJsonAdapterFactory()).build()
|
||||
private val adapter = moshi.adapter(NetworkInstanceRunningInfoMap::class.java)
|
||||
|
||||
// 监控任务
|
||||
private val monitorRunnable =
|
||||
object : Runnable {
|
||||
override fun run() {
|
||||
if (isRunning) {
|
||||
monitorNetworkStatus()
|
||||
handler.postDelayed(this, MONITOR_INTERVAL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 启动 EasyTier 实例和监控 */
|
||||
fun start() {
|
||||
if (isRunning) {
|
||||
Log.w(TAG, "EasyTier 实例已经在运行中")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 启动 EasyTier 实例
|
||||
val result = EasyTierJNI.runNetworkInstance(networkConfig)
|
||||
if (result == 0) {
|
||||
isRunning = true
|
||||
Log.i(TAG, "EasyTier 实例启动成功: $instanceName")
|
||||
|
||||
// 开始监控网络状态
|
||||
handler.post(monitorRunnable)
|
||||
} else {
|
||||
Log.e(TAG, "EasyTier 实例启动失败: $result")
|
||||
val error = EasyTierJNI.getLastError()
|
||||
Log.e(TAG, "错误信息: $error")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "启动 EasyTier 实例时发生异常", e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 停止 EasyTier 实例和监控 */
|
||||
fun stop() {
|
||||
if (!isRunning) {
|
||||
Log.w(TAG, "EasyTier 实例未在运行")
|
||||
return
|
||||
}
|
||||
|
||||
isRunning = false
|
||||
|
||||
// 停止监控任务
|
||||
handler.removeCallbacks(monitorRunnable)
|
||||
|
||||
try {
|
||||
// 停止 VpnService
|
||||
stopVpnService()
|
||||
|
||||
// 停止 EasyTier 实例
|
||||
EasyTierJNI.stopAllInstances()
|
||||
Log.i(TAG, "EasyTier 实例已停止: $instanceName")
|
||||
|
||||
// 重置状态
|
||||
currentIpv4 = null
|
||||
currentProxyCidrs = emptyList()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "停止 EasyTier 实例时发生异常", e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 监控网络状态 */
|
||||
private fun monitorNetworkStatus() {
|
||||
try {
|
||||
val infosJson = EasyTierJNI.collectNetworkInfos(10)
|
||||
if (infosJson.isNullOrEmpty()) {
|
||||
Log.d(TAG, "未获取到网络信息")
|
||||
return
|
||||
}
|
||||
|
||||
val networkInfoMap = parseNetworkInfo(infosJson)
|
||||
val networkInfo = networkInfoMap?.map?.get(instanceName)
|
||||
|
||||
if (networkInfo == null) {
|
||||
Log.d(TAG, "未找到实例 $instanceName 的网络信息")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "网络信息: $networkInfo")
|
||||
|
||||
// 检查实例是否正在运行
|
||||
if (!networkInfo.running) {
|
||||
Log.w(TAG, "EasyTier 实例未运行: ${networkInfo.error_msg}")
|
||||
return
|
||||
}
|
||||
|
||||
val newIpv4Inet = networkInfo.my_node_info?.virtual_ipv4
|
||||
|
||||
if (newIpv4Inet == null) {
|
||||
Log.w(TAG, "EasyTier No Ipv4: $networkInfo")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取当前节点的 IPv4 地址
|
||||
val newIpv4 = parseIpv4InetToString(newIpv4Inet)
|
||||
|
||||
// 获取所有节点的 proxy_cidrs
|
||||
val newProxyCidrs = mutableListOf<String>()
|
||||
networkInfo.routes?.forEach { route ->
|
||||
route.proxy_cidrs?.let { cidrs -> newProxyCidrs.addAll(cidrs) }
|
||||
}
|
||||
|
||||
// 检查是否有变化
|
||||
val ipv4Changed = newIpv4 != currentIpv4
|
||||
val proxyCidrsChanged = newProxyCidrs != currentProxyCidrs
|
||||
|
||||
if (ipv4Changed || proxyCidrsChanged) {
|
||||
Log.i(TAG, "网络状态发生变化:")
|
||||
Log.i(TAG, " IPv4: $currentIpv4 -> $newIpv4")
|
||||
Log.i(TAG, " Proxy CIDRs: $currentProxyCidrs -> $newProxyCidrs")
|
||||
|
||||
// 更新状态
|
||||
currentIpv4 = newIpv4
|
||||
currentProxyCidrs = newProxyCidrs.toList()
|
||||
|
||||
// 重启 VpnService
|
||||
if (newIpv4 != null) {
|
||||
restartVpnService(newIpv4, newProxyCidrs)
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "网络状态无变化 - IPv4: $currentIpv4, Proxy CIDRs: ${currentProxyCidrs.size} 个")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "监控网络状态时发生异常", e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析网络信息 JSON */
|
||||
private fun parseNetworkInfo(jsonString: String): NetworkInstanceRunningInfoMap? {
|
||||
return try {
|
||||
adapter.fromJson(jsonString)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "解析网络信息失败", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** 重启 VpnService */
|
||||
private fun restartVpnService(ipv4: String, proxyCidrs: List<String>) {
|
||||
try {
|
||||
// 先停止现有的 VpnService
|
||||
stopVpnService()
|
||||
|
||||
// 启动新的 VpnService
|
||||
startVpnService(ipv4, proxyCidrs)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "重启 VpnService 时发生异常", e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 启动 VpnService */
|
||||
private fun startVpnService(ipv4: String, proxyCidrs: List<String>) {
|
||||
try {
|
||||
val intent = Intent(activity, EasyTierVpnService::class.java)
|
||||
intent.putExtra("ipv4_address", ipv4)
|
||||
intent.putStringArrayListExtra("proxy_cidrs", ArrayList(proxyCidrs))
|
||||
intent.putExtra("instance_name", instanceName)
|
||||
|
||||
activity.startService(intent)
|
||||
vpnServiceIntent = intent
|
||||
|
||||
Log.i(TAG, "VpnService 已启动 - IPv4: $ipv4, Proxy CIDRs: $proxyCidrs")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "启动 VpnService 时发生异常", e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 停止 VpnService */
|
||||
private fun stopVpnService() {
|
||||
try {
|
||||
vpnServiceIntent?.let { intent ->
|
||||
activity.stopService(intent)
|
||||
Log.i(TAG, "VpnService 已停止")
|
||||
}
|
||||
vpnServiceIntent = null
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "停止 VpnService 时发生异常", e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取当前状态信息 */
|
||||
fun getStatus(): EasyTierStatus {
|
||||
return EasyTierStatus(
|
||||
isRunning = isRunning,
|
||||
instanceName = instanceName,
|
||||
currentIpv4 = currentIpv4,
|
||||
currentProxyCidrs = currentProxyCidrs.toList()
|
||||
)
|
||||
}
|
||||
|
||||
/** 状态数据类 */
|
||||
data class EasyTierStatus(
|
||||
val isRunning: Boolean,
|
||||
val instanceName: String,
|
||||
val currentIpv4: String?,
|
||||
val currentProxyCidrs: List<String>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.easytier.jni
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.VpnService
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.util.Log
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
class EasyTierVpnService : VpnService() {
|
||||
|
||||
private var vpnInterface: ParcelFileDescriptor? = null
|
||||
private var isRunning = false
|
||||
private var instanceName: String? = null
|
||||
|
||||
companion object {
|
||||
private const val TAG = "EasyTierVpnService"
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.d(TAG, "VPN Service created")
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
// 获取传入的参数
|
||||
val ipv4Address = intent?.getStringExtra("ipv4_address")
|
||||
val proxyCidrs = intent?.getStringArrayListExtra("proxy_cidrs") ?: arrayListOf()
|
||||
instanceName = intent?.getStringExtra("instance_name")
|
||||
|
||||
if (ipv4Address == null || instanceName == null) {
|
||||
Log.e(TAG, "缺少必要参数: ipv4Address=$ipv4Address, instanceName=$instanceName")
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
Log.i(
|
||||
TAG,
|
||||
"启动 VPN Service - IPv4: $ipv4Address, Proxy CIDRs: $proxyCidrs, Instance: $instanceName"
|
||||
)
|
||||
|
||||
thread {
|
||||
try {
|
||||
setupVpnInterface(ipv4Address, proxyCidrs)
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "VPN 设置失败", t)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private fun setupVpnInterface(ipv4Address: String, proxyCidrs: List<String>) {
|
||||
try {
|
||||
// 解析 IPv4 地址和网络长度
|
||||
val (ip, networkLength) = parseIpv4Address(ipv4Address)
|
||||
|
||||
// 1. 准备 VpnService.Builder
|
||||
val builder = Builder()
|
||||
builder.setSession("EasyTier VPN")
|
||||
.addAddress(ip, networkLength)
|
||||
.addDnsServer("223.5.5.5")
|
||||
.addDnsServer("114.114.114.114")
|
||||
.addDisallowedApplication("com.easytier.easytiervpn")
|
||||
|
||||
// 2. 添加路由表 - 为每个 proxy CIDR 添加路由
|
||||
proxyCidrs.forEach { cidr ->
|
||||
try {
|
||||
val (routeIp, routeLength) = parseCidr(cidr)
|
||||
builder.addRoute(routeIp, routeLength)
|
||||
Log.d(TAG, "添加路由: $routeIp/$routeLength")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "解析 CIDR 失败: $cidr", e)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 构建虚拟网络接口
|
||||
vpnInterface = builder.establish()
|
||||
|
||||
if (vpnInterface == null) {
|
||||
Log.e(TAG, "创建 VPN 接口失败")
|
||||
return
|
||||
}
|
||||
|
||||
Log.i(TAG, "VPN 接口创建成功")
|
||||
|
||||
// 4. 将 TUN 文件描述符传递给 EasyTier
|
||||
instanceName?.let { name ->
|
||||
val fd = vpnInterface!!.fd
|
||||
val result = EasyTierJNI.setTunFd(name, fd)
|
||||
if (result == 0) {
|
||||
Log.i(TAG, "TUN 文件描述符设置成功: $fd")
|
||||
} else {
|
||||
Log.e(TAG, "TUN 文件描述符设置失败: $result")
|
||||
}
|
||||
}
|
||||
|
||||
isRunning = true
|
||||
|
||||
// 5. 保持服务运行
|
||||
while (isRunning && vpnInterface != null) {
|
||||
Thread.sleep(1000)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "VPN 接口设置过程中发生错误", t)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析 IPv4 地址,返回 IP 和网络长度 */
|
||||
private fun parseIpv4Address(ipv4Address: String): Pair<String, Int> {
|
||||
return if (ipv4Address.contains("/")) {
|
||||
val parts = ipv4Address.split("/")
|
||||
Pair(parts[0], parts[1].toInt())
|
||||
} else {
|
||||
// 默认使用 /24 网络
|
||||
Pair(ipv4Address, 24)
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析 CIDR,返回 IP 和网络长度 */
|
||||
private fun parseCidr(cidr: String): Pair<String, Int> {
|
||||
val parts = cidr.split("/")
|
||||
if (parts.size != 2) {
|
||||
throw IllegalArgumentException("无效的 CIDR 格式: $cidr")
|
||||
}
|
||||
return Pair(parts[0], parts[1].toInt())
|
||||
}
|
||||
|
||||
private fun cleanup() {
|
||||
isRunning = false
|
||||
vpnInterface?.close()
|
||||
vpnInterface = null
|
||||
Log.i(TAG, "VPN 接口已清理")
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
Log.d(TAG, "VPN Service destroyed")
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# 使用说明
|
||||
|
||||
1. 需要将 proto 文件放入 app/src/main/proto
|
||||
2. android/gradle/libs.versions.toml 中加入依赖
|
||||
|
||||
```
|
||||
# Wire 核心运行时
|
||||
android-wire-runtime = { group = "com.squareup.wire", name = "wire-runtime", version = "5.3.11" }
|
||||
moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" }
|
||||
android-wire-moshi-adapter = { group = "com.squareup.wire", name = "wire-moshi-adapter", version = "5.3.11" }
|
||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version = "1.9.0" }
|
||||
```
|
||||
|
||||
3. build.gradle.kts 中加入
|
||||
|
||||
```
|
||||
plugins {
|
||||
...
|
||||
alias(libs.plugins.wire)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
...
|
||||
implementation(libs.android.wire.runtime)
|
||||
implementation(libs.android.wire.moshi.adapter)
|
||||
implementation(libs.moshi)
|
||||
}
|
||||
|
||||
...
|
||||
|
||||
wire {
|
||||
kotlin {
|
||||
rpcRole = "none"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. 调用 easytier-contrib/easytier-android-jni/build.sh 生成 jni 和 ffi 的 so 文件。
|
||||
并将生成的 so 文件放到 android/app/src/main/jniLibs/arm64-v8a 目录下。
|
||||
|
||||
5. 使用 EasyTierManager 可以拉起 EasyTier 实例并启动 Android VpnService 组件。
|
||||
319
easytier-contrib/easytier-android-jni/src/lib.rs
Normal file
319
easytier-contrib/easytier-android-jni/src/lib.rs
Normal file
@@ -0,0 +1,319 @@
|
||||
use easytier::proto::api::manage::{NetworkInstanceRunningInfo, NetworkInstanceRunningInfoMap};
|
||||
use jni::objects::{JClass, JObjectArray, JString};
|
||||
use jni::sys::{jint, jstring};
|
||||
use jni::JNIEnv;
|
||||
use once_cell::sync::Lazy;
|
||||
use std::ffi::{CStr, CString};
|
||||
use std::ptr;
|
||||
|
||||
// 定义 KeyValuePair 结构体
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct KeyValuePair {
|
||||
pub key: *const std::ffi::c_char,
|
||||
pub value: *const std::ffi::c_char,
|
||||
}
|
||||
|
||||
// 声明外部 C 函数
|
||||
extern "C" {
|
||||
fn set_tun_fd(inst_name: *const std::ffi::c_char, fd: std::ffi::c_int) -> std::ffi::c_int;
|
||||
fn get_error_msg(out: *mut *const std::ffi::c_char);
|
||||
fn free_string(s: *const std::ffi::c_char);
|
||||
fn parse_config(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int;
|
||||
fn run_network_instance(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int;
|
||||
fn retain_network_instance(
|
||||
inst_names: *const *const std::ffi::c_char,
|
||||
length: usize,
|
||||
) -> std::ffi::c_int;
|
||||
fn collect_network_infos(infos: *mut KeyValuePair, max_length: usize) -> std::ffi::c_int;
|
||||
}
|
||||
|
||||
// 初始化 Android 日志
|
||||
static LOGGER_INIT: Lazy<()> = Lazy::new(|| {
|
||||
android_logger::init_once(
|
||||
android_logger::Config::default()
|
||||
.with_max_level(log::LevelFilter::Debug)
|
||||
.with_tag("EasyTier-JNI"),
|
||||
);
|
||||
});
|
||||
|
||||
// 辅助函数:从 Java String 转换为 CString
|
||||
fn jstring_to_cstring(env: &mut JNIEnv, jstr: &JString) -> Result<CString, String> {
|
||||
let java_str = env
|
||||
.get_string(jstr)
|
||||
.map_err(|e| format!("Failed to get string: {:?}", e))?;
|
||||
let rust_str = java_str.to_str().map_err(|_| "Invalid UTF-8".to_string())?;
|
||||
CString::new(rust_str).map_err(|_| "String contains null byte".to_string())
|
||||
}
|
||||
|
||||
// 辅助函数:获取错误消息
|
||||
fn get_last_error() -> Option<String> {
|
||||
unsafe {
|
||||
let mut error_ptr: *const std::ffi::c_char = ptr::null();
|
||||
get_error_msg(&mut error_ptr);
|
||||
if error_ptr.is_null() {
|
||||
None
|
||||
} else {
|
||||
let error_cstr = CStr::from_ptr(error_ptr);
|
||||
let error_str = error_cstr.to_string_lossy().into_owned();
|
||||
free_string(error_ptr);
|
||||
Some(error_str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数:抛出 Java 异常
|
||||
fn throw_exception(env: &mut JNIEnv, message: &str) {
|
||||
let _ = env.throw_new("java/lang/RuntimeException", message);
|
||||
}
|
||||
|
||||
/// 设置 TUN 文件描述符
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_setTunFd(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
inst_name: JString,
|
||||
fd: jint,
|
||||
) -> jint {
|
||||
Lazy::force(&LOGGER_INIT);
|
||||
|
||||
let inst_name_cstr = match jstring_to_cstring(&mut env, &inst_name) {
|
||||
Ok(cstr) => cstr,
|
||||
Err(e) => {
|
||||
throw_exception(&mut env, &format!("Invalid instance name: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
unsafe {
|
||||
let result = set_tun_fd(inst_name_cstr.as_ptr(), fd);
|
||||
if result != 0 {
|
||||
if let Some(error) = get_last_error() {
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析配置
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_parseConfig(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
config: JString,
|
||||
) -> jint {
|
||||
Lazy::force(&LOGGER_INIT);
|
||||
|
||||
let config_cstr = match jstring_to_cstring(&mut env, &config) {
|
||||
Ok(cstr) => cstr,
|
||||
Err(e) => {
|
||||
throw_exception(&mut env, &format!("Invalid config string: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
unsafe {
|
||||
let result = parse_config(config_cstr.as_ptr());
|
||||
if result != 0 {
|
||||
if let Some(error) = get_last_error() {
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// 运行网络实例
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_runNetworkInstance(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
config: JString,
|
||||
) -> jint {
|
||||
Lazy::force(&LOGGER_INIT);
|
||||
|
||||
let config_cstr = match jstring_to_cstring(&mut env, &config) {
|
||||
Ok(cstr) => cstr,
|
||||
Err(e) => {
|
||||
throw_exception(&mut env, &format!("Invalid config string: {}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
unsafe {
|
||||
let result = run_network_instance(config_cstr.as_ptr());
|
||||
if result != 0 {
|
||||
if let Some(error) = get_last_error() {
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// 保持网络实例
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_retainNetworkInstance(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
instance_names: JObjectArray,
|
||||
) -> jint {
|
||||
Lazy::force(&LOGGER_INIT);
|
||||
|
||||
// 处理 null 数组的情况
|
||||
if instance_names.is_null() {
|
||||
unsafe {
|
||||
let result = retain_network_instance(ptr::null(), 0);
|
||||
if result != 0 {
|
||||
if let Some(error) = get_last_error() {
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取数组长度
|
||||
let array_length = match env.get_array_length(&instance_names) {
|
||||
Ok(len) => len as usize,
|
||||
Err(e) => {
|
||||
throw_exception(&mut env, &format!("Failed to get array length: {:?}", e));
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
// 如果数组为空,停止所有实例
|
||||
if array_length == 0 {
|
||||
unsafe {
|
||||
let result = retain_network_instance(ptr::null(), 0);
|
||||
if result != 0 {
|
||||
if let Some(error) = get_last_error() {
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// 转换 Java 字符串数组为 C 字符串数组
|
||||
let mut c_strings = Vec::with_capacity(array_length);
|
||||
let mut c_string_ptrs = Vec::with_capacity(array_length);
|
||||
|
||||
for i in 0..array_length {
|
||||
let java_string = match env.get_object_array_element(&instance_names, i as i32) {
|
||||
Ok(obj) => obj,
|
||||
Err(e) => {
|
||||
throw_exception(
|
||||
&mut env,
|
||||
&format!("Failed to get array element {}: {:?}", i, e),
|
||||
);
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
if java_string.is_null() {
|
||||
continue; // 跳过 null 元素
|
||||
}
|
||||
|
||||
let jstring = JString::from(java_string);
|
||||
let c_string = match jstring_to_cstring(&mut env, &jstring) {
|
||||
Ok(cstr) => cstr,
|
||||
Err(e) => {
|
||||
throw_exception(
|
||||
&mut env,
|
||||
&format!("Invalid instance name at index {}: {}", i, e),
|
||||
);
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
c_string_ptrs.push(c_string.as_ptr());
|
||||
c_strings.push(c_string); // 保持 CString 的所有权
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let result = retain_network_instance(c_string_ptrs.as_ptr(), c_string_ptrs.len());
|
||||
if result != 0 {
|
||||
if let Some(error) = get_last_error() {
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// 收集网络信息
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_collectNetworkInfos(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
) -> jstring {
|
||||
Lazy::force(&LOGGER_INIT);
|
||||
|
||||
const MAX_INFOS: usize = 100;
|
||||
let mut infos = vec![
|
||||
KeyValuePair {
|
||||
key: ptr::null(),
|
||||
value: ptr::null(),
|
||||
};
|
||||
MAX_INFOS
|
||||
];
|
||||
|
||||
unsafe {
|
||||
let count = collect_network_infos(infos.as_mut_ptr(), MAX_INFOS);
|
||||
if count < 0 {
|
||||
if let Some(error) = get_last_error() {
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
return ptr::null_mut();
|
||||
}
|
||||
|
||||
let mut ret = NetworkInstanceRunningInfoMap::default();
|
||||
|
||||
// 使用 serde_json 构建 JSON
|
||||
for info in infos.iter().take(count as usize) {
|
||||
let key_ptr = info.key;
|
||||
let val_ptr = info.value;
|
||||
if key_ptr.is_null() || val_ptr.is_null() {
|
||||
break;
|
||||
}
|
||||
|
||||
let key = CStr::from_ptr(key_ptr).to_string_lossy();
|
||||
let val = CStr::from_ptr(val_ptr).to_string_lossy();
|
||||
let value = match serde_json::from_str::<NetworkInstanceRunningInfo>(val.as_ref()) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
throw_exception(&mut env, "Failed to parse JSON");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
ret.map.insert(key.to_string(), value);
|
||||
}
|
||||
|
||||
let json_str = serde_json::to_string(&ret).unwrap_or_else(|_| "{}".to_string());
|
||||
|
||||
match env.new_string(&json_str) {
|
||||
Ok(jstr) => jstr.into_raw(),
|
||||
Err(_) => {
|
||||
throw_exception(&mut env, "Failed to create JSON string");
|
||||
ptr::null_mut()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取最后的错误信息
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_getLastError(
|
||||
env: JNIEnv,
|
||||
_class: JClass,
|
||||
) -> jstring {
|
||||
match get_last_error() {
|
||||
Some(error) => match env.new_string(&error) {
|
||||
Ok(jstr) => jstr.into_raw(),
|
||||
Err(_) => ptr::null_mut(),
|
||||
},
|
||||
None => ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
@@ -29,8 +29,10 @@ fn set_error_msg(msg: &str) {
|
||||
msg_buf[..len].copy_from_slice(bytes);
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// Set the tun fd
|
||||
#[no_mangle]
|
||||
pub extern "C" fn set_tun_fd(
|
||||
pub unsafe extern "C" fn set_tun_fd(
|
||||
inst_name: *const std::ffi::c_char,
|
||||
fd: std::ffi::c_int,
|
||||
) -> std::ffi::c_int {
|
||||
@@ -43,18 +45,23 @@ pub extern "C" fn set_tun_fd(
|
||||
if !INSTANCE_NAME_ID_MAP.contains_key(&inst_name) {
|
||||
return -1;
|
||||
}
|
||||
match INSTANCE_MANAGER.set_tun_fd(&INSTANCE_NAME_ID_MAP.get(&inst_name).unwrap().value(), fd) {
|
||||
Ok(_) => {
|
||||
0
|
||||
}
|
||||
Err(_) => {
|
||||
-1
|
||||
}
|
||||
|
||||
let inst_id = *INSTANCE_NAME_ID_MAP
|
||||
.get(&inst_name)
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.value();
|
||||
|
||||
match INSTANCE_MANAGER.set_tun_fd(&inst_id, fd) {
|
||||
Ok(_) => 0,
|
||||
Err(_) => -1,
|
||||
}
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// Get the last error message
|
||||
#[no_mangle]
|
||||
pub extern "C" fn get_error_msg(out: *mut *const std::ffi::c_char) {
|
||||
pub unsafe extern "C" fn get_error_msg(out: *mut *const std::ffi::c_char) {
|
||||
let msg_buf = ERROR_MSG.lock().unwrap();
|
||||
if msg_buf.is_empty() {
|
||||
unsafe {
|
||||
@@ -78,8 +85,10 @@ pub extern "C" fn free_string(s: *const std::ffi::c_char) {
|
||||
}
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// Parse the config
|
||||
#[no_mangle]
|
||||
pub extern "C" fn parse_config(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int {
|
||||
pub unsafe extern "C" fn parse_config(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int {
|
||||
let cfg_str = unsafe {
|
||||
assert!(!cfg_str.is_null());
|
||||
std::ffi::CStr::from_ptr(cfg_str)
|
||||
@@ -95,8 +104,10 @@ pub extern "C" fn parse_config(cfg_str: *const std::ffi::c_char) -> std::ffi::c_
|
||||
0
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// Run the network instance
|
||||
#[no_mangle]
|
||||
pub extern "C" fn run_network_instance(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int {
|
||||
pub unsafe extern "C" fn run_network_instance(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int {
|
||||
let cfg_str = unsafe {
|
||||
assert!(!cfg_str.is_null());
|
||||
std::ffi::CStr::from_ptr(cfg_str)
|
||||
@@ -131,8 +142,10 @@ pub extern "C" fn run_network_instance(cfg_str: *const std::ffi::c_char) -> std:
|
||||
0
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// Retain the network instance
|
||||
#[no_mangle]
|
||||
pub extern "C" fn retain_network_instance(
|
||||
pub unsafe extern "C" fn retain_network_instance(
|
||||
inst_names: *const *const std::ffi::c_char,
|
||||
length: usize,
|
||||
) -> std::ffi::c_int {
|
||||
@@ -168,13 +181,15 @@ pub extern "C" fn retain_network_instance(
|
||||
return -1;
|
||||
}
|
||||
|
||||
let _ = INSTANCE_NAME_ID_MAP.retain(|k, _| inst_names.contains(k));
|
||||
INSTANCE_NAME_ID_MAP.retain(|k, _| inst_names.contains(k));
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// Collect the network infos
|
||||
#[no_mangle]
|
||||
pub extern "C" fn collect_network_infos(
|
||||
pub unsafe extern "C" fn collect_network_infos(
|
||||
infos: *mut KeyValuePair,
|
||||
max_length: usize,
|
||||
) -> std::ffi::c_int {
|
||||
@@ -187,7 +202,7 @@ pub extern "C" fn collect_network_infos(
|
||||
std::slice::from_raw_parts_mut(infos, max_length)
|
||||
};
|
||||
|
||||
let collected_infos = match INSTANCE_MANAGER.collect_network_infos() {
|
||||
let collected_infos = match INSTANCE_MANAGER.collect_network_infos_sync() {
|
||||
Ok(infos) => infos,
|
||||
Err(e) => {
|
||||
set_error_msg(&format!("failed to collect network infos: {}", e));
|
||||
@@ -233,8 +248,10 @@ mod tests {
|
||||
network = "test_network"
|
||||
"#;
|
||||
let cstr = std::ffi::CString::new(cfg_str).unwrap();
|
||||
unsafe {
|
||||
assert_eq!(parse_config(cstr.as_ptr()), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_network_instance() {
|
||||
@@ -243,6 +260,8 @@ mod tests {
|
||||
network = "test_network"
|
||||
"#;
|
||||
let cstr = std::ffi::CString::new(cfg_str).unwrap();
|
||||
unsafe {
|
||||
assert_eq!(run_network_instance(cstr.as_ptr()), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,43 @@
|
||||
#!/data/adb/magisk/busybox sh
|
||||
MODDIR=${0%/*}
|
||||
MODULE_PROP="${MODDIR}/module.prop"
|
||||
|
||||
# 查找 easytier-core 进程的 PID
|
||||
PID=$(pgrep easytier-core)
|
||||
ET_STATUS=""
|
||||
REDIR_STATUS=""
|
||||
# 更新module.prop文件中的description
|
||||
update_module_description() {
|
||||
local status_message=$1
|
||||
sed -i "/^description=/c\description=[状态]${status_message}" ${MODULE_PROP}
|
||||
}
|
||||
|
||||
# 检查是否找到了进程
|
||||
if [ -z "$PID" ]; then
|
||||
echo "easytier-core 进程未找到"
|
||||
|
||||
if [ -f "${MODDIR}/disable" ]; then
|
||||
ET_STATUS="已关闭"
|
||||
elif pgrep -f 'easytier-core' >/dev/null; then
|
||||
if [ -f "${MODDIR}/config/command_args"]; then
|
||||
ET_STATUS="主程序已开启(启动参数模式)"
|
||||
else
|
||||
# 结束进程
|
||||
kill $PID
|
||||
echo "已结束 easytier-core 进程 (PID: $PID)"
|
||||
ET_STATUS="主程序已开启(配置文件模式)"
|
||||
fi
|
||||
fi
|
||||
|
||||
#ET_STATUS不存在说明开启模块未正常运行,不修改状态
|
||||
if [ -n "$ET_STATUS" ]; then
|
||||
if [ -f "${MODDIR}/enable_IP_rule" ]; then
|
||||
rm -f "${MODDIR}/enable_IP_rule"
|
||||
${MODDIR}/hotspot_iprule.sh del
|
||||
REDIR_STATUS="转发已禁用"
|
||||
echo "热点子网转发已禁用"
|
||||
echo "[ET-NAT] IP rule disabled." >> "${MODDIR}/log.log"
|
||||
else
|
||||
touch "${MODDIR}/enable_IP_rule"
|
||||
${MODDIR}/hotspot_iprule.sh del
|
||||
${MODDIR}/hotspot_iprule.sh add_once
|
||||
REDIR_STATUS="转发已激活"
|
||||
echo "热点子网转发已激活,热点开启后将自动将热点加入转发网络(要求已配置本地网络cidr=参数)。转发规则将随着热点开关而自动开关。该状态将保持到转发被禁用为止。"
|
||||
echo "[ET-NAT] IP rule enabled." >> "${MODDIR}/log.log"
|
||||
fi
|
||||
update_module_description "${ET_STATUS} | ${REDIR_STATUS}"
|
||||
else
|
||||
echo "主程序未正常启动,请先检查配置文件"
|
||||
fi
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
--config-server udp://127.0.0.1:22020/admin --machine-id easytier-magisk
|
||||
@@ -3,5 +3,7 @@ ui_print '当前架构为' + $ARCH
|
||||
ui_print '当前系统版本为' + $API
|
||||
ui_print '安装目录为: /data/adb/modules/easytier_magisk'
|
||||
ui_print '配置文件位置: /data/adb/modules/easytier_magisk/config/config.toml'
|
||||
ui_print '修改后配置文件后在magisk app点击操作按钮即可生效'
|
||||
ui_print '如果需要自定义启动参数,可将 /data/adb/modules/easytier_magisk/config/command_args_sample 重命名为 command_args,并修改其中内容,使用自定义启动参数时会忽略配置文件'
|
||||
ui_print '修改配置文件后在magisk app禁用应用再启动即可生效'
|
||||
ui_print '点击操作按钮可启动/关闭热点子网转发,配合easytier的子网代理功能实现手机热点访问easytier网络'
|
||||
ui_print '记得重启'
|
||||
@@ -5,6 +5,7 @@ CONFIG_FILE="${MODDIR}/config/config.toml"
|
||||
LOG_FILE="${MODDIR}/log.log"
|
||||
MODULE_PROP="${MODDIR}/module.prop"
|
||||
EASYTIER="${MODDIR}/easytier-core"
|
||||
REDIR_STATUS=""
|
||||
|
||||
# 更新module.prop文件中的description
|
||||
update_module_description() {
|
||||
@@ -12,6 +13,12 @@ update_module_description() {
|
||||
sed -i "/^description=/c\description=[状态]${status_message}" ${MODULE_PROP}
|
||||
}
|
||||
|
||||
if [ -f "${MODDIR}/enable_IP_rule" ]; then
|
||||
REDIR_STATUS="转发已激活"
|
||||
else
|
||||
REDIR_STATUS="转发已禁用"
|
||||
fi
|
||||
|
||||
if [ ! -e /dev/net/tun ]; then
|
||||
if [ ! -d /dev/net ]; then
|
||||
mkdir -p /dev/net
|
||||
@@ -22,7 +29,7 @@ fi
|
||||
|
||||
while true; do
|
||||
if ls $MODDIR | grep -q "disable"; then
|
||||
update_module_description "关闭中"
|
||||
update_module_description "关闭中 | ${REDIR_STATUS}"
|
||||
if pgrep -f 'easytier-core' >/dev/null; then
|
||||
echo "开关控制$(date "+%Y-%m-%d %H:%M:%S") 进程已存在,正在关闭 ..."
|
||||
pkill easytier-core # 关闭进程
|
||||
@@ -35,10 +42,20 @@ while true; do
|
||||
continue
|
||||
fi
|
||||
|
||||
TZ=Asia/Shanghai ${EASYTIER} -c ${CONFIG_FILE} > ${LOG_FILE} &
|
||||
# 如果 config 目录下存在 command_args 文件,则读取其中的内容作为启动参数
|
||||
if [ -f "${MODDIR}/config/command_args" ]; then
|
||||
TZ=Asia/Shanghai ${EASYTIER} $(cat ${MODDIR}/config/command_args) --hostname "$(getprop ro.product.brand)-$(getprop ro.product.model)" > ${LOG_FILE} &
|
||||
sleep 5s # 等待easytier-core启动完成
|
||||
update_module_description "已开启(不一定运行成功)"
|
||||
update_module_description "主程序已开启(启动参数模式) | ${REDIR_STATUS}"
|
||||
else
|
||||
TZ=Asia/Shanghai ${EASYTIER} -c ${CONFIG_FILE} --hostname "$(getprop ro.product.brand)-$(getprop ro.product.model)" > ${LOG_FILE} &
|
||||
sleep 5s # 等待easytier-core启动完成
|
||||
update_module_description "主程序已开启(配置文件模式) | ${REDIR_STATUS}"
|
||||
fi
|
||||
ip rule add from all lookup main
|
||||
if ! pgrep -f 'easytier-core' >/dev/null; then
|
||||
update_module_descriptio "主程序启动失败,请检查配置文件"
|
||||
fi
|
||||
else
|
||||
echo "开关控制$(date "+%Y-%m-%d %H:%M:%S") 进程已存在"
|
||||
fi
|
||||
|
||||
104
easytier-contrib/easytier-magisk/hotspot_iprule.sh
Normal file
104
easytier-contrib/easytier-magisk/hotspot_iprule.sh
Normal file
@@ -0,0 +1,104 @@
|
||||
#!/system/bin/sh
|
||||
MODDIR=${0%/*}
|
||||
CONFIG_FILE="${MODDIR}/config/config.toml"
|
||||
LOG_FILE="${MODDIR}/log.log"
|
||||
ACTION="$1" # 参数:add add_once del
|
||||
|
||||
|
||||
# 获取接口/IP
|
||||
get_et_iface() {
|
||||
awk '
|
||||
BEGIN { IGNORECASE = 1 }
|
||||
/^[[:space:]]*dev_name[[:space:]]*=/ {
|
||||
val = $0
|
||||
sub(/^[^=]*=[[:space:]]*/, "", val)
|
||||
gsub(/[" \t]/, "", val)
|
||||
print val
|
||||
exit
|
||||
}
|
||||
' "$CONFIG_FILE"
|
||||
}
|
||||
get_tun_iface() {
|
||||
ip link | awk -F': ' '/ tun[[:alnum:]]+/ {print $2; exit}'
|
||||
}
|
||||
get_hot_iface() {
|
||||
ip link | awk -F': ' '/(^| )(swlan[[:alnum:]_]*|softap[[:alnum:]_]*|p2p-wlan[[:alnum:]_]*|ap[[:alnum:]_]*)\:/ {print $2; exit}' | cut -d'@' -f1 | head -n1
|
||||
}
|
||||
get_usb_iface() {
|
||||
ip link | awk -F': ' '/(^| )(usb[[:alnum:]_]*|rndis[[:alnum:]_]*|eth[[:alnum:]_]*)\:/ {print $2; exit}' | cut -d'@' -f1 | head -n1
|
||||
}
|
||||
get_hot_cidr() {
|
||||
ip -4 addr show dev "$1" | awk '/inet /{print $2; exit}'
|
||||
}
|
||||
|
||||
|
||||
set_nat_rules() {
|
||||
ET_IFACE=$(get_et_iface)
|
||||
[ -z "$ET_IFACE" ] && ET_IFACE="$(get_tun_iface)"
|
||||
HOT_IFACE=$(get_hot_iface)
|
||||
USB_IFACE=$(get_usb_iface)
|
||||
HOT_CIDR=$(get_hot_cidr "$HOT_IFACE")
|
||||
USB_CIDR=$(get_hot_cidr "$USB_IFACE")
|
||||
|
||||
# 如果热点关闭就删除自定义链
|
||||
[ -n "$ET_IFACE" ] && { [ -n "$HOT_CIDR" ] || [ -n "$USB_CIDR" ]; } || return 1
|
||||
|
||||
# 创建自定义链(如不存在)
|
||||
iptables -t nat -N ET_NAT 2>/dev/null
|
||||
iptables -N ET_FWD 2>/dev/null
|
||||
|
||||
# 确保主链首条跳转到自定义链
|
||||
iptables -t nat -C POSTROUTING -j ET_NAT 2>/dev/null || \
|
||||
iptables -t nat -I POSTROUTING 1 -j ET_NAT
|
||||
iptables -C FORWARD -j ET_FWD 2>/dev/null || \
|
||||
iptables -I FORWARD 1 -j ET_FWD
|
||||
|
||||
# 添加规则
|
||||
if [ -n "$HOT_CIDR" ]; then
|
||||
iptables -t nat -A ET_NAT -s "$HOT_CIDR" -o "$ET_IFACE" -j MASQUERADE
|
||||
iptables -A ET_FWD -i "$HOT_IFACE" -o "$ET_IFACE" \
|
||||
-m state --state NEW,ESTABLISHED,RELATED -j ACCEPT
|
||||
iptables -A ET_FWD -i "$ET_IFACE" -o "$HOT_IFACE" \
|
||||
-m state --state ESTABLISHED,RELATED -j ACCEPT
|
||||
echo "[ET-NAT] Rules applied: $HOT_IFACE $HOT_CIDR ↔ $ET_IFACE" >> "$LOG_FILE"
|
||||
fi
|
||||
if [ -n "$USB_CIDR" ]; then
|
||||
iptables -t nat -A ET_NAT -s "$USB_CIDR" -o "$ET_IFACE" -j MASQUERADE
|
||||
iptables -A ET_FWD -i "$USB_IFACE" -o "$ET_IFACE" \
|
||||
-m state --state NEW,ESTABLISHED,RELATED -j ACCEPT
|
||||
iptables -A ET_FWD -i "$ET_IFACE" -o "$USB_IFACE" \
|
||||
-m state --state ESTABLISHED,RELATED -j ACCEPT
|
||||
echo "[ET-NAT] Rules applied: $USB_IFACE $USB_CIDR ↔ $ET_IFACE" >> "$LOG_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
flush_rules() {
|
||||
iptables -t nat -F ET_NAT 2>/dev/null
|
||||
iptables -F ET_FWD 2>/dev/null
|
||||
echo "[ET-NAT] Custom chains flushed." >> "$LOG_FILE"
|
||||
}
|
||||
|
||||
case "$ACTION" in
|
||||
add)
|
||||
set_nat_rules
|
||||
echo "[ET-NAT] Guard started." >> "$LOG_FILE"
|
||||
ip monitor link addr | while read -r _; do
|
||||
if [ -f "${MODDIR}/enable_IP_rule" ]; then
|
||||
flush_rules
|
||||
set_nat_rules
|
||||
fi
|
||||
done
|
||||
;;
|
||||
add_once)
|
||||
flush_rules
|
||||
set_nat_rules
|
||||
echo "[ET-NAT] One-time rules applied." >> "$LOG_FILE"
|
||||
;;
|
||||
del)
|
||||
flush_rules
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 [add|del]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -1,6 +1,6 @@
|
||||
id=easytier_magisk
|
||||
name=EasyTier_Magisk
|
||||
version=v2.4.1
|
||||
version=v2.4.5
|
||||
versionCode=1
|
||||
author=EasyTier
|
||||
description=easytier magisk module @EasyTier(https://github.com/EasyTier/EasyTier)
|
||||
|
||||
@@ -18,10 +18,7 @@ sed -i 's/$(description=)$[^"]*/\1[状态]关闭中/' "$MODDIR/module.prop"
|
||||
sleep 3s
|
||||
|
||||
"${MODDIR}/easytier_core.sh" &
|
||||
"${MODDIR}/hotspot_iprule.sh" add &
|
||||
|
||||
# 检查是否启用模块
|
||||
while [ ! -f ${MODDIR}/disable ]; do
|
||||
sleep 2
|
||||
done
|
||||
|
||||
pkill easytier-core
|
||||
# easytier_core.sh 和 hotspot_iprule.sh 都有内部循环做守护,
|
||||
# 所以这里不需要再做守护了
|
||||
|
||||
9
easytier-contrib/easytier-ohrs/.gitignore
vendored
Normal file
9
easytier-contrib/easytier-ohrs/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
dist/
|
||||
target/
|
||||
.DS_Store
|
||||
.idea/
|
||||
package/libs
|
||||
|
||||
*.har
|
||||
|
||||
Cargo.lock
|
||||
1083
easytier-contrib/easytier-ohrs/Cargo.lock
generated
1083
easytier-contrib/easytier-ohrs/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -9,8 +9,8 @@ crate-type=["cdylib"]
|
||||
[dependencies]
|
||||
ohos-hilog-binding = {version = "*", features = ["redirect"]}
|
||||
easytier = { git = "https://github.com/EasyTier/EasyTier.git" }
|
||||
napi-derive-ohos = "1.0.4"
|
||||
napi-ohos = { version = "1.0.4", default-features = false, features = [
|
||||
napi-derive-ohos = "1.1"
|
||||
napi-ohos = { version = "1.1", default-features = false, features = [
|
||||
"serde-json",
|
||||
"latin1",
|
||||
"chrono_date",
|
||||
@@ -33,7 +33,7 @@ tracing = "0.1.41"
|
||||
uuid = { version = "1.17.0", features = ["v4"] }
|
||||
|
||||
[build-dependencies]
|
||||
napi-build-ohos = "1.0.4"
|
||||
napi-build-ohos = "1.1"
|
||||
[profile.dev]
|
||||
panic = "unwind"
|
||||
debug = true
|
||||
|
||||
2
easytier-contrib/easytier-ohrs/package/CHANGELOG.md
Executable file
2
easytier-contrib/easytier-ohrs/package/CHANGELOG.md
Executable file
@@ -0,0 +1,2 @@
|
||||
# 0.0.1
|
||||
- init package
|
||||
165
easytier-contrib/easytier-ohrs/package/LICENSE
Executable file
165
easytier-contrib/easytier-ohrs/package/LICENSE
Executable file
@@ -0,0 +1,165 @@
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
|
||||
This version of the GNU Lesser General Public License incorporates
|
||||
the terms and conditions of version 3 of the GNU General Public
|
||||
License, supplemented by the additional permissions listed below.
|
||||
|
||||
0. Additional Definitions.
|
||||
|
||||
As used herein, "this License" refers to version 3 of the GNU Lesser
|
||||
General Public License, and the "GNU GPL" refers to version 3 of the GNU
|
||||
General Public License.
|
||||
|
||||
"The Library" refers to a covered work governed by this License,
|
||||
other than an Application or a Combined Work as defined below.
|
||||
|
||||
An "Application" is any work that makes use of an interface provided
|
||||
by the Library, but which is not otherwise based on the Library.
|
||||
Defining a subclass of a class defined by the Library is deemed a mode
|
||||
of using an interface provided by the Library.
|
||||
|
||||
A "Combined Work" is a work produced by combining or linking an
|
||||
Application with the Library. The particular version of the Library
|
||||
with which the Combined Work was made is also called the "Linked
|
||||
Version".
|
||||
|
||||
The "Minimal Corresponding Source" for a Combined Work means the
|
||||
Corresponding Source for the Combined Work, excluding any source code
|
||||
for portions of the Combined Work that, considered in isolation, are
|
||||
based on the Application, and not on the Linked Version.
|
||||
|
||||
The "Corresponding Application Code" for a Combined Work means the
|
||||
object code and/or source code for the Application, including any data
|
||||
and utility programs needed for reproducing the Combined Work from the
|
||||
Application, but excluding the System Libraries of the Combined Work.
|
||||
|
||||
1. Exception to Section 3 of the GNU GPL.
|
||||
|
||||
You may convey a covered work under sections 3 and 4 of this License
|
||||
without being bound by section 3 of the GNU GPL.
|
||||
|
||||
2. Conveying Modified Versions.
|
||||
|
||||
If you modify a copy of the Library, and, in your modifications, a
|
||||
facility refers to a function or data to be supplied by an Application
|
||||
that uses the facility (other than as an argument passed when the
|
||||
facility is invoked), then you may convey a copy of the modified
|
||||
version:
|
||||
|
||||
a) under this License, provided that you make a good faith effort to
|
||||
ensure that, in the event an Application does not supply the
|
||||
function or data, the facility still operates, and performs
|
||||
whatever part of its purpose remains meaningful, or
|
||||
|
||||
b) under the GNU GPL, with none of the additional permissions of
|
||||
this License applicable to that copy.
|
||||
|
||||
3. Object Code Incorporating Material from Library Header Files.
|
||||
|
||||
The object code form of an Application may incorporate material from
|
||||
a header file that is part of the Library. You may convey such object
|
||||
code under terms of your choice, provided that, if the incorporated
|
||||
material is not limited to numerical parameters, data structure
|
||||
layouts and accessors, or small macros, inline functions and templates
|
||||
(ten or fewer lines in length), you do both of the following:
|
||||
|
||||
a) Give prominent notice with each copy of the object code that the
|
||||
Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the object code with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
4. Combined Works.
|
||||
|
||||
You may convey a Combined Work under terms of your choice that,
|
||||
taken together, effectively do not restrict modification of the
|
||||
portions of the Library contained in the Combined Work and reverse
|
||||
engineering for debugging such modifications, if you also do each of
|
||||
the following:
|
||||
|
||||
a) Give prominent notice with each copy of the Combined Work that
|
||||
the Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the Combined Work with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
c) For a Combined Work that displays copyright notices during
|
||||
execution, include the copyright notice for the Library among
|
||||
these notices, as well as a reference directing the user to the
|
||||
copies of the GNU GPL and this license document.
|
||||
|
||||
d) Do one of the following:
|
||||
|
||||
0) Convey the Minimal Corresponding Source under the terms of this
|
||||
License, and the Corresponding Application Code in a form
|
||||
suitable for, and under terms that permit, the user to
|
||||
recombine or relink the Application with a modified version of
|
||||
the Linked Version to produce a modified Combined Work, in the
|
||||
manner specified by section 6 of the GNU GPL for conveying
|
||||
Corresponding Source.
|
||||
|
||||
1) Use a suitable shared library mechanism for linking with the
|
||||
Library. A suitable mechanism is one that (a) uses at run time
|
||||
a copy of the Library already present on the user's computer
|
||||
system, and (b) will operate properly with a modified version
|
||||
of the Library that is interface-compatible with the Linked
|
||||
Version.
|
||||
|
||||
e) Provide Installation Information, but only if you would otherwise
|
||||
be required to provide such information under section 6 of the
|
||||
GNU GPL, and only to the extent that such information is
|
||||
necessary to install and execute a modified version of the
|
||||
Combined Work produced by recombining or relinking the
|
||||
Application with a modified version of the Linked Version. (If
|
||||
you use option 4d0, the Installation Information must accompany
|
||||
the Minimal Corresponding Source and Corresponding Application
|
||||
Code. If you use option 4d1, you must provide the Installation
|
||||
Information in the manner specified by section 6 of the GNU GPL
|
||||
for conveying Corresponding Source.)
|
||||
|
||||
5. Combined Libraries.
|
||||
|
||||
You may place library facilities that are a work based on the
|
||||
Library side by side in a single library together with other library
|
||||
facilities that are not Applications and are not covered by this
|
||||
License, and convey such a combined library under terms of your
|
||||
choice, if you do both of the following:
|
||||
|
||||
a) Accompany the combined library with a copy of the same work based
|
||||
on the Library, uncombined with any other library facilities,
|
||||
conveyed under the terms of this License.
|
||||
|
||||
b) Give prominent notice with the combined library that part of it
|
||||
is a work based on the Library, and explaining where to find the
|
||||
accompanying uncombined form of the same work.
|
||||
|
||||
6. Revised Versions of the GNU Lesser General Public License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions
|
||||
of the GNU Lesser General Public License from time to time. Such new
|
||||
versions will be similar in spirit to the present version, but may
|
||||
differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Library as you received it specifies that a certain numbered version
|
||||
of the GNU Lesser General Public License "or any later version"
|
||||
applies to it, you have the option of following the terms and
|
||||
conditions either of that published version or of any later version
|
||||
published by the Free Software Foundation. If the Library as you
|
||||
received it does not specify a version number of the GNU Lesser
|
||||
General Public License, you may choose any version of the GNU Lesser
|
||||
General Public License ever published by the Free Software Foundation.
|
||||
|
||||
If the Library as you received it specifies that a proxy can decide
|
||||
whether future versions of the GNU Lesser General Public License shall
|
||||
apply, that proxy's public statement of acceptance of any version is
|
||||
permanent authorization for you to choose that version for the
|
||||
Library.
|
||||
21
easytier-contrib/easytier-ohrs/package/README.md
Executable file
21
easytier-contrib/easytier-ohrs/package/README.md
Executable file
@@ -0,0 +1,21 @@
|
||||
# `easytier-ohrs`
|
||||
|
||||
## Install
|
||||
|
||||
use `ohpm` to install package.
|
||||
|
||||
```shell
|
||||
ohpm install easytier-ohrs
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
```ts
|
||||
// todo
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
// todo
|
||||
```
|
||||
4
easytier-contrib/easytier-ohrs/package/index.ets
Executable file
4
easytier-contrib/easytier-ohrs/package/index.ets
Executable file
@@ -0,0 +1,4 @@
|
||||
import * as api from "libeasytier_ohrs.so";
|
||||
|
||||
export * from 'libeasytier_ohrs.so';
|
||||
export default api;
|
||||
10
easytier-contrib/easytier-ohrs/package/oh-package.json5
Executable file
10
easytier-contrib/easytier-ohrs/package/oh-package.json5
Executable file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"license": "LGPL-3.0",
|
||||
"author": "easytier",
|
||||
"name": "easytier-ohrs",
|
||||
"description": "",
|
||||
"main": "index.ets",
|
||||
"version": "0.0.1",
|
||||
"types": "libs/index.d.ts",
|
||||
"dependencies": {}
|
||||
}
|
||||
7
easytier-contrib/easytier-ohrs/package/src/main/module.json5
Executable file
7
easytier-contrib/easytier-ohrs/package/src/main/module.json5
Executable file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"module": {
|
||||
"name": "easytier-ohrs",
|
||||
"type": "har",
|
||||
"deviceTypes": ["default", "tablet", "2in1"]
|
||||
},
|
||||
}
|
||||
@@ -18,13 +18,9 @@ pub struct KeyValuePair {
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn set_tun_fd(
|
||||
inst_id: String,
|
||||
fd: i32,
|
||||
) -> bool {
|
||||
pub fn set_tun_fd(inst_id: String, fd: i32) -> bool {
|
||||
match Uuid::try_parse(&inst_id) {
|
||||
Ok(uuid) => {
|
||||
match INSTANCE_MANAGER.set_tun_fd(&uuid, fd) {
|
||||
Ok(uuid) => match INSTANCE_MANAGER.set_tun_fd(&uuid, fd) {
|
||||
Ok(_) => {
|
||||
hilog_debug!("[Rust] set tun fd {} to {}.", fd, inst_id);
|
||||
true
|
||||
@@ -33,8 +29,7 @@ pub fn set_tun_fd(
|
||||
hilog_error!("[Rust] cant set tun fd {} to {}. {}", fd, inst_id, e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
hilog_error!("[Rust] cant covert {} to uuid. {}", inst_id, e);
|
||||
false
|
||||
@@ -45,9 +40,7 @@ pub fn set_tun_fd(
|
||||
#[napi]
|
||||
pub fn parse_config(cfg_str: String) -> bool {
|
||||
match TomlConfigLoader::new_from_str(&cfg_str) {
|
||||
Ok(_) => {
|
||||
true
|
||||
}
|
||||
Ok(_) => true,
|
||||
Err(e) => {
|
||||
hilog_error!("[Rust] parse config failed {}", e);
|
||||
false
|
||||
@@ -99,7 +92,7 @@ pub fn stop_network_instance(inst_names: Vec<String>) {
|
||||
#[napi]
|
||||
pub fn collect_network_infos() -> Vec<KeyValuePair> {
|
||||
let mut result = Vec::new();
|
||||
match INSTANCE_MANAGER.collect_network_infos() {
|
||||
match INSTANCE_MANAGER.collect_network_infos_sync() {
|
||||
Ok(map) => {
|
||||
for (uuid, info) in map.iter() {
|
||||
// convert value to json string
|
||||
@@ -134,15 +127,10 @@ pub fn collect_running_network() -> Vec<String> {
|
||||
#[napi]
|
||||
pub fn is_running_network(inst_id: String) -> bool {
|
||||
match Uuid::try_parse(&inst_id) {
|
||||
Ok(uuid) => {
|
||||
INSTANCE_MANAGER
|
||||
.list_network_instance_ids()
|
||||
.contains(&uuid)
|
||||
}
|
||||
Ok(uuid) => INSTANCE_MANAGER.list_network_instance_ids().contains(&uuid),
|
||||
Err(e) => {
|
||||
hilog_error!("[Rust] cant covert {} to uuid. {}", inst_id, e);
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use napi_derive_ohos::napi;
|
||||
use ohos_hilog_binding::{
|
||||
LogOptions, hilog_debug, hilog_error, hilog_info, hilog_warn, set_global_options,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::panic;
|
||||
use napi_derive_ohos::napi;
|
||||
use ohos_hilog_binding::{hilog_debug, hilog_error, hilog_info, hilog_warn, set_global_options, LogOptions};
|
||||
use tracing::{Event, Subscriber};
|
||||
use tracing_core::Level;
|
||||
use tracing_subscriber::layer::{Context, Layer};
|
||||
@@ -20,10 +22,7 @@ pub fn init_panic_hook() {
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn hilog_global_options(
|
||||
domain: u32,
|
||||
tag: String,
|
||||
) {
|
||||
pub fn hilog_global_options(domain: u32, tag: String) {
|
||||
ohos_hilog_binding::forward_stdio_to_hilog();
|
||||
set_global_options(LogOptions {
|
||||
domain,
|
||||
@@ -34,11 +33,9 @@ pub fn hilog_global_options(
|
||||
#[napi]
|
||||
pub fn init_tracing_subscriber() {
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
CallbackLayer {
|
||||
.with(CallbackLayer {
|
||||
callback: Box::new(tracing_callback),
|
||||
}
|
||||
)
|
||||
})
|
||||
.init();
|
||||
}
|
||||
|
||||
@@ -93,6 +90,7 @@ impl<'a> tracing::field::Visit for FieldCollector<'a> {
|
||||
}
|
||||
|
||||
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
|
||||
self.0.insert(field.name().to_string(), format!("{:?}", value));
|
||||
self.0
|
||||
.insert(field.name().to_string(), format!("{:?}", value));
|
||||
}
|
||||
}
|
||||
17
easytier-contrib/easytier-uptime/.env
Normal file
17
easytier-contrib/easytier-uptime/.env
Normal 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
|
||||
17
easytier-contrib/easytier-uptime/.env.development
Normal file
17
easytier-contrib/easytier-uptime/.env.development
Normal 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
|
||||
29
easytier-contrib/easytier-uptime/.env.example
Normal file
29
easytier-contrib/easytier-uptime/.env.example
Normal 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
|
||||
21
easytier-contrib/easytier-uptime/.env.production
Normal file
21
easytier-contrib/easytier-uptime/.env.production
Normal 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
|
||||
3
easytier-contrib/easytier-uptime/.gitignore
vendored
Normal file
3
easytier-contrib/easytier-uptime/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
64
easytier-contrib/easytier-uptime/Cargo.toml
Normal file
64
easytier-contrib/easytier-uptime/Cargo.toml
Normal file
@@ -0,0 +1,64 @@
|
||||
[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"] }
|
||||
axum-extra = { version = "0.10", features = ["query"] }
|
||||
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"
|
||||
272
easytier-contrib/easytier-uptime/README.md
Normal file
272
easytier-contrib/easytier-uptime/README.md
Normal 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 或联系开发团队。
|
||||
24
easytier-contrib/easytier-uptime/frontend/.gitignore
vendored
Normal file
24
easytier-contrib/easytier-uptime/frontend/.gitignore
vendored
Normal 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?
|
||||
5
easytier-contrib/easytier-uptime/frontend/README.md
Normal file
5
easytier-contrib/easytier-uptime/frontend/README.md
Normal 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).
|
||||
13
easytier-contrib/easytier-uptime/frontend/index.html
Normal file
13
easytier-contrib/easytier-uptime/frontend/index.html
Normal 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>
|
||||
2557
easytier-contrib/easytier-uptime/frontend/package-lock.json
generated
Normal file
2557
easytier-contrib/easytier-uptime/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
easytier-contrib/easytier-uptime/frontend/package.json
Normal file
26
easytier-contrib/easytier-uptime/frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
340
easytier-contrib/easytier-uptime/frontend/src/App.vue
Normal file
340
easytier-contrib/easytier-uptime/frontend/src/App.vue
Normal file
@@ -0,0 +1,340 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } 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'
|
||||
}
|
||||
]
|
||||
|
||||
// 根据当前路由计算默认激活的菜单项
|
||||
const activeMenuIndex = computed(() => {
|
||||
const p = route.path
|
||||
if (p.startsWith('/submit')) return 'submit'
|
||||
return 'dashboard'
|
||||
})
|
||||
|
||||
// 处理菜单选择,避免返回 Promise 导致异步补丁问题
|
||||
const handleMenuSelect = (key) => {
|
||||
const item = menuItems.find((i) => i.name === key)
|
||||
if (item && item.path) {
|
||||
router.push(item.path)
|
||||
}
|
||||
}
|
||||
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="activeMenuIndex" mode="horizontal" class="nav-menu"
|
||||
@select="handleMenuSelect">
|
||||
<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>
|
||||
195
easytier-contrib/easytier-uptime/frontend/src/api/index.js
Normal file
195
easytier-contrib/easytier-uptime/frontend/src/api/index.js
Normal file
@@ -0,0 +1,195 @@
|
||||
import axios from 'axios'
|
||||
|
||||
// 创建axios实例
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
// 保证数组参数使用 repeated keys 风格序列化:tags=a&tags=b
|
||||
paramsSerializer: params => {
|
||||
const usp = new URLSearchParams()
|
||||
Object.entries(params || {}).forEach(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(v => usp.append(key, v))
|
||||
} else if (value !== undefined && value !== null && value !== '') {
|
||||
usp.append(key, value)
|
||||
}
|
||||
})
|
||||
return usp.toString()
|
||||
}
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
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 = {
|
||||
// 获取节点列表(支持传入 AbortController.signal 用于取消)
|
||||
async getNodes(params = {}, options = {}) {
|
||||
const response = await api.get('/api/nodes', { params, signal: options.signal })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取所有标签
|
||||
async getAllTags() {
|
||||
const response = await api.get('/api/tags')
|
||||
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
|
||||
},
|
||||
|
||||
// 兼容方法:获取所有节点(参数转换)
|
||||
async getAllNodes(params = {}) {
|
||||
const mapped = {
|
||||
page: params.page,
|
||||
per_page: params.page_size ?? params.per_page,
|
||||
is_approved: params.approved ?? params.is_approved,
|
||||
is_active: params.online ?? params.is_active,
|
||||
protocol: params.protocol,
|
||||
search: params.search,
|
||||
tag: params.tag
|
||||
}
|
||||
// 移除未定义的字段
|
||||
Object.keys(mapped).forEach(k => {
|
||||
if (mapped[k] === undefined || mapped[k] === null || mapped[k] === '') {
|
||||
delete mapped[k]
|
||||
}
|
||||
})
|
||||
// 直接复用现有接口
|
||||
const response = await api.get('/api/admin/nodes', { params: mapped })
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
export default api
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -0,0 +1,563 @@
|
||||
<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 v-if="props.showTags" label="标签" prop="tags">
|
||||
<el-select v-model="form.tags" multiple filterable allow-create default-first-option :multiple-limit="10"
|
||||
placeholder="输入后按回车添加,如:北京、联通、IPv6、高带宽">
|
||||
<el-option v-for="opt in (form.tags || [])" :key="opt" :label="opt" :value="opt" />
|
||||
</el-select>
|
||||
<div class="form-tip">用于分类与检索,建议 1-6 个标签,每个不超过 32 字符</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: '',
|
||||
tags: [],
|
||||
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
|
||||
},
|
||||
// 新增:是否显示标签管理
|
||||
showTags: {
|
||||
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'
|
||||
}
|
||||
],
|
||||
// 新增:标签规则(仅在显示标签管理时生效)
|
||||
tags: [
|
||||
{
|
||||
validator: (rule, value, callback) => {
|
||||
if (!props.showTags) {
|
||||
callback()
|
||||
return
|
||||
}
|
||||
if (!Array.isArray(form.tags)) {
|
||||
callback(new Error('标签格式错误'))
|
||||
return
|
||||
}
|
||||
if (form.tags.length > 10) {
|
||||
callback(new Error('最多添加 10 个标签'))
|
||||
return
|
||||
}
|
||||
for (const t of form.tags) {
|
||||
const s = (t || '').trim()
|
||||
if (s.length === 0) {
|
||||
callback(new Error('标签不能为空'))
|
||||
return
|
||||
}
|
||||
if (s.length > 32) {
|
||||
callback(new Error('每个标签不超过 32 字符'))
|
||||
return
|
||||
}
|
||||
}
|
||||
callback()
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 是否可以测试连接
|
||||
const canTest = computed(() => {
|
||||
return form.host && form.port && form.protocol && form.network_name && form.network_secret
|
||||
})
|
||||
|
||||
const buildDataFromForm = () => {
|
||||
const data = {
|
||||
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
|
||||
}
|
||||
// 仅在管理员编辑时附带标签
|
||||
if (props.showTags) {
|
||||
data.tags = Array.isArray(form.tags) ? form.tags : []
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// 测试连接
|
||||
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()
|
||||
}
|
||||
// 重置标签
|
||||
if (props.showTags) {
|
||||
form.tags = []
|
||||
}
|
||||
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>
|
||||
22
easytier-contrib/easytier-uptime/frontend/src/main.js
Normal file
22
easytier-contrib/easytier-uptime/frontend/src/main.js
Normal 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')
|
||||
@@ -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
|
||||
243
easytier-contrib/easytier-uptime/frontend/src/style.css
Normal file
243
easytier-contrib/easytier-uptime/frontend/src/style.css
Normal 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);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Deterministic tag color generator (pure frontend)
|
||||
// Same tag => same color; different tags => different colors
|
||||
|
||||
function stringHash(str) {
|
||||
const s = String(str)
|
||||
let hash = 5381
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
hash = (hash * 33) ^ s.charCodeAt(i)
|
||||
}
|
||||
return hash >>> 0 // ensure positive
|
||||
}
|
||||
|
||||
function hslToRgb(h, s, l) {
|
||||
// h,s,l in [0,1]
|
||||
let r, g, b
|
||||
|
||||
if (s === 0) {
|
||||
r = g = b = l // achromatic
|
||||
} else {
|
||||
const hue2rgb = (p, q, t) => {
|
||||
if (t < 0) t += 1
|
||||
if (t > 1) t -= 1
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t
|
||||
if (t < 1 / 2) return q
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6
|
||||
return p
|
||||
}
|
||||
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
|
||||
const p = 2 * l - q
|
||||
r = hue2rgb(p, q, h + 1 / 3)
|
||||
g = hue2rgb(p, q, h)
|
||||
b = hue2rgb(p, q, h - 1 / 3)
|
||||
}
|
||||
|
||||
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]
|
||||
}
|
||||
|
||||
function rgbToHex(r, g, b) {
|
||||
const toHex = (v) => v.toString(16).padStart(2, '0')
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
|
||||
}
|
||||
|
||||
export function getTagStyle(tag) {
|
||||
const hash = stringHash(tag)
|
||||
const hue = hash % 360 // 0-359
|
||||
const saturation = 65 // percentage
|
||||
const lightness = 47 // percentage
|
||||
|
||||
const rgb = hslToRgb(hue / 360, saturation / 100, lightness / 100)
|
||||
const hex = rgbToHex(rgb[0], rgb[1], rgb[2])
|
||||
|
||||
// Perceived brightness for text color selection
|
||||
const brightness = rgb[0] * 0.299 + rgb[1] * 0.587 + rgb[2] * 0.114
|
||||
const textColor = brightness > 160 ? '#1f1f1f' : '#ffffff'
|
||||
|
||||
return {
|
||||
backgroundColor: hex,
|
||||
borderColor: hex,
|
||||
color: textColor
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
<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="tags" label="标签" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<div class="tags-list">
|
||||
<el-tag v-for="(tag, idx) in row.tags" :key="tag + idx" size="small" class="tag-chip" :style="getTagStyle(tag)">
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
<span v-if="!row.tags || row.tags.length === 0" class="text-muted">无</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<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" :show-tags="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'
|
||||
import { getTagStyle } from '../utils/tagColor'
|
||||
|
||||
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: '',
|
||||
tags: []
|
||||
},
|
||||
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: {
|
||||
getTagStyle,
|
||||
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
|
||||
// 只取需要的字段,并复制 tags 数组以避免引用问题
|
||||
this.editForm = {
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
host: node.host,
|
||||
port: node.port,
|
||||
protocol: node.protocol,
|
||||
version: node.version,
|
||||
max_connections: node.max_connections,
|
||||
description: node.description || '',
|
||||
allow_relay: node.allow_relay,
|
||||
network_name: node.network_name,
|
||||
network_secret: node.network_secret,
|
||||
wechat: node.wechat,
|
||||
qq_number: node.qq_number,
|
||||
mail: node.mail,
|
||||
tags: Array.isArray(node.tags) ? [...node.tags] : []
|
||||
}
|
||||
this.editDialogVisible = true
|
||||
},
|
||||
async handleUpdateNode(formData) {
|
||||
try {
|
||||
this.updating = true
|
||||
// 确保提交包含 tags 字段(为空数组也传)
|
||||
const payload = {
|
||||
name: formData.name,
|
||||
host: formData.host,
|
||||
port: formData.port,
|
||||
protocol: formData.protocol,
|
||||
version: formData.version,
|
||||
max_connections: formData.max_connections,
|
||||
description: formData.description,
|
||||
allow_relay: formData.allow_relay,
|
||||
network_name: formData.network_name,
|
||||
network_secret: formData.network_secret,
|
||||
wechat: formData.wechat,
|
||||
qq_number: formData.qq_number,
|
||||
mail: formData.mail,
|
||||
tags: Array.isArray(formData.tags) ? formData.tags : []
|
||||
}
|
||||
await adminApi.updateNode(this.editingNodeId, payload)
|
||||
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;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
margin-right: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,718 @@
|
||||
<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="26">
|
||||
<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-select v-model="selectedTags" multiple collapse-tags collapse-tags-tooltip filterable clearable
|
||||
placeholder="按标签筛选(可多选)" @change="handleFilter">
|
||||
<el-option v-for="tag in allTags" :key="tag" :label="tag" :value="tag">
|
||||
<span class="tag-option" :style="getTagStyle(tag)">{{ tag }}</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</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 ref="nodesCardRef" class="nodes-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>
|
||||
节点列表
|
||||
<el-button type="text" :loading="loading" @click="refreshData" style="margin-left: 8px;">
|
||||
<el-icon>
|
||||
<Refresh />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</span>
|
||||
<el-tag :type="loading ? 'info' : 'success'">
|
||||
{{ loading ? '加载中...' : `共 ${pagination.total} 个节点` }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table ref="tableRef" :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 label="标签" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<div class="tags-list">
|
||||
<el-tag v-for="(tag, idx) in row.tags" :key="tag + idx" size="small" class="tag-chip"
|
||||
:style="getTagStyle(tag)" style="margin: 2px 6px 2px 0;">
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
<span v-if="!row.tags || row.tags.length === 0" class="text-muted">无</span>
|
||||
</div>
|
||||
</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-item label="标签" :span="2">
|
||||
<div class="tags-list">
|
||||
<el-tag v-for="(tag, idx) in selectedNode.tags" :key="tag + idx" size="small" class="tag-chip"
|
||||
style="margin: 2px 6px 2px 0;">
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
<span v-if="!selectedNode.tags || selectedNode.tags.length === 0" class="text-muted">无</span>
|
||||
</div>
|
||||
</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, watch, nextTick, onBeforeUnmount } 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'
|
||||
import { getTagStyle } from '../utils/tagColor'
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const nodes = ref([])
|
||||
const searchText = ref('')
|
||||
const statusFilter = ref('')
|
||||
const protocolFilter = ref('')
|
||||
const selectedTags = ref([])
|
||||
const allTags = ref([])
|
||||
const detailDialogVisible = ref(false)
|
||||
const selectedNode = ref(null)
|
||||
const healthStats = ref(null)
|
||||
const expandedRows = ref([])
|
||||
const apiUrl = ref(window.location.href)
|
||||
const tableRef = ref(null)
|
||||
const nodesCardRef = ref(null)
|
||||
|
||||
// 请求取消控制(避免重复请求覆盖)
|
||||
let fetchController = null
|
||||
|
||||
// 分页数据
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
per_page: 50,
|
||||
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 fetchTags = async () => {
|
||||
try {
|
||||
const resp = await nodeApi.getAllTags()
|
||||
if (resp.success && Array.isArray(resp.data)) {
|
||||
allTags.value = resp.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取标签列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if (selectedTags.value && selectedTags.value.length > 0) {
|
||||
params.tags = selectedTags.value
|
||||
}
|
||||
|
||||
// 取消上一请求,创建新的请求控制器
|
||||
if (fetchController) {
|
||||
try { fetchController.abort() } catch (_) { }
|
||||
}
|
||||
fetchController = new AbortController()
|
||||
|
||||
const response = await nodeApi.getNodes(params, { signal: fetchController.signal })
|
||||
if (response.success && response.data) {
|
||||
nodes.value = response.data.items
|
||||
pagination.total = response.data.total
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name === 'CanceledError' || error.name === 'AbortError') {
|
||||
// 被取消的旧请求,忽略
|
||||
return
|
||||
}
|
||||
console.error('获取节点列表失败:', error)
|
||||
ElMessage.error('获取节点列表失败')
|
||||
} finally {
|
||||
if (with_loading) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const refreshData = () => {
|
||||
pagination.page = 1
|
||||
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(() => {
|
||||
fetchTags()
|
||||
fetchNodes()
|
||||
|
||||
// 设置定时刷新
|
||||
setInterval(() => {
|
||||
fetchNodes(false)
|
||||
}, 30000) // 每30秒刷新一次
|
||||
})
|
||||
|
||||
// 智能滚动处理:纵向滚动时页面整体滚动,横向滚动时表格内部滚动
|
||||
let wheelHandler = null
|
||||
let wheelTargets = []
|
||||
|
||||
const detachWheelHandlers = () => {
|
||||
if (wheelTargets && wheelTargets.length) {
|
||||
wheelTargets.forEach((el) => {
|
||||
try { el.removeEventListener('wheel', wheelHandler, { capture: true }) } catch (_) { }
|
||||
})
|
||||
}
|
||||
wheelTargets = []
|
||||
}
|
||||
|
||||
const attachWheelHandler = () => {
|
||||
const tableEl = tableRef.value?.$el
|
||||
const body = tableEl ? tableEl.querySelector('.el-table__body-wrapper') : null
|
||||
if (!body) return
|
||||
|
||||
detachWheelHandlers()
|
||||
const wrap = body.querySelector('.el-scrollbar__wrap') || body
|
||||
|
||||
wheelHandler = (e) => {
|
||||
const deltaX = e.deltaX
|
||||
const deltaY = e.deltaY
|
||||
|
||||
// 如果是横向滚动(Shift + 滚轮 或 触摸板横向滑动)
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY) || e.shiftKey) {
|
||||
// 允许表格内部横向滚动,不阻止默认行为
|
||||
return
|
||||
}
|
||||
|
||||
// 如果是纵向滚动,阻止表格内部滚动,让页面整体滚动
|
||||
if (deltaY) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const scroller = document.scrollingElement || document.documentElement
|
||||
scroller.scrollTop += deltaY
|
||||
}
|
||||
}
|
||||
|
||||
body.addEventListener('wheel', wheelHandler, { passive: false, capture: true })
|
||||
wheelTargets.push(body)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(attachWheelHandler)
|
||||
})
|
||||
|
||||
watch(nodes, () => {
|
||||
nextTick(attachWheelHandler)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
detachWheelHandlers()
|
||||
})
|
||||
</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;
|
||||
}
|
||||
|
||||
.tag-option {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
:deep(.el-table__body-wrapper) {
|
||||
overflow-x: auto !important;
|
||||
overflow-y: hidden !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
:deep(.el-card__body) {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
:deep(.el-table__body-wrapper .el-scrollbar__wrap) {
|
||||
overflow-x: auto !important;
|
||||
overflow-y: hidden !important;
|
||||
height: auto !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
30
easytier-contrib/easytier-uptime/frontend/vite.config.js
Normal file
30
easytier-contrib/easytier-uptime/frontend/vite.config.js
Normal 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:11030',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/health': {
|
||||
target: 'http://localhost:11030',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
80
easytier-contrib/easytier-uptime/src/api/error.rs
Normal file
80
easytier-contrib/easytier-uptime/src/api/error.rs
Normal 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("; "))
|
||||
}
|
||||
}
|
||||
607
easytier-contrib/easytier-uptime/src/api/handlers.rs
Normal file
607
easytier-contrib/easytier-uptime/src/api/handlers.rs
Normal file
@@ -0,0 +1,607 @@
|
||||
use std::ops::{Div, Mul};
|
||||
|
||||
use axum::extract::{Path, 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 axum_extra::extract::Query;
|
||||
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)),
|
||||
);
|
||||
}
|
||||
|
||||
// 标签过滤(支持单标签与多标签 OR)
|
||||
let mut filtered_ids: Option<Vec<i32>> = None;
|
||||
if !filters.tags.is_empty() {
|
||||
let ids_any =
|
||||
NodeOperations::filter_node_ids_by_tags_any(&app_state.db, &filters.tags).await?;
|
||||
filtered_ids = match filtered_ids {
|
||||
Some(mut existing) => {
|
||||
// 合并去重
|
||||
existing.extend(ids_any);
|
||||
existing.sort();
|
||||
existing.dedup();
|
||||
Some(existing)
|
||||
}
|
||||
None => Some(ids_any),
|
||||
};
|
||||
}
|
||||
if let Some(ids) = filtered_ids {
|
||||
if ids.is_empty() {
|
||||
return Ok(Json(ApiResponse::success(PaginatedResponse {
|
||||
items: vec![],
|
||||
total: 0,
|
||||
page,
|
||||
per_page,
|
||||
total_pages: 0,
|
||||
})));
|
||||
}
|
||||
query = query.filter(entity::shared_nodes::Column::Id.is_in(ids));
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// 补充标签
|
||||
let ids: Vec<i32> = node_responses.iter().map(|n| n.id).collect();
|
||||
let tags_map = NodeOperations::get_nodes_tags_map(&app_state.db, &ids).await?;
|
||||
for n in &mut node_responses {
|
||||
n.tags = tags_map.get(&n.id).cloned().unwrap_or_default();
|
||||
}
|
||||
|
||||
// 为每个节点添加健康状态信息
|
||||
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| {
|
||||
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)))?;
|
||||
|
||||
let mut resp = NodeResponse::from(node);
|
||||
resp.tags = NodeOperations::get_node_tags(&app_state.db, resp.id).await?;
|
||||
|
||||
Ok(Json(ApiResponse::success(resp)))
|
||||
}
|
||||
|
||||
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(200);
|
||||
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)),
|
||||
);
|
||||
}
|
||||
|
||||
// 标签过滤(支持单标签与多标签 OR)
|
||||
let mut filtered_ids: Option<Vec<i32>> = None;
|
||||
if let Some(tag) = filters.tag {
|
||||
let ids = NodeOperations::filter_node_ids_by_tag(&app_state.db, &tag).await?;
|
||||
filtered_ids = Some(ids);
|
||||
}
|
||||
if let Some(tags) = filters.tags {
|
||||
if !tags.is_empty() {
|
||||
let ids_any = NodeOperations::filter_node_ids_by_tags_any(&app_state.db, &tags).await?;
|
||||
filtered_ids = match filtered_ids {
|
||||
Some(mut existing) => {
|
||||
existing.extend(ids_any);
|
||||
existing.sort();
|
||||
existing.dedup();
|
||||
Some(existing)
|
||||
}
|
||||
None => Some(ids_any),
|
||||
};
|
||||
}
|
||||
}
|
||||
if let Some(ids) = filtered_ids {
|
||||
if ids.is_empty() {
|
||||
return Ok(Json(ApiResponse::success(PaginatedResponse {
|
||||
items: vec![],
|
||||
total: 0,
|
||||
page,
|
||||
per_page,
|
||||
total_pages: 0,
|
||||
})));
|
||||
}
|
||||
query = query.filter(entity::shared_nodes::Column::Id.is_in(ids));
|
||||
}
|
||||
|
||||
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 mut node_responses: Vec<NodeResponse> = nodes.into_iter().map(NodeResponse::from).collect();
|
||||
|
||||
// 补充标签
|
||||
let ids: Vec<i32> = node_responses.iter().map(|n| n.id).collect();
|
||||
let tags_map = NodeOperations::get_nodes_tags_map(&app_state.db, &ids).await?;
|
||||
for n in &mut node_responses {
|
||||
n.tags = tags_map.get(&n.id).cloned().unwrap_or_default();
|
||||
}
|
||||
|
||||
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?;
|
||||
|
||||
let mut resp = NodeResponse::from(updated_node);
|
||||
resp.tags = NodeOperations::get_node_tags(&app_state.db, resp.id).await?;
|
||||
|
||||
Ok(Json(ApiResponse::success(resp)))
|
||||
}
|
||||
|
||||
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?;
|
||||
|
||||
// 更新标签
|
||||
if let Some(tags) = request.tags {
|
||||
NodeOperations::set_node_tags(&app_state.db, updated_node.id, tags).await?;
|
||||
}
|
||||
|
||||
let mut resp = NodeResponse::from(updated_node);
|
||||
resp.tags = NodeOperations::get_node_tags(&app_state.db, resp.id).await?;
|
||||
|
||||
Ok(Json(ApiResponse::success(resp)))
|
||||
}
|
||||
|
||||
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?;
|
||||
|
||||
let mut resp = NodeResponse::from(updated_node);
|
||||
resp.tags = NodeOperations::get_node_tags(&app_state.db, resp.id).await?;
|
||||
|
||||
Ok(Json(ApiResponse::success(resp)))
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
pub async fn get_all_tags(
|
||||
State(app_state): State<AppState>,
|
||||
) -> ApiResult<Json<ApiResponse<Vec<String>>>> {
|
||||
let tags = NodeOperations::get_all_tags(&app_state.db).await?;
|
||||
Ok(Json(ApiResponse::success(tags)))
|
||||
}
|
||||
8
easytier-contrib/easytier-uptime/src/api/mod.rs
Normal file
8
easytier-contrib/easytier-uptime/src/api/mod.rs
Normal 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::*;
|
||||
325
easytier-contrib/easytier-uptime/src/api/models.rs
Normal file
325
easytier-contrib/easytier-uptime/src/api/models.rs
Normal file
@@ -0,0 +1,325 @@
|
||||
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>,
|
||||
|
||||
// 标签字段(仅管理员可用)
|
||||
pub tags: Option<Vec<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>,
|
||||
pub tags: Vec<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)
|
||||
},
|
||||
tags: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<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>,
|
||||
pub tag: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
}
|
||||
65
easytier-contrib/easytier-uptime/src/api/routes.rs
Normal file
65
easytier-contrib/easytier-uptime/src/api/routes.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
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_all_tags, 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/tags", get(get_all_tags))
|
||||
.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
|
||||
}
|
||||
206
easytier-contrib/easytier-uptime/src/config.rs
Normal file
206
easytier-contrib/easytier-uptime/src/config.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
use std::env;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use easytier::common::config::{ConsoleLoggerConfig, FileLoggerConfig, LoggingConfig};
|
||||
|
||||
#[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 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 {
|
||||
file_logger: Some(FileLoggerConfig {
|
||||
level: Some(env::var("LOG_LEVEL").unwrap_or_else(|_| "info".to_string())),
|
||||
file: Some("easytier-uptime.log".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
console_logger: Some(ConsoleLoggerConfig {
|
||||
level: Some(env::var("LOG_LEVEL").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 {
|
||||
file_logger: Some(FileLoggerConfig {
|
||||
level: Some("info".to_string()),
|
||||
file: Some("easytier-uptime.log".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
console_logger: Some(ConsoleLoggerConfig {
|
||||
level: Some("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"
|
||||
}
|
||||
}
|
||||
360
easytier-contrib/easytier-uptime/src/db/cleanup.rs
Normal file
360
easytier-contrib/easytier-uptime/src/db/cleanup.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 {}
|
||||
7
easytier-contrib/easytier-uptime/src/db/entity/mod.rs
Normal file
7
easytier-contrib/easytier-uptime/src/db/entity/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
|
||||
|
||||
pub mod prelude;
|
||||
|
||||
pub mod health_records;
|
||||
pub mod node_tags;
|
||||
pub mod shared_nodes;
|
||||
32
easytier-contrib/easytier-uptime/src/db/entity/node_tags.rs
Normal file
32
easytier-contrib/easytier-uptime/src/db/entity/node_tags.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
//! `SeaORM` Entity for node tags
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "node_tags")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub node_id: i32,
|
||||
pub tag: String,
|
||||
pub created_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"
|
||||
)]
|
||||
SharedNodes,
|
||||
}
|
||||
|
||||
impl Related<super::shared_nodes::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::SharedNodes.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
|
||||
|
||||
pub use super::health_records::Entity as HealthRecords;
|
||||
pub use super::node_tags::Entity as NodeTags;
|
||||
pub use super::shared_nodes::Entity as SharedNodes;
|
||||
@@ -0,0 +1,53 @@
|
||||
//! `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,
|
||||
// add relation to node_tags
|
||||
#[sea_orm(has_many = "super::node_tags::Entity")]
|
||||
NodeTags,
|
||||
}
|
||||
|
||||
impl Related<super::health_records::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::HealthRecords.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::node_tags::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::NodeTags.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
351
easytier-contrib/easytier-uptime/src/db/mod.rs
Normal file
351
easytier-contrib/easytier-uptime/src/db/mod.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
466
easytier-contrib/easytier-uptime/src/db/operations.rs
Normal file
466
easytier-contrib/easytier-uptime/src/db/operations.rs
Normal file
@@ -0,0 +1,466 @@
|
||||
use crate::api::CreateNodeRequest;
|
||||
use crate::db::entity::*;
|
||||
use crate::db::Db;
|
||||
use crate::db::HealthStats;
|
||||
use crate::db::HealthStatus;
|
||||
use sea_orm::*;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// 节点管理操作
|
||||
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)
|
||||
}
|
||||
}
|
||||
impl NodeOperations {
|
||||
/// 获取节点的全部标签
|
||||
pub async fn get_node_tags(db: &Db, node_id: i32) -> Result<Vec<String>, DbErr> {
|
||||
let tags = node_tags::Entity::find()
|
||||
.filter(node_tags::Column::NodeId.eq(node_id))
|
||||
.all(db.orm_db())
|
||||
.await?;
|
||||
Ok(tags.into_iter().map(|m| m.tag).collect())
|
||||
}
|
||||
|
||||
/// 批量获取节点的标签映射
|
||||
pub async fn get_nodes_tags_map(
|
||||
db: &Db,
|
||||
node_ids: &[i32],
|
||||
) -> Result<HashMap<i32, Vec<String>>, DbErr> {
|
||||
if node_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
let tags = node_tags::Entity::find()
|
||||
.filter(node_tags::Column::NodeId.is_in(node_ids.to_vec()))
|
||||
.order_by_asc(node_tags::Column::NodeId)
|
||||
.all(db.orm_db())
|
||||
.await?;
|
||||
let mut map: HashMap<i32, Vec<String>> = HashMap::new();
|
||||
for t in tags {
|
||||
map.entry(t.node_id).or_default().push(t.tag);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// 使用标签过滤节点(返回节点ID)
|
||||
pub async fn filter_node_ids_by_tag(db: &Db, tag: &str) -> Result<Vec<i32>, DbErr> {
|
||||
let tagged = node_tags::Entity::find()
|
||||
.filter(node_tags::Column::Tag.eq(tag))
|
||||
.all(db.orm_db())
|
||||
.await?;
|
||||
Ok(tagged.into_iter().map(|m| m.node_id).collect())
|
||||
}
|
||||
|
||||
/// 设置节点标签(替换为给定集合)
|
||||
pub async fn set_node_tags(db: &Db, node_id: i32, tags: Vec<String>) -> Result<(), DbErr> {
|
||||
// 去重与清理空白
|
||||
let mut set: HashSet<String> = HashSet::new();
|
||||
for tag in tags.into_iter() {
|
||||
let trimmed = tag.trim();
|
||||
if !trimmed.is_empty() {
|
||||
set.insert(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 取出当前标签
|
||||
let existing = node_tags::Entity::find()
|
||||
.filter(node_tags::Column::NodeId.eq(node_id))
|
||||
.all(db.orm_db())
|
||||
.await?;
|
||||
|
||||
let existing_set: HashSet<String> = existing.iter().map(|m| m.tag.clone()).collect();
|
||||
|
||||
// 需要删除的
|
||||
let to_delete: Vec<i32> = existing
|
||||
.iter()
|
||||
.filter(|m| !set.contains(&m.tag))
|
||||
.map(|m| m.id)
|
||||
.collect();
|
||||
|
||||
// 需要新增的
|
||||
let to_insert: Vec<String> = set
|
||||
.into_iter()
|
||||
.filter(|t| !existing_set.contains(t))
|
||||
.collect();
|
||||
|
||||
// 执行删除
|
||||
if !to_delete.is_empty() {
|
||||
node_tags::Entity::delete_many()
|
||||
.filter(node_tags::Column::Id.is_in(to_delete))
|
||||
.exec(db.orm_db())
|
||||
.await?;
|
||||
}
|
||||
|
||||
// 执行新增
|
||||
for t in to_insert {
|
||||
let now = chrono::Utc::now().fixed_offset();
|
||||
let am = node_tags::ActiveModel {
|
||||
id: NotSet,
|
||||
node_id: Set(node_id),
|
||||
tag: Set(t),
|
||||
created_at: Set(now),
|
||||
};
|
||||
node_tags::Entity::insert(am).exec(db.orm_db()).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 新增:获取所有唯一标签(按字母排序)
|
||||
pub async fn get_all_tags(db: &Db) -> Result<Vec<String>, DbErr> {
|
||||
let rows = node_tags::Entity::find().all(db.orm_db()).await?;
|
||||
let mut set: HashSet<String> = HashSet::new();
|
||||
for r in rows {
|
||||
set.insert(r.tag);
|
||||
}
|
||||
let mut list: Vec<String> = set.into_iter().collect();
|
||||
list.sort();
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
// 新增:使用多标签(OR 语义)过滤节点,返回匹配的节点ID
|
||||
pub async fn filter_node_ids_by_tags_any(db: &Db, tags: &[String]) -> Result<Vec<i32>, DbErr> {
|
||||
if tags.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let tagged = node_tags::Entity::find()
|
||||
.filter(node_tags::Column::Tag.is_in(tags.to_vec()))
|
||||
.all(db.orm_db())
|
||||
.await?;
|
||||
let mut set: HashSet<i32> = HashSet::new();
|
||||
for m in tagged {
|
||||
set.insert(m.node_id);
|
||||
}
|
||||
Ok(set.into_iter().collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
660
easytier-contrib/easytier-uptime/src/health_checker.rs
Normal file
660
easytier-contrib/easytier-uptime/src/health_checker.rs
Normal 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::Web)
|
||||
.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).await 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(format!("inst id: {}, err: {}", inst_id, e)),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
160
easytier-contrib/easytier-uptime/src/health_checker_manager.rs
Normal file
160
easytier-contrib/easytier-uptime/src/health_checker_manager.rs
Normal 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, ¤t_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
|
||||
}
|
||||
}
|
||||
143
easytier-contrib/easytier-uptime/src/main.rs
Normal file
143
easytier-contrib/easytier-uptime/src/main.rs
Normal file
@@ -0,0 +1,143 @@
|
||||
#![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 easytier::utils::init_logger;
|
||||
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();
|
||||
|
||||
// 初始化日志
|
||||
let _ = init_logger(&config.logging, false);
|
||||
|
||||
// 解析命令行参数
|
||||
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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
use sea_orm_migration::{prelude::*, schema::*};
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum NodeTags {
|
||||
Table,
|
||||
Id,
|
||||
NodeId,
|
||||
Tag,
|
||||
CreatedAt,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum SharedNodes {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// 创建 node_tags 表
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(NodeTags::Table)
|
||||
.if_not_exists()
|
||||
.col(pk_auto(NodeTags::Id).not_null())
|
||||
.col(integer(NodeTags::NodeId).not_null())
|
||||
.col(string(NodeTags::Tag).not_null())
|
||||
.col(
|
||||
timestamp_with_time_zone(NodeTags::CreatedAt)
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_node_tags_node")
|
||||
.from(NodeTags::Table, NodeTags::NodeId)
|
||||
.to(SharedNodes::Table, SharedNodes::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.on_update(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 索引:NodeId
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_node_tags_node")
|
||||
.table(NodeTags::Table)
|
||||
.col(NodeTags::NodeId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 索引:Tag
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_node_tags_tag")
|
||||
.table(NodeTags::Table)
|
||||
.col(NodeTags::Tag)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 唯一索引:每个节点的标签唯一
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("uniq_node_tag_per_node")
|
||||
.table(NodeTags::Table)
|
||||
.col(NodeTags::NodeId)
|
||||
.col(NodeTags::Tag)
|
||||
.unique()
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// 先删除索引
|
||||
manager
|
||||
.drop_index(
|
||||
Index::drop()
|
||||
.name("idx_node_tags_node")
|
||||
.table(NodeTags::Table)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.drop_index(
|
||||
Index::drop()
|
||||
.name("idx_node_tags_tag")
|
||||
.table(NodeTags::Table)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.drop_index(
|
||||
Index::drop()
|
||||
.name("uniq_node_tag_per_node")
|
||||
.table(NodeTags::Table)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_table(Table::drop().table(NodeTags::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
16
easytier-contrib/easytier-uptime/src/migrator/mod.rs
Normal file
16
easytier-contrib/easytier-uptime/src/migrator/mod.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
mod m20250101_000001_create_tables;
|
||||
mod m20250101_000002_create_node_tags;
|
||||
|
||||
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),
|
||||
Box::new(m20250101_000002_create_node_tags::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
95
easytier-contrib/easytier-uptime/start-dev.sh
Executable file
95
easytier-contrib/easytier-uptime/start-dev.sh
Executable 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
|
||||
98
easytier-contrib/easytier-uptime/start-prod.sh
Executable file
98
easytier-contrib/easytier-uptime/start-prod.sh
Executable 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/"
|
||||
46
easytier-contrib/easytier-uptime/stop-prod.sh
Executable file
46
easytier-contrib/easytier-uptime/stop-prod.sh
Executable 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!"
|
||||
144
easytier-contrib/easytier-uptime/test-integration.sh
Executable file
144
easytier-contrib/easytier-uptime/test-integration.sh
Executable 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"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user