mirror of
				https://github.com/EasyTier/EasyTier.git
				synced 2025-10-31 20:12:53 +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: | | ||||
|   | ||||
							
								
								
									
										23
									
								
								.github/workflows/ohos.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										23
									
								
								.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,7 +248,9 @@ mod tests { | ||||
|             network = "test_network" | ||||
|         "#; | ||||
|         let cstr = std::ffi::CString::new(cfg_str).unwrap(); | ||||
|         assert_eq!(parse_config(cstr.as_ptr()), 0); | ||||
|         unsafe { | ||||
|             assert_eq!(parse_config(cstr.as_ptr()), 0); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
| @@ -243,6 +260,8 @@ mod tests { | ||||
|             network = "test_network" | ||||
|         "#; | ||||
|         let cstr = std::ffi::CString::new(cfg_str).unwrap(); | ||||
|         assert_eq!(run_network_instance(cstr.as_ptr()), 0); | ||||
|         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 进程未找到" | ||||
| else | ||||
|     # 结束进程 | ||||
|     kill $PID | ||||
|     echo "已结束 easytier-core 进程 (PID: $PID)" | ||||
|  | ||||
| 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 | ||||
|         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 '记得重启' | ||||
| 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} & | ||||
|             sleep 5s # 等待easytier-core启动完成 | ||||
|             update_module_description "已开启(不一定运行成功)" | ||||
|             # 如果 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 "主程序已开启(启动参数模式) | ${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 | ||||
|   | ||||
| @@ -1,3 +1,3 @@ | ||||
| fn main () { | ||||
| fn main() { | ||||
|     napi_build_ohos::setup(); | ||||
| } | ||||
| } | ||||
|   | ||||
							
								
								
									
										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,23 +18,18 @@ 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(_) => { | ||||
|                     hilog_debug!("[Rust] set tun fd {} to {}.", fd, inst_id); | ||||
|                     true | ||||
|                 } | ||||
|                 Err(e) => { | ||||
|                     hilog_error!("[Rust] cant set tun fd {} to {}. {}", fd, inst_id, e); | ||||
|                     false | ||||
|                 } | ||||
|         Ok(uuid) => match INSTANCE_MANAGER.set_tun_fd(&uuid, fd) { | ||||
|             Ok(_) => { | ||||
|                 hilog_debug!("[Rust] set tun fd {} to {}.", fd, inst_id); | ||||
|                 true | ||||
|             } | ||||
|         } | ||||
|             Err(e) => { | ||||
|                 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 | ||||
| @@ -64,8 +57,8 @@ pub fn run_network_instance(cfg_str: String) -> bool { | ||||
|             return false; | ||||
|         } | ||||
|     }; | ||||
|      | ||||
|     if INSTANCE_MANAGER.list_network_instance_ids().len() > 0 {  | ||||
|  | ||||
|     if INSTANCE_MANAGER.list_network_instance_ids().len() > 0 { | ||||
|         hilog_error!("[Rust] there is a running instance!"); | ||||
|         return 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,12 +22,9 @@ 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{ | ||||
|     set_global_options(LogOptions { | ||||
|         domain, | ||||
|         tag: Box::leak(tag.clone().into_boxed_str()), | ||||
|     }) | ||||
| @@ -34,11 +33,9 @@ pub fn hilog_global_options( | ||||
| #[napi] | ||||
| pub fn init_tracing_subscriber() { | ||||
|     tracing_subscriber::registry() | ||||
|         .with( | ||||
|             CallbackLayer { | ||||
|                 callback: Box::new(tracing_callback), | ||||
|             } | ||||
|         ) | ||||
|         .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!" | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user