Compare commits
61 Commits
v2.4.3
...
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 |
4
.github/workflows/core.yml
vendored
4
.github/workflows/core.yml
vendored
@@ -186,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.*$ || $TARGET =~ ^loongarch64.*$ ]]; then
|
||||
elif [[ $TARGET =~ ^riscv64.*$ || $TARGET =~ ^loongarch64.*$ || $TARGET =~ ^aarch64.*$ ]]; then
|
||||
CORE_FEATURES="--features=mimalloc"
|
||||
else
|
||||
CORE_FEATURES="--features=jemalloc"
|
||||
|
||||
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
@@ -11,7 +11,7 @@ on:
|
||||
image_tag:
|
||||
description: 'Tag for this image build'
|
||||
type: string
|
||||
default: 'v2.4.3'
|
||||
default: 'v2.4.5'
|
||||
required: true
|
||||
mark_latest:
|
||||
description: 'Mark this image as latest'
|
||||
|
||||
2
.github/workflows/gui.yml
vendored
2
.github/workflows/gui.yml
vendored
@@ -115,7 +115,7 @@ 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"
|
||||
|
||||
|
||||
4
.github/workflows/install_rust.sh
vendored
4
.github/workflows/install_rust.sh
vendored
@@ -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.
|
||||
|
||||
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.3'
|
||||
default: 'v2.4.5'
|
||||
required: true
|
||||
make_latest:
|
||||
description: 'Mark this release as latest'
|
||||
|
||||
245
Cargo.lock
generated
245
Cargo.lock
generated
@@ -129,6 +129,24 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
|
||||
|
||||
[[package]]
|
||||
name = "android_log-sys"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d"
|
||||
|
||||
[[package]]
|
||||
name = "android_logger"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c494134f746c14dc653a35a4ea5aca24ac368529da5370ecf41fe0341c35772f"
|
||||
dependencies = [
|
||||
"android_log-sys",
|
||||
"env_logger",
|
||||
"log",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
@@ -294,16 +312,6 @@ dependencies = [
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-event"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1222afd3d2bce3995035054046a279ae7aa154d70d0766cea050073f3fd7ddf"
|
||||
dependencies = [
|
||||
"loom 0.5.6",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-executor"
|
||||
version = "1.13.0"
|
||||
@@ -436,9 +444,9 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.81"
|
||||
version = "0.1.89"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e0c28dcc82d7c8ead5cb13beb15405b57b8546e93215673ff8ca0349a028107"
|
||||
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -645,6 +653,31 @@ dependencies = [
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-extra"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "45bf463831f5131b7d3c756525b305d40f1185b688565648a92e1392ca35713d"
|
||||
dependencies = [
|
||||
"axum 0.8.4",
|
||||
"axum-core 0.5.2",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"serde",
|
||||
"serde_html_form",
|
||||
"serde_path_to_error",
|
||||
"tower 0.5.2",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-login"
|
||||
version = "0.16.0"
|
||||
@@ -1240,9 +1273,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cidr"
|
||||
version = "0.2.3"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6bdf600c45bd958cf2945c445264471cca8b6c8e67bc87b71affd6d7e5682621"
|
||||
checksum = "bd1b64030216239a2e7c364b13cd96a2097ebf0dfe5025f2dedee14a23f2ab60"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
@@ -1921,16 +1954,6 @@ dependencies = [
|
||||
"syn 2.0.87",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "diatomic-waker"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28025fb55a9d815acf7b0877555f437254f373036eec6ed265116c7a5c0825e9"
|
||||
dependencies = [
|
||||
"loom 0.5.6",
|
||||
"waker-fn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -2090,7 +2113,7 @@ checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125"
|
||||
|
||||
[[package]]
|
||||
name = "easytier"
|
||||
version = "2.4.3"
|
||||
version = "2.4.5"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -2134,6 +2157,7 @@ dependencies = [
|
||||
"http_req",
|
||||
"humansize",
|
||||
"humantime-serde",
|
||||
"idna 1.0.3",
|
||||
"kcp-sys",
|
||||
"machine-uid",
|
||||
"maplit",
|
||||
@@ -2147,11 +2171,13 @@ dependencies = [
|
||||
"nix 0.29.0",
|
||||
"once_cell",
|
||||
"openssl",
|
||||
"ordered_hash_map",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"petgraph 0.8.1",
|
||||
"pin-project-lite",
|
||||
"pnet",
|
||||
"prefix-trie",
|
||||
"prost",
|
||||
"prost-build",
|
||||
"prost-reflect",
|
||||
@@ -2178,7 +2204,7 @@ dependencies = [
|
||||
"stun_codec",
|
||||
"sys-locale",
|
||||
"tabled",
|
||||
"tachyonix",
|
||||
"tempfile",
|
||||
"thiserror 1.0.63",
|
||||
"thunk-rs",
|
||||
"tikv-jemalloc-ctl",
|
||||
@@ -2195,7 +2221,6 @@ dependencies = [
|
||||
"toml 0.8.19",
|
||||
"tonic-build",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"tun-easytier",
|
||||
"url",
|
||||
@@ -2213,6 +2238,19 @@ dependencies = [
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "easytier-android-jni"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"easytier",
|
||||
"jni",
|
||||
"log",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "easytier-ffi"
|
||||
version = "0.1.0"
|
||||
@@ -2227,9 +2265,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "easytier-gui"
|
||||
version = "2.4.3"
|
||||
version = "2.4.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"dunce",
|
||||
@@ -2282,6 +2321,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"axum 0.8.4",
|
||||
"axum-extra",
|
||||
"chrono",
|
||||
"clap",
|
||||
"dashmap",
|
||||
@@ -2314,7 +2354,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "easytier-web"
|
||||
version = "2.4.3"
|
||||
version = "2.4.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -2517,6 +2557,16 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe"
|
||||
|
||||
[[package]]
|
||||
name = "env_logger"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580"
|
||||
dependencies = [
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.1"
|
||||
@@ -2595,9 +2645,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.1.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a"
|
||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
@@ -2970,19 +3020,6 @@ dependencies = [
|
||||
"x11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generator"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5cc16584ff22b460a382b7feec54b23d2908d858152e5739a120b949293bd74e"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"log",
|
||||
"rustversion",
|
||||
"windows 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generator"
|
||||
version = "0.8.4"
|
||||
@@ -3364,9 +3401,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "heapless"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad"
|
||||
checksum = "b1edcd5a338e64688fbdcb7531a846cfd3476a54784dcb918a0844682bc7ada5"
|
||||
dependencies = [
|
||||
"hash32",
|
||||
"stable_deref_trait",
|
||||
@@ -4224,7 +4261,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "kcp-sys"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/EasyTier/kcp-sys?rev=0f0a0558391ba391c089806c23f369651f6c9eeb#0f0a0558391ba391c089806c23f369651f6c9eeb"
|
||||
source = "git+https://github.com/EasyTier/kcp-sys?rev=71eff18c573a4a71bf99c7fabc6a8b9f211c84c1#71eff18c573a4a71bf99c7fabc6a8b9f211c84c1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"auto_impl",
|
||||
@@ -4442,19 +4479,6 @@ version = "0.4.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
|
||||
|
||||
[[package]]
|
||||
name = "loom"
|
||||
version = "0.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff50ecb28bb86013e935fb6683ab1f6d3a20016f123c76fd4c27470076ac30f5"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"generator 0.7.5",
|
||||
"scoped-tls",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "loom"
|
||||
version = "0.7.2"
|
||||
@@ -4462,7 +4486,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"generator 0.8.4",
|
||||
"generator",
|
||||
"scoped-tls",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
@@ -4705,7 +4729,7 @@ dependencies = [
|
||||
"crossbeam-channel",
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
"loom 0.7.2",
|
||||
"loom",
|
||||
"parking_lot",
|
||||
"portable-atomic",
|
||||
"rustc_version",
|
||||
@@ -5523,6 +5547,15 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered_hash_map"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6c699f8a30f345785be969deed7eee4c73a5de58c7faf61d6a3251ef798ff61"
|
||||
dependencies = [
|
||||
"hashbrown 0.15.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_info"
|
||||
version = "3.8.2"
|
||||
@@ -6161,6 +6194,7 @@ version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85cf4c7c25f1dd66c76b451e9041a8cfce26e4ca754934fa7aed8d5a59a01d20"
|
||||
dependencies = [
|
||||
"cidr",
|
||||
"ipnet",
|
||||
"num-traits",
|
||||
]
|
||||
@@ -7486,10 +7520,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.207"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5665e14a49a4ea1b91029ba7d3bca9f299e1f7cfa194388ccc20f14743e784f2"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
@@ -7505,10 +7540,19 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.207"
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6aea2634c86b0e8ef2cfdc0c340baede54ec27b1e46febd7f80dffb2aa44a00e"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -7526,6 +7570,19 @@ dependencies = [
|
||||
"syn 2.0.87",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_html_form"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d2de91cf02bbc07cde38891769ccd5d4f073d22a40683aa4bc7a95781aaa2c4"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"indexmap 2.7.1",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.125"
|
||||
@@ -7856,8 +7913,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "smoltcp"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dad095989c1533c1c266d9b1e8d70a1329dd3723c3edac6d03bbd67e7bf6f4bb"
|
||||
source = "git+https://github.com/smoltcp-rs/smoltcp.git?rev=0a926767a68bc88d5512afefa7529c5ecdade4ea#0a926767a68bc88d5512afefa7529c5ecdade4ea"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"byteorder",
|
||||
@@ -8372,20 +8428,6 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tachyonix"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1924ef47bc3b427ea2a0b55ba97d0e9116e9103483ecd75a43f47a66443527c5"
|
||||
dependencies = [
|
||||
"async-event",
|
||||
"crossbeam-utils",
|
||||
"diatomic-waker",
|
||||
"futures-core",
|
||||
"loom 0.5.6",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tagptr"
|
||||
version = "0.2.0"
|
||||
@@ -8803,15 +8845,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.12.0"
|
||||
version = "3.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04cbcdd0c794ebb0d4cf35e88edd2f7d2c4c3e9a5a6dab322839b321c6a87a64"
|
||||
checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"fastrand",
|
||||
"getrandom 0.3.2",
|
||||
"once_cell",
|
||||
"rustix 0.38.34",
|
||||
"windows-sys 0.59.0",
|
||||
"rustix 1.0.7",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9440,18 +9482,6 @@ dependencies = [
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-appender"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"thiserror 1.0.63",
|
||||
"time",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.28"
|
||||
@@ -9906,12 +9936,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "waker-fn"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
@@ -10357,15 +10381,6 @@ dependencies = [
|
||||
"windows-version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f"
|
||||
dependencies = [
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.52.0"
|
||||
|
||||
@@ -7,6 +7,7 @@ members = [
|
||||
"easytier-web",
|
||||
"easytier-contrib/easytier-ffi",
|
||||
"easytier-contrib/easytier-uptime",
|
||||
"easytier-contrib/easytier-android-jni",
|
||||
]
|
||||
default-members = ["easytier", "easytier-web"]
|
||||
exclude = [
|
||||
|
||||
@@ -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.3-70e69a38~ |
|
||||
| 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.4.3-70e69a38~ |
|
||||
| | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.4.3-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:
|
||||
|
||||
@@ -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.3-70e69a38~ |
|
||||
| 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.4.3-70e69a38~ |
|
||||
| | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.4.3-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~ |
|
||||
```
|
||||
|
||||
您可以测试节点之间的连通性:
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
@@ -202,7 +202,7 @@ pub unsafe 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));
|
||||
|
||||
@@ -44,11 +44,11 @@ while true; do
|
||||
|
||||
# 如果 config 目录下存在 command_args 文件,则读取其中的内容作为启动参数
|
||||
if [ -f "${MODDIR}/config/command_args" ]; then
|
||||
TZ=Asia/Shanghai ${EASYTIER} $(cat ${MODDIR}/config/command_args) > ${LOG_FILE} &
|
||||
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} > ${LOG_FILE} &
|
||||
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
|
||||
|
||||
@@ -22,7 +22,10 @@ get_tun_iface() {
|
||||
ip link | awk -F': ' '/ tun[[:alnum:]]+/ {print $2; exit}'
|
||||
}
|
||||
get_hot_iface() {
|
||||
ip link | awk -F': ' '/(^| )(swlan[[:alnum:]_]*|softap[[:alnum:]_]*|ap[[:alnum:]_]*)\:/ {print $2; exit}' | cut -d'@' -f1 | head -n1
|
||||
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}'
|
||||
@@ -33,10 +36,12 @@ 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" ] || return 1
|
||||
[ -n "$ET_IFACE" ] && { [ -n "$HOT_CIDR" ] || [ -n "$USB_CIDR" ]; } || return 1
|
||||
|
||||
# 创建自定义链(如不存在)
|
||||
iptables -t nat -N ET_NAT 2>/dev/null
|
||||
@@ -49,13 +54,22 @@ set_nat_rules() {
|
||||
iptables -I FORWARD 1 -j ET_FWD
|
||||
|
||||
# 添加规则
|
||||
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"
|
||||
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() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
id=easytier_magisk
|
||||
name=EasyTier_Magisk
|
||||
version=v2.4.3
|
||||
version=v2.4.5
|
||||
versionCode=1
|
||||
author=EasyTier
|
||||
description=easytier magisk module @EasyTier(https://github.com/EasyTier/EasyTier)
|
||||
|
||||
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
|
||||
@@ -15,6 +15,7 @@ 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"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { healthApi } from './api'
|
||||
import {
|
||||
@@ -70,6 +70,20 @@ const menuItems = [
|
||||
}
|
||||
]
|
||||
|
||||
// 根据当前路由计算默认激活的菜单项
|
||||
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()
|
||||
// 定期检查健康状态
|
||||
@@ -89,8 +103,8 @@ onMounted(() => {
|
||||
<h1 class="app-title">EasyTier Uptime</h1>
|
||||
</div>
|
||||
|
||||
<el-menu :default-active="route.name" mode="horizontal" class="nav-menu"
|
||||
@select="(key) => router.push(menuItems.find(item => item.name === key)?.path || '/')">
|
||||
<el-menu :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" />
|
||||
|
||||
@@ -6,6 +6,18 @@ const api = axios.create({
|
||||
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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -50,9 +62,15 @@ api.interceptors.response.use(
|
||||
|
||||
// 节点相关API
|
||||
export const nodeApi = {
|
||||
// 获取节点列表
|
||||
async getNodes(params = {}) {
|
||||
const response = await api.get('/api/nodes', { params })
|
||||
// 获取节点列表(支持传入 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
|
||||
},
|
||||
|
||||
@@ -149,6 +167,28 @@ export const adminApi = {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,15 @@
|
||||
<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">
|
||||
@@ -238,6 +247,7 @@ const props = defineProps({
|
||||
wechat: '',
|
||||
qq_number: '',
|
||||
mail: '',
|
||||
tags: [],
|
||||
agreed: false
|
||||
})
|
||||
},
|
||||
@@ -264,6 +274,11 @@ const props = defineProps({
|
||||
showCancel: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 新增:是否显示标签管理
|
||||
showTags: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -353,6 +368,38 @@ const rules = {
|
||||
},
|
||||
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'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -362,7 +409,7 @@ const canTest = computed(() => {
|
||||
})
|
||||
|
||||
const buildDataFromForm = () => {
|
||||
return {
|
||||
const data = {
|
||||
name: form.name || 'Test Node',
|
||||
host: form.host,
|
||||
port: form.port,
|
||||
@@ -376,6 +423,11 @@ const buildDataFromForm = () => {
|
||||
qq_number: form.qq_number || null,
|
||||
mail: form.mail || null
|
||||
}
|
||||
// 仅在管理员编辑时附带标签
|
||||
if (props.showTags) {
|
||||
data.tags = Array.isArray(form.tags) ? form.tags : []
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// 测试连接
|
||||
@@ -441,6 +493,10 @@ const resetFields = () => {
|
||||
if (formRef.value) {
|
||||
formRef.value.resetFields()
|
||||
}
|
||||
// 重置标签
|
||||
if (props.showTags) {
|
||||
form.tags = []
|
||||
}
|
||||
testResult.value = null
|
||||
emit('reset')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -196,6 +196,17 @@
|
||||
|
||||
<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) }}
|
||||
@@ -228,8 +239,8 @@
|
||||
<!-- 编辑节点对话框 -->
|
||||
<el-dialog v-model="editDialogVisible" title="编辑节点" width="800px" destroy-on-close>
|
||||
<NodeForm v-if="editDialogVisible" v-model="editForm" :submitting="updating" submit-text="更新节点" submit-icon="Edit"
|
||||
:show-connection-test="false" :show-agreement="false" :show-cancel="true" @submit="handleUpdateNode"
|
||||
@cancel="editDialogVisible = false" @reset="resetEditForm" />
|
||||
:show-connection-test="false" :show-agreement="false" :show-cancel="true" :show-tags="true"
|
||||
@submit="handleUpdateNode" @cancel="editDialogVisible = false" @reset="resetEditForm" />
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -240,6 +251,7 @@ 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',
|
||||
@@ -270,7 +282,8 @@ export default {
|
||||
protocol: 'tcp',
|
||||
version: '',
|
||||
max_connections: 100,
|
||||
description: ''
|
||||
description: '',
|
||||
tags: []
|
||||
},
|
||||
editingNodeId: null,
|
||||
updating: false
|
||||
@@ -302,6 +315,7 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getTagStyle,
|
||||
async loadNodes() {
|
||||
try {
|
||||
this.loading = true
|
||||
@@ -379,13 +393,47 @@ export default {
|
||||
},
|
||||
editNode(node) {
|
||||
this.editingNodeId = node.id
|
||||
this.editForm = node
|
||||
// 只取需要的字段,并复制 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
|
||||
await adminApi.updateNode(this.editingNodeId, formData)
|
||||
// 确保提交包含 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()
|
||||
@@ -576,4 +624,8 @@ export default {
|
||||
.text-secondary {
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
margin-right: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -56,7 +56,7 @@
|
||||
|
||||
<!-- 搜索和筛选 -->
|
||||
<el-card class="filter-card">
|
||||
<el-row :gutter="20">
|
||||
<el-row :gutter="26">
|
||||
<el-col :span="8">
|
||||
<el-input v-model="searchText" placeholder="搜索节点名称、主机地址或描述" prefix-icon="Search" clearable
|
||||
@input="handleSearch" />
|
||||
@@ -77,14 +77,16 @@
|
||||
<el-option label="WSS" value="wss" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<!-- 新增:标签多选筛选 -->
|
||||
<el-col :span="4">
|
||||
<el-button type="primary" @click="refreshData" :loading="loading">
|
||||
<el-icon>
|
||||
<Refresh />
|
||||
</el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
<el-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>
|
||||
@@ -97,17 +99,24 @@
|
||||
</el-card>
|
||||
|
||||
<!-- 节点列表 -->
|
||||
<el-card class="nodes-card">
|
||||
<el-card ref="nodesCardRef" class="nodes-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>节点列表</span>
|
||||
<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 :data="nodes" v-loading="loading" stripe style="width: 100%" row-key="id">
|
||||
<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 }">
|
||||
@@ -176,6 +185,18 @@
|
||||
<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 }">
|
||||
@@ -223,6 +244,16 @@
|
||||
<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>
|
||||
|
||||
<!-- 健康状态统计 -->
|
||||
@@ -261,7 +292,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ref, reactive, onMounted, computed, watch, nextTick, onBeforeUnmount } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { nodeApi } from '../api'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -276,23 +307,31 @@ import {
|
||||
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('true')
|
||||
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: 20,
|
||||
per_page: 50,
|
||||
total: 0
|
||||
})
|
||||
|
||||
@@ -309,6 +348,17 @@ const averageUptime = computed(() => {
|
||||
})
|
||||
|
||||
// 方法
|
||||
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) {
|
||||
@@ -328,13 +378,26 @@ const fetchNodes = async (with_loading = true) => {
|
||||
if (protocolFilter.value) {
|
||||
params.protocol = protocolFilter.value
|
||||
}
|
||||
if (selectedTags.value && selectedTags.value.length > 0) {
|
||||
params.tags = selectedTags.value
|
||||
}
|
||||
|
||||
const response = await nodeApi.getNodes(params)
|
||||
// 取消上一请求,创建新的请求控制器
|
||||
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 {
|
||||
@@ -345,6 +408,7 @@ const fetchNodes = async (with_loading = true) => {
|
||||
}
|
||||
|
||||
const refreshData = () => {
|
||||
pagination.page = 1
|
||||
fetchNodes()
|
||||
}
|
||||
|
||||
@@ -408,12 +472,69 @@ const copyAddress = (address) => {
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
fetchTags()
|
||||
fetchNodes()
|
||||
|
||||
// 设置定时刷新
|
||||
setInterval(() => {
|
||||
fetchNodes(false)
|
||||
}, 3000) // 每30秒刷新一次
|
||||
}, 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>
|
||||
|
||||
@@ -570,4 +691,28 @@ onMounted(() => {
|
||||
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>
|
||||
|
||||
@@ -18,11 +18,11 @@ export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
target: 'http://localhost:11030',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/health': {
|
||||
target: 'http://localhost:8080',
|
||||
target: 'http://localhost:11030',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::ops::{Div, Mul};
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::extract::{Path, State};
|
||||
use axum::Json;
|
||||
use sea_orm::{
|
||||
ColumnTrait, Condition, EntityTrait, IntoActiveModel, ModelTrait, Order, PaginatorTrait,
|
||||
@@ -16,6 +16,7 @@ use crate::api::{
|
||||
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)]
|
||||
@@ -60,6 +61,35 @@ pub async fn get_nodes(
|
||||
);
|
||||
}
|
||||
|
||||
// 标签过滤(支持单标签与多标签 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)
|
||||
@@ -71,6 +101,13 @@ pub async fn get_nodes(
|
||||
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
|
||||
@@ -99,7 +136,6 @@ pub async fn get_nodes(
|
||||
|
||||
// remove sensitive information
|
||||
node_responses.iter_mut().for_each(|node| {
|
||||
tracing::info!("node: {:?}", node);
|
||||
node.network_name = None;
|
||||
node.network_secret = None;
|
||||
|
||||
@@ -161,7 +197,10 @@ pub async fn get_node(
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Node with id {} not found", id)))?;
|
||||
|
||||
Ok(Json(ApiResponse::success(NodeResponse::from(node))))
|
||||
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(
|
||||
@@ -299,7 +338,7 @@ pub async fn admin_get_nodes(
|
||||
verify_admin_token(&headers)?;
|
||||
|
||||
let page = pagination.page.unwrap_or(1);
|
||||
let per_page = pagination.per_page.unwrap_or(20);
|
||||
let per_page = pagination.per_page.unwrap_or(200);
|
||||
let offset = (page - 1) * per_page;
|
||||
|
||||
let mut query = entity::shared_nodes::Entity::find();
|
||||
@@ -325,6 +364,39 @@ pub async fn admin_get_nodes(
|
||||
);
|
||||
}
|
||||
|
||||
// 标签过滤(支持单标签与多标签 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
|
||||
@@ -334,7 +406,14 @@ pub async fn admin_get_nodes(
|
||||
.all(app_state.db.orm_db())
|
||||
.await?;
|
||||
|
||||
let node_responses: Vec<NodeResponse> = nodes.into_iter().map(NodeResponse::from).collect();
|
||||
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;
|
||||
|
||||
@@ -366,7 +445,10 @@ pub async fn admin_approve_node(
|
||||
.exec(app_state.db.orm_db())
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::success(NodeResponse::from(updated_node))))
|
||||
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(
|
||||
@@ -432,7 +514,15 @@ pub async fn admin_update_node(
|
||||
.exec(app_state.db.orm_db())
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::success(NodeResponse::from(updated_node))))
|
||||
// 更新标签
|
||||
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(
|
||||
@@ -454,7 +544,10 @@ pub async fn admin_revoke_approval(
|
||||
.exec(app_state.db.orm_db())
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::success(NodeResponse::from(updated_node))))
|
||||
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(
|
||||
@@ -505,3 +598,10 @@ fn verify_admin_token(headers: &HeaderMap) -> ApiResult<()> {
|
||||
|
||||
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)))
|
||||
}
|
||||
|
||||
@@ -162,6 +162,9 @@ pub struct UpdateNodeRequest {
|
||||
|
||||
#[validate(email)]
|
||||
pub mail: Option<String>,
|
||||
|
||||
// 标签字段(仅管理员可用)
|
||||
pub tags: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
@@ -198,6 +201,7 @@ pub struct NodeResponse {
|
||||
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 {
|
||||
@@ -247,6 +251,7 @@ impl From<entity::shared_nodes::Model> for NodeResponse {
|
||||
} else {
|
||||
Some(node.mail)
|
||||
},
|
||||
tags: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -281,6 +286,8 @@ 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)]
|
||||
@@ -313,4 +320,6 @@ pub struct AdminNodeFilterParams {
|
||||
pub is_approved: Option<bool>,
|
||||
pub protocol: Option<String>,
|
||||
pub search: Option<String>,
|
||||
pub tag: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use tower_http::cors::CorsLayer;
|
||||
use super::handlers::AppState;
|
||||
use super::handlers::{
|
||||
admin_approve_node, admin_delete_node, admin_get_nodes, admin_login, admin_revoke_approval,
|
||||
admin_update_node, admin_verify_token, create_node, get_node, get_node_health,
|
||||
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};
|
||||
@@ -38,6 +38,7 @@ pub fn create_routes() -> Router<AppState> {
|
||||
.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))
|
||||
|
||||
@@ -2,6 +2,8 @@ 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,
|
||||
@@ -32,12 +34,6 @@ pub struct HealthCheckConfig {
|
||||
pub max_retries: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoggingConfig {
|
||||
pub level: String,
|
||||
pub rust_log: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CorsConfig {
|
||||
pub allowed_origins: Vec<String>,
|
||||
@@ -100,8 +96,14 @@ impl AppConfig {
|
||||
};
|
||||
|
||||
let logging_config = LoggingConfig {
|
||||
level: env::var("LOG_LEVEL").unwrap_or_else(|_| "info".to_string()),
|
||||
rust_log: env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()),
|
||||
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 {
|
||||
@@ -161,8 +163,14 @@ impl AppConfig {
|
||||
max_retries: 3,
|
||||
},
|
||||
logging: LoggingConfig {
|
||||
level: "info".to_string(),
|
||||
rust_log: "info".to_string(),
|
||||
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![
|
||||
|
||||
@@ -3,4 +3,5 @@
|
||||
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 {}
|
||||
@@ -1,4 +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;
|
||||
|
||||
@@ -33,6 +33,9 @@ pub struct Model {
|
||||
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 {
|
||||
@@ -41,4 +44,10 @@ impl Related<super::health_records::Entity> for Entity {
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::node_tags::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::NodeTags.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::db::Db;
|
||||
use crate::db::HealthStats;
|
||||
use crate::db::HealthStatus;
|
||||
use sea_orm::*;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// 节点管理操作
|
||||
pub struct NodeOperations;
|
||||
@@ -229,6 +230,128 @@ impl HealthOperations {
|
||||
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 {
|
||||
|
||||
@@ -436,7 +436,7 @@ impl HealthChecker {
|
||||
);
|
||||
|
||||
self.instance_mgr
|
||||
.run_network_instance(cfg.clone(), ConfigSource::FFI)
|
||||
.run_network_instance(cfg.clone(), ConfigSource::Web)
|
||||
.with_context(|| "failed to run network instance")?;
|
||||
self.inst_id_map.insert(node_id, cfg.get_id());
|
||||
|
||||
@@ -497,7 +497,7 @@ impl HealthChecker {
|
||||
instance_mgr: Arc<NetworkInstanceManager>,
|
||||
// return version, response time on healthy, conn_count
|
||||
) -> anyhow::Result<(String, u64, u32)> {
|
||||
let Some(instance) = instance_mgr.get_network_info(&inst_id) else {
|
||||
let Some(instance) = instance_mgr.get_network_info(&inst_id).await else {
|
||||
anyhow::bail!("healthy check node is not started");
|
||||
};
|
||||
|
||||
@@ -650,7 +650,7 @@ impl HealthChecker {
|
||||
node_id,
|
||||
HealthStatus::Unhealthy,
|
||||
None,
|
||||
Some(e.to_string()),
|
||||
Some(format!("inst id: {}, err: {}", inst_id, e)),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ 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;
|
||||
@@ -36,18 +37,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
let config = AppConfig::default();
|
||||
|
||||
// 初始化日志
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(match config.logging.level.as_str() {
|
||||
"debug" => tracing::Level::DEBUG,
|
||||
"info" => tracing::Level::INFO,
|
||||
"warn" => tracing::Level::WARN,
|
||||
"error" => tracing::Level::ERROR,
|
||||
_ => tracing::Level::INFO,
|
||||
})
|
||||
.with_target(false)
|
||||
.with_thread_ids(true)
|
||||
.with_env_filter(EnvFilter::new("easytier_uptime"))
|
||||
.init();
|
||||
let _ = init_logger(&config.logging, false);
|
||||
|
||||
// 解析命令行参数
|
||||
let args = Args::parse();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,12 +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)]
|
||||
vec![
|
||||
Box::new(m20250101_000001_create_tables::Migration),
|
||||
Box::new(m20250101_000002_create_node_tags::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "easytier-gui",
|
||||
"type": "module",
|
||||
"version": "2.4.3",
|
||||
"version": "2.4.5",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.12.1+sha512.e5a7e52a4183a02d5931057f7a0dbff9d5e9ce3161e33fa68ae392125b79282a8a8a470a51dfc8a0ed86221442eb2fb57019b0990ed24fab519bf0e1bc5ccfc4",
|
||||
"scripts": {
|
||||
@@ -13,18 +13,17 @@
|
||||
"lint:fix": "eslint . --ignore-pattern src-tauri --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@primevue/themes": "4.3.3",
|
||||
"@primeuix/themes": "^1.2.3",
|
||||
"@tauri-apps/plugin-autostart": "2.0.0",
|
||||
"@tauri-apps/plugin-clipboard-manager": "2.3.0",
|
||||
"@tauri-apps/plugin-os": "2.3.0",
|
||||
"@tauri-apps/plugin-process": "2.3.0",
|
||||
"@tauri-apps/plugin-shell": "2.3.0",
|
||||
"@vueuse/core": "^11.2.0",
|
||||
"aura": "link:@primevue\\themes\\aura",
|
||||
"easytier-frontend-lib": "workspace:*",
|
||||
"ip-num": "1.5.1",
|
||||
"pinia": "^2.2.4",
|
||||
"primevue": "4.3.3",
|
||||
"primevue": "^4.3.9",
|
||||
"tauri-plugin-vpnservice-api": "workspace:*",
|
||||
"vue": "^3.5.12",
|
||||
"vue-router": "^4.4.5"
|
||||
@@ -32,7 +31,7 @@
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^3.7.3",
|
||||
"@intlify/unplugin-vue-i18n": "^5.2.0",
|
||||
"@primevue/auto-import-resolver": "4.3.3",
|
||||
"@primevue/auto-import-resolver": "4.3.9",
|
||||
"@tauri-apps/api": "2.7.0",
|
||||
"@tauri-apps/cli": "2.7.1",
|
||||
"@types/default-gateway": "^7.2.2",
|
||||
|
||||
7220
easytier-gui/pnpm-lock.yaml
generated
7220
easytier-gui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "easytier-gui"
|
||||
version = "2.4.3"
|
||||
version = "2.4.5"
|
||||
description = "EasyTier GUI"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
@@ -52,6 +52,7 @@ tauri-plugin-vpnservice = { path = "../../tauri-plugin-vpnservice" }
|
||||
tauri-plugin-os = "2.3.0"
|
||||
tauri-plugin-autostart = "2.5.0"
|
||||
uuid = "1.17.0"
|
||||
async-trait = "0.1.89"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = { version = "0.52", features = ["Win32_Foundation", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
|
||||
|
||||
@@ -3,41 +3,67 @@
|
||||
|
||||
mod elevate;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use easytier::proto::api::manage::{
|
||||
CollectNetworkInfoResponse, ValidateConfigResponse, WebClientService,
|
||||
WebClientServiceClientFactory,
|
||||
};
|
||||
use easytier::rpc_service::remote_client::{
|
||||
GetNetworkMetasResponse, ListNetworkInstanceIdsJsonResp, ListNetworkProps, RemoteClientManager,
|
||||
Storage,
|
||||
};
|
||||
use easytier::{
|
||||
common::config::{ConfigLoader, FileLoggerConfig, LoggingConfigBuilder, TomlConfigLoader},
|
||||
instance_manager::NetworkInstanceManager,
|
||||
launcher::{ConfigSource, NetworkConfig, NetworkInstanceRunningInfo},
|
||||
launcher::NetworkConfig,
|
||||
rpc_service::ApiRpcServer,
|
||||
tunnel::ring::RingTunnelListener,
|
||||
utils::{self, NewFilterSender},
|
||||
};
|
||||
use std::ops::Deref;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use tauri::Manager as _;
|
||||
|
||||
pub const AUTOSTART_ARG: &str = "--autostart";
|
||||
use tauri::{AppHandle, Emitter, Manager as _};
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
|
||||
|
||||
static INSTANCE_MANAGER: once_cell::sync::Lazy<NetworkInstanceManager> =
|
||||
once_cell::sync::Lazy::new(NetworkInstanceManager::new);
|
||||
pub const AUTOSTART_ARG: &str = "--autostart";
|
||||
|
||||
static INSTANCE_MANAGER: once_cell::sync::Lazy<Arc<NetworkInstanceManager>> =
|
||||
once_cell::sync::Lazy::new(|| Arc::new(NetworkInstanceManager::new()));
|
||||
|
||||
static mut LOGGER_LEVEL_SENDER: once_cell::sync::Lazy<Option<NewFilterSender>> =
|
||||
once_cell::sync::Lazy::new(Default::default);
|
||||
|
||||
static RPC_RING_UUID: once_cell::sync::Lazy<uuid::Uuid> =
|
||||
once_cell::sync::Lazy::new(uuid::Uuid::new_v4);
|
||||
|
||||
static CLIENT_MANAGER: once_cell::sync::OnceCell<manager::GUIClientManager> =
|
||||
once_cell::sync::OnceCell::new();
|
||||
|
||||
#[tauri::command]
|
||||
fn easytier_version() -> Result<String, String> {
|
||||
Ok(easytier::VERSION.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn is_autostart() -> Result<bool, String> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
println!("{:?}", args);
|
||||
Ok(args.contains(&AUTOSTART_ARG.to_owned()))
|
||||
fn set_dock_visibility(app: tauri::AppHandle, visible: bool) -> Result<(), String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use tauri::ActivationPolicy;
|
||||
app.set_activation_policy(if visible {
|
||||
ActivationPolicy::Regular
|
||||
} else {
|
||||
ActivationPolicy::Accessory
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let _ = (app, visible);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
|
||||
#[tauri::command]
|
||||
fn parse_network_config(cfg: NetworkConfig) -> Result<String, String> {
|
||||
let toml = cfg.gen_config().map_err(|e| e.to_string())?;
|
||||
@@ -52,46 +78,48 @@ fn generate_network_config(toml_config: String) -> Result<NetworkConfig, String>
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn run_network_instance(cfg: NetworkConfig) -> Result<(), String> {
|
||||
async fn run_network_instance(app: AppHandle, cfg: NetworkConfig) -> Result<(), String> {
|
||||
let instance_id = cfg.instance_id().to_string();
|
||||
let cfg = cfg.gen_config().map_err(|e| e.to_string())?;
|
||||
INSTANCE_MANAGER
|
||||
.run_network_instance(cfg, ConfigSource::GUI)
|
||||
.map_err(|e| e.to_string())?;
|
||||
println!("instance {} started", instance_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn retain_network_instance(instance_ids: Vec<String>) -> Result<(), String> {
|
||||
let instance_ids = instance_ids
|
||||
.into_iter()
|
||||
.filter_map(|id| uuid::Uuid::parse_str(&id).ok())
|
||||
.collect();
|
||||
let retained = INSTANCE_MANAGER
|
||||
.retain_network_instance(instance_ids)
|
||||
.map_err(|e| e.to_string())?;
|
||||
println!("instance {:?} retained", retained);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn collect_network_infos() -> Result<BTreeMap<String, NetworkInstanceRunningInfo>, String> {
|
||||
let infos = INSTANCE_MANAGER
|
||||
.collect_network_infos()
|
||||
app.emit("pre_run_network_instance", cfg.instance_id())
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut ret = BTreeMap::new();
|
||||
for (uuid, info) in infos {
|
||||
ret.insert(uuid.to_string(), info);
|
||||
#[cfg(target_os = "android")]
|
||||
if cfg.no_tun() == false {
|
||||
CLIENT_MANAGER
|
||||
.get()
|
||||
.unwrap()
|
||||
.disable_instances_with_tun(&app)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(ret)
|
||||
CLIENT_MANAGER
|
||||
.get()
|
||||
.unwrap()
|
||||
.handle_run_network_instance(app.clone(), cfg)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
app.emit("post_run_network_instance", instance_id)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_os_hostname() -> Result<String, String> {
|
||||
Ok(gethostname::gethostname().to_string_lossy().to_string())
|
||||
async fn collect_network_info(
|
||||
app: AppHandle,
|
||||
instance_id: String,
|
||||
) -> Result<CollectNetworkInfoResponse, String> {
|
||||
let instance_id = instance_id
|
||||
.parse()
|
||||
.map_err(|e: uuid::Error| e.to_string())?;
|
||||
CLIENT_MANAGER
|
||||
.get()
|
||||
.unwrap()
|
||||
.handle_collect_network_info(app, Some(vec![instance_id]))
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -103,14 +131,138 @@ fn set_logging_level(level: String) -> Result<(), String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_tun_fd(instance_id: String, fd: i32) -> Result<(), String> {
|
||||
let uuid = uuid::Uuid::parse_str(&instance_id).map_err(|e| e.to_string())?;
|
||||
INSTANCE_MANAGER
|
||||
.set_tun_fd(&uuid, fd)
|
||||
fn set_tun_fd(fd: i32) -> Result<(), String> {
|
||||
if let Some(uuid) = CLIENT_MANAGER
|
||||
.get()
|
||||
.unwrap()
|
||||
.get_enabled_instances_with_tun_ids()
|
||||
.next()
|
||||
{
|
||||
INSTANCE_MANAGER
|
||||
.set_tun_fd(&uuid, fd)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn list_network_instance_ids(
|
||||
app: AppHandle,
|
||||
) -> Result<ListNetworkInstanceIdsJsonResp, String> {
|
||||
CLIENT_MANAGER
|
||||
.get()
|
||||
.unwrap()
|
||||
.handle_list_network_instance_ids(app)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn remove_network_instance(app: AppHandle, instance_id: String) -> Result<(), String> {
|
||||
let instance_id = instance_id
|
||||
.parse()
|
||||
.map_err(|e: uuid::Error| e.to_string())?;
|
||||
CLIENT_MANAGER
|
||||
.get()
|
||||
.unwrap()
|
||||
.handle_remove_network_instances(app.clone(), vec![instance_id])
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
CLIENT_MANAGER
|
||||
.get()
|
||||
.unwrap()
|
||||
.notify_vpn_stop_if_no_tun(&app)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn update_network_config_state(
|
||||
app: AppHandle,
|
||||
instance_id: String,
|
||||
disabled: bool,
|
||||
) -> Result<(), String> {
|
||||
let instance_id = instance_id
|
||||
.parse()
|
||||
.map_err(|e: uuid::Error| e.to_string())?;
|
||||
CLIENT_MANAGER
|
||||
.get()
|
||||
.unwrap()
|
||||
.handle_update_network_state(app.clone(), instance_id, disabled)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if disabled {
|
||||
CLIENT_MANAGER
|
||||
.get()
|
||||
.unwrap()
|
||||
.notify_vpn_stop_if_no_tun(&app)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_network_config(app: AppHandle, cfg: NetworkConfig) -> Result<(), String> {
|
||||
let instance_id = cfg
|
||||
.instance_id()
|
||||
.parse()
|
||||
.map_err(|e: uuid::Error| e.to_string())?;
|
||||
CLIENT_MANAGER
|
||||
.get()
|
||||
.unwrap()
|
||||
.handle_save_network_config(app, instance_id, cfg)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn validate_config(
|
||||
app: AppHandle,
|
||||
config: NetworkConfig,
|
||||
) -> Result<ValidateConfigResponse, String> {
|
||||
CLIENT_MANAGER
|
||||
.get()
|
||||
.unwrap()
|
||||
.handle_validate_config(app, config)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_config(app: AppHandle, instance_id: String) -> Result<NetworkConfig, String> {
|
||||
let cfg = CLIENT_MANAGER
|
||||
.get()
|
||||
.unwrap()
|
||||
.storage
|
||||
.get_network_config(app, &instance_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("Config not found for instance ID: {}", instance_id))?;
|
||||
Ok(cfg.1)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn load_configs(configs: Vec<NetworkConfig>, enabled_networks: Vec<String>) -> Result<(), String> {
|
||||
CLIENT_MANAGER
|
||||
.get()
|
||||
.unwrap()
|
||||
.storage
|
||||
.load_configs(configs, enabled_networks)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_network_metas(
|
||||
app: AppHandle,
|
||||
instance_ids: Vec<uuid::Uuid>,
|
||||
) -> Result<GetNetworkMetasResponse, String> {
|
||||
CLIENT_MANAGER
|
||||
.get()
|
||||
.unwrap()
|
||||
.handle_get_network_metas(app, instance_ids)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn toggle_window_visibility<R: tauri::Runtime>(app: &tauri::AppHandle<R>) {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
@@ -148,6 +300,266 @@ fn check_sudo() -> bool {
|
||||
is_elevated
|
||||
}
|
||||
|
||||
mod manager {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use dashmap::{DashMap, DashSet};
|
||||
use easytier::launcher::{ConfigSource, NetworkConfig};
|
||||
use easytier::proto::rpc_impl::bidirect::BidirectRpcManager;
|
||||
use easytier::proto::rpc_types::controller::BaseController;
|
||||
use easytier::rpc_service::remote_client::PersistentConfig;
|
||||
use easytier::tunnel::ring::RingTunnelConnector;
|
||||
use easytier::tunnel::TunnelConnector;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct GUIConfig(String, pub(crate) NetworkConfig);
|
||||
impl PersistentConfig<anyhow::Error> for GUIConfig {
|
||||
fn get_network_inst_id(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
fn get_network_config(&self) -> Result<NetworkConfig, anyhow::Error> {
|
||||
Ok(self.1.clone())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct GUIStorage {
|
||||
network_configs: DashMap<Uuid, GUIConfig>,
|
||||
enabled_networks: DashSet<Uuid>,
|
||||
}
|
||||
impl GUIStorage {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
network_configs: DashMap::new(),
|
||||
enabled_networks: DashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn load_configs(
|
||||
&self,
|
||||
configs: Vec<NetworkConfig>,
|
||||
enabled_networks: Vec<String>,
|
||||
) -> anyhow::Result<()> {
|
||||
self.network_configs.clear();
|
||||
for cfg in configs {
|
||||
let instance_id = cfg.instance_id();
|
||||
self.network_configs.insert(
|
||||
instance_id.parse()?,
|
||||
GUIConfig(instance_id.to_string(), cfg),
|
||||
);
|
||||
}
|
||||
|
||||
self.enabled_networks.clear();
|
||||
INSTANCE_MANAGER
|
||||
.filter_network_instance(|_, _| true)
|
||||
.into_iter()
|
||||
.for_each(|id| {
|
||||
self.enabled_networks.insert(id);
|
||||
});
|
||||
for id in enabled_networks {
|
||||
if let Ok(uuid) = id.parse() {
|
||||
if !self.enabled_networks.contains(&uuid) {
|
||||
let config = self
|
||||
.network_configs
|
||||
.get(&uuid)
|
||||
.map(|i| i.value().1.gen_config())
|
||||
.ok_or_else(|| anyhow::anyhow!("Config not found"))??;
|
||||
INSTANCE_MANAGER.run_network_instance(config, ConfigSource::GUI)?;
|
||||
self.enabled_networks.insert(uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save_configs(&self, app: &AppHandle) -> anyhow::Result<()> {
|
||||
let configs: Result<Vec<String>, _> = self
|
||||
.network_configs
|
||||
.iter()
|
||||
.map(|entry| serde_json::to_string(&entry.value().1))
|
||||
.collect();
|
||||
let payload = format!("[{}]", configs?.join(","));
|
||||
app.emit_str("save_configs", payload)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save_enabled_networks(&self, app: &AppHandle) -> anyhow::Result<()> {
|
||||
let payload: Vec<String> = self
|
||||
.enabled_networks
|
||||
.iter()
|
||||
.map(|entry| entry.key().to_string())
|
||||
.collect();
|
||||
app.emit("save_enabled_networks", payload)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save_config(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
inst_id: Uuid,
|
||||
cfg: NetworkConfig,
|
||||
) -> anyhow::Result<()> {
|
||||
let config = GUIConfig(inst_id.to_string(), cfg);
|
||||
self.network_configs.insert(inst_id, config);
|
||||
self.save_configs(app)
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl Storage<AppHandle, GUIConfig, anyhow::Error> for GUIStorage {
|
||||
async fn insert_or_update_user_network_config(
|
||||
&self,
|
||||
app: AppHandle,
|
||||
network_inst_id: Uuid,
|
||||
network_config: NetworkConfig,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
self.save_config(&app, network_inst_id, network_config)?;
|
||||
self.enabled_networks.insert(network_inst_id);
|
||||
self.save_enabled_networks(&app)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_network_configs(
|
||||
&self,
|
||||
app: AppHandle,
|
||||
network_inst_ids: &[Uuid],
|
||||
) -> Result<(), anyhow::Error> {
|
||||
for network_inst_id in network_inst_ids {
|
||||
self.network_configs.remove(network_inst_id);
|
||||
self.enabled_networks.remove(network_inst_id);
|
||||
}
|
||||
self.save_configs(&app)
|
||||
}
|
||||
|
||||
async fn update_network_config_state(
|
||||
&self,
|
||||
app: AppHandle,
|
||||
network_inst_id: Uuid,
|
||||
disabled: bool,
|
||||
) -> Result<GUIConfig, anyhow::Error> {
|
||||
if disabled {
|
||||
self.enabled_networks.remove(&network_inst_id);
|
||||
} else {
|
||||
self.enabled_networks.insert(network_inst_id);
|
||||
}
|
||||
self.save_enabled_networks(&app)?;
|
||||
let cfg = self
|
||||
.network_configs
|
||||
.get(&network_inst_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Config not found"))?;
|
||||
Ok(cfg.value().clone())
|
||||
}
|
||||
|
||||
async fn list_network_configs(
|
||||
&self,
|
||||
_: AppHandle,
|
||||
props: ListNetworkProps,
|
||||
) -> Result<Vec<GUIConfig>, anyhow::Error> {
|
||||
let mut ret = Vec::new();
|
||||
for entry in self.network_configs.iter() {
|
||||
let id: Uuid = entry.key().to_owned();
|
||||
match props {
|
||||
ListNetworkProps::All => {
|
||||
ret.push(entry.value().clone());
|
||||
}
|
||||
ListNetworkProps::EnabledOnly => {
|
||||
if self.enabled_networks.contains(&id) {
|
||||
ret.push(entry.value().clone());
|
||||
}
|
||||
}
|
||||
ListNetworkProps::DisabledOnly => {
|
||||
if !self.enabled_networks.contains(&id) {
|
||||
ret.push(entry.value().clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ret)
|
||||
}
|
||||
|
||||
async fn get_network_config(
|
||||
&self,
|
||||
_: AppHandle,
|
||||
network_inst_id: &str,
|
||||
) -> Result<Option<GUIConfig>, anyhow::Error> {
|
||||
let uuid = Uuid::parse_str(network_inst_id)?;
|
||||
Ok(self
|
||||
.network_configs
|
||||
.get(&uuid)
|
||||
.map(|entry| entry.value().clone()))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct GUIClientManager {
|
||||
pub(super) storage: GUIStorage,
|
||||
rpc_manager: BidirectRpcManager,
|
||||
}
|
||||
impl GUIClientManager {
|
||||
pub async fn new() -> Result<Self, anyhow::Error> {
|
||||
let mut connector = RingTunnelConnector::new(
|
||||
format!("ring://{}", RPC_RING_UUID.deref()).parse().unwrap(),
|
||||
);
|
||||
let tunnel = connector.connect().await?;
|
||||
let rpc_manager = BidirectRpcManager::new();
|
||||
rpc_manager.run_with_tunnel(tunnel);
|
||||
|
||||
Ok(Self {
|
||||
storage: GUIStorage::new(),
|
||||
rpc_manager,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_enabled_instances_with_tun_ids(&self) -> impl Iterator<Item = uuid::Uuid> + '_ {
|
||||
self.storage
|
||||
.network_configs
|
||||
.iter()
|
||||
.filter(|v| self.storage.enabled_networks.contains(v.key()))
|
||||
.filter(|v| !v.1.no_tun())
|
||||
.filter_map(|c| c.1.instance_id().parse::<uuid::Uuid>().ok())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub(super) async fn disable_instances_with_tun(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
) -> Result<(), easytier::rpc_service::remote_client::RemoteClientError<anyhow::Error>>
|
||||
{
|
||||
for inst_id in self.get_enabled_instances_with_tun_ids() {
|
||||
self.handle_update_network_state(app.clone(), inst_id, true)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn notify_vpn_stop_if_no_tun(&self, app: &AppHandle) -> Result<(), String> {
|
||||
let has_tun = self.get_enabled_instances_with_tun_ids().any(|_| true);
|
||||
if !has_tun {
|
||||
app.emit("vpn_service_stop", "")
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl RemoteClientManager<AppHandle, GUIConfig, anyhow::Error> for GUIClientManager {
|
||||
fn get_rpc_client(
|
||||
&self,
|
||||
_: AppHandle,
|
||||
) -> Option<Box<dyn WebClientService<Controller = BaseController> + Send>> {
|
||||
Some(
|
||||
self.rpc_manager
|
||||
.rpc_client()
|
||||
.scoped_client::<WebClientServiceClientFactory<BaseController>>(
|
||||
1,
|
||||
1,
|
||||
"".to_string(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn get_storage(&self) -> &impl Storage<AppHandle, GUIConfig, anyhow::Error> {
|
||||
&self.storage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
#[cfg(not(target_os = "android"))]
|
||||
@@ -158,6 +570,24 @@ pub fn run() {
|
||||
|
||||
utils::setup_panic_handler();
|
||||
|
||||
let _rpc_server_handle = tauri::async_runtime::spawn(async move {
|
||||
let rpc_server = ApiRpcServer::from_tunnel(
|
||||
RingTunnelListener::new(format!("ring://{}", RPC_RING_UUID.deref()).parse().unwrap()),
|
||||
INSTANCE_MANAGER.clone(),
|
||||
)
|
||||
.serve()
|
||||
.await
|
||||
.expect("Failed to start RPC server");
|
||||
|
||||
let _ = CLIENT_MANAGER.set(
|
||||
manager::GUIClientManager::new()
|
||||
.await
|
||||
.expect("Failed to create GUI client manager"),
|
||||
);
|
||||
|
||||
rpc_server
|
||||
});
|
||||
|
||||
let mut builder = tauri::Builder::default();
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
@@ -199,6 +629,8 @@ pub fn run() {
|
||||
dir: Some(log_dir.to_string_lossy().to_string()),
|
||||
level: None,
|
||||
file: None,
|
||||
size_mb: None,
|
||||
count: None,
|
||||
})
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -237,13 +669,19 @@ pub fn run() {
|
||||
parse_network_config,
|
||||
generate_network_config,
|
||||
run_network_instance,
|
||||
retain_network_instance,
|
||||
collect_network_infos,
|
||||
get_os_hostname,
|
||||
collect_network_info,
|
||||
set_logging_level,
|
||||
set_tun_fd,
|
||||
is_autostart,
|
||||
easytier_version
|
||||
easytier_version,
|
||||
set_dock_visibility,
|
||||
list_network_instance_ids,
|
||||
remove_network_instance,
|
||||
update_network_config_state,
|
||||
save_network_config,
|
||||
validate_config,
|
||||
get_config,
|
||||
load_configs,
|
||||
get_network_metas,
|
||||
])
|
||||
.on_window_event(|_win, event| match event {
|
||||
#[cfg(not(target_os = "android"))]
|
||||
|
||||
@@ -17,9 +17,13 @@
|
||||
"createUpdaterArtifacts": false
|
||||
},
|
||||
"productName": "easytier-gui",
|
||||
"version": "2.4.3",
|
||||
"version": "2.4.5",
|
||||
"identifier": "com.kkrainbow.easytier",
|
||||
"plugins": {},
|
||||
"plugins": {
|
||||
"shell": {
|
||||
"open": "^.+"
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
|
||||
68
easytier-gui/src/auto-imports.d.ts
vendored
68
easytier-gui/src/auto-imports.d.ts
vendored
@@ -9,36 +9,34 @@ declare global {
|
||||
const EffectScope: typeof import('vue')['EffectScope']
|
||||
const MenuItemExit: typeof import('./composables/tray')['MenuItemExit']
|
||||
const MenuItemShow: typeof import('./composables/tray')['MenuItemShow']
|
||||
const ReinitTray: typeof import('./composables/tray')['ReinitTray']
|
||||
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
|
||||
const collectNetworkInfos: typeof import('./composables/network')['collectNetworkInfos']
|
||||
const collectNetworkInfo: typeof import('./composables/backend')['collectNetworkInfo']
|
||||
const computed: typeof import('vue')['computed']
|
||||
const createApp: typeof import('vue')['createApp']
|
||||
const createPinia: typeof import('pinia')['createPinia']
|
||||
const customRef: typeof import('vue')['customRef']
|
||||
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
|
||||
const defineComponent: typeof import('vue')['defineComponent']
|
||||
const definePage: typeof import('unplugin-vue-router/runtime')['definePage']
|
||||
const defineStore: typeof import('pinia')['defineStore']
|
||||
const deleteNetworkInstance: typeof import('./composables/backend')['deleteNetworkInstance']
|
||||
const effectScope: typeof import('vue')['effectScope']
|
||||
const event2human: typeof import('./composables/utils')['event2human']
|
||||
const generateMenuItem: typeof import('./composables/tray')['generateMenuItem']
|
||||
const generateNetworkConfig: typeof import('./composables/network')['generateNetworkConfig']
|
||||
const generateNetworkConfig: typeof import('./composables/backend')['generateNetworkConfig']
|
||||
const getActivePinia: typeof import('pinia')['getActivePinia']
|
||||
const getConfig: typeof import('./composables/backend')['getConfig']
|
||||
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
||||
const getCurrentScope: typeof import('vue')['getCurrentScope']
|
||||
const getEasytierVersion: typeof import('./composables/network')['getEasytierVersion']
|
||||
const getOsHostname: typeof import('./composables/network')['getOsHostname']
|
||||
const getEasytierVersion: typeof import('./composables/backend')['getEasytierVersion']
|
||||
const getNetworkMetas: typeof import('./composables/backend')['getNetworkMetas']
|
||||
const h: typeof import('vue')['h']
|
||||
const initMobileService: typeof import('./composables/mobile_vpn')['initMobileService']
|
||||
const initMobileVpnService: typeof import('./composables/mobile_vpn')['initMobileVpnService']
|
||||
const inject: typeof import('vue')['inject']
|
||||
const isAutostart: typeof import('./composables/network')['isAutostart']
|
||||
const isProxy: typeof import('vue')['isProxy']
|
||||
const isReactive: typeof import('vue')['isReactive']
|
||||
const isReadonly: typeof import('vue')['isReadonly']
|
||||
const isRef: typeof import('vue')['isRef']
|
||||
const loadRunningInstanceIdsFromLocalStorage: typeof import('./stores/network')['loadRunningInstanceIdsFromLocalStorage']
|
||||
const listNetworkInstanceIds: typeof import('./composables/backend')['listNetworkInstanceIds']
|
||||
const listenGlobalEvents: typeof import('./composables/event')['listenGlobalEvents']
|
||||
const mapActions: typeof import('pinia')['mapActions']
|
||||
const mapGetters: typeof import('pinia')['mapGetters']
|
||||
const mapState: typeof import('pinia')['mapState']
|
||||
@@ -46,8 +44,6 @@ declare global {
|
||||
const mapWritableState: typeof import('pinia')['mapWritableState']
|
||||
const markRaw: typeof import('vue')['markRaw']
|
||||
const nextTick: typeof import('vue')['nextTick']
|
||||
const num2ipv4: typeof import('./composables/utils')['num2ipv4']
|
||||
const num2ipv6: typeof import('./composables/utils')['num2ipv6']
|
||||
const onActivated: typeof import('vue')['onActivated']
|
||||
const onBeforeMount: typeof import('vue')['onBeforeMount']
|
||||
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
|
||||
@@ -57,6 +53,7 @@ declare global {
|
||||
const onDeactivated: typeof import('vue')['onDeactivated']
|
||||
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
|
||||
const onMounted: typeof import('vue')['onMounted']
|
||||
const onNetworkInstanceChange: typeof import('./composables/mobile_vpn')['onNetworkInstanceChange']
|
||||
const onRenderTracked: typeof import('vue')['onRenderTracked']
|
||||
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
|
||||
const onScopeDispose: typeof import('vue')['onScopeDispose']
|
||||
@@ -64,34 +61,34 @@ declare global {
|
||||
const onUnmounted: typeof import('vue')['onUnmounted']
|
||||
const onUpdated: typeof import('vue')['onUpdated']
|
||||
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
|
||||
const parseNetworkConfig: typeof import('./composables/network')['parseNetworkConfig']
|
||||
const parseNetworkConfig: typeof import('./composables/backend')['parseNetworkConfig']
|
||||
const prepareVpnService: typeof import('./composables/mobile_vpn')['prepareVpnService']
|
||||
const provide: typeof import('vue')['provide']
|
||||
const reactive: typeof import('vue')['reactive']
|
||||
const readonly: typeof import('vue')['readonly']
|
||||
const ref: typeof import('vue')['ref']
|
||||
const resolveComponent: typeof import('vue')['resolveComponent']
|
||||
const retainNetworkInstance: typeof import('./composables/network')['retainNetworkInstance']
|
||||
const runNetworkInstance: typeof import('./composables/network')['runNetworkInstance']
|
||||
const runNetworkInstance: typeof import('./composables/backend')['runNetworkInstance']
|
||||
const saveNetworkConfig: typeof import('./composables/backend')['saveNetworkConfig']
|
||||
const sendConfigs: typeof import('./composables/backend')['sendConfigs']
|
||||
const setActivePinia: typeof import('pinia')['setActivePinia']
|
||||
const setAutoLaunchStatus: typeof import('./composables/network')['setAutoLaunchStatus']
|
||||
const setLoggingLevel: typeof import('./composables/network')['setLoggingLevel']
|
||||
const setLoggingLevel: typeof import('./composables/backend')['setLoggingLevel']
|
||||
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
|
||||
const setTrayMenu: typeof import('./composables/tray')['setTrayMenu']
|
||||
const setTrayRunState: typeof import('./composables/tray')['setTrayRunState']
|
||||
const setTrayTooltip: typeof import('./composables/tray')['setTrayTooltip']
|
||||
const setTunFd: typeof import('./composables/network')['setTunFd']
|
||||
const setTunFd: typeof import('./composables/backend')['setTunFd']
|
||||
const shallowReactive: typeof import('vue')['shallowReactive']
|
||||
const shallowReadonly: typeof import('vue')['shallowReadonly']
|
||||
const shallowRef: typeof import('vue')['shallowRef']
|
||||
const storeToRefs: typeof import('pinia')['storeToRefs']
|
||||
const timeAgoCn: typeof import('./composables/utils')['timeAgoCn']
|
||||
const toRaw: typeof import('vue')['toRaw']
|
||||
const toRef: typeof import('vue')['toRef']
|
||||
const toRefs: typeof import('vue')['toRefs']
|
||||
const toValue: typeof import('vue')['toValue']
|
||||
const triggerRef: typeof import('vue')['triggerRef']
|
||||
const unref: typeof import('vue')['unref']
|
||||
const updateNetworkConfigState: typeof import('./composables/backend')['updateNetworkConfigState']
|
||||
const useAttrs: typeof import('vue')['useAttrs']
|
||||
const useCssModule: typeof import('vue')['useCssModule']
|
||||
const useCssVars: typeof import('vue')['useCssVars']
|
||||
@@ -99,12 +96,12 @@ declare global {
|
||||
const useId: typeof import('vue')['useId']
|
||||
const useLink: typeof import('vue-router/auto')['useLink']
|
||||
const useModel: typeof import('vue')['useModel']
|
||||
const useNetworkStore: typeof import('./stores/network')['useNetworkStore']
|
||||
const useRoute: typeof import('vue-router')['useRoute']
|
||||
const useRouter: typeof import('vue-router')['useRouter']
|
||||
const useSlots: typeof import('vue')['useSlots']
|
||||
const useTemplateRef: typeof import('vue')['useTemplateRef']
|
||||
const useTray: typeof import('./composables/tray')['useTray']
|
||||
const validateConfig: typeof import('./composables/backend')['validateConfig']
|
||||
const watch: typeof import('vue')['watch']
|
||||
const watchEffect: typeof import('vue')['watchEffect']
|
||||
const watchPostEffect: typeof import('vue')['watchPostEffect']
|
||||
@@ -116,6 +113,7 @@ declare global {
|
||||
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
}
|
||||
|
||||
// for vue template auto import
|
||||
import { UnwrapRef } from 'vue'
|
||||
declare module 'vue' {
|
||||
@@ -125,7 +123,7 @@ declare module 'vue' {
|
||||
readonly MenuItemExit: UnwrapRef<typeof import('./composables/tray')['MenuItemExit']>
|
||||
readonly MenuItemShow: UnwrapRef<typeof import('./composables/tray')['MenuItemShow']>
|
||||
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
|
||||
readonly collectNetworkInfos: UnwrapRef<typeof import('./composables/network')['collectNetworkInfos']>
|
||||
readonly collectNetworkInfo: UnwrapRef<typeof import('./composables/backend')['collectNetworkInfo']>
|
||||
readonly computed: UnwrapRef<typeof import('vue')['computed']>
|
||||
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
|
||||
readonly createPinia: UnwrapRef<typeof import('pinia')['createPinia']>
|
||||
@@ -133,22 +131,25 @@ declare module 'vue' {
|
||||
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
|
||||
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
|
||||
readonly defineStore: UnwrapRef<typeof import('pinia')['defineStore']>
|
||||
readonly deleteNetworkInstance: UnwrapRef<typeof import('./composables/backend')['deleteNetworkInstance']>
|
||||
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
|
||||
readonly generateMenuItem: UnwrapRef<typeof import('./composables/tray')['generateMenuItem']>
|
||||
readonly generateNetworkConfig: UnwrapRef<typeof import('./composables/network')['generateNetworkConfig']>
|
||||
readonly generateNetworkConfig: UnwrapRef<typeof import('./composables/backend')['generateNetworkConfig']>
|
||||
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
|
||||
readonly getConfig: UnwrapRef<typeof import('./composables/backend')['getConfig']>
|
||||
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
|
||||
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
|
||||
readonly getEasytierVersion: UnwrapRef<typeof import('./composables/network')['getEasytierVersion']>
|
||||
readonly getOsHostname: UnwrapRef<typeof import('./composables/network')['getOsHostname']>
|
||||
readonly getEasytierVersion: UnwrapRef<typeof import('./composables/backend')['getEasytierVersion']>
|
||||
readonly getNetworkMetas: UnwrapRef<typeof import('./composables/backend')['getNetworkMetas']>
|
||||
readonly h: UnwrapRef<typeof import('vue')['h']>
|
||||
readonly initMobileVpnService: UnwrapRef<typeof import('./composables/mobile_vpn')['initMobileVpnService']>
|
||||
readonly inject: UnwrapRef<typeof import('vue')['inject']>
|
||||
readonly isAutostart: UnwrapRef<typeof import('./composables/network')['isAutostart']>
|
||||
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
|
||||
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
|
||||
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
|
||||
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
|
||||
readonly listNetworkInstanceIds: UnwrapRef<typeof import('./composables/backend')['listNetworkInstanceIds']>
|
||||
readonly listenGlobalEvents: UnwrapRef<typeof import('./composables/event')['listenGlobalEvents']>
|
||||
readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']>
|
||||
readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']>
|
||||
readonly mapState: UnwrapRef<typeof import('pinia')['mapState']>
|
||||
@@ -165,6 +166,7 @@ declare module 'vue' {
|
||||
readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']>
|
||||
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
|
||||
readonly onMounted: UnwrapRef<typeof import('vue')['onMounted']>
|
||||
readonly onNetworkInstanceChange: UnwrapRef<typeof import('./composables/mobile_vpn')['onNetworkInstanceChange']>
|
||||
readonly onRenderTracked: UnwrapRef<typeof import('vue')['onRenderTracked']>
|
||||
readonly onRenderTriggered: UnwrapRef<typeof import('vue')['onRenderTriggered']>
|
||||
readonly onScopeDispose: UnwrapRef<typeof import('vue')['onScopeDispose']>
|
||||
@@ -172,22 +174,23 @@ declare module 'vue' {
|
||||
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
|
||||
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
|
||||
readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']>
|
||||
readonly parseNetworkConfig: UnwrapRef<typeof import('./composables/network')['parseNetworkConfig']>
|
||||
readonly parseNetworkConfig: UnwrapRef<typeof import('./composables/backend')['parseNetworkConfig']>
|
||||
readonly prepareVpnService: UnwrapRef<typeof import('./composables/mobile_vpn')['prepareVpnService']>
|
||||
readonly provide: UnwrapRef<typeof import('vue')['provide']>
|
||||
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
|
||||
readonly readonly: UnwrapRef<typeof import('vue')['readonly']>
|
||||
readonly ref: UnwrapRef<typeof import('vue')['ref']>
|
||||
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
|
||||
readonly retainNetworkInstance: UnwrapRef<typeof import('./composables/network')['retainNetworkInstance']>
|
||||
readonly runNetworkInstance: UnwrapRef<typeof import('./composables/network')['runNetworkInstance']>
|
||||
readonly runNetworkInstance: UnwrapRef<typeof import('./composables/backend')['runNetworkInstance']>
|
||||
readonly saveNetworkConfig: UnwrapRef<typeof import('./composables/backend')['saveNetworkConfig']>
|
||||
readonly sendConfigs: UnwrapRef<typeof import('./composables/backend')['sendConfigs']>
|
||||
readonly setActivePinia: UnwrapRef<typeof import('pinia')['setActivePinia']>
|
||||
readonly setLoggingLevel: UnwrapRef<typeof import('./composables/network')['setLoggingLevel']>
|
||||
readonly setLoggingLevel: UnwrapRef<typeof import('./composables/backend')['setLoggingLevel']>
|
||||
readonly setMapStoreSuffix: UnwrapRef<typeof import('pinia')['setMapStoreSuffix']>
|
||||
readonly setTrayMenu: UnwrapRef<typeof import('./composables/tray')['setTrayMenu']>
|
||||
readonly setTrayRunState: UnwrapRef<typeof import('./composables/tray')['setTrayRunState']>
|
||||
readonly setTrayTooltip: UnwrapRef<typeof import('./composables/tray')['setTrayTooltip']>
|
||||
readonly setTunFd: UnwrapRef<typeof import('./composables/network')['setTunFd']>
|
||||
readonly setTunFd: UnwrapRef<typeof import('./composables/backend')['setTunFd']>
|
||||
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
|
||||
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
|
||||
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
|
||||
@@ -198,6 +201,7 @@ declare module 'vue' {
|
||||
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
|
||||
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
|
||||
readonly unref: UnwrapRef<typeof import('vue')['unref']>
|
||||
readonly updateNetworkConfigState: UnwrapRef<typeof import('./composables/backend')['updateNetworkConfigState']>
|
||||
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
|
||||
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
|
||||
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
|
||||
@@ -205,15 +209,15 @@ declare module 'vue' {
|
||||
readonly useId: UnwrapRef<typeof import('vue')['useId']>
|
||||
readonly useLink: UnwrapRef<typeof import('vue-router/auto')['useLink']>
|
||||
readonly useModel: UnwrapRef<typeof import('vue')['useModel']>
|
||||
readonly useNetworkStore: UnwrapRef<typeof import('./stores/network')['useNetworkStore']>
|
||||
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
|
||||
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
|
||||
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
|
||||
readonly useTemplateRef: UnwrapRef<typeof import('vue')['useTemplateRef']>
|
||||
readonly useTray: UnwrapRef<typeof import('./composables/tray')['useTray']>
|
||||
readonly validateConfig: UnwrapRef<typeof import('./composables/backend')['validateConfig']>
|
||||
readonly watch: UnwrapRef<typeof import('vue')['watch']>
|
||||
readonly watchEffect: UnwrapRef<typeof import('vue')['watchEffect']>
|
||||
readonly watchPostEffect: UnwrapRef<typeof import('vue')['watchPostEffect']>
|
||||
readonly watchSyncEffect: UnwrapRef<typeof import('vue')['watchSyncEffect']>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { getEasytierVersion } from '~/composables/network'
|
||||
import { getEasytierVersion } from '~/composables/backend'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
||||
70
easytier-gui/src/composables/backend.ts
Normal file
70
easytier-gui/src/composables/backend.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { Api, type NetworkTypes } from 'easytier-frontend-lib'
|
||||
import { GetNetworkMetasResponse } from 'node_modules/easytier-frontend-lib/dist/modules/api'
|
||||
import { getAutoLaunchStatusAsync } from '~/modules/auto_launch'
|
||||
|
||||
type NetworkConfig = NetworkTypes.NetworkConfig
|
||||
type ValidateConfigResponse = Api.ValidateConfigResponse
|
||||
type ListNetworkInstanceIdResponse = Api.ListNetworkInstanceIdResponse
|
||||
|
||||
export async function parseNetworkConfig(cfg: NetworkConfig) {
|
||||
return invoke<string>('parse_network_config', { cfg })
|
||||
}
|
||||
|
||||
export async function generateNetworkConfig(tomlConfig: string) {
|
||||
return invoke<NetworkConfig>('generate_network_config', { tomlConfig })
|
||||
}
|
||||
|
||||
export async function runNetworkInstance(cfg: NetworkConfig) {
|
||||
return invoke('run_network_instance', { cfg })
|
||||
}
|
||||
|
||||
export async function collectNetworkInfo(instanceId: string) {
|
||||
return await invoke<Api.CollectNetworkInfoResponse>('collect_network_info', { instanceId })
|
||||
}
|
||||
|
||||
export async function setLoggingLevel(level: string) {
|
||||
return await invoke('set_logging_level', { level })
|
||||
}
|
||||
|
||||
export async function setTunFd(fd: number) {
|
||||
return await invoke('set_tun_fd', { fd })
|
||||
}
|
||||
|
||||
export async function getEasytierVersion() {
|
||||
return await invoke<string>('easytier_version')
|
||||
}
|
||||
|
||||
export async function listNetworkInstanceIds() {
|
||||
return await invoke<ListNetworkInstanceIdResponse>('list_network_instance_ids')
|
||||
}
|
||||
|
||||
export async function deleteNetworkInstance(instanceId: string) {
|
||||
return await invoke('remove_network_instance', { instanceId })
|
||||
}
|
||||
|
||||
export async function updateNetworkConfigState(instanceId: string, disabled: boolean) {
|
||||
return await invoke('update_network_config_state', { instanceId, disabled })
|
||||
}
|
||||
|
||||
export async function saveNetworkConfig(cfg: NetworkConfig) {
|
||||
return await invoke('save_network_config', { cfg })
|
||||
}
|
||||
|
||||
export async function validateConfig(cfg: NetworkConfig) {
|
||||
return await invoke<ValidateConfigResponse>('validate_config', { cfg })
|
||||
}
|
||||
|
||||
export async function getConfig(instanceId: string) {
|
||||
return await invoke<NetworkConfig>('get_config', { instanceId })
|
||||
}
|
||||
|
||||
export async function sendConfigs() {
|
||||
let networkList: NetworkConfig[] = JSON.parse(localStorage.getItem('networkList') || '[]');
|
||||
let autoStartInstIds = getAutoLaunchStatusAsync() ? JSON.parse(localStorage.getItem('autoStartInstIds') || '[]') : []
|
||||
return await invoke('load_configs', { configs: networkList, enabledNetworks: autoStartInstIds })
|
||||
}
|
||||
|
||||
export async function getNetworkMetas(instanceIds: string[]) {
|
||||
return await invoke<GetNetworkMetasResponse>('get_network_metas', { instanceIds })
|
||||
}
|
||||
51
easytier-gui/src/composables/event.ts
Normal file
51
easytier-gui/src/composables/event.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Event, listen } from "@tauri-apps/api/event";
|
||||
import { type } from "@tauri-apps/plugin-os";
|
||||
import { NetworkTypes } from "easytier-frontend-lib"
|
||||
|
||||
const EVENTS = Object.freeze({
|
||||
SAVE_CONFIGS: 'save_configs',
|
||||
SAVE_ENABLED_NETWORKS: 'save_enabled_networks',
|
||||
PRE_RUN_NETWORK_INSTANCE: 'pre_run_network_instance',
|
||||
POST_RUN_NETWORK_INSTANCE: 'post_run_network_instance',
|
||||
VPN_SERVICE_STOP: 'vpn_service_stop',
|
||||
});
|
||||
|
||||
function onSaveConfigs(event: Event<NetworkTypes.NetworkConfig[]>) {
|
||||
console.log(`Received event '${EVENTS.SAVE_CONFIGS}': ${event.payload}`);
|
||||
localStorage.setItem('networkList', JSON.stringify(event.payload));
|
||||
}
|
||||
|
||||
function onSaveEnabledNetworks(event: Event<string[]>) {
|
||||
console.log(`Received event '${EVENTS.SAVE_ENABLED_NETWORKS}': ${event.payload}`);
|
||||
localStorage.setItem('autoStartInstIds', JSON.stringify(event.payload));
|
||||
}
|
||||
|
||||
async function onPreRunNetworkInstance(event: Event<string>) {
|
||||
if (type() === 'android') {
|
||||
await prepareVpnService(event.payload);
|
||||
}
|
||||
}
|
||||
|
||||
async function onPostRunNetworkInstance(event: Event<string>) {
|
||||
if (type() === 'android') {
|
||||
await onNetworkInstanceChange(event.payload);
|
||||
}
|
||||
}
|
||||
|
||||
async function onVpnServiceStop(event: Event<string>) {
|
||||
await onNetworkInstanceChange(event.payload);
|
||||
}
|
||||
|
||||
export async function listenGlobalEvents() {
|
||||
const unlisteners = [
|
||||
await listen(EVENTS.SAVE_CONFIGS, onSaveConfigs),
|
||||
await listen(EVENTS.SAVE_ENABLED_NETWORKS, onSaveEnabledNetworks),
|
||||
await listen(EVENTS.PRE_RUN_NETWORK_INSTANCE, onPreRunNetworkInstance),
|
||||
await listen(EVENTS.POST_RUN_NETWORK_INSTANCE, onPostRunNetworkInstance),
|
||||
await listen(EVENTS.VPN_SERVICE_STOP, onVpnServiceStop),
|
||||
];
|
||||
|
||||
return () => {
|
||||
unlisteners.forEach(unlisten => unlisten());
|
||||
};
|
||||
}
|
||||
@@ -5,8 +5,6 @@ import { prepare_vpn, start_vpn, stop_vpn } from 'tauri-plugin-vpnservice-api'
|
||||
|
||||
type Route = NetworkTypes.Route
|
||||
|
||||
const networkStore = useNetworkStore()
|
||||
|
||||
interface vpnStatus {
|
||||
running: boolean
|
||||
ipv4Addr: string | null | undefined
|
||||
@@ -69,7 +67,7 @@ async function onVpnServiceStart(payload: any) {
|
||||
console.log('vpn service start', JSON.stringify(payload))
|
||||
curVpnStatus.running = true
|
||||
if (payload.fd) {
|
||||
setTunFd(networkStore.networkInstanceIds[0], payload.fd)
|
||||
setTunFd(payload.fd)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +91,7 @@ async function registerVpnServiceListener() {
|
||||
)
|
||||
}
|
||||
|
||||
function getRoutesForVpn(routes: Route[]): string[] {
|
||||
function getRoutesForVpn(routes: Route[], node_config: NetworkTypes.NetworkConfig): string[] {
|
||||
if (!routes) {
|
||||
return []
|
||||
}
|
||||
@@ -108,24 +106,25 @@ function getRoutesForVpn(routes: Route[]): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
node_config.routes.forEach(r => {
|
||||
ret.push(r)
|
||||
})
|
||||
|
||||
// sort and dedup
|
||||
return Array.from(new Set(ret)).sort()
|
||||
}
|
||||
|
||||
async function onNetworkInstanceChange() {
|
||||
console.error('vpn service watch network instance change ids', JSON.stringify(networkStore.networkInstanceIds))
|
||||
const insts = networkStore.networkInstanceIds
|
||||
const no_tun = networkStore.isNoTunEnabled(insts[0])
|
||||
if (no_tun) {
|
||||
export async function onNetworkInstanceChange(instanceId: string) {
|
||||
console.error('vpn service network instance change id', instanceId)
|
||||
if (!instanceId) {
|
||||
await doStopVpn()
|
||||
return
|
||||
}
|
||||
if (!insts) {
|
||||
await doStopVpn()
|
||||
const config = await getConfig(instanceId)
|
||||
if (config.no_tun) {
|
||||
return
|
||||
}
|
||||
|
||||
const curNetworkInfo = networkStore.networkInfos[insts[0]]
|
||||
const curNetworkInfo = (await collectNetworkInfo(instanceId)).info.map[instanceId]
|
||||
if (!curNetworkInfo || curNetworkInfo?.error_msg?.length) {
|
||||
await doStopVpn()
|
||||
return
|
||||
@@ -142,7 +141,7 @@ async function onNetworkInstanceChange() {
|
||||
network_length = 24
|
||||
}
|
||||
|
||||
const routes = getRoutesForVpn(curNetworkInfo?.routes)
|
||||
const routes = getRoutesForVpn(curNetworkInfo?.routes, config)
|
||||
|
||||
const ipChanged = virtual_ip !== curVpnStatus.ipv4Addr
|
||||
const routesChanged = JSON.stringify(routes) !== JSON.stringify(curVpnStatus.routes)
|
||||
@@ -160,48 +159,25 @@ async function onNetworkInstanceChange() {
|
||||
await doStartVpn(virtual_ip, 24, routes)
|
||||
}
|
||||
catch (e) {
|
||||
console.error('start vpn service failed, clear all network insts.', e)
|
||||
networkStore.clearNetworkInstances()
|
||||
await retainNetworkInstance(networkStore.networkInstanceIds)
|
||||
console.error('start vpn service failed, stop all other network insts.', e)
|
||||
await runNetworkInstance(config);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function watchNetworkInstance() {
|
||||
let subscribe_running = false
|
||||
networkStore.$subscribe(async () => {
|
||||
if (subscribe_running) {
|
||||
return
|
||||
}
|
||||
subscribe_running = true
|
||||
try {
|
||||
await onNetworkInstanceChange()
|
||||
}
|
||||
catch (_) {
|
||||
}
|
||||
subscribe_running = false
|
||||
})
|
||||
console.error('vpn service watch network instance')
|
||||
}
|
||||
|
||||
function isNoTunEnabled(instanceId: string | undefined) {
|
||||
async function isNoTunEnabled(instanceId: string | undefined) {
|
||||
if (!instanceId) {
|
||||
return false
|
||||
}
|
||||
const no_tun = networkStore.isNoTunEnabled(instanceId)
|
||||
if (no_tun) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return (await getConfig(instanceId)).no_tun ?? false
|
||||
}
|
||||
|
||||
export async function initMobileVpnService() {
|
||||
await registerVpnServiceListener()
|
||||
await watchNetworkInstance()
|
||||
}
|
||||
|
||||
export async function prepareVpnService(instanceId: string) {
|
||||
if (isNoTunEnabled(instanceId)) {
|
||||
if (await isNoTunEnabled(instanceId)) {
|
||||
return
|
||||
}
|
||||
console.log('prepare vpn')
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { NetworkTypes } from 'easytier-frontend-lib'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
type NetworkConfig = NetworkTypes.NetworkConfig
|
||||
type NetworkInstanceRunningInfo = NetworkTypes.NetworkInstanceRunningInfo
|
||||
|
||||
export async function parseNetworkConfig(cfg: NetworkConfig) {
|
||||
return invoke<string>('parse_network_config', { cfg })
|
||||
}
|
||||
|
||||
export async function generateNetworkConfig(tomlConfig: string) {
|
||||
return invoke<NetworkConfig>('generate_network_config', { tomlConfig })
|
||||
}
|
||||
|
||||
export async function runNetworkInstance(cfg: NetworkConfig) {
|
||||
return invoke('run_network_instance', { cfg })
|
||||
}
|
||||
|
||||
export async function retainNetworkInstance(instanceIds: string[]) {
|
||||
return invoke('retain_network_instance', { instanceIds })
|
||||
}
|
||||
|
||||
export async function collectNetworkInfos() {
|
||||
return await invoke<Record<string, NetworkInstanceRunningInfo>>('collect_network_infos')
|
||||
}
|
||||
|
||||
export async function getOsHostname() {
|
||||
return await invoke<string>('get_os_hostname')
|
||||
}
|
||||
|
||||
export async function isAutostart() {
|
||||
return await invoke<boolean>('is_autostart')
|
||||
}
|
||||
|
||||
export async function setLoggingLevel(level: string) {
|
||||
return await invoke('set_logging_level', { level })
|
||||
}
|
||||
|
||||
export async function setTunFd(instanceId: string, fd: number) {
|
||||
return await invoke('set_tun_fd', { instanceId, fd })
|
||||
}
|
||||
|
||||
export async function getEasytierVersion() {
|
||||
return await invoke<string>('easytier_version')
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
import Aura from '@primevue/themes/aura'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import ToastService from 'primevue/toastservice'
|
||||
import Aura from '@primeuix/themes/aura';
|
||||
import PrimeVue from 'primevue/config';
|
||||
|
||||
import { createRouter, createWebHistory } from 'vue-router/auto'
|
||||
import { routes } from 'vue-router/auto-routes'
|
||||
import App from '~/App.vue'
|
||||
import EasyTierFrontendLib, { I18nUtils } from 'easytier-frontend-lib'
|
||||
import EasyTierFrontendLib, { I18nUtils } from 'easytier-frontend-lib';
|
||||
import { createRouter, createWebHistory } from 'vue-router/auto';
|
||||
import { routes } from 'vue-router/auto-routes';
|
||||
import App from '~/App.vue';
|
||||
|
||||
import { getAutoLaunchStatusAsync, loadAutoLaunchStatusAsync } from './modules/auto_launch'
|
||||
import '~/styles.css'
|
||||
import 'easytier-frontend-lib/style.css'
|
||||
import 'easytier-frontend-lib/style.css';
|
||||
import { ConfirmationService, DialogService, ToastService } from 'primevue';
|
||||
import '~/styles.css';
|
||||
import { getAutoLaunchStatusAsync, loadAutoLaunchStatusAsync } from './modules/auto_launch';
|
||||
|
||||
if (import.meta.env.PROD) {
|
||||
document.addEventListener('keydown', (event) => {
|
||||
@@ -55,7 +55,9 @@ async function main() {
|
||||
},
|
||||
},
|
||||
})
|
||||
app.use(ToastService as any)
|
||||
app.use(ToastService)
|
||||
app.use(DialogService)
|
||||
app.use(ConfirmationService)
|
||||
app.mount('#app')
|
||||
}
|
||||
|
||||
|
||||
47
easytier-gui/src/modules/api.ts
Normal file
47
easytier-gui/src/modules/api.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { type Api, type NetworkTypes } from "easytier-frontend-lib";
|
||||
import * as backend from "~/composables/backend";
|
||||
|
||||
export class GUIRemoteClient implements Api.RemoteClient {
|
||||
async validate_config(config: NetworkTypes.NetworkConfig): Promise<Api.ValidateConfigResponse> {
|
||||
return backend.validateConfig(config);
|
||||
}
|
||||
async run_network(config: NetworkTypes.NetworkConfig): Promise<undefined> {
|
||||
await backend.runNetworkInstance(config);
|
||||
}
|
||||
async get_network_info(inst_id: string): Promise<NetworkTypes.NetworkInstanceRunningInfo | undefined> {
|
||||
return backend.collectNetworkInfo(inst_id).then(infos => infos.info.map[inst_id]);
|
||||
}
|
||||
async list_network_instance_ids(): Promise<Api.ListNetworkInstanceIdResponse> {
|
||||
return backend.listNetworkInstanceIds();
|
||||
}
|
||||
async delete_network(inst_id: string): Promise<undefined> {
|
||||
await backend.deleteNetworkInstance(inst_id);
|
||||
}
|
||||
async update_network_instance_state(inst_id: string, disabled: boolean): Promise<undefined> {
|
||||
await backend.updateNetworkConfigState(inst_id, disabled);
|
||||
}
|
||||
async save_config(config: NetworkTypes.NetworkConfig): Promise<undefined> {
|
||||
await backend.saveNetworkConfig(config);
|
||||
}
|
||||
async get_network_config(inst_id: string): Promise<NetworkTypes.NetworkConfig> {
|
||||
return backend.getConfig(inst_id);
|
||||
}
|
||||
async generate_config(config: NetworkTypes.NetworkConfig): Promise<Api.GenerateConfigResponse> {
|
||||
try {
|
||||
return { toml_config: await backend.parseNetworkConfig(config) };
|
||||
} catch (e) {
|
||||
return { error: e + "" };
|
||||
}
|
||||
}
|
||||
async parse_config(toml_config: string): Promise<Api.ParseConfigResponse> {
|
||||
try {
|
||||
return { config: await backend.generateNetworkConfig(toml_config) }
|
||||
} catch (e) {
|
||||
return { error: e + "" };
|
||||
}
|
||||
}
|
||||
async get_network_metas(instance_ids: string[]): Promise<Api.GetNetworkMetasResponse> {
|
||||
return await backend.getNetworkMetas(instance_ids);
|
||||
}
|
||||
|
||||
}
|
||||
18
easytier-gui/src/modules/dock_visibility.ts
Normal file
18
easytier-gui/src/modules/dock_visibility.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
export async function loadDockVisibilityAsync(visible: boolean): Promise<boolean> {
|
||||
try {
|
||||
await invoke('set_dock_visibility', { visible })
|
||||
localStorage.setItem('dock_visibility', JSON.stringify(visible))
|
||||
return visible
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Failed to set dock visibility:', e)
|
||||
return getDockVisibilityStatus()
|
||||
}
|
||||
}
|
||||
|
||||
export function getDockVisibilityStatus(): boolean {
|
||||
const stored = localStorage.getItem('dock_visibility')
|
||||
return stored !== null ? JSON.parse(stored) : true
|
||||
}
|
||||
@@ -1,147 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { appLogDir } from '@tauri-apps/api/path'
|
||||
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { writeText } from '@tauri-apps/plugin-clipboard-manager'
|
||||
import { type } from '@tauri-apps/plugin-os'
|
||||
import { exit } from '@tauri-apps/plugin-process'
|
||||
import { open } from '@tauri-apps/plugin-shell'
|
||||
import TieredMenu from 'primevue/tieredmenu'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { NetworkTypes, Config, Status, Utils, I18nUtils, ConfigEditDialog } from 'easytier-frontend-lib'
|
||||
|
||||
import { isAutostart, setLoggingLevel } from '~/composables/network'
|
||||
import { appLogDir } from '@tauri-apps/api/path'
|
||||
import { writeText } from '@tauri-apps/plugin-clipboard-manager'
|
||||
import { exit } from '@tauri-apps/plugin-process'
|
||||
import { I18nUtils, RemoteManagement } from "easytier-frontend-lib"
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { useTray } from '~/composables/tray'
|
||||
import { GUIRemoteClient } from '~/modules/api'
|
||||
import { getAutoLaunchStatusAsync as getAutoLaunchStatus, loadAutoLaunchStatusAsync } from '~/modules/auto_launch'
|
||||
import { getDockVisibilityStatus, loadDockVisibilityAsync } from '~/modules/dock_visibility'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const visible = ref(false)
|
||||
const aboutVisible = ref(false)
|
||||
const tomlConfig = ref('')
|
||||
|
||||
useTray(true)
|
||||
|
||||
const items = ref([
|
||||
{
|
||||
label: () => activeStep.value == "2" ? t('show_config') : t('edit_config'),
|
||||
icon: 'pi pi-file-edit',
|
||||
command: async () => {
|
||||
try {
|
||||
const ret = await parseNetworkConfig(networkStore.curNetwork)
|
||||
tomlConfig.value = ret
|
||||
}
|
||||
catch (e: any) {
|
||||
tomlConfig.value = e
|
||||
}
|
||||
visible.value = true
|
||||
},
|
||||
},
|
||||
{
|
||||
label: () => t('del_cur_network'),
|
||||
icon: 'pi pi-times',
|
||||
command: async () => {
|
||||
networkStore.removeNetworkInstance(networkStore.curNetwork.instance_id)
|
||||
await retainNetworkInstance(networkStore.networkInstanceIds)
|
||||
networkStore.delCurNetwork()
|
||||
},
|
||||
disabled: () => networkStore.networkList.length <= 1,
|
||||
},
|
||||
])
|
||||
const remoteClient = computed(() => new GUIRemoteClient());
|
||||
const instanceId = ref<string | undefined>(undefined);
|
||||
|
||||
enum Severity {
|
||||
None = 'none',
|
||||
Success = 'success',
|
||||
Info = 'info',
|
||||
Warn = 'warn',
|
||||
Error = 'error',
|
||||
}
|
||||
|
||||
const messageBarSeverity = ref(Severity.None)
|
||||
const messageBarContent = ref('')
|
||||
const toast = useToast()
|
||||
|
||||
const networkStore = useNetworkStore()
|
||||
|
||||
const curNetworkConfig = computed(() => {
|
||||
if (networkStore.curNetworkId) {
|
||||
// console.log('instanceId', props.instanceId)
|
||||
const c = networkStore.networkList.find(n => n.instance_id === networkStore.curNetworkId)
|
||||
if (c !== undefined)
|
||||
return c
|
||||
}
|
||||
|
||||
return networkStore.curNetwork
|
||||
})
|
||||
|
||||
const curNetworkInst = computed<NetworkTypes.NetworkInstance | null>(() => {
|
||||
let ret = networkStore.networkInstances.find(n => n.instance_id === curNetworkConfig.value.instance_id)
|
||||
console.log('curNetworkInst', ret)
|
||||
if (ret === undefined) {
|
||||
return null;
|
||||
} else {
|
||||
return ret;
|
||||
}
|
||||
})
|
||||
|
||||
function addNewNetwork() {
|
||||
networkStore.addNewNetwork()
|
||||
networkStore.curNetwork = networkStore.lastNetwork
|
||||
}
|
||||
|
||||
networkStore.$subscribe(async () => {
|
||||
networkStore.saveToLocalStorage()
|
||||
try {
|
||||
await parseNetworkConfig(networkStore.curNetwork)
|
||||
messageBarSeverity.value = Severity.None
|
||||
}
|
||||
catch (e: any) {
|
||||
messageBarContent.value = e
|
||||
messageBarSeverity.value = Severity.Error
|
||||
}
|
||||
})
|
||||
|
||||
async function runNetworkCb(cfg: NetworkTypes.NetworkConfig, cb: () => void) {
|
||||
if (type() === 'android') {
|
||||
await prepareVpnService(cfg.instance_id)
|
||||
networkStore.clearNetworkInstances()
|
||||
}
|
||||
else {
|
||||
networkStore.removeNetworkInstance(cfg.instance_id)
|
||||
}
|
||||
|
||||
await retainNetworkInstance(networkStore.networkInstanceIds)
|
||||
networkStore.addNetworkInstance(cfg.instance_id)
|
||||
|
||||
try {
|
||||
await runNetworkInstance(cfg)
|
||||
networkStore.addAutoStartInstId(cfg.instance_id)
|
||||
}
|
||||
catch (e: any) {
|
||||
// console.error(e)
|
||||
toast.add({ severity: 'info', detail: e })
|
||||
}
|
||||
|
||||
cb()
|
||||
}
|
||||
|
||||
async function stopNetworkCb(cfg: NetworkTypes.NetworkConfig, cb: () => void) {
|
||||
// console.log('stopNetworkCb', cfg, cb)
|
||||
cb()
|
||||
networkStore.removeNetworkInstance(cfg.instance_id)
|
||||
await retainNetworkInstance(networkStore.networkInstanceIds)
|
||||
networkStore.removeAutoStartInstId(cfg.instance_id)
|
||||
}
|
||||
|
||||
async function updateNetworkInfos() {
|
||||
networkStore.updateWithNetworkInfos(await collectNetworkInfos())
|
||||
}
|
||||
|
||||
let intervalId = 0
|
||||
onMounted(async () => {
|
||||
intervalId = window.setInterval(async () => {
|
||||
await updateNetworkInfos()
|
||||
}, 500)
|
||||
|
||||
window.setTimeout(async () => {
|
||||
await setTrayMenu([
|
||||
await MenuItemShow(t('tray.show')),
|
||||
@@ -149,16 +28,47 @@ onMounted(async () => {
|
||||
])
|
||||
}, 1000)
|
||||
})
|
||||
onUnmounted(() => clearInterval(intervalId))
|
||||
|
||||
const activeStep = computed(() => {
|
||||
return networkStore.networkInstanceIds.includes(networkStore.curNetworkId) ? '2' : '1'
|
||||
})
|
||||
|
||||
let current_log_level = 'off'
|
||||
|
||||
const setting_menu = ref()
|
||||
const setting_menu_items = ref([
|
||||
const log_menu = ref()
|
||||
const log_menu_items_popup: Ref<MenuItem[]> = ref([
|
||||
...['off', 'warn', 'info', 'debug', 'trace'].map(level => ({
|
||||
label: () => t(`logging_level_${level}`) + (current_log_level === level ? ' ✓' : ''),
|
||||
command: async () => {
|
||||
current_log_level = level
|
||||
await setLoggingLevel(level)
|
||||
},
|
||||
})),
|
||||
{
|
||||
separator: true,
|
||||
},
|
||||
{
|
||||
label: () => t('logging_open_dir'),
|
||||
icon: 'pi pi-folder-open',
|
||||
command: async () => {
|
||||
// console.log('open log dir', await appLogDir())
|
||||
await open(await appLogDir())
|
||||
},
|
||||
},
|
||||
{
|
||||
label: () => t('logging_copy_dir'),
|
||||
icon: 'pi pi-tablet',
|
||||
command: async () => {
|
||||
await writeText(await appLogDir())
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
function toggle_log_menu(event: any) {
|
||||
log_menu.value.toggle(event)
|
||||
}
|
||||
|
||||
function getLabel(item: MenuItem) {
|
||||
return typeof item.label === 'function' ? item.label() : item.label
|
||||
}
|
||||
|
||||
const setting_menu_items: Ref<MenuItem[]> = ref([
|
||||
{
|
||||
label: () => t('exchange_language'),
|
||||
icon: 'pi pi-language',
|
||||
@@ -178,40 +88,18 @@ const setting_menu_items = ref([
|
||||
},
|
||||
},
|
||||
{
|
||||
label: () => getDockVisibilityStatus() ? t('hide_dock_icon') : t('show_dock_icon'),
|
||||
icon: 'pi pi-eye-slash',
|
||||
command: async () => {
|
||||
await loadDockVisibilityAsync(!getDockVisibilityStatus())
|
||||
},
|
||||
visible: () => type() === 'macos',
|
||||
},
|
||||
{
|
||||
key: 'logging_menu',
|
||||
label: () => t('logging'),
|
||||
icon: 'pi pi-file',
|
||||
items: (function () {
|
||||
const levels = ['off', 'warn', 'info', 'debug', 'trace']
|
||||
const items = []
|
||||
for (const level of levels) {
|
||||
items.push({
|
||||
label: () => t(`logging_level_${level}`) + (current_log_level === level ? ' ✓' : ''),
|
||||
command: async () => {
|
||||
current_log_level = level
|
||||
await setLoggingLevel(level)
|
||||
},
|
||||
})
|
||||
}
|
||||
items.push({
|
||||
separator: true,
|
||||
})
|
||||
items.push({
|
||||
label: () => t('logging_open_dir'),
|
||||
icon: 'pi pi-folder-open',
|
||||
command: async () => {
|
||||
// console.log('open log dir', await appLogDir())
|
||||
await open(await appLogDir())
|
||||
},
|
||||
})
|
||||
items.push({
|
||||
label: () => t('logging_copy_dir'),
|
||||
icon: 'pi pi-tablet',
|
||||
command: async () => {
|
||||
await writeText(await appLogDir())
|
||||
},
|
||||
})
|
||||
return items
|
||||
})(),
|
||||
items: [], // Keep this to show it's a parent menu
|
||||
},
|
||||
{
|
||||
label: () => t('about.title'),
|
||||
@@ -229,25 +117,6 @@ const setting_menu_items = ref([
|
||||
},
|
||||
])
|
||||
|
||||
function toggle_setting_menu(event: any) {
|
||||
setting_menu.value.toggle(event)
|
||||
}
|
||||
|
||||
onBeforeMount(async () => {
|
||||
networkStore.loadFromLocalStorage()
|
||||
if (type() !== 'android' && getAutoLaunchStatus() && await isAutostart()) {
|
||||
getCurrentWindow().hide()
|
||||
const autoStartIds = networkStore.autoStartInstIds
|
||||
for (const id of autoStartIds) {
|
||||
const cfg = networkStore.networkList.find((item: NetworkTypes.NetworkConfig) => item.instance_id === id)
|
||||
if (cfg) {
|
||||
networkStore.addNetworkInstance(cfg.instance_id)
|
||||
await runNetworkInstance(cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (type() === 'android') {
|
||||
try {
|
||||
@@ -257,123 +126,37 @@ onMounted(async () => {
|
||||
console.error("easytier init vpn service failed", e)
|
||||
}
|
||||
}
|
||||
const unlisten = await listenGlobalEvents()
|
||||
await sendConfigs()
|
||||
return () => {
|
||||
unlisten()
|
||||
}
|
||||
})
|
||||
|
||||
function isRunning(id: string) {
|
||||
return networkStore.networkInstanceIds.includes(id)
|
||||
}
|
||||
|
||||
async function saveTomlConfig(tomlConfig: string) {
|
||||
const config = await generateNetworkConfig(tomlConfig)
|
||||
networkStore.replaceCurNetwork(config);
|
||||
toast.add({ severity: 'success', detail: t('config_saved'), life: 3000 })
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="root" class="flex flex-col">
|
||||
<ConfigEditDialog v-model:visible="visible" :cur-network="curNetworkConfig" :readonly="activeStep !== '1'"
|
||||
:save-config="saveTomlConfig" :generate-config="parseNetworkConfig" />
|
||||
|
||||
<Dialog v-model:visible="aboutVisible" modal :header="t('about.title')" :style="{ width: '70%' }">
|
||||
<About />
|
||||
</Dialog>
|
||||
<Menu ref="log_menu" :model="log_menu_items_popup" :popup="true" />
|
||||
|
||||
<div>
|
||||
<Toolbar>
|
||||
<template #start>
|
||||
<div class="flex items-center">
|
||||
<Button icon="pi pi-plus" severity="primary" :label="t('add_new_network')" @click="addNewNetwork" />
|
||||
</div>
|
||||
</template>
|
||||
<RemoteManagement class="flex-1 overflow-y-auto" :api="remoteClient" v-bind:instance-id="instanceId" />
|
||||
|
||||
<template #center>
|
||||
<div class="min-w-40">
|
||||
<Select v-model="networkStore.curNetwork" :options="networkStore.networkList" :highlight-on-select="false"
|
||||
:placeholder="t('select_network')" class="w-full">
|
||||
<template #value="slotProps">
|
||||
<div class="flex items-start content-center">
|
||||
<div class="mr-4 flex-col">
|
||||
<span>{{ slotProps.value.network_name }}</span>
|
||||
</div>
|
||||
<Tag class="my-auto leading-3" :severity="isRunning(slotProps.value.instance_id) ? 'success' : 'info'"
|
||||
:value="t(isRunning(slotProps.value.instance_id) ? 'network_running' : 'network_stopped')" />
|
||||
</div>
|
||||
</template>
|
||||
<template #option="slotProps">
|
||||
<div class="flex flex-col items-start content-center max-w-full">
|
||||
<div class="flex">
|
||||
<div class="mr-4">
|
||||
{{ t('network_name') }}: {{ slotProps.option.network_name }}
|
||||
</div>
|
||||
<Tag class="my-auto leading-3"
|
||||
:severity="isRunning(slotProps.option.instance_id) ? 'success' : 'info'"
|
||||
:value="t(isRunning(slotProps.option.instance_id) ? 'network_running' : 'network_stopped')" />
|
||||
</div>
|
||||
<div v-if="slotProps.option.networking_method !== NetworkTypes.NetworkingMethod.Standalone"
|
||||
class="max-w-full overflow-hidden text-ellipsis">
|
||||
{{ slotProps.option.networking_method === NetworkTypes.NetworkingMethod.Manual
|
||||
? slotProps.option.peer_urls.join(', ')
|
||||
: slotProps.option.public_server_url }}
|
||||
</div>
|
||||
<div
|
||||
v-if="isRunning(slotProps.option.instance_id) && networkStore.instances[slotProps.option.instance_id].detail && (!!networkStore.instances[slotProps.option.instance_id].detail?.my_node_info.virtual_ipv4)">
|
||||
{{
|
||||
Utils.ipv4InetToString(networkStore.instances[slotProps.option.instance_id].detail?.my_node_info.virtual_ipv4)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #end>
|
||||
<Button icon="pi pi-cog" severity="secondary" aria-haspopup="true" :label="t('settings')"
|
||||
aria-controls="overlay_setting_menu" @click="toggle_setting_menu" />
|
||||
<TieredMenu id="overlay_setting_menu" ref="setting_menu" :model="setting_menu_items" :popup="true" />
|
||||
</template>
|
||||
</Toolbar>
|
||||
</div>
|
||||
|
||||
<Panel class="h-full overflow-y-auto">
|
||||
<Stepper :value="activeStep">
|
||||
<StepList value="1">
|
||||
<Step value="1">
|
||||
{{ t('config_network') }}
|
||||
</Step>
|
||||
<Step value="2">
|
||||
{{ t('running') }}
|
||||
</Step>
|
||||
</StepList>
|
||||
<StepPanels value="1">
|
||||
<StepPanel v-slot="{ activateCallback = (s: string) => { } } = {}" value="1">
|
||||
<Config :instance-id="networkStore.curNetworkId" :config-invalid="messageBarSeverity !== Severity.None"
|
||||
:cur-network="curNetworkConfig" @run-network="runNetworkCb($event, () => activateCallback('2'))" />
|
||||
</StepPanel>
|
||||
<StepPanel v-slot="{ activateCallback = (s: string) => { } } = {}" value="2">
|
||||
<div class="flex flex-col">
|
||||
<Status :cur-network-inst="curNetworkInst" />
|
||||
</div>
|
||||
<div class="flex pt-6 justify-center">
|
||||
<Button :label="t('stop_network')" severity="danger" icon="pi pi-arrow-left"
|
||||
@click="stopNetworkCb(networkStore.curNetwork, () => activateCallback('1'))" />
|
||||
</div>
|
||||
</StepPanel>
|
||||
</StepPanels>
|
||||
</Stepper>
|
||||
</Panel>
|
||||
|
||||
<div>
|
||||
<Menubar :model="items" breakpoint="300px" />
|
||||
<InlineMessage v-if="messageBarSeverity !== Severity.None" class="absolute bottom-0 right-0" severity="error">
|
||||
{{ messageBarContent }}
|
||||
</InlineMessage>
|
||||
</div>
|
||||
<Menubar :model="setting_menu_items" breakpoint="560px">
|
||||
<template #item="{ item, props }">
|
||||
<a v-if="item.key === 'logging_menu'" v-bind="props.action" @click="toggle_log_menu">
|
||||
<span :class="item.icon" />
|
||||
<span class="p-menubar-item-label">{{ getLabel(item) }}</span>
|
||||
<span class="pi pi-angle-down p-menubar-item-icon text-[9px]"></span>
|
||||
</a>
|
||||
<a v-else v-bind="props.action">
|
||||
<span :class="item.icon" />
|
||||
<span class="p-menubar-item-label">{{ getLabel(item) }}</span>
|
||||
</a>
|
||||
</template>
|
||||
</Menubar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
import { NetworkTypes } from 'easytier-frontend-lib'
|
||||
|
||||
export const useNetworkStore = defineStore('networkStore', {
|
||||
state: () => {
|
||||
const networkList = [NetworkTypes.DEFAULT_NETWORK_CONFIG()]
|
||||
return {
|
||||
// for initially empty lists
|
||||
networkList: networkList as NetworkTypes.NetworkConfig[],
|
||||
// for data that is not yet loaded
|
||||
curNetwork: networkList[0],
|
||||
|
||||
// uuid -> instance
|
||||
instances: {} as Record<string, NetworkTypes.NetworkInstance>,
|
||||
|
||||
networkInfos: {} as Record<string, NetworkTypes.NetworkInstanceRunningInfo>,
|
||||
|
||||
autoStartInstIds: [] as string[],
|
||||
}
|
||||
},
|
||||
|
||||
getters: {
|
||||
lastNetwork(): NetworkTypes.NetworkConfig {
|
||||
return this.networkList[this.networkList.length - 1]
|
||||
},
|
||||
|
||||
curNetworkId(): string {
|
||||
return this.curNetwork.instance_id
|
||||
},
|
||||
|
||||
networkInstances(): Array<NetworkTypes.NetworkInstance> {
|
||||
return Object.values(this.instances)
|
||||
},
|
||||
|
||||
networkInstanceIds(): Array<string> {
|
||||
return Object.keys(this.instances)
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
addNewNetwork() {
|
||||
this.networkList.push(NetworkTypes.DEFAULT_NETWORK_CONFIG())
|
||||
},
|
||||
|
||||
delCurNetwork() {
|
||||
const curNetworkIdx = this.networkList.indexOf(this.curNetwork)
|
||||
this.networkList.splice(curNetworkIdx, 1)
|
||||
const nextCurNetworkIdx = Math.min(curNetworkIdx, this.networkList.length - 1)
|
||||
this.curNetwork = this.networkList[nextCurNetworkIdx]
|
||||
},
|
||||
|
||||
replaceCurNetwork(cfg: NetworkTypes.NetworkConfig) {
|
||||
const curNetworkIdx = this.networkList.indexOf(this.curNetwork)
|
||||
this.networkList[curNetworkIdx] = cfg
|
||||
this.curNetwork = cfg
|
||||
},
|
||||
|
||||
removeNetworkInstance(instanceId: string) {
|
||||
delete this.instances[instanceId]
|
||||
},
|
||||
|
||||
addNetworkInstance(instanceId: string) {
|
||||
this.instances[instanceId] = {
|
||||
instance_id: instanceId,
|
||||
running: false,
|
||||
error_msg: '',
|
||||
detail: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
clearNetworkInstances() {
|
||||
this.instances = {}
|
||||
},
|
||||
|
||||
updateWithNetworkInfos(networkInfos: Record<string, NetworkTypes.NetworkInstanceRunningInfo>) {
|
||||
this.networkInfos = networkInfos
|
||||
for (const [instanceId, info] of Object.entries(networkInfos)) {
|
||||
if (this.instances[instanceId] === undefined)
|
||||
this.addNetworkInstance(instanceId)
|
||||
|
||||
this.instances[instanceId].running = info.running
|
||||
this.instances[instanceId].error_msg = info.error_msg || ''
|
||||
this.instances[instanceId].detail = info
|
||||
}
|
||||
},
|
||||
|
||||
loadFromLocalStorage() {
|
||||
let networkList: NetworkTypes.NetworkConfig[]
|
||||
|
||||
// if localStorage default is [{}], instanceId will be undefined
|
||||
networkList = JSON.parse(localStorage.getItem('networkList') || '[]')
|
||||
networkList = networkList.map((cfg) => {
|
||||
return { ...NetworkTypes.DEFAULT_NETWORK_CONFIG(), ...cfg } as NetworkTypes.NetworkConfig
|
||||
})
|
||||
|
||||
// prevent a empty list from localStorage, should not happen
|
||||
if (networkList.length === 0)
|
||||
networkList = [NetworkTypes.DEFAULT_NETWORK_CONFIG()]
|
||||
|
||||
this.networkList = networkList
|
||||
this.curNetwork = this.networkList[0]
|
||||
|
||||
this.loadAutoStartInstIdsFromLocalStorage()
|
||||
},
|
||||
|
||||
saveToLocalStorage() {
|
||||
localStorage.setItem('networkList', JSON.stringify(this.networkList))
|
||||
},
|
||||
|
||||
saveAutoStartInstIdsToLocalStorage() {
|
||||
localStorage.setItem('autoStartInstIds', JSON.stringify(this.autoStartInstIds))
|
||||
},
|
||||
|
||||
loadAutoStartInstIdsFromLocalStorage() {
|
||||
try {
|
||||
this.autoStartInstIds = JSON.parse(localStorage.getItem('autoStartInstIds') || '[]')
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e)
|
||||
this.autoStartInstIds = []
|
||||
}
|
||||
},
|
||||
|
||||
addAutoStartInstId(instanceId: string) {
|
||||
if (!this.autoStartInstIds.includes(instanceId)) {
|
||||
this.autoStartInstIds.push(instanceId)
|
||||
}
|
||||
this.saveAutoStartInstIdsToLocalStorage()
|
||||
},
|
||||
|
||||
removeAutoStartInstId(instanceId: string) {
|
||||
const idx = this.autoStartInstIds.indexOf(instanceId)
|
||||
if (idx !== -1) {
|
||||
this.autoStartInstIds.splice(idx, 1)
|
||||
}
|
||||
this.saveAutoStartInstIdsToLocalStorage()
|
||||
},
|
||||
|
||||
isNoTunEnabled(instanceId: string): boolean {
|
||||
const cfg = this.networkList.find((cfg) => cfg.instance_id === instanceId)
|
||||
if (!cfg)
|
||||
return false
|
||||
return cfg.no_tun ?? false
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (import.meta.hot)
|
||||
import.meta.hot.accept(acceptHMRUpdate(useNetworkStore as any, import.meta.hot))
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "easytier-web"
|
||||
version = "2.4.3"
|
||||
version = "2.4.5"
|
||||
edition = "2021"
|
||||
description = "Config server for easytier. easytier-core gets config from this and web frontend use it as restful api server."
|
||||
|
||||
|
||||
@@ -18,18 +18,17 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@primevue/themes": "4.3.3",
|
||||
"@primeuix/themes": "^1.2.3",
|
||||
"@vueuse/core": "^11.1.0",
|
||||
"aura": "link:@primevue\\themes\\aura",
|
||||
"axios": "^1.7.7",
|
||||
"chart.js": "^4.5.0",
|
||||
"floating-vue": "^5.2",
|
||||
"ip-num": "1.5.1",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "4.3.3",
|
||||
"tailwindcss-primeui": "^0.3.4",
|
||||
"ts-md5": "^1.3.1",
|
||||
"uuid": "^11.0.2",
|
||||
"vue": "^3.5.12",
|
||||
"vue-chartjs": "^5.3.2",
|
||||
"vue-i18n": "^10.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -45,5 +44,9 @@
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-dts": "^4.3.0",
|
||||
"vue-tsc": "^2.1.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.12",
|
||||
"primevue": "^4.3.9"
|
||||
}
|
||||
}
|
||||
@@ -170,7 +170,7 @@ const bool_flags: BoolFlag[] = [
|
||||
{ field: 'enable_private_mode', help: 'enable_private_mode_help' },
|
||||
]
|
||||
|
||||
const portForwardProtocolOptions = ref(["tcp","udp"]);
|
||||
const portForwardProtocolOptions = ref(["tcp", "udp"]);
|
||||
|
||||
</script>
|
||||
|
||||
@@ -178,7 +178,7 @@ const portForwardProtocolOptions = ref(["tcp","udp"]);
|
||||
<div class="frontend-lib">
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex flex-col">
|
||||
<div class="w-11/12 self-center ">
|
||||
<div class="w-full self-center ">
|
||||
<Panel :header="t('basic_settings')">
|
||||
<div class="flex flex-col gap-y-2">
|
||||
<div class="flex flex-row gap-x-9 flex-wrap">
|
||||
@@ -227,9 +227,8 @@ const portForwardProtocolOptions = ref(["tcp","udp"]);
|
||||
class="grow" multiple fluid :suggestions="peerSuggestions" @complete="searchPeerSuggestions" />
|
||||
|
||||
<AutoComplete v-if="curNetwork.networking_method === NetworkingMethod.PublicServer"
|
||||
v-model="curNetwork.public_server_url" :suggestions="publicServerSuggestions"
|
||||
class="grow" dropdown :complete-on-focus="false"
|
||||
@complete="searchPresetPublicServers" />
|
||||
v-model="curNetwork.public_server_url" :suggestions="publicServerSuggestions" class="grow"
|
||||
dropdown :complete-on-focus="false" @complete="searchPresetPublicServers" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -308,23 +307,6 @@ const portForwardProtocolOptions = ref(["tcp","udp"]);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row gap-x-9 flex-wrap">
|
||||
<div class="flex flex-col gap-2 basis-5/12 grow">
|
||||
<label for="rpc_port">{{ t('rpc_port') }}</label>
|
||||
<InputNumber id="rpc_port" v-model="curNetwork.rpc_port" aria-describedby="rpc_port-help"
|
||||
:format="false" :min="0" :max="65535" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row gap-x-9 flex-wrap w-full">
|
||||
<div class="flex flex-col gap-2 grow p-fluid">
|
||||
<label for="">{{ t('rpc_portal_whitelists') }}</label>
|
||||
<AutoComplete id="rpc_portal_whitelists" v-model="curNetwork.rpc_portal_whitelists"
|
||||
:placeholder="t('chips_placeholder', ['127.0.0.0/8'])" class="w-full" multiple fluid
|
||||
:suggestions="inetSuggestions" @complete="searchInetSuggestions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row gap-x-9 flex-wrap">
|
||||
<div class="flex flex-col gap-2 basis-5/12 grow">
|
||||
<label for="dev_name">{{ t('dev_name') }}</label>
|
||||
@@ -436,56 +418,36 @@ const portForwardProtocolOptions = ref(["tcp","udp"]);
|
||||
</div>
|
||||
<div v-for="(row, index) in curNetwork.port_forwards" class="form-row">
|
||||
<div style="display: flex; gap: 0.5rem; align-items: flex-end;">
|
||||
<SelectButton v-model="row.proto" :options="portForwardProtocolOptions" :allow-empty="false"/>
|
||||
<SelectButton v-model="row.proto" :options="portForwardProtocolOptions" :allow-empty="false" />
|
||||
<div style="flex-grow: 4;">
|
||||
<InputGroup>
|
||||
<InputText
|
||||
v-model="row.bind_ip"
|
||||
:placeholder="t('port_forwards_bind_addr')"
|
||||
/>
|
||||
<InputText v-model="row.bind_ip" :placeholder="t('port_forwards_bind_addr')" />
|
||||
<InputGroupAddon>
|
||||
<span style="font-weight: bold">:</span>
|
||||
</InputGroupAddon>
|
||||
<InputNumber v-model="row.bind_port" :format="false"
|
||||
inputId="horizontal-buttons" :step="1" mode="decimal" :min="1"
|
||||
:max="65535" fluid
|
||||
class="max-w-20"/>
|
||||
<InputNumber v-model="row.bind_port" :format="false" inputId="horizontal-buttons" :step="1"
|
||||
mode="decimal" :min="1" :max="65535" fluid class="max-w-20" />
|
||||
</InputGroup>
|
||||
</div>
|
||||
<div style="flex-grow: 4;">
|
||||
<InputGroup>
|
||||
<InputText
|
||||
v-model="row.dst_ip"
|
||||
:placeholder="t('port_forwards_dst_addr')"
|
||||
/>
|
||||
<InputText v-model="row.dst_ip" :placeholder="t('port_forwards_dst_addr')" />
|
||||
<InputGroupAddon>
|
||||
<span style="font-weight: bold">:</span>
|
||||
</InputGroupAddon>
|
||||
<InputNumber v-model="row.dst_port" :format="false"
|
||||
inputId="horizontal-buttons" :step="1" mode="decimal" :min="1"
|
||||
:max="65535" fluid
|
||||
class="max-w-20"/>
|
||||
<InputNumber v-model="row.dst_port" :format="false" inputId="horizontal-buttons" :step="1"
|
||||
mode="decimal" :min="1" :max="65535" fluid class="max-w-20" />
|
||||
</InputGroup>
|
||||
</div>
|
||||
<div style="flex-grow: 1;">
|
||||
<Button
|
||||
v-if="curNetwork.port_forwards.length > 0"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
text
|
||||
rounded
|
||||
@click="removeRow(index,curNetwork.port_forwards)"
|
||||
/>
|
||||
<Button v-if="curNetwork.port_forwards.length > 0" icon="pi pi-trash" severity="danger" text
|
||||
rounded @click="removeRow(index, curNetwork.port_forwards)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-content-end mt-4">
|
||||
<Button
|
||||
icon="pi pi-plus"
|
||||
:label="t('port_forwards_add_btn')"
|
||||
severity="success"
|
||||
@click="addRow(curNetwork.port_forwards)"
|
||||
/>
|
||||
<Button icon="pi pi-plus" :label="t('port_forwards_add_btn')" severity="success"
|
||||
@click="addRow(curNetwork.port_forwards)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
279
easytier-web/frontend-lib/src/components/NetworkChart.vue
Normal file
279
easytier-web/frontend-lib/src/components/NetworkChart.vue
Normal file
@@ -0,0 +1,279 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-blue-900/20 dark:to-indigo-800/20 rounded-xl p-4 border border-blue-200 dark:border-blue-700 shadow-md hover:shadow-lg transition-all duration-300">
|
||||
<div class="flex items-center justify-center mb-3">
|
||||
<div class="flex gap-2 text-sm">
|
||||
<span class="flex items-center gap-1 w-32">
|
||||
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span class="text-green-600 dark:text-green-400 truncate">{{ t('upload') }}: {{ currentUpload }}/s</span>
|
||||
</span>
|
||||
<span class="flex items-center gap-1 w-32">
|
||||
<div class="w-2 h-2 bg-blue-500 rounded-full"></div>
|
||||
<span class="text-blue-600 dark:text-blue-400 truncate">{{ t('download') }}: {{ currentDownload }}/s</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-32">
|
||||
<canvas ref="chartCanvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
LineController,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
} from 'chart.js'
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// 注册Chart.js组件
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
LineController,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
)
|
||||
|
||||
interface Props {
|
||||
uploadRate: string
|
||||
downloadRate: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const chartCanvas = ref<HTMLCanvasElement>()
|
||||
let chart: ChartJS | null = null
|
||||
let updateTimer: number | null = null
|
||||
|
||||
// 存储历史数据,最多保存30个数据点(1分钟历史)
|
||||
const maxDataPoints = 120
|
||||
const uploadHistory: number[] = []
|
||||
const downloadHistory: number[] = []
|
||||
const timeLabels: string[] = []
|
||||
|
||||
const currentUpload = ref('0')
|
||||
const currentDownload = ref('0')
|
||||
|
||||
// 将带单位的速率字符串转换为字节数
|
||||
function parseRateToBytes(rateStr: string): number {
|
||||
if (!rateStr || rateStr === '0') return 0
|
||||
|
||||
const match = rateStr.match(/([0-9.]+)\s*([KMGT]?i?B)/i)
|
||||
if (!match) return 0
|
||||
|
||||
const value = parseFloat(match[1])
|
||||
const unit = match[2].toUpperCase()
|
||||
|
||||
const multipliers: { [key: string]: number } = {
|
||||
'B': 1,
|
||||
'KB': 1000,
|
||||
'KIB': 1024,
|
||||
'MB': 1000000,
|
||||
'MIB': 1024 * 1024,
|
||||
'GB': 1000000000,
|
||||
'GIB': 1024 * 1024 * 1024,
|
||||
'TB': 1000000000000,
|
||||
'TIB': 1024 * 1024 * 1024 * 1024
|
||||
}
|
||||
|
||||
return value * (multipliers[unit] || 1)
|
||||
}
|
||||
|
||||
// 格式化字节为可读格式
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1) return bytes.toFixed(1) + ' B'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
// 更新数据
|
||||
function updateData() {
|
||||
const uploadBytes = parseRateToBytes(props.uploadRate)
|
||||
const downloadBytes = parseRateToBytes(props.downloadRate)
|
||||
|
||||
currentUpload.value = formatBytes(uploadBytes)
|
||||
currentDownload.value = formatBytes(downloadBytes)
|
||||
|
||||
// 添加新数据点
|
||||
uploadHistory.push(uploadBytes)
|
||||
downloadHistory.push(downloadBytes)
|
||||
|
||||
// 生成时间标签
|
||||
const now = new Date()
|
||||
const timeStr = now.toLocaleTimeString('zh-CN', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
timeLabels.push(timeStr)
|
||||
|
||||
// 保持数据点数量不超过最大值
|
||||
if (uploadHistory.length > maxDataPoints) {
|
||||
uploadHistory.shift()
|
||||
downloadHistory.shift()
|
||||
timeLabels.shift()
|
||||
}
|
||||
|
||||
// 更新图表
|
||||
if (chart) {
|
||||
chart.data.labels = timeLabels
|
||||
chart.data.datasets[0].data = uploadHistory
|
||||
chart.data.datasets[1].data = downloadHistory
|
||||
chart.update('none')
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化图表
|
||||
function initChart() {
|
||||
if (!chartCanvas.value) return
|
||||
|
||||
const ctx = chartCanvas.value.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
chart = new ChartJS(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: timeLabels,
|
||||
datasets: [
|
||||
{
|
||||
label: t('upload'),
|
||||
data: uploadHistory,
|
||||
borderColor: 'rgb(34, 197, 94)',
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4
|
||||
},
|
||||
{
|
||||
label: t('download'),
|
||||
data: downloadHistory,
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function (context: any) {
|
||||
const value = context.parsed.y
|
||||
return `${context.dataset.label}: ${formatBytes(value)}/s`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
grid: {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
maxTicksLimit: 3,
|
||||
font: {
|
||||
size: 8
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
beginAtZero: true,
|
||||
min: 0,
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
callback: function (value: any) {
|
||||
return formatBytes(value as number)
|
||||
},
|
||||
font: {
|
||||
size: 8
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
duration: 10
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 监听props变化
|
||||
watch([() => props.uploadRate, () => props.downloadRate], () => {
|
||||
updateData()
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(async () => {
|
||||
// add initial point
|
||||
const now = new Date();
|
||||
for (let i = 0; i < maxDataPoints; i++) {
|
||||
let date = new Date(now.getTime() - (maxDataPoints - i) * 2000)
|
||||
const timeStr = date.toLocaleTimeString(navigator.language, {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
uploadHistory.push(0)
|
||||
downloadHistory.push(0)
|
||||
timeLabels.push(timeStr)
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
initChart()
|
||||
updateData()
|
||||
|
||||
// 启动定时器,每2秒更新一次图表
|
||||
updateTimer = window.setInterval(() => {
|
||||
updateData()
|
||||
}, 2000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chart) {
|
||||
chart.destroy()
|
||||
}
|
||||
if (updateTimer) {
|
||||
clearInterval(updateTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
686
easytier-web/frontend-lib/src/components/RemoteManagement.vue
Normal file
686
easytier-web/frontend-lib/src/components/RemoteManagement.vue
Normal file
@@ -0,0 +1,686 @@
|
||||
<script setup lang="ts">
|
||||
import { Button, ConfirmPopup, Divider, IftaLabel, Menu, Message, Select, Tag, useConfirm, useToast, type VirtualScrollerLazyEvent } from 'primevue';
|
||||
import { computed, onMounted, onUnmounted, Ref, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import * as Api from '../modules/api';
|
||||
import * as Utils from '../modules/utils';
|
||||
import * as NetworkTypes from '../types/network';
|
||||
import { type MenuItem } from 'primevue/menuitem';
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
api: Api.RemoteClient;
|
||||
newConfigGenerator?: () => NetworkTypes.NetworkConfig;
|
||||
}>();
|
||||
|
||||
const instanceId = defineModel('instanceId', {
|
||||
type: String as () => string | undefined,
|
||||
required: false,
|
||||
})
|
||||
|
||||
const emits = defineEmits(['update']);
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const configFile = ref();
|
||||
|
||||
const curNetworkInfo = ref<NetworkTypes.NetworkInstance | null>(null);
|
||||
|
||||
const showConfigEditDialog = ref(false);
|
||||
const isEditingNetwork = ref(false); // Flag to indicate if we're in network editing mode
|
||||
const currentNetworkConfig = ref<NetworkTypes.NetworkConfig | undefined>(undefined);
|
||||
|
||||
const listInstanceIdResponse = ref<Api.ListNetworkInstanceIdResponse | undefined>(undefined);
|
||||
|
||||
const isRunning = (instanceId: string) => {
|
||||
return listInstanceIdResponse.value?.running_inst_ids.map(Utils.UuidToStr).includes(instanceId);
|
||||
}
|
||||
|
||||
const networkMetaCache = ref<Record<string, Api.NetworkMeta>>({});
|
||||
const loadNetworkMetas = async (instanceIds: string[]) => {
|
||||
const missingIds = instanceIds.filter(id => !networkMetaCache.value[id]);
|
||||
|
||||
if (missingIds.length === 0) return;
|
||||
|
||||
try {
|
||||
const response = await props.api.get_network_metas(missingIds);
|
||||
Object.assign(networkMetaCache.value, response.metas);
|
||||
} catch (e) {
|
||||
console.error("Failed to load network metas", e);
|
||||
}
|
||||
};
|
||||
const onLazyLoadNetworkMetas = async (event: VirtualScrollerLazyEvent) => {
|
||||
const instanceIds = instanceList.value
|
||||
.slice(event.first, event.last + 1)
|
||||
.map(item => item.uuid);
|
||||
await loadNetworkMetas(instanceIds);
|
||||
};
|
||||
|
||||
const instanceList = ref<Array<{ uuid: string; meta?: Api.NetworkMeta }>>([]);
|
||||
const updateInstanceList = () => {
|
||||
let insts = new Set<string>();
|
||||
let t = listInstanceIdResponse.value;
|
||||
if (t) {
|
||||
t.running_inst_ids.forEach((u) => insts.add(Utils.UuidToStr(u)));
|
||||
t.disabled_inst_ids.forEach((u) => insts.add(Utils.UuidToStr(u)));
|
||||
}
|
||||
|
||||
const newList = Array.from(insts).map((instance: string) => {
|
||||
return {
|
||||
uuid: instance,
|
||||
meta: networkMetaCache.value[instance]
|
||||
};
|
||||
});
|
||||
|
||||
if (JSON.stringify(newList) !== JSON.stringify(instanceList.value)) {
|
||||
instanceList.value = newList;
|
||||
}
|
||||
}
|
||||
watch(listInstanceIdResponse, updateInstanceList, { deep: false });
|
||||
watch(networkMetaCache, updateInstanceList, { deep: true });
|
||||
watch(instanceList, async (newVal) => {
|
||||
if (newVal) {
|
||||
const instanceIds = new Set(newVal.map(item => item.uuid));
|
||||
Object.keys(networkMetaCache.value).forEach(id => {
|
||||
if (!instanceIds.has(id)) {
|
||||
delete networkMetaCache.value[id];
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const selectedInstanceId = computed({
|
||||
get() {
|
||||
return instanceList.value.find((instance) => instance.uuid === instanceId.value);
|
||||
},
|
||||
set(value: any) {
|
||||
console.log("set instanceId", value);
|
||||
instanceId.value = value ? value.uuid : undefined;
|
||||
}
|
||||
});
|
||||
watch(selectedInstanceId, async (newVal, oldVal) => {
|
||||
if (newVal?.uuid !== oldVal?.uuid && (networkIsDisabled.value || isEditingNetwork.value)) {
|
||||
await loadCurrentNetworkConfig();
|
||||
} else {
|
||||
await loadCurrentNetworkInfo();
|
||||
}
|
||||
|
||||
if (newVal?.uuid && !networkMetaCache.value[newVal.uuid]) {
|
||||
await loadNetworkMetas([newVal.uuid]);
|
||||
}
|
||||
});
|
||||
|
||||
const needShowNetworkStatus = computed(() => {
|
||||
if (!selectedInstanceId.value) {
|
||||
// nothing selected
|
||||
return false;
|
||||
}
|
||||
if (networkIsDisabled.value) {
|
||||
// network is disabled
|
||||
return false;
|
||||
}
|
||||
if (isEditingNetwork.value) {
|
||||
// editing network
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
|
||||
const networkIsDisabled = computed(() => {
|
||||
if (!selectedInstanceId.value) {
|
||||
return false;
|
||||
}
|
||||
return listInstanceIdResponse.value?.disabled_inst_ids.map(Utils.UuidToStr).includes(selectedInstanceId.value?.uuid);
|
||||
});
|
||||
watch(networkIsDisabled, async (newVal, oldVal) => {
|
||||
if (newVal !== oldVal && newVal === true) {
|
||||
await loadCurrentNetworkConfig();
|
||||
}
|
||||
});
|
||||
|
||||
const loadCurrentNetworkConfig = async () => {
|
||||
currentNetworkConfig.value = undefined;
|
||||
|
||||
if (!selectedInstanceId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
let ret = await props.api.get_network_config(selectedInstanceId.value!.uuid);
|
||||
currentNetworkConfig.value = ret;
|
||||
}
|
||||
|
||||
const updateNetworkState = async (disabled: boolean) => {
|
||||
if (!selectedInstanceId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (disabled || !currentNetworkConfig.value) {
|
||||
await props.api.update_network_instance_state(selectedInstanceId.value.uuid, disabled);
|
||||
} else if (currentNetworkConfig.value) {
|
||||
await props.api.delete_network(currentNetworkConfig.value.instance_id);
|
||||
await props.api.run_network(currentNetworkConfig.value);
|
||||
}
|
||||
await loadNetworkInstanceIds();
|
||||
}
|
||||
|
||||
const confirm = useConfirm();
|
||||
const confirmDeleteNetwork = (event: any) => {
|
||||
confirm.require({
|
||||
target: event.currentTarget,
|
||||
message: 'Do you want to delete this network?',
|
||||
icon: 'pi pi-info-circle',
|
||||
rejectProps: {
|
||||
label: 'Cancel',
|
||||
severity: 'secondary',
|
||||
outlined: true
|
||||
},
|
||||
acceptProps: {
|
||||
label: 'Delete',
|
||||
severity: 'danger'
|
||||
},
|
||||
accept: async () => {
|
||||
try {
|
||||
await props.api.delete_network(instanceId.value!);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
emits('update');
|
||||
},
|
||||
reject: () => {
|
||||
return;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const saveAndRunNewNetwork = async () => {
|
||||
if (!currentNetworkConfig.value) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await props.api.delete_network(instanceId.value!);
|
||||
let ret = await props.api.run_network(currentNetworkConfig.value);
|
||||
console.debug("saveAndRunNewNetwork", ret);
|
||||
|
||||
delete networkMetaCache.value[currentNetworkConfig.value.instance_id];
|
||||
await loadNetworkMetas([currentNetworkConfig.value.instance_id]);
|
||||
|
||||
selectedInstanceId.value = { uuid: currentNetworkConfig.value.instance_id };
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to create network, error: ' + JSON.stringify(e.response.data), life: 2000 });
|
||||
return;
|
||||
}
|
||||
emits('update');
|
||||
// showCreateNetworkDialog.value = false;
|
||||
isEditingNetwork.value = false; // Exit creation mode after successful network creation
|
||||
}
|
||||
|
||||
const saveNetworkConfig = async () => {
|
||||
if (!currentNetworkConfig.value) {
|
||||
return;
|
||||
}
|
||||
await props.api.save_config(currentNetworkConfig.value);
|
||||
|
||||
delete networkMetaCache.value[currentNetworkConfig.value.instance_id];
|
||||
await loadNetworkMetas([currentNetworkConfig.value.instance_id]);
|
||||
|
||||
toast.add({ severity: 'success', summary: t("web.common.success"), detail: t("web.device_management.config_saved"), life: 2000 });
|
||||
}
|
||||
const newNetwork = async () => {
|
||||
const newNetworkConfig = props.newConfigGenerator?.() ?? NetworkTypes.DEFAULT_NETWORK_CONFIG();
|
||||
await props.api.save_config(newNetworkConfig);
|
||||
selectedInstanceId.value = { uuid: newNetworkConfig.instance_id };
|
||||
currentNetworkConfig.value = newNetworkConfig;
|
||||
await loadNetworkInstanceIds();
|
||||
}
|
||||
|
||||
const cancelEditNetwork = () => {
|
||||
isEditingNetwork.value = false;
|
||||
}
|
||||
|
||||
const editNetwork = async () => {
|
||||
if (!instanceId.value) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: 'No network instance selected', life: 2000 });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let ret = await props.api.get_network_config(instanceId.value!);
|
||||
console.debug("editNetwork", ret);
|
||||
currentNetworkConfig.value = ret;
|
||||
isEditingNetwork.value = true; // Switch to editing mode instead
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to edit network, error: ' + JSON.stringify(e.response.data), life: 2000 });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const loadNetworkInstanceIds = async () => {
|
||||
listInstanceIdResponse.value = await props.api.list_network_instance_ids();
|
||||
}
|
||||
|
||||
const loadCurrentNetworkInfo = async () => {
|
||||
if (!selectedInstanceId.value) {
|
||||
return;
|
||||
}
|
||||
if (!needShowNetworkStatus.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
let network_info = await props.api.get_network_info(selectedInstanceId.value.uuid);
|
||||
|
||||
curNetworkInfo.value = {
|
||||
instance_id: selectedInstanceId.value.uuid,
|
||||
running: network_info?.running ?? false,
|
||||
error_msg: network_info?.error_msg ?? '',
|
||||
detail: network_info,
|
||||
} as NetworkTypes.NetworkInstance;
|
||||
}
|
||||
|
||||
const exportConfig = async () => {
|
||||
if (!instanceId.value) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: 'No network instance selected', life: 2000 });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { instance_id, ...networkConfig } = await props.api.get_network_config(instanceId.value!);
|
||||
let { toml_config: tomlConfig, error } = await props.api.generate_config(networkConfig as NetworkTypes.NetworkConfig);
|
||||
if (error) {
|
||||
throw { response: { data: error } };
|
||||
}
|
||||
exportTomlFile(tomlConfig ?? '', instanceId.value + '.toml');
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to export network config, error: ' + JSON.stringify(e.response.data), life: 2000 });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const importConfig = () => {
|
||||
configFile.value.click();
|
||||
}
|
||||
|
||||
const handleFileUpload = (event: Event) => {
|
||||
const files = (event.target as HTMLInputElement).files;
|
||||
const file = files ? files[0] : null;
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
let tomlConfig = e.target?.result?.toString();
|
||||
if (!tomlConfig) return;
|
||||
const resp = await props.api.parse_config(tomlConfig);
|
||||
if (resp.error) {
|
||||
throw resp.error;
|
||||
}
|
||||
|
||||
const config = resp.config;
|
||||
if (!config) return;
|
||||
|
||||
config.instance_id = currentNetworkConfig.value?.instance_id ?? config?.instance_id;
|
||||
currentNetworkConfig.value = config;
|
||||
toast.add({ severity: 'success', summary: 'Import Success', detail: "Config file import success", life: 2000 });
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: 'Config file parse error: ' + error, life: 2000 });
|
||||
}
|
||||
configFile.value.value = null;
|
||||
}
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
const exportTomlFile = (context: string, name: string) => {
|
||||
let url = window.URL.createObjectURL(new Blob([context], { type: 'application/toml' }));
|
||||
let link = document.createElement('a');
|
||||
link.style.display = 'none';
|
||||
link.href = url;
|
||||
link.setAttribute('download', name);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const generateConfig = async (config: NetworkTypes.NetworkConfig): Promise<string> => {
|
||||
let { toml_config: tomlConfig, error } = await props.api.generate_config(config);
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
return tomlConfig ?? '';
|
||||
}
|
||||
|
||||
const syncTomlConfig = async (tomlConfig: string): Promise<void> => {
|
||||
let resp = await props.api.parse_config(tomlConfig);
|
||||
if (resp.error) {
|
||||
throw resp.error;
|
||||
};
|
||||
const config = resp.config;
|
||||
if (!config) {
|
||||
throw new Error("Parsed config is empty");
|
||||
}
|
||||
config.instance_id = currentNetworkConfig.value?.instance_id ?? config?.instance_id;
|
||||
currentNetworkConfig.value = config;
|
||||
}
|
||||
|
||||
// 响应式屏幕宽度
|
||||
const screenWidth = ref(window.innerWidth);
|
||||
const updateScreenWidth = () => {
|
||||
screenWidth.value = window.innerWidth;
|
||||
};
|
||||
|
||||
// 菜单引用和菜单项
|
||||
const menuRef = ref();
|
||||
const actionMenu: Ref<MenuItem[]> = ref([
|
||||
{
|
||||
label: t('web.device_management.edit_network'),
|
||||
icon: 'pi pi-pencil',
|
||||
visible: () => !(networkIsDisabled.value ?? true),
|
||||
command: () => editNetwork()
|
||||
},
|
||||
{
|
||||
label: t('web.device_management.export_config'),
|
||||
icon: 'pi pi-download',
|
||||
command: () => exportConfig()
|
||||
},
|
||||
{
|
||||
label: t('web.device_management.delete_network'),
|
||||
icon: 'pi pi-trash',
|
||||
class: 'p-error',
|
||||
command: () => confirmDeleteNetwork(new Event('click'))
|
||||
}
|
||||
]);
|
||||
|
||||
let periodFunc = new Utils.PeriodicTask(async () => {
|
||||
try {
|
||||
await Promise.all([loadNetworkInstanceIds(), loadCurrentNetworkInfo()]);
|
||||
} catch (e) {
|
||||
console.debug(e);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
onMounted(async () => {
|
||||
periodFunc.start();
|
||||
|
||||
// 添加屏幕尺寸监听
|
||||
window.addEventListener('resize', updateScreenWidth);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
periodFunc.stop();
|
||||
|
||||
// 移除屏幕尺寸监听
|
||||
window.removeEventListener('resize', updateScreenWidth);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="device-management">
|
||||
<input type="file" @change="handleFileUpload" class="hidden" accept="application/toml" ref="configFile" />
|
||||
<ConfirmPopup></ConfirmPopup>
|
||||
|
||||
<!-- 网络选择和操作按钮始终在同一行 -->
|
||||
<div class="network-header bg-surface-50 p-3 rounded-lg shadow-sm mb-1">
|
||||
<div class="flex flex-row justify-between items-center gap-2" style="align-items: center;">
|
||||
<!-- 网络选择 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<IftaLabel class="w-full">
|
||||
<Select v-model="selectedInstanceId" :options="instanceList" optionLabel="uuid" class="w-full"
|
||||
inputId="dd-inst-id" :placeholder="t('web.device_management.select_network')"
|
||||
:pt="{ root: { class: 'network-select-container' } }" :virtualScrollerOptions="{
|
||||
lazy: true,
|
||||
onLazyLoad: onLazyLoadNetworkMetas,
|
||||
itemSize: 60,
|
||||
delay: 50
|
||||
}">
|
||||
<template #value="slotProps">
|
||||
<div v-if="slotProps.value" class="flex items-center content-center min-w-0">
|
||||
<div class="mr-4 flex-col min-w-0 flex-1">
|
||||
<span class="truncate block">
|
||||
|
||||
<span v-if="slotProps.value.meta">
|
||||
{{ slotProps.value.meta.instance_name }} ({{ slotProps.value.uuid }})
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ slotProps.value.uuid }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<Tag class="my-auto leading-3 shrink-0"
|
||||
:severity="isRunning(slotProps.value.uuid) ? 'success' : 'info'"
|
||||
:value="t(isRunning(slotProps.value.uuid) ? 'network_running' : 'network_stopped')" />
|
||||
</div>
|
||||
<span v-else>
|
||||
{{ slotProps.placeholder }}
|
||||
</span>
|
||||
</template>
|
||||
<template #option="slotProps">
|
||||
<div class="flex flex-col items-start content-center max-w-full">
|
||||
<div class="flex items-center min-w-0">
|
||||
<div class="mr-4 min-w-0 flex-1">
|
||||
<span class="truncate block">{{ t('network_name') }}: {{
|
||||
slotProps.option.meta.instance_name }}</span>
|
||||
</div>
|
||||
<Tag class="my-auto leading-3 shrink-0"
|
||||
:severity="isRunning(slotProps.option.uuid) ? 'success' : 'info'"
|
||||
:value="t(isRunning(slotProps.option.uuid) ? 'network_running' : 'network_stopped')" />
|
||||
</div>
|
||||
<div class="max-w-full overflow-hidden text-ellipsis text-gray-500">
|
||||
{{ slotProps.option.uuid }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Select>
|
||||
<label class="network-label mr-2 font-medium" for="dd-inst-id">{{
|
||||
t('web.device_management.network') }}</label>
|
||||
</IftaLabel>
|
||||
</div>
|
||||
|
||||
<!-- 简化的按钮区域 - 无论屏幕大小都显示 -->
|
||||
<div class="flex gap-2 shrink-0 button-container items-center">
|
||||
<!-- Create/Cancel button based on state -->
|
||||
<Button v-if="!isEditingNetwork" @click="newNetwork" icon="pi pi-plus"
|
||||
:label="screenWidth > 640 ? t('web.device_management.create_new') : undefined"
|
||||
:class="['create-button', screenWidth <= 640 ? 'p-button-icon-only' : '']"
|
||||
:style="screenWidth <= 640 ? 'width: 3rem !important; height: 3rem !important; font-size: 1.2rem' : ''"
|
||||
:tooltip="screenWidth <= 640 ? t('web.device_management.create_network') : undefined"
|
||||
tooltipOptions="{ position: 'bottom' }" severity="primary" />
|
||||
|
||||
<Button v-else @click="cancelEditNetwork" icon="pi pi-times"
|
||||
:label="screenWidth > 640 ? t('web.device_management.cancel_edit') : undefined"
|
||||
:class="['cancel-button', screenWidth <= 640 ? 'p-button-icon-only' : '']"
|
||||
:style="screenWidth <= 640 ? 'width: 3rem !important; height: 3rem !important; font-size: 1.2rem' : ''"
|
||||
:tooltip="screenWidth <= 640 ? t('web.device_management.cancel_edit') : undefined"
|
||||
tooltipOptions="{ position: 'bottom' }" severity="secondary" />
|
||||
|
||||
<!-- More actions menu -->
|
||||
<Menu ref="menuRef" :model="actionMenu" :popup="true" />
|
||||
<Button v-if="!isEditingNetwork && selectedInstanceId" icon="pi pi-ellipsis-v"
|
||||
class="p-button-rounded flex items-center justify-center" severity="help"
|
||||
style="width: 3rem !important; height: 3rem !important; font-size: 1.2rem"
|
||||
@click="menuRef.toggle($event)" :aria-label="t('web.device_management.more_actions')"
|
||||
:tooltip="t('web.device_management.more_actions')" tooltipOptions="{ position: 'bottom' }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="network-content bg-surface-0 p-4 rounded-lg shadow-sm">
|
||||
<!-- Network Creation Form -->
|
||||
<div v-if="isEditingNetwork || networkIsDisabled" class="network-creation-container">
|
||||
<div class="network-creation-header flex items-center gap-2 mb-3">
|
||||
<i class="pi pi-plus-circle text-primary text-xl"></i>
|
||||
<h2 class="text-xl font-medium">{{ t('web.device_management.edit_network') }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex gap-2 flex-wrap justify-start mb-3">
|
||||
<Button @click="showConfigEditDialog = true" icon="pi pi-file-edit"
|
||||
:label="t('web.device_management.edit_as_file')" iconPos="left" severity="secondary" />
|
||||
<Button @click="importConfig" icon="pi pi-upload" :label="t('web.device_management.import_config')"
|
||||
iconPos="left" severity="help" />
|
||||
<Button v-if="networkIsDisabled" @click="saveNetworkConfig" icon="pi pi-save"
|
||||
:label="t('web.device_management.save_config')" iconPos="left" severity="success" />
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Config :cur-network="currentNetworkConfig" @run-network="saveAndRunNewNetwork"></Config>
|
||||
</div>
|
||||
|
||||
<!-- Network Status (for running networks) -->
|
||||
<div v-else-if="needShowNetworkStatus" class="network-status-container">
|
||||
<div class="network-status-header flex items-center gap-2 mb-3">
|
||||
<i class="pi pi-chart-line text-primary text-xl"></i>
|
||||
<h2 class="text-xl font-medium">{{ t('web.device_management.network_status') }}</h2>
|
||||
</div>
|
||||
|
||||
<Status v-if="(curNetworkInfo?.error_msg ?? '') === ''" v-bind:cur-network-inst="curNetworkInfo"
|
||||
class="mb-4">
|
||||
</Status>
|
||||
<Message v-else severity="error" class="mb-4">{{ curNetworkInfo?.error_msg }}</Message>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<Button @click="updateNetworkState(true)" :label="t('web.device_management.disable_network')"
|
||||
severity="warning" icon="pi pi-power-off" iconPos="left" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="empty-state flex flex-col items-center py-12">
|
||||
<i class="pi pi-sitemap text-5xl text-secondary mb-4 opacity-50"></i>
|
||||
<div class="text-xl text-center font-medium mb-3">{{ t('web.device_management.no_network_selected') }}
|
||||
</div>
|
||||
<p class="text-secondary text-center mb-6 max-w-md">
|
||||
{{ t('web.device_management.select_existing_network_or_create_new') }}
|
||||
</p>
|
||||
<Button @click="newNetwork" :label="t('web.device_management.create_network')" icon="pi pi-plus"
|
||||
iconPos="left" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Keep only the config edit dialogs -->
|
||||
<!-- <ConfigEditDialog v-if="networkIsDisabled" v-model:visible="showCreateNetworkDialog"
|
||||
:cur-network="currentNetworkConfig" :generate-config="generateConfig" :save-config="saveConfig" /> -->
|
||||
|
||||
<ConfigEditDialog v-model:visible="showConfigEditDialog" :cur-network="currentNetworkConfig"
|
||||
:generate-config="generateConfig" :save-config="syncTomlConfig" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.device-management {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.network-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 按钮样式 */
|
||||
.button-container {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.create-button {
|
||||
font-weight: 600;
|
||||
min-width: 3rem;
|
||||
}
|
||||
|
||||
/* 菜单样式定制 */
|
||||
:deep(.p-menu) {
|
||||
min-width: 12rem;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
:deep(.p-menu .p-menuitem) {
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
:deep(.p-menu .p-menuitem-link) {
|
||||
padding: 0.65rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
:deep(.p-menu .p-menuitem-icon) {
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
:deep(.p-menu .p-menuitem.p-error .p-menuitem-text,
|
||||
.p-menu .p-menuitem.p-error .p-menuitem-icon) {
|
||||
color: var(--red-500);
|
||||
}
|
||||
|
||||
:deep(.p-menu .p-menuitem:hover.p-error .p-menuitem-link) {
|
||||
background-color: var(--red-50);
|
||||
}
|
||||
|
||||
/* 按钮图标样式 */
|
||||
:deep(.p-button-icon-only) {
|
||||
width: 2.5rem !important;
|
||||
padding: 0.5rem !important;
|
||||
}
|
||||
|
||||
:deep(.p-button-icon-only .p-button-icon) {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* 网络选择相关样式 */
|
||||
.network-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:deep(.network-select-container) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Dark mode adaptations */
|
||||
:deep(.bg-surface-50) {
|
||||
background-color: var(--surface-50, #f8fafc);
|
||||
}
|
||||
|
||||
:deep(.bg-surface-0) {
|
||||
background-color: var(--surface-card, #ffffff);
|
||||
}
|
||||
|
||||
:deep(.text-primary) {
|
||||
color: var(--primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
:deep(.text-secondary) {
|
||||
color: var(--text-color-secondary, #64748b);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:deep(.bg-surface-50) {
|
||||
background-color: var(--surface-ground, #0f172a);
|
||||
}
|
||||
|
||||
:deep(.bg-surface-0) {
|
||||
background-color: var(--surface-card, #1e293b);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive design for mobile devices */
|
||||
@media (max-width: 768px) {
|
||||
.network-header {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.network-content {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
/* 在小屏幕上缩短网络标签文本 */
|
||||
.network-label {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { useTimeAgo } from '@vueuse/core'
|
||||
import { IPv4 } from 'ip-num/IPNumber'
|
||||
import { NetworkInstance, type NodeInfo, type PeerRoutePair } from '../types/network'
|
||||
import { NetworkInstance, type TunnelInfo, type NodeInfo, type PeerRoutePair } from '../types/network'
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { ipv4InetToString, ipv4ToString, ipv6ToString } from '../modules/utils';
|
||||
import { DataTable, Column, Tag, Chip, Button, Dialog, ScrollPanel, Timeline, Divider, Card, } from 'primevue';
|
||||
import { Badge, DataTable, Column, Tag, Chip, Button, Dialog, ScrollPanel, Timeline, Divider, Card, } from 'primevue';
|
||||
import NetworkChart from './NetworkChart.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
curNetworkInst: NetworkInstance | null,
|
||||
@@ -21,6 +22,7 @@ const peerRouteInfos = computed(() => {
|
||||
ipv4_addr: my_node_info?.virtual_ipv4,
|
||||
hostname: my_node_info?.hostname,
|
||||
version: my_node_info?.version,
|
||||
stun_info: my_node_info?.stun_info
|
||||
},
|
||||
}, ...(props.curNetworkInst.detail?.peer_route_pairs || [])]
|
||||
}
|
||||
@@ -106,8 +108,30 @@ function ipFormat(info: PeerRoutePair) {
|
||||
return ip ? `${IPv4.fromNumber(ip.address.addr)}/${ip.network_length}` : ''
|
||||
}
|
||||
|
||||
function oneTunnelProto(tunnel?: TunnelInfo): string {
|
||||
if (!tunnel)
|
||||
return ''
|
||||
|
||||
const local_addr = tunnel.local_addr
|
||||
let isIPv6 = false;
|
||||
if (local_addr?.url) {
|
||||
try {
|
||||
const urlObj = new URL(local_addr.url, 'http://dummy');
|
||||
// IPv6 addresses in URLs are enclosed in brackets and contain ':'
|
||||
isIPv6 = /^\[.*:.*\]$/.test(urlObj.hostname);
|
||||
} catch (e) {
|
||||
// fallback to original check if URL parsing fails
|
||||
isIPv6 = local_addr.url.indexOf('[') >= 0;
|
||||
}
|
||||
}
|
||||
if (isIPv6)
|
||||
return `${tunnel.tunnel_type}6`
|
||||
else
|
||||
return tunnel.tunnel_type
|
||||
}
|
||||
|
||||
function tunnelProto(info: PeerRoutePair) {
|
||||
return [...new Set(info.peer?.conns.map(c => c.tunnel?.tunnel_type))].join(',')
|
||||
return [...new Set(info.peer?.conns.map(c => oneTunnelProto(c.tunnel)))].join(',')
|
||||
}
|
||||
|
||||
const myNodeInfo = computed(() => {
|
||||
@@ -122,6 +146,34 @@ interface Chip {
|
||||
icon: string
|
||||
}
|
||||
|
||||
// udp nat type
|
||||
enum NatType {
|
||||
// has NAT; but own a single public IP, port is not changed
|
||||
Unknown = 0,
|
||||
OpenInternet = 1,
|
||||
NoPAT = 2,
|
||||
FullCone = 3,
|
||||
Restricted = 4,
|
||||
PortRestricted = 5,
|
||||
Symmetric = 6,
|
||||
SymUdpFirewall = 7,
|
||||
SymmetricEasyInc = 8,
|
||||
SymmetricEasyDec = 9,
|
||||
};
|
||||
|
||||
const udpNatTypeStrMap = {
|
||||
[NatType.Unknown]: 'Unknown',
|
||||
[NatType.OpenInternet]: 'Open Internet',
|
||||
[NatType.NoPAT]: 'No PAT',
|
||||
[NatType.FullCone]: 'Full Cone',
|
||||
[NatType.Restricted]: 'Restricted',
|
||||
[NatType.PortRestricted]: 'Port Restricted',
|
||||
[NatType.Symmetric]: 'Symmetric',
|
||||
[NatType.SymUdpFirewall]: 'Symmetric UDP Firewall',
|
||||
[NatType.SymmetricEasyInc]: 'Symmetric Easy Inc',
|
||||
[NatType.SymmetricEasyDec]: 'Symmetric Easy Dec',
|
||||
}
|
||||
|
||||
const myNodeInfoChips = computed(() => {
|
||||
if (!props.curNetworkInst)
|
||||
return []
|
||||
@@ -190,35 +242,8 @@ const myNodeInfoChips = computed(() => {
|
||||
} as Chip)
|
||||
}
|
||||
|
||||
// udp nat type
|
||||
enum NatType {
|
||||
// has NAT; but own a single public IP, port is not changed
|
||||
Unknown = 0,
|
||||
OpenInternet = 1,
|
||||
NoPAT = 2,
|
||||
FullCone = 3,
|
||||
Restricted = 4,
|
||||
PortRestricted = 5,
|
||||
Symmetric = 6,
|
||||
SymUdpFirewall = 7,
|
||||
SymmetricEasyInc = 8,
|
||||
SymmetricEasyDec = 9,
|
||||
};
|
||||
const udpNatType: NatType = my_node_info.stun_info?.udp_nat_type
|
||||
if (udpNatType !== undefined) {
|
||||
const udpNatTypeStrMap = {
|
||||
[NatType.Unknown]: 'Unknown',
|
||||
[NatType.OpenInternet]: 'Open Internet',
|
||||
[NatType.NoPAT]: 'No PAT',
|
||||
[NatType.FullCone]: 'Full Cone',
|
||||
[NatType.Restricted]: 'Restricted',
|
||||
[NatType.PortRestricted]: 'Port Restricted',
|
||||
[NatType.Symmetric]: 'Symmetric',
|
||||
[NatType.SymUdpFirewall]: 'Symmetric UDP Firewall',
|
||||
[NatType.SymmetricEasyInc]: 'Symmetric Easy Inc',
|
||||
[NatType.SymmetricEasyDec]: 'Symmetric Easy Dec',
|
||||
}
|
||||
|
||||
chips.push({
|
||||
label: `UDP NAT Type: ${udpNatTypeStrMap[udpNatType]}`,
|
||||
icon: '',
|
||||
@@ -249,6 +274,14 @@ function rxGlobalSum() {
|
||||
return globalSumCommon('stats.rx_bytes')
|
||||
}
|
||||
|
||||
function natType(info: PeerRoutePair): string {
|
||||
const udpNatType = info.route?.stun_info?.udp_nat_type;
|
||||
if (udpNatType !== undefined)
|
||||
return udpNatTypeStrMap[udpNatType as NatType]
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const peerCount = computed(() => {
|
||||
if (!peerRouteInfos.value)
|
||||
return 0
|
||||
@@ -263,6 +296,10 @@ let prevTxSum = 0
|
||||
let prevRxSum = 0
|
||||
const txRate = ref('0')
|
||||
const rxRate = ref('0')
|
||||
|
||||
// 控制节点详细信息chips的显示/隐藏
|
||||
const showNodeDetails = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
rateIntervalId = window.setInterval(() => {
|
||||
const curTxSum = txGlobalSum()
|
||||
@@ -343,36 +380,23 @@ function showEventLogs() {
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="flex w-full flex-col gap-y-5">
|
||||
<div class="m-0 flex flex-row justify-center gap-x-5">
|
||||
<div class="rounded-full w-32 h-32 flex flex-col items-center pt-6" style="border: 1px solid green">
|
||||
<div class="font-bold">
|
||||
{{ t('peer_count') }}
|
||||
</div>
|
||||
<div class="text-5xl mt-1">
|
||||
{{ peerCount }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-full w-32 h-32 flex flex-col items-center pt-6" style="border: 1px solid purple">
|
||||
<div class="font-bold">
|
||||
{{ t('upload') }}
|
||||
</div>
|
||||
<div class="text-xl mt-2">
|
||||
{{ txRate }}/s
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-full w-32 h-32 flex flex-col items-center pt-6" style="border: 1px solid fuchsia">
|
||||
<div class="font-bold">
|
||||
{{ t('download') }}
|
||||
</div>
|
||||
<div class="text-xl mt-2">
|
||||
{{ rxRate }}/s
|
||||
</div>
|
||||
<div class="gap-4">
|
||||
<!-- 网络流量图表 -->
|
||||
<div class="w-full">
|
||||
<NetworkChart :upload-rate="txRate" :download-rate="rxRate" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-center flex-wrap w-full max-h-40 overflow-scroll">
|
||||
<!-- 展开/收起节点详细信息的divider按钮 -->
|
||||
<div class="w-full">
|
||||
<Button @click="showNodeDetails = !showNodeDetails"
|
||||
:icon="showNodeDetails ? 'pi pi-chevron-up' : 'pi pi-chevron-down'"
|
||||
:label="showNodeDetails ? t('hide_node_details') : t('show_node_details')" severity="secondary" outlined
|
||||
class="w-full justify-center" size="small" />
|
||||
</div>
|
||||
|
||||
<!-- 节点详细信息chips,根据showNodeDetails状态显示/隐藏 -->
|
||||
<div v-show="showNodeDetails" class="flex flex-row items-center flex-wrap w-full max-h-40 overflow-scroll">
|
||||
<Chip v-for="(chip, i) in myNodeInfoChips" :key="i" :label="chip.label" :icon="chip.icon"
|
||||
class="mr-2 mt-2 text-sm" />
|
||||
</div>
|
||||
@@ -389,7 +413,15 @@ function showEventLogs() {
|
||||
|
||||
<Card>
|
||||
<template #title>
|
||||
{{ t('peer_info') }}
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ t('peer_info') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Badge :value="peerCount" severity="info"
|
||||
class="text-lg font-semibold px-2 py-1 rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<DataTable :value="peerRouteInfos" column-resize-mode="fit" table-class="w-full">
|
||||
@@ -417,6 +449,7 @@ function showEventLogs() {
|
||||
<Column :field="txBytes" :header="t('upload_bytes')" />
|
||||
<Column :field="rxBytes" :header="t('download_bytes')" />
|
||||
<Column :field="lossRate" :header="t('loss_rate')" />
|
||||
<Column :field="natType" :header="t('nat_type')" />
|
||||
<Column :header="t('status.version')">
|
||||
<template #body="slotProps">
|
||||
<span>{{ version(slotProps.data) }}</span>
|
||||
@@ -433,4 +466,8 @@ function showEventLogs() {
|
||||
.p-timeline :deep(.p-timeline-event-opposite) {
|
||||
@apply flex-none;
|
||||
}
|
||||
|
||||
:deep(.p-datatable .p-datatable-column-title) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { default as Config } from './Config.vue';
|
||||
export { default as Status } from './Status.vue';
|
||||
export { default as ConfigEditDialog } from './ConfigEditDialog.vue';
|
||||
export { default as RemoteManagement } from './RemoteManagement.vue';
|
||||
@@ -1,8 +1,8 @@
|
||||
import './style.css'
|
||||
|
||||
import type { App } from 'vue';
|
||||
import { Config, Status, ConfigEditDialog } from "./components";
|
||||
import Aura from '@primevue/themes/aura'
|
||||
import { Config, Status, ConfigEditDialog, RemoteManagement } from "./components";
|
||||
import Aura from '@primeuix/themes/aura';
|
||||
import PrimeVue from 'primevue/config'
|
||||
|
||||
import I18nUtils from './modules/i18n'
|
||||
@@ -44,8 +44,9 @@ export default {
|
||||
app.component('ConfigEditDialog', ConfigEditDialog);
|
||||
app.component('Status', Status);
|
||||
app.component('HumanEvent', HumanEvent);
|
||||
app.component('RemoteManagement', RemoteManagement);
|
||||
app.directive('tooltip', vTooltip as any);
|
||||
}
|
||||
};
|
||||
|
||||
export { Config, ConfigEditDialog, Status, I18nUtils, NetworkTypes, Api, Utils };
|
||||
export { Config, ConfigEditDialog, RemoteManagement, Status, I18nUtils, NetworkTypes, Api, Utils };
|
||||
|
||||
@@ -44,8 +44,12 @@ logging_open_dir: 打开日志目录
|
||||
logging_copy_dir: 复制日志路径
|
||||
disable_auto_launch: 关闭开机自启
|
||||
enable_auto_launch: 开启开机自启
|
||||
hide_dock_icon: 隐藏 Dock 图标
|
||||
show_dock_icon: 显示 Dock 图标
|
||||
exit: 退出
|
||||
chips_placeholder: 例如: {0}, 输入后在下拉框中选择生效
|
||||
show_node_details: 显示节点详细信息
|
||||
hide_node_details: 隐藏节点详细信息
|
||||
hostname_placeholder: '留空默认为主机名: {0}'
|
||||
dev_name_placeholder: 注意:当多个网络同时使用相同的TUN接口名称时,将会在设置TUN的IP时产生冲突,留空以自动生成随机名称
|
||||
off_text: 点击关闭
|
||||
@@ -74,6 +78,7 @@ latency: 延迟
|
||||
upload_bytes: 上传
|
||||
download_bytes: 下载
|
||||
loss_rate: 丢包率
|
||||
nat_type: NAT 类型
|
||||
|
||||
flags_switch: 功能开关
|
||||
|
||||
@@ -210,6 +215,7 @@ event:
|
||||
ConnectionError: 连接错误
|
||||
Connecting: 正在连接
|
||||
ConnectError: 连接错误
|
||||
VpnPortalStarted: VPN门户已启动
|
||||
VpnPortalClientConnected: VPN门户客户端已连接
|
||||
VpnPortalClientDisconnected: VPN门户客户端已断开连接
|
||||
DhcpIpv4Changed: DHCP IPv4地址更改
|
||||
@@ -283,9 +289,11 @@ web:
|
||||
network: 网络
|
||||
select_network: 选择网络
|
||||
create_network: 创建网络
|
||||
cancel_creation: 取消创建
|
||||
cancel_edit: 取消编辑
|
||||
more_actions: 更多操作
|
||||
edit_as_file: 编辑为文件
|
||||
save_config: 保存配置
|
||||
config_saved: 配置已保存
|
||||
import_config: 导入配置
|
||||
create_new: 创建新网络
|
||||
network_status: 网络状态
|
||||
|
||||
@@ -44,8 +44,12 @@ logging_open_dir: Open Log Directory
|
||||
logging_copy_dir: Copy Log Path
|
||||
disable_auto_launch: Disable Launch on Reboot
|
||||
enable_auto_launch: Enable Launch on Reboot
|
||||
hide_dock_icon: Hide Dock Icon
|
||||
show_dock_icon: Show Dock Icon
|
||||
exit: Exit
|
||||
use_latency_first: Latency First Mode
|
||||
show_node_details: Show Node Details
|
||||
hide_node_details: Hide Node Details
|
||||
chips_placeholder: 'e.g: {0}, select from the dropdown after input'
|
||||
hostname_placeholder: 'Leave blank and default to host name: {0}'
|
||||
dev_name_placeholder: 'Note: When multiple networks use the same TUN interface name at the same time, there will be a conflict when setting the TUN''s IP. Leave blank to automatically generate a random name.'
|
||||
@@ -73,6 +77,7 @@ latency: Latency
|
||||
upload_bytes: Upload
|
||||
download_bytes: Download
|
||||
loss_rate: Loss Rate
|
||||
nat_type: NAT Type
|
||||
|
||||
flags_switch: Feature Switch
|
||||
|
||||
@@ -210,6 +215,7 @@ event:
|
||||
ConnectionError: ConnectionError
|
||||
Connecting: Connecting
|
||||
ConnectError: ConnectError
|
||||
VpnPortalStarted: VpnPortalStarted
|
||||
VpnPortalClientConnected: VpnPortalClientConnected
|
||||
VpnPortalClientDisconnected: VpnPortalClientDisconnected
|
||||
DhcpIpv4Changed: DhcpIpv4Changed
|
||||
@@ -283,9 +289,11 @@ web:
|
||||
network: Network
|
||||
select_network: Select Network
|
||||
create_network: Create Network
|
||||
cancel_creation: Cancel Creation
|
||||
cancel_edit: Cancel Edit
|
||||
more_actions: More Actions
|
||||
edit_as_file: Edit as File
|
||||
save_config: Save Config
|
||||
config_saved: Config Saved
|
||||
import_config: Import Config
|
||||
create_new: Create New Network
|
||||
network_status: Network Status
|
||||
|
||||
@@ -1,241 +1,49 @@
|
||||
import axios, { AxiosError, AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
|
||||
import { Md5 } from 'ts-md5'
|
||||
import { UUID } from './utils';
|
||||
import { NetworkConfig } from '../types/network';
|
||||
import { NetworkConfig, NetworkInstanceRunningInfo } from '../types/network';
|
||||
|
||||
export interface ValidateConfigResponse {
|
||||
toml_config: string;
|
||||
}
|
||||
|
||||
// 定义接口返回的数据结构
|
||||
export interface LoginResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RegisterResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// 定义请求体数据结构
|
||||
export interface Credential {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RegisterData {
|
||||
credentials: Credential;
|
||||
captcha: string;
|
||||
}
|
||||
|
||||
export interface Summary {
|
||||
device_count: number;
|
||||
}
|
||||
|
||||
export interface ListNetworkInstanceIdResponse {
|
||||
running_inst_ids: Array<UUID>,
|
||||
disabled_inst_ids: Array<UUID>,
|
||||
}
|
||||
|
||||
export interface GenerateConfigRequest {
|
||||
config: NetworkConfig;
|
||||
}
|
||||
|
||||
export interface GenerateConfigResponse {
|
||||
toml_config?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ParseConfigRequest {
|
||||
toml_config: string;
|
||||
}
|
||||
|
||||
export interface ParseConfigResponse {
|
||||
config?: NetworkConfig;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
private client: AxiosInstance;
|
||||
private authFailedCb: Function | undefined;
|
||||
|
||||
constructor(baseUrl: string, authFailedCb: Function | undefined = undefined) {
|
||||
this.client = axios.create({
|
||||
baseURL: baseUrl + '/api/v1',
|
||||
withCredentials: true, // 如果需要支持跨域携带cookie
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
this.authFailedCb = authFailedCb;
|
||||
|
||||
// 添加请求拦截器
|
||||
this.client.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
return config;
|
||||
}, (error: any) => {
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
// 添加响应拦截器
|
||||
this.client.interceptors.response.use((response: AxiosResponse) => {
|
||||
console.debug('Axios Response:', response);
|
||||
return response.data; // 假设服务器返回的数据都在data属性中
|
||||
}, (error: any) => {
|
||||
if (error.response) {
|
||||
let response: AxiosResponse = error.response;
|
||||
if (response.status == 401 && this.authFailedCb) {
|
||||
console.error('Unauthorized:', response.data);
|
||||
this.authFailedCb();
|
||||
} else {
|
||||
// 请求已发出,但是服务器响应的状态码不在2xx范围
|
||||
console.error('Response Error:', error.response.data);
|
||||
}
|
||||
} else if (error.request) {
|
||||
// 请求已发出,但是没有收到响应
|
||||
console.error('Request Error:', error.request);
|
||||
} else {
|
||||
// 发生了一些问题导致请求未发出
|
||||
console.error('Error:', error.message);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
});
|
||||
}
|
||||
|
||||
// 注册
|
||||
public async register(data: RegisterData): Promise<RegisterResponse> {
|
||||
try {
|
||||
data.credentials.password = Md5.hashStr(data.credentials.password);
|
||||
const response = await this.client.post<RegisterResponse>('/auth/register', data);
|
||||
console.log("register response:", response);
|
||||
return { success: true, message: 'Register success', };
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
return { success: false, message: 'Failed to register, error: ' + JSON.stringify(error.response?.data), };
|
||||
}
|
||||
return { success: false, message: 'Unknown error, error: ' + error, };
|
||||
}
|
||||
}
|
||||
|
||||
// 登录
|
||||
public async login(data: Credential): Promise<LoginResponse> {
|
||||
try {
|
||||
data.password = Md5.hashStr(data.password);
|
||||
const response = await this.client.post<any>('/auth/login', data);
|
||||
console.log("login response:", response);
|
||||
return { success: true, message: 'Login success', };
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
if (error.response?.status === 401) {
|
||||
return { success: false, message: 'Invalid username or password', };
|
||||
} else {
|
||||
return { success: false, message: 'Unknown error, status code: ' + error.response?.status, };
|
||||
}
|
||||
}
|
||||
return { success: false, message: 'Unknown error, error: ' + error, };
|
||||
}
|
||||
}
|
||||
|
||||
public async logout() {
|
||||
await this.client.get('/auth/logout');
|
||||
if (this.authFailedCb) {
|
||||
this.authFailedCb();
|
||||
}
|
||||
}
|
||||
|
||||
public async change_password(new_password: string) {
|
||||
await this.client.put('/auth/password', { new_password: Md5.hashStr(new_password) });
|
||||
}
|
||||
|
||||
public async check_login_status() {
|
||||
try {
|
||||
await this.client.get('/auth/check_login_status');
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async list_session() {
|
||||
const response = await this.client.get('/sessions');
|
||||
return response;
|
||||
}
|
||||
|
||||
public async list_machines(): Promise<Array<any>> {
|
||||
const response = await this.client.get<any, Record<string, Array<any>>>('/machines');
|
||||
return response.machines;
|
||||
}
|
||||
|
||||
public async list_deivce_instance_ids(machine_id: string): Promise<ListNetworkInstanceIdResponse> {
|
||||
const response = await this.client.get<any, ListNetworkInstanceIdResponse>('/machines/' + machine_id + '/networks');
|
||||
return response;
|
||||
}
|
||||
|
||||
public async update_device_instance_state(machine_id: string, inst_id: string, disabled: boolean): Promise<undefined> {
|
||||
await this.client.put<string>('/machines/' + machine_id + '/networks/' + inst_id, {
|
||||
disabled: disabled,
|
||||
});
|
||||
}
|
||||
|
||||
public async get_network_info(machine_id: string, inst_id: string): Promise<any> {
|
||||
const response = await this.client.get<any, Record<string, any>>('/machines/' + machine_id + '/networks/info/' + inst_id);
|
||||
return response.info.map;
|
||||
}
|
||||
|
||||
public async get_network_config(machine_id: string, inst_id: string): Promise<any> {
|
||||
const response = await this.client.get<any, Record<string, any>>('/machines/' + machine_id + '/networks/config/' + inst_id);
|
||||
return response;
|
||||
}
|
||||
|
||||
public async validate_config(machine_id: string, config: any): Promise<ValidateConfigResponse> {
|
||||
const response = await this.client.post<any, ValidateConfigResponse>(`/machines/${machine_id}/validate-config`, {
|
||||
config: config,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
public async run_network(machine_id: string, config: any): Promise<undefined> {
|
||||
await this.client.post<string>(`/machines/${machine_id}/networks`, {
|
||||
config: config,
|
||||
});
|
||||
}
|
||||
|
||||
public async delete_network(machine_id: string, inst_id: string): Promise<undefined> {
|
||||
await this.client.delete<string>(`/machines/${machine_id}/networks/${inst_id}`);
|
||||
}
|
||||
|
||||
public async get_summary(): Promise<Summary> {
|
||||
const response = await this.client.get<any, Summary>('/summary');
|
||||
return response;
|
||||
}
|
||||
|
||||
public captcha_url() {
|
||||
return this.client.defaults.baseURL + '/auth/captcha';
|
||||
}
|
||||
|
||||
public async generate_config(config: GenerateConfigRequest): Promise<GenerateConfigResponse> {
|
||||
try {
|
||||
const response = await this.client.post<any, GenerateConfigResponse>('/generate-config', config);
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
return { error: error.response?.data };
|
||||
}
|
||||
return { error: 'Unknown error: ' + error };
|
||||
}
|
||||
}
|
||||
|
||||
public async parse_config(config: ParseConfigRequest): Promise<ParseConfigResponse> {
|
||||
try {
|
||||
const response = await this.client.post<any, ParseConfigResponse>('/parse-config', config);
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
return { error: error.response?.data };
|
||||
}
|
||||
return { error: 'Unknown error: ' + error };
|
||||
}
|
||||
export interface CollectNetworkInfoResponse {
|
||||
info: {
|
||||
map: Record<string, NetworkInstanceRunningInfo | undefined>;
|
||||
}
|
||||
}
|
||||
|
||||
export default ApiClient;
|
||||
export interface NetworkMeta {
|
||||
instance_name: string;
|
||||
}
|
||||
|
||||
export interface GetNetworkMetasResponse {
|
||||
metas: Record<string, NetworkMeta>;
|
||||
}
|
||||
|
||||
export interface RemoteClient {
|
||||
validate_config(config: NetworkConfig): Promise<ValidateConfigResponse>;
|
||||
run_network(config: NetworkConfig): Promise<undefined>;
|
||||
get_network_info(inst_id: string): Promise<NetworkInstanceRunningInfo | undefined>;
|
||||
list_network_instance_ids(): Promise<ListNetworkInstanceIdResponse>;
|
||||
delete_network(inst_id: string): Promise<undefined>;
|
||||
update_network_instance_state(inst_id: string, disabled: boolean): Promise<undefined>;
|
||||
save_config(config: NetworkConfig): Promise<undefined>;
|
||||
get_network_config(inst_id: string): Promise<NetworkConfig>;
|
||||
generate_config(config: NetworkConfig): Promise<GenerateConfigResponse>;
|
||||
parse_config(toml_config: string): Promise<ParseConfigResponse>;
|
||||
get_network_metas(instance_ids: string[]): Promise<GetNetworkMetasResponse>;
|
||||
}
|
||||
@@ -31,7 +31,6 @@ export interface NetworkConfig {
|
||||
advanced_settings: boolean
|
||||
|
||||
listener_urls: string[]
|
||||
rpc_port: number
|
||||
latency_first: boolean
|
||||
|
||||
dev_name: string
|
||||
@@ -70,8 +69,6 @@ export interface NetworkConfig {
|
||||
enable_magic_dns?: boolean
|
||||
enable_private_mode?: boolean
|
||||
|
||||
rpc_portal_whitelists: string[]
|
||||
|
||||
port_forwards: PortForwardConfig[]
|
||||
}
|
||||
|
||||
@@ -104,7 +101,6 @@ export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
|
||||
'udp://0.0.0.0:11010',
|
||||
'wg://0.0.0.0:11011',
|
||||
],
|
||||
rpc_port: 0,
|
||||
latency_first: false,
|
||||
dev_name: '',
|
||||
|
||||
@@ -135,7 +131,6 @@ export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
|
||||
mapped_listeners: [],
|
||||
enable_magic_dns: false,
|
||||
enable_private_mode: false,
|
||||
rpc_portal_whitelists: [],
|
||||
port_forwards: [],
|
||||
}
|
||||
}
|
||||
@@ -246,10 +241,14 @@ export interface PeerRoutePair {
|
||||
peer?: PeerInfo
|
||||
}
|
||||
|
||||
export interface UrlPb {
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface TunnelInfo {
|
||||
tunnel_type: string
|
||||
local_addr: string
|
||||
remote_addr: string
|
||||
local_addr: UrlPb
|
||||
remote_addr: UrlPb
|
||||
}
|
||||
|
||||
export interface PeerConnStats {
|
||||
@@ -302,6 +301,7 @@ export enum EventType {
|
||||
Connecting = 'Connecting', // any
|
||||
ConnectError = 'ConnectError', // string, string, string
|
||||
|
||||
VpnPortalStarted = 'VpnPortalStarted', // string
|
||||
VpnPortalClientConnected = 'VpnPortalClientConnected', // string, string
|
||||
VpnPortalClientDisconnected = 'VpnPortalClientDisconnected', // string, string, string
|
||||
|
||||
|
||||
@@ -24,12 +24,13 @@ export default defineConfig({
|
||||
},
|
||||
// make sure to externalize deps that shouldn't be bundled
|
||||
// into your library
|
||||
external: ['vue'],
|
||||
external: ['vue', 'primevue'],
|
||||
output: {
|
||||
// Provide global variables to use in the UMD build
|
||||
// for externalized deps
|
||||
globals: {
|
||||
vue: 'Vue',
|
||||
primevue: 'primevue',
|
||||
},
|
||||
exports: "named"
|
||||
},
|
||||
|
||||
@@ -9,18 +9,19 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@primevue/themes": "4.3.3",
|
||||
"aura": "link:@primevue/themes/aura",
|
||||
"@modyfi/vite-plugin-yaml": "^1.1.0",
|
||||
"@primeuix/themes": "^1.2.3",
|
||||
"axios": "^1.7.7",
|
||||
"easytier-frontend-lib": "workspace:*",
|
||||
"primevue": "4.3.3",
|
||||
"primevue": "^4.3.9",
|
||||
"tailwindcss-primeui": "^0.3.4",
|
||||
"ts-md5": "^1.3.1",
|
||||
"vue": "^3.5.12",
|
||||
"vue-router": "4",
|
||||
"vue-i18n": "^9.9.1",
|
||||
"@modyfi/vite-plugin-yaml": "^1.1.0"
|
||||
"vue-router": "4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@primevue/auto-import-resolver": "4.3.9",
|
||||
"@types/node": "^22.8.6",
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject, ref } from 'vue';
|
||||
import { Card, Password, Button } from 'primevue';
|
||||
import { Api } from 'easytier-frontend-lib';
|
||||
import ApiClient from '../modules/api';
|
||||
|
||||
const dialogRef = inject<any>('dialogRef');
|
||||
|
||||
const api = computed<Api.ApiClient>(() => dialogRef.value.data.api);
|
||||
const api = computed<ApiClient>(() => dialogRef.value.data.api);
|
||||
|
||||
const password = ref('');
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { NetworkTypes } from 'easytier-frontend-lib';
|
||||
import {computed, ref} from 'vue';
|
||||
import { Api } from 'easytier-frontend-lib'
|
||||
import {AutoComplete, Divider, Button, Textarea} from "primevue";
|
||||
import {getInitialApiHost, cleanAndLoadApiHosts, saveApiHost} from "../modules/api-host"
|
||||
import { computed, ref } from 'vue';
|
||||
import { AutoComplete, Divider, Button, Textarea } from "primevue";
|
||||
import { getInitialApiHost, cleanAndLoadApiHosts, saveApiHost } from "../modules/api-host"
|
||||
import ApiClient from '../modules/api';
|
||||
|
||||
const api = computed<Api.ApiClient>(() => new Api.ApiClient(apiHost.value));
|
||||
const api = computed<ApiClient>(() => new ApiClient(apiHost.value));
|
||||
|
||||
const apiHost = ref<string>(getInitialApiHost())
|
||||
const apiHostSuggestions = ref<Array<string>>([])
|
||||
@@ -27,28 +27,24 @@ const errorMessage = ref<string>("");
|
||||
const generateConfig = (config: NetworkTypes.NetworkConfig) => {
|
||||
saveApiHost(apiHost.value)
|
||||
errorMessage.value = "";
|
||||
api.value?.generate_config({
|
||||
config: config
|
||||
}).then((res) => {
|
||||
if (res.error) {
|
||||
errorMessage.value = "Generation failed: " + res.error;
|
||||
} else if (res.toml_config) {
|
||||
toml_config.value = res.toml_config;
|
||||
} else {
|
||||
errorMessage.value = "Api server returned an unexpected response";
|
||||
}
|
||||
}).catch(err => {
|
||||
errorMessage.value = "Generate request failed: " + (err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
api.value?.get_remote_client("").generate_config(config).then((res) => {
|
||||
if (res.error) {
|
||||
errorMessage.value = "Generation failed: " + res.error;
|
||||
} else if (res.toml_config) {
|
||||
toml_config.value = res.toml_config;
|
||||
} else {
|
||||
errorMessage.value = "Api server returned an unexpected response";
|
||||
}
|
||||
}).catch(err => {
|
||||
errorMessage.value = "Generate request failed: " + (err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
};
|
||||
|
||||
const parseConfig = async () => {
|
||||
try {
|
||||
errorMessage.value = "";
|
||||
const res = await api.value?.parse_config({
|
||||
toml_config: toml_config.value
|
||||
});
|
||||
|
||||
const res = await api.value?.get_remote_client("").parse_config(toml_config.value);
|
||||
|
||||
if (res.error) {
|
||||
errorMessage.value = "Parse failed: " + res.error;
|
||||
} else if (res.config) {
|
||||
@@ -64,31 +60,29 @@ const parseConfig = async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-center m-5">
|
||||
<div class="sm:block md:flex w-full">
|
||||
<div class="sm:w-full md:w-1/2 p-4">
|
||||
<div class="flex flex-col">
|
||||
<div class="w-11/12 self-center ">
|
||||
<label>ApiHost</label>
|
||||
<AutoComplete id="api-host" v-model="apiHost" dropdown :suggestions="apiHostSuggestions"
|
||||
@complete="apiHostSearch" class="w-full" />
|
||||
<Divider />
|
||||
</div>
|
||||
</div>
|
||||
<Config :cur-network="newNetworkConfig" @run-network="generateConfig" />
|
||||
</div>
|
||||
<div class="sm:w-full md:w-1/2 p-4 flex flex-col h-[calc(100vh-80px)]">
|
||||
<pre v-if="errorMessage" class="mb-2 p-2 rounded text-sm overflow-auto bg-red-100 text-red-700 max-h-40">{{ errorMessage }}</pre>
|
||||
<Textarea
|
||||
v-model="toml_config"
|
||||
spellcheck="false"
|
||||
class="w-full flex-grow p-2 whitespace-pre-wrap font-mono resize-none"
|
||||
placeholder="Press 'Run Network' to generate TOML configuration, or paste your TOML configuration here to parse it"
|
||||
></Textarea>
|
||||
<div class="mt-3 flex justify-center">
|
||||
<Button label="Parse Config" icon="pi pi-arrow-left" icon-pos="left" @click="parseConfig" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-center m-5">
|
||||
<div class="sm:block md:flex w-full">
|
||||
<div class="sm:w-full md:w-1/2 p-4">
|
||||
<div class="flex flex-col">
|
||||
<div class="w-full self-center ">
|
||||
<label>ApiHost</label>
|
||||
<AutoComplete id="api-host" v-model="apiHost" dropdown :suggestions="apiHostSuggestions"
|
||||
@complete="apiHostSearch" class="w-full" />
|
||||
<Divider />
|
||||
</div>
|
||||
</div>
|
||||
<Config :cur-network="newNetworkConfig" @run-network="generateConfig" />
|
||||
</div>
|
||||
<div class="sm:w-full md:w-1/2 p-4 flex flex-col h-[calc(100vh-80px)]">
|
||||
<pre v-if="errorMessage"
|
||||
class="mb-2 p-2 rounded text-sm overflow-auto bg-red-100 text-red-700 max-h-40">{{ errorMessage }}</pre>
|
||||
<Textarea v-model="toml_config" spellcheck="false"
|
||||
class="w-full flex-grow p-2 whitespace-pre-wrap font-mono resize-none"
|
||||
placeholder="Press 'Run Network' to generate TOML configuration, or paste your TOML configuration here to parse it"></Textarea>
|
||||
<div class="mt-3 flex justify-center">
|
||||
<Button label="Parse Config" icon="pi pi-arrow-left" icon-pos="left" @click="parseConfig" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { Card, useToast } from 'primevue';
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { Api, Utils } from 'easytier-frontend-lib';
|
||||
import { Utils } from 'easytier-frontend-lib';
|
||||
import ApiClient, { Summary } from '../modules/api';
|
||||
|
||||
const props = defineProps({
|
||||
api: Api.ApiClient,
|
||||
api: ApiClient,
|
||||
});
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const summary = ref<Api.Summary | undefined>(undefined);
|
||||
const summary = ref<Summary | undefined>(undefined);
|
||||
|
||||
const loadSummary = async () => {
|
||||
const resp = await props.api?.get_summary();
|
||||
|
||||
@@ -3,9 +3,10 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { Button, Drawer, ProgressSpinner, useToast, InputSwitch, Popover, Dropdown, Toolbar } from 'primevue';
|
||||
import Tooltip from 'primevue/tooltip';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { Api, Utils } from 'easytier-frontend-lib';
|
||||
import { Utils } from 'easytier-frontend-lib';
|
||||
import DeviceDetails from './DeviceDetails.vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ApiClient from '../modules/api';
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -15,7 +16,7 @@ declare const window: Window & typeof globalThis;
|
||||
const vTooltip = Tooltip;
|
||||
|
||||
const props = defineProps({
|
||||
api: Api.ApiClient,
|
||||
api: ApiClient,
|
||||
});
|
||||
|
||||
const detailPopover = ref();
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { IftaLabel, Select, Button, ConfirmPopup, useConfirm, useToast, Divider, Menu } from 'primevue';
|
||||
import { NetworkTypes, Status, Utils, Api, ConfigEditDialog } from 'easytier-frontend-lib';
|
||||
import { watch, computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { NetworkTypes, Utils, Api, RemoteManagement } from 'easytier-frontend-lib';
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ApiClient from '../modules/api';
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
api: Api.ApiClient;
|
||||
api: ApiClient;
|
||||
deviceList: Array<Utils.DeviceInfo> | undefined;
|
||||
}>();
|
||||
|
||||
@@ -16,7 +14,6 @@ const emits = defineEmits(['update']);
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
|
||||
const deviceId = computed<string>(() => {
|
||||
return route.params.deviceId as string;
|
||||
@@ -30,469 +27,29 @@ const deviceInfo = computed<Utils.DeviceInfo | undefined | null>(() => {
|
||||
return deviceId.value ? props.deviceList?.find((device) => device.machine_id === deviceId.value) : null;
|
||||
});
|
||||
|
||||
const configFile = ref();
|
||||
|
||||
const curNetworkInfo = ref<NetworkTypes.NetworkInstance | null>(null);
|
||||
|
||||
const isEditing = ref(false);
|
||||
const showCreateNetworkDialog = ref(false);
|
||||
const showConfigEditDialog = ref(false);
|
||||
const isCreatingNetwork = ref(false); // Flag to indicate if we're in network creation mode
|
||||
const newNetworkConfig = ref<NetworkTypes.NetworkConfig>(NetworkTypes.DEFAULT_NETWORK_CONFIG());
|
||||
|
||||
const listInstanceIdResponse = ref<Api.ListNetworkInstanceIdResponse | undefined>(undefined);
|
||||
|
||||
const instanceIdList = computed(() => {
|
||||
let insts = new Set(deviceInfo.value?.running_network_instances || []);
|
||||
let t = listInstanceIdResponse.value;
|
||||
if (t) {
|
||||
t.running_inst_ids.forEach((u) => insts.add(Utils.UuidToStr(u)));
|
||||
t.disabled_inst_ids.forEach((u) => insts.add(Utils.UuidToStr(u)));
|
||||
}
|
||||
let options = Array.from(insts).map((instance: string) => {
|
||||
return { uuid: instance };
|
||||
});
|
||||
return options;
|
||||
});
|
||||
|
||||
const selectedInstanceId = computed({
|
||||
get() {
|
||||
return instanceIdList.value.find((instance) => instance.uuid === instanceId.value);
|
||||
return instanceId.value;
|
||||
},
|
||||
set(value: any) {
|
||||
console.log("set instanceId", value);
|
||||
router.push({ name: 'deviceManagement', params: { deviceId: deviceId.value, instanceId: value.uuid } });
|
||||
set(value: string) {
|
||||
console.log("selectedInstanceId", value);
|
||||
router.push({ name: 'deviceManagement', params: { deviceId: deviceId.value, instanceId: value } });
|
||||
}
|
||||
});
|
||||
|
||||
const needShowNetworkStatus = computed(() => {
|
||||
if (!selectedInstanceId.value) {
|
||||
// nothing selected
|
||||
return false;
|
||||
}
|
||||
if (networkIsDisabled.value) {
|
||||
// network is disabled
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
const remoteClient = computed<Api.RemoteClient>(() => props.api.get_remote_client(deviceId.value));
|
||||
|
||||
const networkIsDisabled = computed(() => {
|
||||
if (!selectedInstanceId.value) {
|
||||
return false;
|
||||
}
|
||||
return listInstanceIdResponse.value?.disabled_inst_ids.map(Utils.UuidToStr).includes(selectedInstanceId.value?.uuid);
|
||||
});
|
||||
|
||||
watch(selectedInstanceId, async (newVal, oldVal) => {
|
||||
if (newVal?.uuid !== oldVal?.uuid && networkIsDisabled.value) {
|
||||
await loadDisabledNetworkConfig();
|
||||
}
|
||||
});
|
||||
|
||||
const disabledNetworkConfig = ref<NetworkTypes.NetworkConfig | undefined>(undefined);
|
||||
|
||||
const loadDisabledNetworkConfig = async () => {
|
||||
disabledNetworkConfig.value = undefined;
|
||||
|
||||
if (!deviceId.value || !selectedInstanceId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
let ret = await props.api?.get_network_config(deviceId.value, selectedInstanceId.value.uuid);
|
||||
disabledNetworkConfig.value = ret;
|
||||
const newConfigGenerator = () => {
|
||||
const config = NetworkTypes.DEFAULT_NETWORK_CONFIG();
|
||||
config.hostname = deviceInfo.value?.hostname;
|
||||
return config;
|
||||
}
|
||||
|
||||
const updateNetworkState = async (disabled: boolean) => {
|
||||
if (!deviceId.value || !selectedInstanceId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (disabled || !disabledNetworkConfig.value) {
|
||||
await props.api?.update_device_instance_state(deviceId.value, selectedInstanceId.value.uuid, disabled);
|
||||
} else if (disabledNetworkConfig.value) {
|
||||
await props.api?.delete_network(deviceId.value, disabledNetworkConfig.value.instance_id);
|
||||
await props.api?.run_network(deviceId.value, disabledNetworkConfig.value);
|
||||
}
|
||||
await loadNetworkInstanceIds();
|
||||
}
|
||||
|
||||
const confirm = useConfirm();
|
||||
const confirmDeleteNetwork = (event: any) => {
|
||||
confirm.require({
|
||||
target: event.currentTarget,
|
||||
message: 'Do you want to delete this network?',
|
||||
icon: 'pi pi-info-circle',
|
||||
rejectProps: {
|
||||
label: 'Cancel',
|
||||
severity: 'secondary',
|
||||
outlined: true
|
||||
},
|
||||
acceptProps: {
|
||||
label: 'Delete',
|
||||
severity: 'danger'
|
||||
},
|
||||
accept: async () => {
|
||||
try {
|
||||
await props.api?.delete_network(deviceId.value, instanceId.value);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
emits('update');
|
||||
},
|
||||
reject: () => {
|
||||
return;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// const verifyNetworkConfig = async (): Promise<ValidateConfigResponse | undefined> => {
|
||||
// let ret = await props.api?.validate_config(deviceId.value, newNetworkConfig.value);
|
||||
// console.log("verifyNetworkConfig", ret);
|
||||
// return ret;
|
||||
// }
|
||||
|
||||
const createNewNetwork = async () => {
|
||||
try {
|
||||
if (isEditing.value) {
|
||||
await props.api?.delete_network(deviceId.value, instanceId.value);
|
||||
}
|
||||
let ret = await props.api?.run_network(deviceId.value, newNetworkConfig.value);
|
||||
console.debug("createNewNetwork", ret);
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to create network, error: ' + JSON.stringify(e.response.data), life: 2000 });
|
||||
return;
|
||||
}
|
||||
emits('update');
|
||||
showCreateNetworkDialog.value = false;
|
||||
isCreatingNetwork.value = false; // Exit creation mode after successful network creation
|
||||
}
|
||||
|
||||
const newNetwork = () => {
|
||||
newNetworkConfig.value = NetworkTypes.DEFAULT_NETWORK_CONFIG();
|
||||
newNetworkConfig.value.hostname = deviceInfo.value?.hostname;
|
||||
isEditing.value = false;
|
||||
// showCreateNetworkDialog.value = true; // Old dialog approach
|
||||
isCreatingNetwork.value = true; // Switch to creation mode instead
|
||||
}
|
||||
|
||||
const cancelNetworkCreation = () => {
|
||||
isCreatingNetwork.value = false;
|
||||
}
|
||||
|
||||
const editNetwork = async () => {
|
||||
if (!deviceId.value || !instanceId.value) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: 'No network instance selected', life: 2000 });
|
||||
return;
|
||||
}
|
||||
|
||||
isEditing.value = true;
|
||||
|
||||
try {
|
||||
let ret = await props.api?.get_network_config(deviceId.value, instanceId.value);
|
||||
console.debug("editNetwork", ret);
|
||||
newNetworkConfig.value = ret;
|
||||
// showCreateNetworkDialog.value = true; // Old dialog approach
|
||||
isCreatingNetwork.value = true; // Switch to creation mode instead
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to edit network, error: ' + JSON.stringify(e.response.data), life: 2000 });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const loadNetworkInstanceIds = async () => {
|
||||
if (!deviceId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
listInstanceIdResponse.value = await props.api?.list_deivce_instance_ids(deviceId.value);
|
||||
console.debug("loadNetworkInstanceIds", listInstanceIdResponse.value);
|
||||
}
|
||||
|
||||
const loadDeviceInfo = async () => {
|
||||
if (!deviceId.value || !instanceId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
let ret = await props.api?.get_network_info(deviceId.value, instanceId.value);
|
||||
let device_info = ret[instanceId.value];
|
||||
|
||||
curNetworkInfo.value = {
|
||||
instance_id: instanceId.value,
|
||||
running: device_info.running,
|
||||
error_msg: device_info.error_msg,
|
||||
detail: device_info,
|
||||
} as NetworkTypes.NetworkInstance;
|
||||
}
|
||||
|
||||
const exportConfig = async () => {
|
||||
if (!deviceId.value || !instanceId.value) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: 'No network instance selected', life: 2000 });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let networkConfig = await props.api?.get_network_config(deviceId.value, instanceId.value);
|
||||
delete networkConfig.instance_id;
|
||||
let { toml_config: tomlConfig, error } = await props.api?.generate_config({
|
||||
config: networkConfig
|
||||
});
|
||||
if (error) {
|
||||
throw { response: { data: error } };
|
||||
}
|
||||
exportTomlFile(tomlConfig ?? '', instanceId.value + '.toml');
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to export network config, error: ' + JSON.stringify(e.response.data), life: 2000 });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const importConfig = () => {
|
||||
configFile.value.click();
|
||||
}
|
||||
|
||||
const handleFileUpload = (event: Event) => {
|
||||
const files = (event.target as HTMLInputElement).files;
|
||||
const file = files ? files[0] : null;
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
let tomlConfig = e.target?.result?.toString();
|
||||
if (!tomlConfig) return;
|
||||
const resp = await props.api?.parse_config({ toml_config: tomlConfig });
|
||||
if (resp.error) {
|
||||
throw resp.error;
|
||||
}
|
||||
|
||||
const config = resp.config;
|
||||
if (!config) return;
|
||||
|
||||
config.instance_id = newNetworkConfig.value?.instance_id ?? config?.instance_id;
|
||||
|
||||
Object.assign(newNetworkConfig.value, resp.config);
|
||||
toast.add({ severity: 'success', summary: 'Import Success', detail: "Config file import success", life: 2000 });
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: 'Config file parse error: ' + error, life: 2000 });
|
||||
}
|
||||
configFile.value.value = null;
|
||||
}
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
const exportTomlFile = (context: string, name: string) => {
|
||||
let url = window.URL.createObjectURL(new Blob([context], { type: 'application/toml' }));
|
||||
let link = document.createElement('a');
|
||||
link.style.display = 'none';
|
||||
link.href = url;
|
||||
link.setAttribute('download', name);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const generateConfig = async (config: NetworkTypes.NetworkConfig): Promise<string> => {
|
||||
let { toml_config: tomlConfig, error } = await props.api?.generate_config({ config });
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
return tomlConfig ?? '';
|
||||
}
|
||||
|
||||
const saveConfig = async (tomlConfig: string): Promise<void> => {
|
||||
let resp = await props.api?.parse_config({ toml_config: tomlConfig });
|
||||
if (resp.error) {
|
||||
throw resp.error;
|
||||
};
|
||||
const config = resp.config;
|
||||
if (!config) {
|
||||
throw new Error("Parsed config is empty");
|
||||
}
|
||||
config.instance_id = disabledNetworkConfig.value?.instance_id ?? config?.instance_id;
|
||||
if (networkIsDisabled.value) {
|
||||
disabledNetworkConfig.value = config;
|
||||
} else {
|
||||
newNetworkConfig.value = config;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式屏幕宽度
|
||||
const screenWidth = ref(window.innerWidth);
|
||||
const updateScreenWidth = () => {
|
||||
screenWidth.value = window.innerWidth;
|
||||
};
|
||||
|
||||
// 菜单引用和菜单项
|
||||
const menuRef = ref();
|
||||
const actionMenu = ref([
|
||||
{
|
||||
label: t('web.device_management.edit_network'),
|
||||
icon: 'pi pi-pencil',
|
||||
command: () => editNetwork()
|
||||
},
|
||||
{
|
||||
label: t('web.device_management.export_config'),
|
||||
icon: 'pi pi-download',
|
||||
command: () => exportConfig()
|
||||
},
|
||||
{
|
||||
label: t('web.device_management.delete_network'),
|
||||
icon: 'pi pi-trash',
|
||||
class: 'p-error',
|
||||
command: () => confirmDeleteNetwork(new Event('click'))
|
||||
}
|
||||
]);
|
||||
|
||||
let periodFunc = new Utils.PeriodicTask(async () => {
|
||||
try {
|
||||
await Promise.all([loadNetworkInstanceIds(), loadDeviceInfo()]);
|
||||
} catch (e) {
|
||||
console.debug(e);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
onMounted(async () => {
|
||||
periodFunc.start();
|
||||
|
||||
// 添加屏幕尺寸监听
|
||||
window.addEventListener('resize', updateScreenWidth);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
periodFunc.stop();
|
||||
|
||||
// 移除屏幕尺寸监听
|
||||
window.removeEventListener('resize', updateScreenWidth);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="device-management">
|
||||
<input type="file" @change="handleFileUpload" class="hidden" accept="application/toml" ref="configFile" />
|
||||
<ConfirmPopup></ConfirmPopup>
|
||||
|
||||
<!-- 网络选择和操作按钮始终在同一行 -->
|
||||
<div class="network-header bg-surface-50 p-3 rounded-lg shadow-sm mb-1">
|
||||
<div class="flex flex-row justify-between items-center gap-2" style="align-items: center;">
|
||||
<!-- 网络选择 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<IftaLabel class="w-full">
|
||||
<Select v-model="selectedInstanceId" :options="instanceIdList" optionLabel="uuid" class="w-full"
|
||||
inputId="dd-inst-id" :placeholder="t('web.device_management.select_network')"
|
||||
:pt="{ root: { class: 'network-select-container' } }" />
|
||||
<label class="network-label mr-2 font-medium" for="dd-inst-id">{{
|
||||
t('web.device_management.network') }}</label>
|
||||
</IftaLabel>
|
||||
</div>
|
||||
|
||||
<!-- 简化的按钮区域 - 无论屏幕大小都显示 -->
|
||||
<div class="flex gap-2 shrink-0 button-container items-center">
|
||||
<!-- Create/Cancel button based on state -->
|
||||
<Button v-if="!isCreatingNetwork" @click="newNetwork" icon="pi pi-plus"
|
||||
:label="screenWidth > 640 ? t('web.device_management.create_new') : undefined"
|
||||
:class="['create-button', screenWidth <= 640 ? 'p-button-icon-only' : '']"
|
||||
:style="screenWidth <= 640 ? 'width: 3rem !important; height: 3rem !important; font-size: 1.2rem' : ''"
|
||||
:tooltip="screenWidth <= 640 ? t('web.device_management.create_network') : undefined"
|
||||
tooltipOptions="{ position: 'bottom' }" severity="primary" />
|
||||
|
||||
<Button v-else @click="cancelNetworkCreation" icon="pi pi-times"
|
||||
:label="screenWidth > 640 ? t('web.device_management.cancel_creation') : undefined"
|
||||
:class="['cancel-button', screenWidth <= 640 ? 'p-button-icon-only' : '']"
|
||||
:style="screenWidth <= 640 ? 'width: 3rem !important; height: 3rem !important; font-size: 1.2rem' : ''"
|
||||
:tooltip="screenWidth <= 640 ? t('web.device_management.cancel_creation') : undefined"
|
||||
tooltipOptions="{ position: 'bottom' }" severity="secondary" />
|
||||
|
||||
<!-- More actions menu -->
|
||||
<Menu ref="menuRef" :model="actionMenu" :popup="true" />
|
||||
<Button v-if="!isCreatingNetwork && selectedInstanceId" icon="pi pi-ellipsis-v"
|
||||
class="p-button-rounded flex items-center justify-center" severity="help"
|
||||
style="width: 3rem !important; height: 3rem !important; font-size: 1.2rem"
|
||||
@click="menuRef.toggle($event)" :aria-label="t('web.device_management.more_actions')"
|
||||
:tooltip="t('web.device_management.more_actions')" tooltipOptions="{ position: 'bottom' }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="network-content bg-surface-0 p-4 rounded-lg shadow-sm">
|
||||
<!-- Network Creation Form -->
|
||||
<div v-if="isCreatingNetwork" class="network-creation-container">
|
||||
<div class="network-creation-header flex items-center gap-2 mb-3">
|
||||
<i class="pi pi-plus-circle text-primary text-xl"></i>
|
||||
<h2 class="text-xl font-medium">{{ isEditing ? t('web.device_management.edit_network') :
|
||||
t('web.device_management.create_network') }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex gap-2 flex-wrap justify-start mb-3">
|
||||
<Button @click="showConfigEditDialog = true" icon="pi pi-file-edit"
|
||||
:label="t('web.device_management.edit_as_file')" iconPos="left" severity="secondary" />
|
||||
<Button @click="importConfig" icon="pi pi-upload" :label="t('web.device_management.import_config')"
|
||||
iconPos="left" severity="help" />
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Config :cur-network="newNetworkConfig" @run-network="createNewNetwork"></Config>
|
||||
</div>
|
||||
|
||||
<!-- Network Status (for running networks) -->
|
||||
<div v-else-if="needShowNetworkStatus" class="network-status-container">
|
||||
<div class="network-status-header flex items-center gap-2 mb-3">
|
||||
<i class="pi pi-chart-line text-primary text-xl"></i>
|
||||
<h2 class="text-xl font-medium">{{ t('web.device_management.network_status') }}</h2>
|
||||
</div>
|
||||
|
||||
<Status v-bind:cur-network-inst="curNetworkInfo" class="mb-4"></Status>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<Button @click="updateNetworkState(true)" :label="t('web.device_management.disable_network')"
|
||||
severity="warning" icon="pi pi-power-off" iconPos="left" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Configuration (for disabled networks) -->
|
||||
<div v-else-if="networkIsDisabled" class="network-config-container">
|
||||
<div class="network-config-header flex items-center gap-2 mb-3">
|
||||
<i class="pi pi-cog text-secondary text-xl"></i>
|
||||
<h2 class="text-xl font-medium">{{ t('web.device_management.network_configuration') }}</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="disabledNetworkConfig" class="mb-4">
|
||||
<Config :cur-network="disabledNetworkConfig" @run-network="updateNetworkState(false)" />
|
||||
</div>
|
||||
<div v-else class="network-loading-placeholder text-center py-8">
|
||||
<i class="pi pi-spin pi-spinner text-3xl text-primary mb-3"></i>
|
||||
<div class="text-xl text-secondary">{{ t('web.device_management.loading_network_configuration') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="empty-state flex flex-col items-center py-12">
|
||||
<i class="pi pi-sitemap text-5xl text-secondary mb-4 opacity-50"></i>
|
||||
<div class="text-xl text-center font-medium mb-3">{{ t('web.device_management.no_network_selected') }}
|
||||
</div>
|
||||
<p class="text-secondary text-center mb-6 max-w-md">
|
||||
{{ t('web.device_management.select_existing_network_or_create_new') }}
|
||||
</p>
|
||||
<Button @click="newNetwork" :label="t('web.device_management.create_network')" icon="pi pi-plus"
|
||||
iconPos="left" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Keep only the config edit dialogs -->
|
||||
<ConfigEditDialog v-if="networkIsDisabled" v-model:visible="showCreateNetworkDialog"
|
||||
:cur-network="disabledNetworkConfig" :generate-config="generateConfig" :save-config="saveConfig" />
|
||||
|
||||
<ConfigEditDialog v-else v-model:visible="showConfigEditDialog" :cur-network="newNetworkConfig"
|
||||
:generate-config="generateConfig" :save-config="saveConfig" />
|
||||
</div>
|
||||
<RemoteManagement :api="remoteClient" v-model:instance-id="selectedInstanceId"
|
||||
:new-config-generator="newConfigGenerator" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -3,9 +3,10 @@ import { computed, onMounted, ref } from 'vue';
|
||||
import { Card, InputText, Password, Button, AutoComplete } from 'primevue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { Api, I18nUtils } from 'easytier-frontend-lib';
|
||||
import { I18nUtils } from 'easytier-frontend-lib';
|
||||
import { getInitialApiHost, cleanAndLoadApiHosts, saveApiHost } from "../modules/api-host"
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ApiClient, { Credential, RegisterData } from '../modules/api';
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -13,7 +14,7 @@ defineProps<{
|
||||
isRegistering: boolean;
|
||||
}>();
|
||||
|
||||
const api = computed<Api.ApiClient>(() => new Api.ApiClient(apiHost.value));
|
||||
const api = computed<ApiClient>(() => new ApiClient(apiHost.value));
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
|
||||
@@ -28,7 +29,7 @@ const captchaSrc = computed(() => api.value.captcha_url());
|
||||
const onSubmit = async () => {
|
||||
// Add your login logic here
|
||||
saveApiHost(apiHost.value);
|
||||
const credential: Api.Credential = { username: username.value, password: password.value, };
|
||||
const credential: Credential = { username: username.value, password: password.value, };
|
||||
let ret = await api.value?.login(credential);
|
||||
if (ret.success) {
|
||||
localStorage.setItem('apiHost', btoa(apiHost.value));
|
||||
@@ -43,8 +44,8 @@ const onSubmit = async () => {
|
||||
|
||||
const onRegister = async () => {
|
||||
saveApiHost(apiHost.value);
|
||||
const credential: Api.Credential = { username: registerUsername.value, password: registerPassword.value };
|
||||
const registerReq: Api.RegisterData = { credentials: credential, captcha: captcha.value };
|
||||
const credential: Credential = { username: registerUsername.value, password: registerPassword.value };
|
||||
const registerReq: RegisterData = { credentials: credential, captcha: captcha.value };
|
||||
let ret = await api.value?.register(registerReq);
|
||||
if (ret.success) {
|
||||
toast.add({ severity: 'success', summary: 'Register Success', detail: ret.message, life: 2000 });
|
||||
@@ -108,12 +109,12 @@ onMounted(() => {
|
||||
<form v-else @submit.prevent="onRegister" class="space-y-4">
|
||||
<div class="p-field">
|
||||
<label for="register-username" class="block text-sm font-medium">{{ t('web.login.username')
|
||||
}}</label>
|
||||
}}</label>
|
||||
<InputText id="register-username" v-model="registerUsername" required class="w-full" />
|
||||
</div>
|
||||
<div class="p-field">
|
||||
<label for="register-password" class="block text-sm font-medium">{{ t('web.login.password')
|
||||
}}</label>
|
||||
}}</label>
|
||||
<Password id="register-password" v-model="registerPassword" required toggleMask
|
||||
:feedback="false" class="w-full" />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Api, I18nUtils } from 'easytier-frontend-lib'
|
||||
import { I18nUtils } from 'easytier-frontend-lib'
|
||||
import { computed, onMounted, ref, onUnmounted, nextTick } from 'vue';
|
||||
import { Button, TieredMenu } from 'primevue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
@@ -7,13 +7,14 @@ import { useDialog } from 'primevue/usedialog';
|
||||
import ChangePassword from './ChangePassword.vue';
|
||||
import Icon from '../assets/easytier.png'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ApiClient from '../modules/api';
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const api = computed<Api.ApiClient | undefined>(() => {
|
||||
const api = computed<ApiClient | undefined>(() => {
|
||||
try {
|
||||
return new Api.ApiClient(atob(route.params.apiHost as string), () => {
|
||||
return new ApiClient(atob(route.params.apiHost as string), () => {
|
||||
router.push({ name: 'login' });
|
||||
})
|
||||
} catch (e) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import './style.css'
|
||||
import App from './App.vue'
|
||||
import EasytierFrontendLib from 'easytier-frontend-lib'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Aura from '@primevue/themes/aura'
|
||||
import Aura from '@primeuix/themes/aura';
|
||||
import ConfirmationService from 'primevue/confirmationservice';
|
||||
import { I18nUtils } from 'easytier-frontend-lib'
|
||||
|
||||
|
||||
254
easytier-web/frontend/src/modules/api.ts
Normal file
254
easytier-web/frontend/src/modules/api.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import axios, { AxiosError, AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
|
||||
import { type Api, type NetworkTypes, Utils } from 'easytier-frontend-lib';
|
||||
import { Md5 } from 'ts-md5';
|
||||
|
||||
export interface ValidateConfigResponse {
|
||||
toml_config: string;
|
||||
}
|
||||
|
||||
// 定义接口返回的数据结构
|
||||
export interface LoginResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RegisterResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// 定义请求体数据结构
|
||||
export interface Credential {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RegisterData {
|
||||
credentials: Credential;
|
||||
captcha: string;
|
||||
}
|
||||
|
||||
export interface Summary {
|
||||
device_count: number;
|
||||
}
|
||||
|
||||
export interface ListNetworkInstanceIdResponse {
|
||||
running_inst_ids: Array<Utils.UUID>,
|
||||
disabled_inst_ids: Array<Utils.UUID>,
|
||||
}
|
||||
|
||||
export interface GenerateConfigRequest {
|
||||
config: NetworkTypes.NetworkConfig;
|
||||
}
|
||||
|
||||
export interface GenerateConfigResponse {
|
||||
toml_config?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ParseConfigRequest {
|
||||
toml_config: string;
|
||||
}
|
||||
|
||||
export interface ParseConfigResponse {
|
||||
config?: NetworkTypes.NetworkConfig;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
private client: AxiosInstance;
|
||||
private authFailedCb: Function | undefined;
|
||||
|
||||
constructor(baseUrl: string, authFailedCb: Function | undefined = undefined) {
|
||||
this.client = axios.create({
|
||||
baseURL: baseUrl + '/api/v1',
|
||||
withCredentials: true, // 如果需要支持跨域携带cookie
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
this.authFailedCb = authFailedCb;
|
||||
|
||||
// 添加请求拦截器
|
||||
this.client.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
return config;
|
||||
}, (error: any) => {
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
// 添加响应拦截器
|
||||
this.client.interceptors.response.use((response: AxiosResponse) => {
|
||||
console.debug('Axios Response:', response);
|
||||
return response.data; // 假设服务器返回的数据都在data属性中
|
||||
}, (error: any) => {
|
||||
if (error.response) {
|
||||
let response: AxiosResponse = error.response;
|
||||
if (response.status == 401 && this.authFailedCb) {
|
||||
console.error('Unauthorized:', response.data);
|
||||
this.authFailedCb();
|
||||
} else {
|
||||
// 请求已发出,但是服务器响应的状态码不在2xx范围
|
||||
console.error('Response Error:', error.response.data);
|
||||
}
|
||||
} else if (error.request) {
|
||||
// 请求已发出,但是没有收到响应
|
||||
console.error('Request Error:', error.request);
|
||||
} else {
|
||||
// 发生了一些问题导致请求未发出
|
||||
console.error('Error:', error.message);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
});
|
||||
}
|
||||
|
||||
// 注册
|
||||
public async register(data: RegisterData): Promise<RegisterResponse> {
|
||||
try {
|
||||
data.credentials.password = Md5.hashStr(data.credentials.password);
|
||||
const response = await this.client.post<RegisterResponse>('/auth/register', data);
|
||||
console.log("register response:", response);
|
||||
return { success: true, message: 'Register success', };
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
return { success: false, message: 'Failed to register, error: ' + JSON.stringify(error.response?.data), };
|
||||
}
|
||||
return { success: false, message: 'Unknown error, error: ' + error, };
|
||||
}
|
||||
}
|
||||
|
||||
// 登录
|
||||
public async login(data: Credential): Promise<LoginResponse> {
|
||||
try {
|
||||
data.password = Md5.hashStr(data.password);
|
||||
const response = await this.client.post<any>('/auth/login', data);
|
||||
console.log("login response:", response);
|
||||
return { success: true, message: 'Login success', };
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
if (error.response?.status === 401) {
|
||||
return { success: false, message: 'Invalid username or password', };
|
||||
} else {
|
||||
return { success: false, message: 'Unknown error, status code: ' + error.response?.status, };
|
||||
}
|
||||
}
|
||||
return { success: false, message: 'Unknown error, error: ' + error, };
|
||||
}
|
||||
}
|
||||
|
||||
public async logout() {
|
||||
await this.client.get('/auth/logout');
|
||||
if (this.authFailedCb) {
|
||||
this.authFailedCb();
|
||||
}
|
||||
}
|
||||
|
||||
public async change_password(new_password: string) {
|
||||
await this.client.put('/auth/password', { new_password: Md5.hashStr(new_password) });
|
||||
}
|
||||
|
||||
public async check_login_status() {
|
||||
try {
|
||||
await this.client.get('/auth/check_login_status');
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async list_session() {
|
||||
const response = await this.client.get('/sessions');
|
||||
return response;
|
||||
}
|
||||
|
||||
public async list_machines(): Promise<Array<any>> {
|
||||
const response = await this.client.get<any, Record<string, Array<any>>>('/machines');
|
||||
return response.machines;
|
||||
}
|
||||
|
||||
public async get_summary(): Promise<Summary> {
|
||||
const response = await this.client.get<any, Summary>('/summary');
|
||||
return response;
|
||||
}
|
||||
|
||||
public captcha_url() {
|
||||
return this.client.defaults.baseURL + '/auth/captcha';
|
||||
}
|
||||
|
||||
public get_remote_client(machine_id: string): Api.RemoteClient {
|
||||
return new WebRemoteClient(machine_id, this.client);
|
||||
}
|
||||
}
|
||||
|
||||
class WebRemoteClient implements Api.RemoteClient {
|
||||
private machine_id: string;
|
||||
private client: AxiosInstance;
|
||||
|
||||
constructor(machine_id: string, client: AxiosInstance) {
|
||||
this.machine_id = machine_id;
|
||||
this.client = client;
|
||||
}
|
||||
async validate_config(config: NetworkTypes.NetworkConfig): Promise<Api.ValidateConfigResponse> {
|
||||
const response = await this.client.post<NetworkTypes.NetworkConfig, ValidateConfigResponse>(`/machines/${this.machine_id}/validate-config`, {
|
||||
config: config,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
async run_network(config: NetworkTypes.NetworkConfig): Promise<undefined> {
|
||||
await this.client.post<string>(`/machines/${this.machine_id}/networks`, {
|
||||
config: config,
|
||||
});
|
||||
}
|
||||
async get_network_info(inst_id: string): Promise<NetworkTypes.NetworkInstanceRunningInfo | undefined> {
|
||||
const response = await this.client.get<any, Api.CollectNetworkInfoResponse>('/machines/' + this.machine_id + '/networks/info/' + inst_id);
|
||||
return response.info.map[inst_id];
|
||||
}
|
||||
async list_network_instance_ids(): Promise<Api.ListNetworkInstanceIdResponse> {
|
||||
const response = await this.client.get<any, ListNetworkInstanceIdResponse>('/machines/' + this.machine_id + '/networks');
|
||||
return response;
|
||||
}
|
||||
async delete_network(inst_id: string): Promise<undefined> {
|
||||
await this.client.delete<string>(`/machines/${this.machine_id}/networks/${inst_id}`);
|
||||
}
|
||||
async update_network_instance_state(inst_id: string, disabled: boolean): Promise<undefined> {
|
||||
await this.client.put<string>('/machines/' + this.machine_id + '/networks/' + inst_id, {
|
||||
disabled: disabled,
|
||||
});
|
||||
}
|
||||
async save_config(config: NetworkTypes.NetworkConfig): Promise<undefined> {
|
||||
await this.client.put(`/machines/${this.machine_id}/networks/config/${config.instance_id}`, { config });
|
||||
}
|
||||
async get_network_config(inst_id: string): Promise<NetworkTypes.NetworkConfig> {
|
||||
const response = await this.client.get<any, NetworkTypes.NetworkConfig>('/machines/' + this.machine_id + '/networks/config/' + inst_id);
|
||||
return response;
|
||||
}
|
||||
async generate_config(config: NetworkTypes.NetworkConfig): Promise<Api.GenerateConfigResponse> {
|
||||
try {
|
||||
const response = await this.client.post<any, GenerateConfigResponse>('/generate-config', { config });
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
return { error: error.response?.data };
|
||||
}
|
||||
return { error: 'Unknown error: ' + error };
|
||||
}
|
||||
}
|
||||
async parse_config(toml_config: string): Promise<Api.ParseConfigResponse> {
|
||||
try {
|
||||
const response = await this.client.post<any, ParseConfigResponse>('/parse-config', { toml_config });
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
return { error: error.response?.data };
|
||||
}
|
||||
return { error: 'Unknown error: ' + error };
|
||||
}
|
||||
}
|
||||
async get_network_metas(instance_ids: string[]): Promise<Api.GetNetworkMetasResponse> {
|
||||
const response = await this.client.post<any, Api.GetNetworkMetasResponse>(`/machines/${this.machine_id}/networks/metas`, {
|
||||
instance_ids: instance_ids
|
||||
});
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
export default ApiClient;
|
||||
@@ -7,13 +7,19 @@ use std::sync::{
|
||||
};
|
||||
|
||||
use dashmap::DashMap;
|
||||
use easytier::{proto::web::HeartbeatRequest, tunnel::TunnelListener};
|
||||
use easytier::{
|
||||
proto::{
|
||||
api::manage::WebClientService, rpc_types::controller::BaseController, web::HeartbeatRequest,
|
||||
},
|
||||
rpc_service::remote_client::{self, RemoteClientManager},
|
||||
tunnel::TunnelListener,
|
||||
};
|
||||
use maxminddb::geoip2;
|
||||
use session::{Location, Session};
|
||||
use storage::{Storage, StorageToken};
|
||||
use tokio::task::JoinSet;
|
||||
|
||||
use crate::db::{Db, UserIdInDb};
|
||||
use crate::db::{entity::user_running_network_configs, Db, UserIdInDb};
|
||||
|
||||
#[derive(rust_embed::Embed)]
|
||||
#[folder = "resources/"]
|
||||
@@ -152,7 +158,7 @@ impl ClientManager {
|
||||
s.data().read().await.location().cloned()
|
||||
}
|
||||
|
||||
pub fn db(&self) -> &Db {
|
||||
fn db(&self) -> &Db {
|
||||
self.storage.db()
|
||||
}
|
||||
|
||||
@@ -245,11 +251,38 @@ impl ClientManager {
|
||||
}
|
||||
}
|
||||
|
||||
impl
|
||||
RemoteClientManager<
|
||||
(UserIdInDb, uuid::Uuid),
|
||||
user_running_network_configs::Model,
|
||||
sea_orm::DbErr,
|
||||
> for ClientManager
|
||||
{
|
||||
fn get_rpc_client(
|
||||
&self,
|
||||
(user_id, machine_id): (UserIdInDb, uuid::Uuid),
|
||||
) -> Option<Box<dyn WebClientService<Controller = BaseController> + Send>> {
|
||||
let s = self.get_session_by_machine_id(user_id, &machine_id)?;
|
||||
Some(s.scoped_rpc_client())
|
||||
}
|
||||
|
||||
fn get_storage(
|
||||
&self,
|
||||
) -> &impl remote_client::Storage<
|
||||
(UserIdInDb, uuid::Uuid),
|
||||
user_running_network_configs::Model,
|
||||
sea_orm::DbErr,
|
||||
> {
|
||||
self.storage.db()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use easytier::{
|
||||
instance_manager::NetworkInstanceManager,
|
||||
tunnel::{
|
||||
common::tests::wait_for_condition,
|
||||
udp::{UdpTunnelConnector, UdpTunnelListener},
|
||||
@@ -273,7 +306,12 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let connector = UdpTunnelConnector::new("udp://127.0.0.1:54333".parse().unwrap());
|
||||
let _c = WebClient::new(connector, "test", "test");
|
||||
let _c = WebClient::new(
|
||||
connector,
|
||||
"test",
|
||||
"test",
|
||||
Arc::new(NetworkInstanceManager::new()),
|
||||
);
|
||||
|
||||
wait_for_condition(
|
||||
|| async { mgr.client_sessions.len() == 1 },
|
||||
|
||||
@@ -4,20 +4,19 @@ use anyhow::Context;
|
||||
use easytier::{
|
||||
common::scoped_task::ScopedTask,
|
||||
proto::{
|
||||
api::manage::{
|
||||
NetworkConfig, RunNetworkInstanceRequest, WebClientService,
|
||||
WebClientServiceClientFactory,
|
||||
},
|
||||
rpc_impl::bidirect::BidirectRpcManager,
|
||||
rpc_types::{self, controller::BaseController},
|
||||
web::{
|
||||
HeartbeatRequest, HeartbeatResponse, NetworkConfig, RunNetworkInstanceRequest,
|
||||
WebClientService, WebClientServiceClientFactory, WebServerService,
|
||||
WebServerServiceServer,
|
||||
},
|
||||
web::{HeartbeatRequest, HeartbeatResponse, WebServerService, WebServerServiceServer},
|
||||
},
|
||||
rpc_service::remote_client::{ListNetworkProps, Storage as _},
|
||||
tunnel::Tunnel,
|
||||
};
|
||||
use tokio::sync::{broadcast, RwLock};
|
||||
|
||||
use crate::db::ListNetworkProps;
|
||||
|
||||
use super::storage::{Storage, StorageToken, WeakRefStorage};
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
@@ -224,10 +223,10 @@ impl Session {
|
||||
}
|
||||
|
||||
let req = req.unwrap();
|
||||
if req.machine_id.is_none() {
|
||||
let Some(machine_id) = req.machine_id else {
|
||||
tracing::warn!(?req, "Machine id is not set, ignore");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let running_inst_ids = req
|
||||
.running_network_instances
|
||||
@@ -257,11 +256,7 @@ impl Session {
|
||||
|
||||
let local_configs = match storage
|
||||
.db
|
||||
.list_network_configs(
|
||||
user_id,
|
||||
Some(req.machine_id.unwrap().into()),
|
||||
ListNetworkProps::EnabledOnly,
|
||||
)
|
||||
.list_network_configs((user_id, machine_id.into()), ListNetworkProps::EnabledOnly)
|
||||
.await
|
||||
{
|
||||
Ok(configs) => configs,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
|
||||
|
||||
use easytier::{launcher::NetworkConfig, rpc_service::remote_client::PersistentConfig};
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -39,3 +40,12 @@ impl Related<super::users::Entity> for Entity {
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
|
||||
impl PersistentConfig<DbErr> for Model {
|
||||
fn get_network_inst_id(&self) -> &str {
|
||||
&self.network_instance_id
|
||||
}
|
||||
fn get_network_config(&self) -> Result<NetworkConfig, DbErr> {
|
||||
serde_json::from_str(&self.network_config).map_err(|e| DbErr::Json(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
#[allow(unused_imports)]
|
||||
pub mod entity;
|
||||
|
||||
use easytier::{
|
||||
launcher::NetworkConfig,
|
||||
rpc_service::remote_client::{ListNetworkProps, Storage},
|
||||
};
|
||||
use entity::user_running_network_configs;
|
||||
use sea_orm::{
|
||||
prelude::Expr, sea_query::OnConflict, ColumnTrait as _, DatabaseConnection, DbErr, EntityTrait,
|
||||
@@ -9,17 +13,13 @@ use sea_orm::{
|
||||
};
|
||||
use sea_orm_migration::MigratorTrait as _;
|
||||
use sqlx::{migrate::MigrateDatabase as _, types::chrono, Sqlite, SqlitePool};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::migrator;
|
||||
use async_trait::async_trait;
|
||||
|
||||
pub type UserIdInDb = i32;
|
||||
|
||||
pub enum ListNetworkProps {
|
||||
All,
|
||||
EnabledOnly,
|
||||
DisabledOnly,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Db {
|
||||
db_path: String,
|
||||
@@ -68,12 +68,36 @@ impl Db {
|
||||
&self.orm_db
|
||||
}
|
||||
|
||||
pub async fn insert_or_update_user_network_config<T: ToString>(
|
||||
pub async fn get_user_id<T: ToString>(
|
||||
&self,
|
||||
user_id: UserIdInDb,
|
||||
device_id: uuid::Uuid,
|
||||
network_inst_id: uuid::Uuid,
|
||||
network_config: T,
|
||||
user_name: T,
|
||||
) -> Result<Option<UserIdInDb>, DbErr> {
|
||||
use entity::users as u;
|
||||
|
||||
let user = u::Entity::find()
|
||||
.filter(u::Column::Username.eq(user_name.to_string()))
|
||||
.one(self.orm_db())
|
||||
.await?;
|
||||
|
||||
Ok(user.map(|u| u.id))
|
||||
}
|
||||
|
||||
// TODO: currently we don't have a token system, so we just use the user name as token
|
||||
pub async fn get_user_id_by_token<T: ToString>(
|
||||
&self,
|
||||
token: T,
|
||||
) -> Result<Option<UserIdInDb>, DbErr> {
|
||||
self.get_user_id(token).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Storage<(UserIdInDb, Uuid), user_running_network_configs::Model, DbErr> for Db {
|
||||
async fn insert_or_update_user_network_config(
|
||||
&self,
|
||||
(user_id, device_id): (UserIdInDb, Uuid),
|
||||
network_inst_id: Uuid,
|
||||
network_config: NetworkConfig,
|
||||
) -> Result<(), DbErr> {
|
||||
let txn = self.orm_db().begin().await?;
|
||||
|
||||
@@ -90,7 +114,9 @@ impl Db {
|
||||
user_id: sea_orm::Set(user_id),
|
||||
device_id: sea_orm::Set(device_id.to_string()),
|
||||
network_instance_id: sea_orm::Set(network_inst_id.to_string()),
|
||||
network_config: sea_orm::Set(network_config.to_string()),
|
||||
network_config: sea_orm::Set(
|
||||
serde_json::to_string(&network_config).map_err(|e| DbErr::Json(e.to_string()))?,
|
||||
),
|
||||
disabled: sea_orm::Set(false),
|
||||
create_time: sea_orm::Set(chrono::Local::now().fixed_offset()),
|
||||
update_time: sea_orm::Set(chrono::Local::now().fixed_offset()),
|
||||
@@ -105,28 +131,31 @@ impl Db {
|
||||
txn.commit().await
|
||||
}
|
||||
|
||||
pub async fn delete_network_config(
|
||||
async fn delete_network_configs(
|
||||
&self,
|
||||
user_id: UserIdInDb,
|
||||
network_inst_id: uuid::Uuid,
|
||||
(user_id, _): (UserIdInDb, Uuid),
|
||||
network_inst_ids: &[Uuid],
|
||||
) -> Result<(), DbErr> {
|
||||
use entity::user_running_network_configs as urnc;
|
||||
|
||||
urnc::Entity::delete_many()
|
||||
.filter(urnc::Column::UserId.eq(user_id))
|
||||
.filter(urnc::Column::NetworkInstanceId.eq(network_inst_id.to_string()))
|
||||
.filter(
|
||||
urnc::Column::NetworkInstanceId
|
||||
.is_in(network_inst_ids.iter().map(|id| id.to_string())),
|
||||
)
|
||||
.exec(self.orm_db())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_network_config_state(
|
||||
async fn update_network_config_state(
|
||||
&self,
|
||||
user_id: UserIdInDb,
|
||||
network_inst_id: uuid::Uuid,
|
||||
(user_id, _): (UserIdInDb, Uuid),
|
||||
network_inst_id: Uuid,
|
||||
disabled: bool,
|
||||
) -> Result<entity::user_running_network_configs::Model, DbErr> {
|
||||
) -> Result<user_running_network_configs::Model, DbErr> {
|
||||
use entity::user_running_network_configs as urnc;
|
||||
|
||||
urnc::Entity::update_many()
|
||||
@@ -151,10 +180,9 @@ impl Db {
|
||||
)))
|
||||
}
|
||||
|
||||
pub async fn list_network_configs(
|
||||
async fn list_network_configs(
|
||||
&self,
|
||||
user_id: UserIdInDb,
|
||||
device_id: Option<uuid::Uuid>,
|
||||
(user_id, device_id): (UserIdInDb, Uuid),
|
||||
props: ListNetworkProps,
|
||||
) -> Result<Vec<user_running_network_configs::Model>, DbErr> {
|
||||
use entity::user_running_network_configs as urnc;
|
||||
@@ -169,7 +197,7 @@ impl Db {
|
||||
} else {
|
||||
configs
|
||||
};
|
||||
let configs = if let Some(device_id) = device_id {
|
||||
let configs = if !device_id.is_nil() {
|
||||
configs.filter(urnc::Column::DeviceId.eq(device_id.to_string()))
|
||||
} else {
|
||||
configs
|
||||
@@ -180,11 +208,10 @@ impl Db {
|
||||
Ok(configs)
|
||||
}
|
||||
|
||||
pub async fn get_network_config(
|
||||
async fn get_network_config(
|
||||
&self,
|
||||
user_id: UserIdInDb,
|
||||
device_id: &uuid::Uuid,
|
||||
network_inst_id: &String,
|
||||
(user_id, device_id): (UserIdInDb, Uuid),
|
||||
network_inst_id: &str,
|
||||
) -> Result<Option<user_running_network_configs::Model>, DbErr> {
|
||||
use entity::user_running_network_configs as urnc;
|
||||
|
||||
@@ -197,32 +224,11 @@ impl Db {
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub async fn get_user_id<T: ToString>(
|
||||
&self,
|
||||
user_name: T,
|
||||
) -> Result<Option<UserIdInDb>, DbErr> {
|
||||
use entity::users as u;
|
||||
|
||||
let user = u::Entity::find()
|
||||
.filter(u::Column::Username.eq(user_name.to_string()))
|
||||
.one(self.orm_db())
|
||||
.await?;
|
||||
|
||||
Ok(user.map(|u| u.id))
|
||||
}
|
||||
|
||||
// TODO: currently we don't have a token system, so we just use the user name as token
|
||||
pub async fn get_user_id_by_token<T: ToString>(
|
||||
&self,
|
||||
token: T,
|
||||
) -> Result<Option<UserIdInDb>, DbErr> {
|
||||
self.get_user_id(token).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use easytier::{proto::api::manage::NetworkConfig, rpc_service::remote_client::Storage};
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter as _};
|
||||
|
||||
use crate::db::{entity::user_running_network_configs, Db, ListNetworkProps};
|
||||
@@ -231,11 +237,15 @@ mod tests {
|
||||
async fn test_user_network_config_management() {
|
||||
let db = Db::memory_db().await;
|
||||
let user_id = 1;
|
||||
let network_config = "test_config";
|
||||
let network_config = NetworkConfig {
|
||||
network_name: Some("test_config".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let network_config_json = serde_json::to_string(&network_config).unwrap();
|
||||
let inst_id = uuid::Uuid::new_v4();
|
||||
let device_id = uuid::Uuid::new_v4();
|
||||
|
||||
db.insert_or_update_user_network_config(user_id, device_id, inst_id, network_config)
|
||||
db.insert_or_update_user_network_config((user_id, device_id), inst_id, network_config)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -246,11 +256,15 @@ mod tests {
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
println!("{:?}", result);
|
||||
assert_eq!(result.network_config, network_config);
|
||||
assert_eq!(result.network_config, network_config_json);
|
||||
|
||||
// overwrite the config
|
||||
let network_config = "test_config2";
|
||||
db.insert_or_update_user_network_config(user_id, device_id, inst_id, network_config)
|
||||
let network_config = NetworkConfig {
|
||||
network_name: Some("test_config2".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let network_config_json = serde_json::to_string(&network_config).unwrap();
|
||||
db.insert_or_update_user_network_config((user_id, device_id), inst_id, network_config)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -261,20 +275,22 @@ mod tests {
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
println!("device: {}, {:?}", device_id, result2);
|
||||
assert_eq!(result2.network_config, network_config);
|
||||
assert_eq!(result2.network_config, network_config_json);
|
||||
|
||||
assert_eq!(result.create_time, result2.create_time);
|
||||
assert_ne!(result.update_time, result2.update_time);
|
||||
|
||||
assert_eq!(
|
||||
db.list_network_configs(user_id, Some(device_id), ListNetworkProps::All)
|
||||
db.list_network_configs((user_id, device_id), ListNetworkProps::All)
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
|
||||
db.delete_network_config(user_id, inst_id).await.unwrap();
|
||||
db.delete_network_configs((user_id, device_id), &[inst_id])
|
||||
.await
|
||||
.unwrap();
|
||||
let result3 = user_running_network_configs::Entity::find()
|
||||
.filter(user_running_network_configs::Column::UserId.eq(user_id))
|
||||
.one(db.orm_db())
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user