feat: add openconnect
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
[server]
|
||||||
|
port = 8055
|
||||||
|
host = 0.0.0.0
|
||||||
|
; url_prefix = /baihu
|
||||||
|
message_server = http://message-nest-demo-site.qwapi.eu.org/
|
||||||
|
pprof_enabled=true
|
||||||
|
|
||||||
|
|
||||||
|
[database]
|
||||||
|
; type = sqlite
|
||||||
|
type = mysql
|
||||||
|
host = app.engigu.cn
|
||||||
|
port = 33066
|
||||||
|
user = root
|
||||||
|
password = Gq19940507+****+
|
||||||
|
dbname = baihu-test2
|
||||||
|
table_prefix = baihu_
|
||||||
|
; debug = true
|
||||||
|
|
||||||
|
[security]
|
||||||
|
secret = baihu_secret_key_change_me
|
||||||
@@ -13,6 +13,7 @@ require (
|
|||||||
github.com/gofrs/flock v0.13.0
|
github.com/gofrs/flock v0.13.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
github.com/hashicorp/yamux v0.1.2
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
github.com/rs/xid v1.6.0
|
github.com/rs/xid v1.6.0
|
||||||
github.com/shirou/gopsutil/v3 v3.24.5
|
github.com/shirou/gopsutil/v3 v3.24.5
|
||||||
@@ -31,28 +32,8 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
cel.dev/expr v0.25.2 // indirect
|
|
||||||
cloud.google.com/go v0.123.0 // indirect
|
|
||||||
cloud.google.com/go/auth v0.20.0 // indirect
|
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
|
||||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
|
||||||
cloud.google.com/go/iam v1.11.0 // indirect
|
|
||||||
cloud.google.com/go/monitoring v1.29.0 // indirect
|
|
||||||
cloud.google.com/go/storage v1.62.3 // indirect
|
|
||||||
dario.cat/mergo v1.0.2 // indirect
|
dario.cat/mergo v1.0.2 // indirect
|
||||||
filippo.io/edwards25519 v1.2.0 // indirect
|
filippo.io/edwards25519 v1.2.0 // indirect
|
||||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 // indirect
|
|
||||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 // indirect
|
|
||||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
|
|
||||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.0 // indirect
|
|
||||||
github.com/Azure/go-autorest v14.2.0+incompatible // indirect
|
|
||||||
github.com/Azure/go-autorest/autorest/to v0.4.1 // indirect
|
|
||||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 // indirect
|
|
||||||
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 // indirect
|
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect
|
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect
|
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect
|
|
||||||
github.com/JohannesKaufmann/dom v0.3.1 // indirect
|
|
||||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 // indirect
|
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 // indirect
|
||||||
github.com/air-verse/air v1.64.5 // indirect
|
github.com/air-verse/air v1.64.5 // indirect
|
||||||
github.com/alecthomas/chroma/v2 v2.27.0 // indirect
|
github.com/alecthomas/chroma/v2 v2.27.0 // indirect
|
||||||
@@ -63,46 +44,8 @@ require (
|
|||||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.9 // indirect
|
github.com/alibabacloud-go/tea-utils/v2 v2.0.9 // indirect
|
||||||
github.com/aliyun/credentials-go v1.4.12 // indirect
|
github.com/aliyun/credentials-go v1.4.12 // indirect
|
||||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2 v1.42.0 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.25 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.28 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.11 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/cloudfront v1.65.2 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect
|
|
||||||
github.com/aws/smithy-go v1.27.2 // indirect
|
|
||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
|
||||||
github.com/bep/clocks v0.5.0 // indirect
|
|
||||||
github.com/bep/debounce v1.2.1 // indirect
|
|
||||||
github.com/bep/gitmap v1.9.0 // indirect
|
|
||||||
github.com/bep/goat v0.5.0 // indirect
|
|
||||||
github.com/bep/godartsass/v2 v2.5.0 // indirect
|
github.com/bep/godartsass/v2 v2.5.0 // indirect
|
||||||
github.com/bep/golibsass v1.2.0 // indirect
|
github.com/bep/golibsass v1.2.0 // indirect
|
||||||
github.com/bep/golocales v0.2.0 // indirect
|
|
||||||
github.com/bep/goportabletext v0.2.0 // indirect
|
|
||||||
github.com/bep/helpers v0.12.0 // indirect
|
|
||||||
github.com/bep/imagemeta v0.17.2 // indirect
|
|
||||||
github.com/bep/lazycache v0.8.1 // indirect
|
|
||||||
github.com/bep/logg v0.4.0 // indirect
|
|
||||||
github.com/bep/mclib v1.20401.20400 // indirect
|
|
||||||
github.com/bep/overlayfs v0.11.0 // indirect
|
|
||||||
github.com/bep/simplecobra v0.7.0 // indirect
|
|
||||||
github.com/bep/textandbinarywriter v0.1.0 // indirect
|
|
||||||
github.com/bep/tmc v0.6.0 // indirect
|
|
||||||
github.com/bits-and-blooms/bitset v1.24.5 // indirect
|
github.com/bits-and-blooms/bitset v1.24.5 // indirect
|
||||||
github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c // indirect
|
github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c // indirect
|
||||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||||
@@ -113,28 +56,18 @@ require (
|
|||||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.7 // indirect
|
github.com/cloudwego/base64x v0.1.7 // indirect
|
||||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
|
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/dlclark/regexp2 v1.12.0 // indirect
|
|
||||||
github.com/dlclark/regexp2/v2 v2.2.2 // indirect
|
github.com/dlclark/regexp2/v2 v2.2.2 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
|
|
||||||
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
|
|
||||||
github.com/evanw/esbuild v0.28.1 // indirect
|
github.com/evanw/esbuild v0.28.1 // indirect
|
||||||
github.com/fatih/color v1.19.0 // indirect
|
github.com/fatih/color v1.19.0 // indirect
|
||||||
github.com/fatih/structs v1.1.0 // indirect
|
github.com/fatih/structs v1.1.0 // indirect
|
||||||
github.com/felixge/httpsnoop v1.1.0 // indirect
|
|
||||||
github.com/frankban/quicktest v1.14.6 // indirect
|
|
||||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||||
github.com/getkin/kin-openapi v0.140.0 // indirect
|
github.com/getkin/kin-openapi v0.140.0 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.1 // indirect
|
github.com/gin-contrib/sse v1.1.1 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.23.1 // indirect
|
github.com/go-openapi/jsonpointer v0.23.1 // indirect
|
||||||
github.com/go-openapi/swag/jsonname v0.26.1 // indirect
|
github.com/go-openapi/swag/jsonname v0.26.1 // indirect
|
||||||
@@ -143,92 +76,43 @@ require (
|
|||||||
github.com/go-playground/validator/v10 v10.30.3 // indirect
|
github.com/go-playground/validator/v10 v10.30.3 // indirect
|
||||||
github.com/go-redis/redis/v8 v8.11.5 // indirect
|
github.com/go-redis/redis/v8 v8.11.5 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.10.0 // indirect
|
github.com/go-sql-driver/mysql v1.10.0 // indirect
|
||||||
github.com/gobuffalo/flect v1.0.3 // indirect
|
|
||||||
github.com/gobwas/glob v0.2.3 // indirect
|
github.com/gobwas/glob v0.2.3 // indirect
|
||||||
github.com/goccy/go-json v0.10.6 // indirect
|
github.com/goccy/go-json v0.10.6 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
github.com/gohugoio/gift v0.2.0 // indirect
|
|
||||||
github.com/gohugoio/go-i18n/v2 v2.1.3-0.20251018145728-cfcc22d823c6 // indirect
|
|
||||||
github.com/gohugoio/go-radix v1.2.0 // indirect
|
|
||||||
github.com/gohugoio/hashstructure v0.6.0 // indirect
|
github.com/gohugoio/hashstructure v0.6.0 // indirect
|
||||||
github.com/gohugoio/httpcache v0.8.0 // indirect
|
|
||||||
github.com/gohugoio/hugo v0.163.3 // indirect
|
github.com/gohugoio/hugo v0.163.3 // indirect
|
||||||
github.com/gohugoio/hugo-goldmark-extensions/extras v0.7.0 // indirect
|
|
||||||
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.5.0 // indirect
|
|
||||||
github.com/google/go-cmp v0.7.0 // indirect
|
|
||||||
github.com/google/s2a-go v0.1.9 // indirect
|
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/google/wire v0.7.0 // indirect
|
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect
|
|
||||||
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
|
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
|
||||||
github.com/hairyhenderson/go-codeowners v0.7.1 // indirect
|
github.com/hairyhenderson/go-codeowners v0.7.1 // indirect
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.10.0 // indirect
|
github.com/jackc/pgx/v5 v5.10.0 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/jdkato/prose v1.2.1 // indirect
|
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/joho/godotenv v1.5.1 // indirect
|
github.com/joho/godotenv v1.5.1 // indirect
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/kr/pretty v0.3.1 // indirect
|
|
||||||
github.com/kr/text v0.2.0 // indirect
|
|
||||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
|
||||||
github.com/kyokomi/emoji/v2 v2.2.13 // indirect
|
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
|
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
|
||||||
github.com/magefile/mage v1.17.2 // indirect
|
|
||||||
github.com/mailru/easyjson v0.9.2 // indirect
|
|
||||||
github.com/makeworld-the-better-one/dither/v2 v2.4.0 // indirect
|
|
||||||
github.com/marekm4/color-extractor v1.2.1 // indirect
|
|
||||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.24 // indirect
|
github.com/mattn/go-runewidth v0.0.24 // indirect
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
|
||||||
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
|
||||||
github.com/muesli/smartcrop v0.3.0 // indirect
|
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/niklasfasching/go-org v1.9.1 // indirect
|
|
||||||
github.com/oasdiff/yaml v0.1.0 // indirect
|
|
||||||
github.com/oasdiff/yaml3 v0.0.13 // indirect
|
|
||||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
|
|
||||||
github.com/olekukonko/errors v1.3.0 // indirect
|
github.com/olekukonko/errors v1.3.0 // indirect
|
||||||
github.com/olekukonko/ll v0.1.8 // indirect
|
github.com/olekukonko/ll v0.1.8 // indirect
|
||||||
github.com/olekukonko/tablewriter v1.1.4 // indirect
|
|
||||||
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect
|
|
||||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.4.0 // indirect
|
github.com/pelletier/go-toml/v2 v2.4.0 // indirect
|
||||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
|
||||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
github.com/quic-go/qpack v0.6.0 // indirect
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
github.com/quic-go/quic-go v0.60.0 // indirect
|
github.com/quic-go/quic-go v0.60.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rogpeppe/go-internal v1.15.0 // indirect
|
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
|
||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
|
||||||
github.com/shoenig/go-m1cpu v0.2.1 // indirect
|
github.com/shoenig/go-m1cpu v0.2.1 // indirect
|
||||||
github.com/spf13/afero v1.15.0 // indirect
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
github.com/spf13/cast v1.10.0 // indirect
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
github.com/spf13/cobra v1.10.2 // indirect
|
|
||||||
github.com/spf13/fsync v0.10.1 // indirect
|
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
|
||||||
github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect
|
|
||||||
github.com/tdewolff/minify/v2 v2.24.13 // indirect
|
|
||||||
github.com/tdewolff/parse/v2 v2.8.13 // indirect
|
github.com/tdewolff/parse/v2 v2.8.13 // indirect
|
||||||
github.com/tdewolff/test v1.0.12 // indirect
|
|
||||||
github.com/tetratelabs/wazero v1.12.0 // indirect
|
|
||||||
github.com/tidwall/gjson v1.19.0 // indirect
|
github.com/tidwall/gjson v1.19.0 // indirect
|
||||||
github.com/tidwall/match v1.2.0 // indirect
|
github.com/tidwall/match v1.2.0 // indirect
|
||||||
github.com/tidwall/pretty v1.2.1 // indirect
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
@@ -237,47 +121,20 @@ require (
|
|||||||
github.com/tklauser/numcpus v0.12.0 // indirect
|
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
github.com/woodsbury/decimal128 v1.4.0 // indirect
|
|
||||||
github.com/yuin/goldmark v1.8.2 // indirect
|
|
||||||
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
|
||||||
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect
|
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect
|
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
|
|
||||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
|
||||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
|
||||||
gocloud.dev v0.46.0 // indirect
|
|
||||||
golang.org/x/arch v0.28.0 // indirect
|
golang.org/x/arch v0.28.0 // indirect
|
||||||
golang.org/x/image v0.43.0 // indirect
|
golang.org/x/image v0.43.0 // indirect
|
||||||
golang.org/x/mod v0.37.0 // indirect
|
|
||||||
golang.org/x/oauth2 v0.36.0 // indirect
|
|
||||||
golang.org/x/sync v0.21.0 // indirect
|
golang.org/x/sync v0.21.0 // indirect
|
||||||
golang.org/x/sys v0.46.0 // indirect
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
golang.org/x/time v0.15.0 // indirect
|
|
||||||
golang.org/x/tools v0.46.0 // indirect
|
golang.org/x/tools v0.46.0 // indirect
|
||||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
|
||||||
google.golang.org/api v0.285.0 // indirect
|
|
||||||
google.golang.org/genproto v0.0.0-20260618152121-87f3d3e198d3 // indirect
|
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260618152121-87f3d3e198d3 // indirect
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260618152121-87f3d3e198d3 // indirect
|
|
||||||
google.golang.org/grpc v1.81.1 // indirect
|
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||||
howett.net/plist v1.0.1 // indirect
|
|
||||||
modernc.org/libc v1.73.0 // indirect
|
modernc.org/libc v1.73.0 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
modernc.org/sqlite v1.52.0 // indirect
|
modernc.org/sqlite v1.52.0 // indirect
|
||||||
rsc.io/qr v0.2.0 // indirect
|
|
||||||
software.sslmate.com/src/go-pkcs12 v0.7.2 // indirect
|
|
||||||
)
|
)
|
||||||
|
|
||||||
tool github.com/air-verse/air
|
tool github.com/air-verse/air
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ import (
|
|||||||
|
|
||||||
"github.com/engigu/baihu-panel/internal/constant"
|
"github.com/engigu/baihu-panel/internal/constant"
|
||||||
"github.com/engigu/baihu-panel/internal/database"
|
"github.com/engigu/baihu-panel/internal/database"
|
||||||
|
"github.com/engigu/baihu-panel/internal/executor"
|
||||||
"github.com/engigu/baihu-panel/internal/logger"
|
"github.com/engigu/baihu-panel/internal/logger"
|
||||||
"github.com/engigu/baihu-panel/internal/router"
|
"github.com/engigu/baihu-panel/internal/router"
|
||||||
"github.com/engigu/baihu-panel/internal/services"
|
"github.com/engigu/baihu-panel/internal/services"
|
||||||
|
"github.com/engigu/baihu-panel/internal/tunnel"
|
||||||
"github.com/engigu/baihu-panel/internal/utils"
|
"github.com/engigu/baihu-panel/internal/utils"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -26,8 +28,19 @@ type App struct {
|
|||||||
func New() *App {
|
func New() *App {
|
||||||
app := InitBasic()
|
app := InitBasic()
|
||||||
app.initRouter()
|
app.initRouter()
|
||||||
|
|
||||||
|
// 初始化完成后将路由引擎注入到隧道模块,以支持高性能的纯内存代理
|
||||||
|
tunnel.SetLocalEngine(app.Router)
|
||||||
|
|
||||||
|
// 初始化隧道后台服务 (读取配置决定角色并启动服务)
|
||||||
|
tunnel.Init()
|
||||||
|
|
||||||
|
// 启动系统级后台定时任务调度器
|
||||||
|
executor.InitSysCron()
|
||||||
|
|
||||||
// 初始化完成后回收一次内存
|
// 初始化完成后回收一次内存
|
||||||
utils.FreeMemory()
|
utils.FreeMemory()
|
||||||
|
|
||||||
return app
|
return app
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +58,7 @@ func InitBasic() *App {
|
|||||||
// 自动加载配置 (内部会自动处理 BH_CONFIG_PATH 环境变量与默认路径的优先级)
|
// 自动加载配置 (内部会自动处理 BH_CONFIG_PATH 环境变量与默认路径的优先级)
|
||||||
app.initConfigWithPath("")
|
app.initConfigWithPath("")
|
||||||
app.initDatabase()
|
app.initDatabase()
|
||||||
|
|
||||||
logger.Infof("[System] 低于1.0.11版本升级最新版本错误指引: https://github.com/engigu/baihu-panel/issues/64")
|
logger.Infof("[System] 低于1.0.11版本升级最新版本错误指引: https://github.com/engigu/baihu-panel/issues/64")
|
||||||
globalApp = app
|
globalApp = app
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,20 +14,20 @@ const (
|
|||||||
DefaultTaskTimeout = 30
|
DefaultTaskTimeout = 30
|
||||||
|
|
||||||
// Settings Section 常量
|
// Settings Section 常量
|
||||||
SectionSite = "site"
|
SectionSite = "site"
|
||||||
SectionSystem = "system"
|
SectionSystem = "system"
|
||||||
SectionScheduler = "scheduler"
|
SectionScheduler = "scheduler"
|
||||||
SectionSecurity = "security"
|
SectionSecurity = "security"
|
||||||
SectionNotify = "notify"
|
SectionNotify = "notify"
|
||||||
|
|
||||||
// Site Settings Key 常量
|
// Site Settings Key 常量
|
||||||
KeyTitle = "title"
|
KeyTitle = "title"
|
||||||
KeySubtitle = "subtitle"
|
KeySubtitle = "subtitle"
|
||||||
KeyIcon = "icon"
|
KeyIcon = "icon"
|
||||||
KeyPageSize = "page_size"
|
KeyPageSize = "page_size"
|
||||||
KeyCookieDays = "cookie_days"
|
KeyCookieDays = "cookie_days"
|
||||||
KeyOpenapiToken = "openapi_token"
|
KeyOpenapiToken = "openapi_token"
|
||||||
KeyActiveWebUI = "active_webui"
|
KeyActiveWebUI = "active_webui"
|
||||||
|
|
||||||
// Security Settings Key 常量
|
// Security Settings Key 常量
|
||||||
KeySecret = "secret"
|
KeySecret = "secret"
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package constant
|
||||||
|
|
||||||
|
const (
|
||||||
|
// CookieActiveInterconnectNodeID 穿越状态下标识目标子节点 ID 的 Cookie 键名
|
||||||
|
CookieActiveInterconnectNodeID = "active_interconnect_node_id"
|
||||||
|
|
||||||
|
// SectionInterconnect 互联设置分组
|
||||||
|
SectionInterconnect = "interconnect"
|
||||||
|
|
||||||
|
// 互联设置相关 Key
|
||||||
|
KeyInterconnectToken = "interconnect_token"
|
||||||
|
KeyInterconnectParentURL = "interconnect_parent_url"
|
||||||
|
KeyInterconnectParentToken = "interconnect_parent_token"
|
||||||
|
KeyInterconnectRole = "interconnect_role"
|
||||||
|
|
||||||
|
// 互联角色
|
||||||
|
InterconnectRoleMaster = "master"
|
||||||
|
InterconnectRoleChild = "child"
|
||||||
|
|
||||||
|
// 互联系统事件
|
||||||
|
EventInterconnectChildStatus = "interconnect_child_status"
|
||||||
|
)
|
||||||
@@ -0,0 +1,526 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/engigu/baihu-panel/internal/constant"
|
||||||
|
"github.com/engigu/baihu-panel/internal/models"
|
||||||
|
"github.com/engigu/baihu-panel/internal/models/vo"
|
||||||
|
"github.com/engigu/baihu-panel/internal/services"
|
||||||
|
"github.com/engigu/baihu-panel/internal/tunnel"
|
||||||
|
"github.com/engigu/baihu-panel/internal/utils"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type InterconnectController struct {
|
||||||
|
interconnectService *services.InterconnectService
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInterconnectController(interconnectService *services.InterconnectService) *InterconnectController {
|
||||||
|
return &InterconnectController{
|
||||||
|
interconnectService: interconnectService,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNodes 获取互联节点列表
|
||||||
|
func (ic *InterconnectController) GetNodes(c *gin.Context) {
|
||||||
|
nodes, err := ic.interconnectService.GetNodes()
|
||||||
|
if err != nil {
|
||||||
|
utils.ServerError(c, "获取互联节点失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateNode 创建互联节点
|
||||||
|
func (ic *InterconnectController) CreateNode(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Token string `json:"token" binding:"required"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
node, err := ic.interconnectService.CreateNode(req.Name, req.URL, req.Token, req.Remark)
|
||||||
|
if err != nil {
|
||||||
|
utils.ServerError(c, "创建互联节点失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Success(c, node)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateNode 更新互联节点
|
||||||
|
func (ic *InterconnectController) UpdateNode(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if id == "" {
|
||||||
|
utils.BadRequest(c, "无效的节点ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Token string `json:"token" binding:"required"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
node, err := ic.interconnectService.UpdateNode(id, req.Name, req.URL, req.Token, req.Remark)
|
||||||
|
if err != nil {
|
||||||
|
utils.ServerError(c, "更新互联节点失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Success(c, node)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteNode 删除互联节点
|
||||||
|
func (ic *InterconnectController) DeleteNode(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if id == "" {
|
||||||
|
utils.BadRequest(c, "无效的节点ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ic.interconnectService.DeleteNode(id)
|
||||||
|
if err != nil {
|
||||||
|
utils.ServerError(c, "删除互联节点失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Success(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNodeStatus 获取单个子节点的状态
|
||||||
|
func (ic *InterconnectController) GetNodeStatus(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
node, err := ic.interconnectService.GetNodeByID(id)
|
||||||
|
if err != nil {
|
||||||
|
utils.NotFound(c, "节点不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 针对反向隧道节点状态检测的特判
|
||||||
|
if strings.HasPrefix(node.URL, "tunnel://") {
|
||||||
|
sess := tunnel.GetSession(node.ID)
|
||||||
|
if sess == nil {
|
||||||
|
c.JSON(200, gin.H{"code": 500, "msg": "节点离线或反向隧道未建立", "data": nil})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用当前 Yamux Session 的虚拟底层连接进行拨号
|
||||||
|
transport := &http.Transport{
|
||||||
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return sess.Session.Open()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: transport,
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", "http://tunnel.local/api/v1/monitor", nil)
|
||||||
|
if err != nil {
|
||||||
|
utils.ServerError(c, "构建检测请求失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+node.Token)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(200, gin.H{"code": 500, "msg": "与子节点逆向连接通讯失败", "data": nil})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
c.JSON(200, gin.H{"code": 500, "msg": "子节点检测异常", "data": string(body)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var jsonResp map[string]interface{}
|
||||||
|
if err := json.Unmarshal(body, &jsonResp); err != nil {
|
||||||
|
utils.ServerError(c, "解析节点检测数据失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if dataMap, ok := jsonResp["data"].(map[string]interface{}); ok {
|
||||||
|
dataMap["tunnel_connected"] = true
|
||||||
|
dataMap["tunnel_url"] = node.URL
|
||||||
|
if hostMap, ok := dataMap["host"].(map[string]interface{}); ok {
|
||||||
|
hostMap["tx_bytes"] = node.Metrics.TxBytes
|
||||||
|
hostMap["rx_bytes"] = node.Metrics.RxBytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Success(c, jsonResp["data"])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL := strings.TrimRight(node.URL, "/") + "/api/v1/monitor"
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
utils.ServerError(c, "构建请求失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+node.Token)
|
||||||
|
|
||||||
|
resp, err := ic.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(200, gin.H{"code": 500, "msg": "节点离线或网络不可达", "data": nil})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
c.JSON(200, gin.H{"code": 500, "msg": "节点返回异常", "data": string(body)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var jsonResp map[string]interface{}
|
||||||
|
if err := json.Unmarshal(body, &jsonResp); err != nil {
|
||||||
|
utils.ServerError(c, "解析节点响应失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Success(c, jsonResp["data"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncScript 将脚本同步到指定的节点列表
|
||||||
|
func (ic *InterconnectController) SyncScript(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
NodeIDs []string `json:"node_ids" binding:"required"`
|
||||||
|
Filename string `json:"filename" binding:"required"`
|
||||||
|
Content string `json:"content" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]map[string]interface{}, 0)
|
||||||
|
|
||||||
|
for _, nodeID := range req.NodeIDs {
|
||||||
|
node, err := ic.interconnectService.GetNodeByID(nodeID)
|
||||||
|
if err != nil {
|
||||||
|
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "节点不存在"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
client, apiURL, err := ic.getClientAndURL(&node, "/api/v1/scripts/save")
|
||||||
|
if err != nil {
|
||||||
|
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "反向隧道未连接"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"filename": req.Filename,
|
||||||
|
"content": req.Content,
|
||||||
|
}
|
||||||
|
payloadBytes, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(payloadBytes))
|
||||||
|
if err != nil {
|
||||||
|
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "构建请求失败"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+node.Token)
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
if err != nil || resp.StatusCode != 200 {
|
||||||
|
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "同步请求失败或超时"})
|
||||||
|
} else {
|
||||||
|
results = append(results, map[string]interface{}{"node_id": nodeID, "success": true, "msg": "同步成功"})
|
||||||
|
}
|
||||||
|
if resp != nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Success(c, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncEnv 将环境变量同步到指定的节点列表
|
||||||
|
func (ic *InterconnectController) SyncEnv(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
NodeIDs []string `json:"node_ids" binding:"required"`
|
||||||
|
Envs []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
} `json:"envs" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]map[string]interface{}, 0)
|
||||||
|
|
||||||
|
for _, nodeID := range req.NodeIDs {
|
||||||
|
node, err := ic.interconnectService.GetNodeByID(nodeID)
|
||||||
|
if err != nil {
|
||||||
|
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "节点不存在"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
client, apiURL, err := ic.getClientAndURL(&node, "/api/v1/env")
|
||||||
|
if err != nil {
|
||||||
|
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "反向隧道未连接"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
successCount := 0
|
||||||
|
for _, env := range req.Envs {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"name": env.Name,
|
||||||
|
"value": env.Value,
|
||||||
|
"remark": env.Remark,
|
||||||
|
"type": "normal",
|
||||||
|
}
|
||||||
|
payloadBytes, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(payloadBytes))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+node.Token)
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
if err == nil && resp.StatusCode == 200 {
|
||||||
|
successCount++
|
||||||
|
}
|
||||||
|
if resp != nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, map[string]interface{}{
|
||||||
|
"node_id": nodeID,
|
||||||
|
"success": true,
|
||||||
|
"msg": "同步完成",
|
||||||
|
"count": successCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Success(c, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncTask 将任务同步到指定的节点列表
|
||||||
|
func (ic *InterconnectController) SyncTask(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
NodeIDs []string `json:"node_ids" binding:"required"`
|
||||||
|
Tasks []vo.TaskVO `json:"tasks" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]map[string]interface{}, 0)
|
||||||
|
|
||||||
|
for _, nodeID := range req.NodeIDs {
|
||||||
|
node, err := ic.interconnectService.GetNodeByID(nodeID)
|
||||||
|
if err != nil {
|
||||||
|
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "节点不存在"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
client, apiURL, err := ic.getClientAndURL(&node, "/api/v1/tasks/bulk_save")
|
||||||
|
if err != nil {
|
||||||
|
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "反向隧道未连接"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
payloadBytes, _ := json.Marshal(req.Tasks)
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(payloadBytes))
|
||||||
|
if err != nil {
|
||||||
|
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "构建请求失败"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+node.Token)
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
if err != nil || resp.StatusCode != 200 {
|
||||||
|
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "同步请求失败或超时"})
|
||||||
|
} else {
|
||||||
|
results = append(results, map[string]interface{}{"node_id": nodeID, "success": true, "msg": "同步成功"})
|
||||||
|
}
|
||||||
|
if resp != nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Success(c, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleTunnel 接受子节点 WebSocket 连接请求
|
||||||
|
func (ic *InterconnectController) HandleTunnel(c *gin.Context) {
|
||||||
|
tunnel.HandleTunnel(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProxyRequest 代理转发请求至目标节点
|
||||||
|
func (ic *InterconnectController) ProxyRequest(c *gin.Context) {
|
||||||
|
nodeID := c.Param("node_id")
|
||||||
|
path := c.Param("path")
|
||||||
|
if nodeID == "" {
|
||||||
|
utils.BadRequest(c, "Node ID required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
node, err := ic.interconnectService.GetNodeByID(nodeID)
|
||||||
|
if err != nil {
|
||||||
|
utils.NotFound(c, "Node not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(node.URL, "tunnel://") {
|
||||||
|
// 走 WebSocket 逆向隧道 (基于 Yamux 流式多路复用)
|
||||||
|
err := tunnel.ProxyHTTP(nodeID, c, path)
|
||||||
|
if err != nil {
|
||||||
|
utils.ServerError(c, "Tunnel request failed: "+err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 走普通 HTTP 直连
|
||||||
|
// Construct the target URL
|
||||||
|
targetURL := strings.TrimRight(node.URL, "/") + path
|
||||||
|
if c.Request.URL.RawQuery != "" {
|
||||||
|
targetURL += "?" + c.Request.URL.RawQuery
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(c.Request.Method, targetURL, c.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
utils.ServerError(c, "Failed to create proxy request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy headers
|
||||||
|
req.Header = c.Request.Header.Clone()
|
||||||
|
|
||||||
|
// If the node token exists, append it as Bearer Auth
|
||||||
|
if node.Token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+node.Token)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := ic.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
utils.ServerError(c, "Failed to connect to target node: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
for k, v := range resp.Header {
|
||||||
|
for _, vv := range v {
|
||||||
|
c.Writer.Header().Add(k, vv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Status(resp.StatusCode)
|
||||||
|
io.Copy(c.Writer, resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReportMonitorData 接收子节点上报的监控数据
|
||||||
|
func (ic *InterconnectController) ReportMonitorData(c *gin.Context) {
|
||||||
|
var req models.NodeMetrics
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authHeader := c.GetHeader("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
c.JSON(401, gin.H{"error": "missing authorization"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tokenStr := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
|
||||||
|
|
||||||
|
node, err := ic.interconnectService.GetNodeByToken(tokenStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(401, gin.H{"error": "invalid token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ic.interconnectService.UpdateNodeMonitorData(node.ID, req)
|
||||||
|
if err != nil {
|
||||||
|
utils.ServerError(c, "更新节点数据失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, gin.H{
|
||||||
|
"tunnel_url": node.URL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetChildStatus 获取本机作为子节点的连接状态
|
||||||
|
func (ic *InterconnectController) GetChildStatus(c *gin.Context) {
|
||||||
|
settingsSvc := services.NewSettingsService()
|
||||||
|
parentURL := settingsSvc.Get(constant.SectionInterconnect, constant.KeyInterconnectParentURL)
|
||||||
|
parentToken := settingsSvc.Get(constant.SectionInterconnect, constant.KeyInterconnectParentToken)
|
||||||
|
|
||||||
|
connected := tunnel.IsTunnelConnected()
|
||||||
|
tunnelURL := tunnel.GetLocalTunnelURL()
|
||||||
|
|
||||||
|
utils.Success(c, gin.H{
|
||||||
|
"parent_url": parentURL,
|
||||||
|
"parent_token": parentToken,
|
||||||
|
"connected": connected,
|
||||||
|
"tunnel_url": tunnelURL,
|
||||||
|
"tx_bytes": tunnel.GetTxBytes(),
|
||||||
|
"rx_bytes": tunnel.GetRxBytes(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// getClientAndURL 辅助方法:根据节点类型决定走直连还是隧道,并返回对应的 Client 和完整 URL
|
||||||
|
func (ic *InterconnectController) getClientAndURL(node *models.InterconnectNode, path string) (*http.Client, string, error) {
|
||||||
|
if strings.HasPrefix(node.URL, "tunnel://") {
|
||||||
|
sess := tunnel.GetSession(node.ID)
|
||||||
|
if sess == nil {
|
||||||
|
return nil, "", net.ErrClosed
|
||||||
|
}
|
||||||
|
transport := &http.Transport{
|
||||||
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return sess.Session.Open()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: transport,
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
return client, "http://tunnel.local" + path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
targetURL := strings.TrimRight(node.URL, "/") + path
|
||||||
|
return ic.httpClient, targetURL, nil
|
||||||
|
}
|
||||||
@@ -1,34 +1,20 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"math/rand"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/engigu/baihu-panel/internal/constant"
|
"github.com/engigu/baihu-panel/internal/services"
|
||||||
"github.com/engigu/baihu-panel/internal/services/tasks"
|
"github.com/engigu/baihu-panel/internal/services/tasks"
|
||||||
"github.com/engigu/baihu-panel/internal/utils"
|
"github.com/engigu/baihu-panel/internal/utils"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
"github.com/shirou/gopsutil/v3/cpu"
|
|
||||||
"github.com/shirou/gopsutil/v3/disk"
|
|
||||||
"github.com/shirou/gopsutil/v3/host"
|
|
||||||
"github.com/shirou/gopsutil/v3/mem"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type MonitorController struct {
|
type MonitorController struct {
|
||||||
executorService *tasks.ExecutorService
|
executorService *tasks.ExecutorService
|
||||||
|
|
||||||
// 缓存物理机状态
|
|
||||||
hostMu sync.RWMutex
|
|
||||||
lastUpdate time.Time
|
|
||||||
cpuPercent float64
|
|
||||||
vMem *mem.VirtualMemoryStat
|
|
||||||
diskUsage *disk.UsageStat
|
|
||||||
hostInfo *host.InfoStat
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMonitorController(executorService *tasks.ExecutorService) *MonitorController {
|
func NewMonitorController(executorService *tasks.ExecutorService) *MonitorController {
|
||||||
@@ -49,118 +35,52 @@ func (mc *MonitorController) GetSystemMonitor(c *gin.Context) {
|
|||||||
utils.Success(c, data)
|
utils.Success(c, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MonitorWS WebSocket 获取系统监控数据
|
// MonitorSSE Server-Sent Events 获取系统监控数据
|
||||||
func (mc *MonitorController) MonitorWS(c *gin.Context) {
|
func (mc *MonitorController) MonitorSSE(c *gin.Context) {
|
||||||
ws, err := monitorUpgrader.Upgrade(c.Writer, c.Request, nil)
|
// 设置 SSE 响应头
|
||||||
if err != nil {
|
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||||
return
|
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||||
}
|
c.Writer.Header().Set("Connection", "keep-alive")
|
||||||
defer ws.Close()
|
c.Writer.Header().Set("Transfer-Encoding", "chunked")
|
||||||
|
|
||||||
// 初始发送一次数据
|
// 初始发送一次数据
|
||||||
if err := mc.sendMonitorData(ws); err != nil {
|
if err := mc.sendMonitorDataSSE(c); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ticker := time.NewTicker(3 * time.Second)
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
if err := mc.sendMonitorData(ws); err != nil {
|
if err := mc.sendMonitorDataSSE(c); err != nil {
|
||||||
return // 客户端断开连接或发送失败
|
return // 客户端断开连接或发送失败
|
||||||
}
|
}
|
||||||
case <-c.Request.Context().Done():
|
case <-c.Request.Context().Done():
|
||||||
return
|
return // 连接已断开,立即退出
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mc *MonitorController) sendMonitorData(ws *websocket.Conn) error {
|
func (mc *MonitorController) sendMonitorDataSSE(c *gin.Context) error {
|
||||||
data := mc.getMonitorData()
|
data := mc.getMonitorData()
|
||||||
return ws.WriteJSON(gin.H{
|
// 使用 Gin 提供的 SSE 方法
|
||||||
|
c.SSEvent("message", gin.H{
|
||||||
"code": 200,
|
"code": 200,
|
||||||
"data": data,
|
"data": data,
|
||||||
"msg": "success",
|
"msg": "success",
|
||||||
})
|
})
|
||||||
}
|
c.Writer.Flush()
|
||||||
|
return nil
|
||||||
func (mc *MonitorController) updateHostMetrics() {
|
|
||||||
mc.hostMu.Lock()
|
|
||||||
defer mc.hostMu.Unlock()
|
|
||||||
|
|
||||||
// 缓存 2 秒
|
|
||||||
if time.Since(mc.lastUpdate) < 2*time.Second && mc.vMem != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if constant.DemoMode {
|
|
||||||
mc.updateDemoMetrics()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cpuPercents, _ := cpu.Percent(0, false)
|
|
||||||
if len(cpuPercents) > 0 {
|
|
||||||
mc.cpuPercent = cpuPercents[0]
|
|
||||||
}
|
|
||||||
mc.vMem, _ = mem.VirtualMemory()
|
|
||||||
mc.diskUsage, _ = disk.Usage("/")
|
|
||||||
mc.hostInfo, _ = host.Info()
|
|
||||||
mc.lastUpdate = time.Now()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mc *MonitorController) updateDemoMetrics() {
|
|
||||||
mc.cpuPercent = 10 + rand.Float64()*40 // 10% - 50% 的随机 CPU 波动
|
|
||||||
|
|
||||||
totalMem := uint64(8 * 1024 * 1024 * 1024) // 8GB 内存
|
|
||||||
usedMem := uint64(float64(totalMem) * (0.3 + rand.Float64()*0.3)) // 30% - 60% 随机使用率
|
|
||||||
mc.vMem = &mem.VirtualMemoryStat{
|
|
||||||
Total: totalMem,
|
|
||||||
Used: usedMem,
|
|
||||||
UsedPercent: float64(usedMem) / float64(totalMem) * 100,
|
|
||||||
}
|
|
||||||
|
|
||||||
totalDisk := uint64(500 * 1024 * 1024 * 1024) // 500GB 硬盘
|
|
||||||
usedDisk := uint64(float64(totalDisk) * 0.45) // 固定 45% 使用率
|
|
||||||
mc.diskUsage = &disk.UsageStat{
|
|
||||||
Total: totalDisk,
|
|
||||||
Used: usedDisk,
|
|
||||||
UsedPercent: float64(usedDisk) / float64(totalDisk) * 100,
|
|
||||||
}
|
|
||||||
|
|
||||||
mc.hostInfo = &host.InfoStat{
|
|
||||||
Platform: "Demo Environment",
|
|
||||||
OS: "linux",
|
|
||||||
Uptime: uint64(time.Now().Unix() - 1700000000), // 生成一个较长且持续增加的运行时间
|
|
||||||
}
|
|
||||||
mc.lastUpdate = time.Now()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mc *MonitorController) getMonitorData() gin.H {
|
func (mc *MonitorController) getMonitorData() gin.H {
|
||||||
var m runtime.MemStats
|
rt := services.GetMonitorService().GetRuntimeMetrics()
|
||||||
runtime.ReadMemStats(&m)
|
m := rt.MemStats
|
||||||
|
|
||||||
// 更新并读取缓存的物理主机指标
|
// 调用统一的监控服务获取物理机指标
|
||||||
mc.updateHostMetrics()
|
metrics := services.GetMonitorService().GetHostMetrics()
|
||||||
|
|
||||||
mc.hostMu.RLock()
|
|
||||||
cpuPercent := mc.cpuPercent
|
|
||||||
vMem := mc.vMem
|
|
||||||
diskUsage := mc.diskUsage
|
|
||||||
hostInfo := mc.hostInfo
|
|
||||||
mc.hostMu.RUnlock()
|
|
||||||
|
|
||||||
// 提供默认值防空指针
|
|
||||||
if vMem == nil {
|
|
||||||
vMem = &mem.VirtualMemoryStat{}
|
|
||||||
}
|
|
||||||
if diskUsage == nil {
|
|
||||||
diskUsage = &disk.UsageStat{}
|
|
||||||
}
|
|
||||||
if hostInfo == nil {
|
|
||||||
hostInfo = &host.InfoStat{}
|
|
||||||
}
|
|
||||||
|
|
||||||
return gin.H{
|
return gin.H{
|
||||||
"env": gin.H{
|
"env": gin.H{
|
||||||
@@ -168,18 +88,18 @@ func (mc *MonitorController) getMonitorData() gin.H {
|
|||||||
"arch": runtime.GOARCH,
|
"arch": runtime.GOARCH,
|
||||||
"go_version": runtime.Version(),
|
"go_version": runtime.Version(),
|
||||||
"num_cpu": runtime.NumCPU(),
|
"num_cpu": runtime.NumCPU(),
|
||||||
"goroutines": runtime.NumGoroutine(),
|
"goroutines": rt.NumGoroutine,
|
||||||
},
|
},
|
||||||
"host": gin.H{
|
"host": gin.H{
|
||||||
"cpu_percent": cpuPercent,
|
"cpu_percent": metrics.CPUPercent,
|
||||||
"mem_total": vMem.Total,
|
"mem_total": metrics.VMem.Total,
|
||||||
"mem_used": vMem.Used,
|
"mem_used": metrics.VMem.Used,
|
||||||
"mem_percent": vMem.UsedPercent,
|
"mem_percent": metrics.VMem.UsedPercent,
|
||||||
"disk_total": diskUsage.Total,
|
"disk_total": metrics.DiskUsage.Total,
|
||||||
"disk_used": diskUsage.Used,
|
"disk_used": metrics.DiskUsage.Used,
|
||||||
"disk_percent": diskUsage.UsedPercent,
|
"disk_percent": metrics.DiskUsage.UsedPercent,
|
||||||
"uptime": hostInfo.Uptime,
|
"uptime": metrics.HostInfo.Uptime,
|
||||||
"platform": hostInfo.Platform + " " + hostInfo.PlatformVersion,
|
"platform": metrics.HostInfo.Platform + " " + metrics.HostInfo.PlatformVersion,
|
||||||
},
|
},
|
||||||
"mem": gin.H{
|
"mem": gin.H{
|
||||||
"alloc": m.Alloc,
|
"alloc": m.Alloc,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"github.com/engigu/baihu-panel/internal/models/vo"
|
"github.com/engigu/baihu-panel/internal/models/vo"
|
||||||
"github.com/engigu/baihu-panel/internal/services"
|
"github.com/engigu/baihu-panel/internal/services"
|
||||||
"github.com/engigu/baihu-panel/internal/services/tasks"
|
"github.com/engigu/baihu-panel/internal/services/tasks"
|
||||||
|
"github.com/engigu/baihu-panel/internal/tunnel"
|
||||||
"github.com/engigu/baihu-panel/internal/utils"
|
"github.com/engigu/baihu-panel/internal/utils"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -134,6 +135,20 @@ func (sc *SettingsController) ChangePassword(c *gin.Context) {
|
|||||||
func (sc *SettingsController) GetSiteSettings(c *gin.Context) {
|
func (sc *SettingsController) GetSiteSettings(c *gin.Context) {
|
||||||
settings := sc.settingsService.GetSection(constant.SectionSite)
|
settings := sc.settingsService.GetSection(constant.SectionSite)
|
||||||
|
|
||||||
|
// 纠正数据库中的空值,防止因配置冲突被意外置空
|
||||||
|
if settings[constant.KeyTitle] == "" {
|
||||||
|
settings[constant.KeyTitle] = "白虎面板"
|
||||||
|
sc.settingsService.Set(constant.SectionSite, constant.KeyTitle, "白虎面板")
|
||||||
|
}
|
||||||
|
if settings[constant.KeySubtitle] == "" {
|
||||||
|
settings[constant.KeySubtitle] = "极致轻量、高性能的自动化任务调度平台"
|
||||||
|
sc.settingsService.Set(constant.SectionSite, constant.KeySubtitle, "极致轻量、高性能的自动化任务调度平台")
|
||||||
|
}
|
||||||
|
if settings[constant.KeyIcon] == "" {
|
||||||
|
settings[constant.KeyIcon] = constant.DefaultIcon
|
||||||
|
sc.settingsService.Set(constant.SectionSite, constant.KeyIcon, constant.DefaultIcon)
|
||||||
|
}
|
||||||
|
|
||||||
// 解析 JSON 格式的 OpenAPI Token
|
// 解析 JSON 格式的 OpenAPI Token
|
||||||
if tokenJson, ok := settings[constant.KeyOpenapiToken]; ok && tokenJson != "" {
|
if tokenJson, ok := settings[constant.KeyOpenapiToken]; ok && tokenJson != "" {
|
||||||
var tokenConfig vo.TokenConfig
|
var tokenConfig vo.TokenConfig
|
||||||
@@ -164,11 +179,25 @@ func (sc *SettingsController) GetSiteSettings(c *gin.Context) {
|
|||||||
// GetPublicSiteSettings 获取公开的站点设置(无需认证)
|
// GetPublicSiteSettings 获取公开的站点设置(无需认证)
|
||||||
func (sc *SettingsController) GetPublicSiteSettings(c *gin.Context) {
|
func (sc *SettingsController) GetPublicSiteSettings(c *gin.Context) {
|
||||||
settings := sc.settingsService.GetSection(constant.SectionSite)
|
settings := sc.settingsService.GetSection(constant.SectionSite)
|
||||||
|
|
||||||
|
title := settings[constant.KeyTitle]
|
||||||
|
if title == "" {
|
||||||
|
title = "白虎面板"
|
||||||
|
}
|
||||||
|
subtitle := settings[constant.KeySubtitle]
|
||||||
|
if subtitle == "" {
|
||||||
|
subtitle = "极致轻量、高性能的自动化任务调度平台"
|
||||||
|
}
|
||||||
|
icon := settings[constant.KeyIcon]
|
||||||
|
if icon == "" {
|
||||||
|
icon = constant.DefaultIcon
|
||||||
|
}
|
||||||
|
|
||||||
// 只返回公开信息
|
// 只返回公开信息
|
||||||
utils.Success(c, gin.H{
|
utils.Success(c, gin.H{
|
||||||
constant.KeyTitle: settings[constant.KeyTitle],
|
constant.KeyTitle: title,
|
||||||
constant.KeySubtitle: settings[constant.KeySubtitle],
|
constant.KeySubtitle: subtitle,
|
||||||
constant.KeyIcon: settings[constant.KeyIcon],
|
constant.KeyIcon: icon,
|
||||||
"demo_mode": constant.DemoMode,
|
"demo_mode": constant.DemoMode,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -530,6 +559,13 @@ func (sc *SettingsController) UpdateSectionSettings(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 当互联配置发生改变时,通知 tunnel 模块立刻应用新角色,启动或停止相关的后台协程
|
||||||
|
if section == constant.SectionInterconnect {
|
||||||
|
if role, ok := values[constant.KeyInterconnectRole]; ok {
|
||||||
|
tunnel.ApplyRole(role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
utils.SuccessMsg(c, "保存成功")
|
utils.SuccessMsg(c, "保存成功")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/engigu/baihu-panel/internal/constant"
|
"github.com/engigu/baihu-panel/internal/constant"
|
||||||
|
"github.com/engigu/baihu-panel/internal/database"
|
||||||
"github.com/engigu/baihu-panel/internal/logger"
|
"github.com/engigu/baihu-panel/internal/logger"
|
||||||
"github.com/engigu/baihu-panel/internal/models"
|
"github.com/engigu/baihu-panel/internal/models"
|
||||||
"github.com/engigu/baihu-panel/internal/models/vo"
|
"github.com/engigu/baihu-panel/internal/models/vo"
|
||||||
@@ -227,6 +228,87 @@ func (tc *TaskController) CreateTask(c *gin.Context) {
|
|||||||
utils.Success(c, vo.ToTaskVO(task))
|
utils.Success(c, vo.ToTaskVO(task))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BulkSaveTask 批量保存/导入任务配置(用于主节点下发同步)
|
||||||
|
// @Summary 批量保存任务
|
||||||
|
// @Description 批量导入任务配置,如果ID或同名存在则更新,不存在则创建
|
||||||
|
// @Tags 任务管理
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /tasks/bulk_save [post]
|
||||||
|
func (tc *TaskController) BulkSaveTask(c *gin.Context) {
|
||||||
|
var reqs []vo.TaskVO
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&reqs); err != nil {
|
||||||
|
utils.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, req := range reqs {
|
||||||
|
param := tasks.TaskParam{
|
||||||
|
Name: req.Name,
|
||||||
|
Remark: req.Remark,
|
||||||
|
Command: req.Command,
|
||||||
|
PreCommand: req.PreCommand,
|
||||||
|
PostCommand: req.PostCommand,
|
||||||
|
Tags: req.Tags,
|
||||||
|
Type: req.Type,
|
||||||
|
Config: req.Config,
|
||||||
|
Schedule: req.Schedule,
|
||||||
|
Timeout: req.Timeout,
|
||||||
|
WorkDir: req.WorkDir,
|
||||||
|
CleanConfig: req.CleanConfig,
|
||||||
|
Envs: req.Envs,
|
||||||
|
Languages: req.Languages,
|
||||||
|
AgentID: req.AgentID,
|
||||||
|
TriggerType: req.TriggerType,
|
||||||
|
RetryCount: req.RetryCount,
|
||||||
|
RetryInterval: req.RetryInterval,
|
||||||
|
RandomRange: req.RandomRange,
|
||||||
|
PinType: req.PinType,
|
||||||
|
Enabled: req.Enabled,
|
||||||
|
SourceID: "", // 不直接覆盖
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingTask *models.Task
|
||||||
|
// 优先按 ID 匹配
|
||||||
|
if req.ID != "" {
|
||||||
|
existingTask = tc.taskService.GetTaskByID(req.ID)
|
||||||
|
}
|
||||||
|
// 如果 ID 没找到,尝试按 Name 匹配
|
||||||
|
if existingTask == nil {
|
||||||
|
var t models.Task
|
||||||
|
res := database.DB.Where("name = ?", req.Name).First(&t)
|
||||||
|
if res.Error == nil {
|
||||||
|
existingTask = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var savedTask *models.Task
|
||||||
|
if existingTask != nil {
|
||||||
|
savedTask = tc.taskService.UpdateTask(existingTask.ID, ¶m)
|
||||||
|
} else {
|
||||||
|
savedTask = tc.taskService.CreateTask(¶m)
|
||||||
|
// 如果原始有 ID,强制覆盖更新 ID 保持强同步一致性
|
||||||
|
if req.ID != "" && savedTask != nil {
|
||||||
|
database.DB.Model(savedTask).Update("id", req.ID)
|
||||||
|
savedTask.ID = req.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是 Agent 任务,通知 Agent;否则添加到本地 cron
|
||||||
|
if savedTask != nil {
|
||||||
|
if savedTask.AgentID != nil && *savedTask.AgentID != "" {
|
||||||
|
tc.agentWSManager.BroadcastTasks(*savedTask.AgentID)
|
||||||
|
} else {
|
||||||
|
tc.executorService.AddCronTask(savedTask)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Success(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
// GetTasks 获取任务列表
|
// GetTasks 获取任务列表
|
||||||
// @Summary 获取任务列表
|
// @Summary 获取任务列表
|
||||||
// @Description 分页获取任务列表,支持按名称、Agent ID、标签、类型筛选
|
// @Description 分页获取任务列表,支持按名称、Agent ID、标签、类型筛选
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ func (tc *TerminalController) handlePtyMode(conn *websocket.Conn, userID string)
|
|||||||
close(pingDone)
|
close(pingDone)
|
||||||
cmd.Process.Kill()
|
cmd.Process.Kill()
|
||||||
cmd.Wait()
|
cmd.Wait()
|
||||||
|
ptmx.Close() // Force close PTY to interrupt the blocking ptmx.Read() in the goroutine
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,9 +370,19 @@ func (tc *TerminalController) handlePipeMode(conn *websocket.Conn, userID string
|
|||||||
}
|
}
|
||||||
|
|
||||||
close(pingDone)
|
close(pingDone)
|
||||||
stdin.Close()
|
|
||||||
cmd.Process.Kill()
|
cmd.Process.Kill()
|
||||||
cmd.Wait()
|
cmd.Wait()
|
||||||
|
|
||||||
|
if stdinCloser, ok := stdin.(io.Closer); ok {
|
||||||
|
stdinCloser.Close()
|
||||||
|
}
|
||||||
|
if stdoutCloser, ok := stdout.(io.Closer); ok {
|
||||||
|
stdoutCloser.Close()
|
||||||
|
}
|
||||||
|
if stderrCloser, ok := stderr.(io.Closer); ok {
|
||||||
|
stderrCloser.Close()
|
||||||
|
}
|
||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ var allModels = []interface{}{
|
|||||||
&models.NotifyBinding{},
|
&models.NotifyBinding{},
|
||||||
&models.DataRelation{},
|
&models.DataRelation{},
|
||||||
&models.DataStorage{},
|
&models.DataStorage{},
|
||||||
|
&models.InterconnectNode{},
|
||||||
}
|
}
|
||||||
|
|
||||||
func Migrate() error {
|
func Migrate() error {
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/engigu/baihu-panel/internal/logger"
|
||||||
|
"github.com/engigu/baihu-panel/internal/systime"
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SysCronManager struct {
|
||||||
|
cron *cron.Cron
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
sysCronInstance *SysCronManager
|
||||||
|
sysCronOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
// InitSysCron 初始化系统的内部定时器
|
||||||
|
func InitSysCron(){
|
||||||
|
GetSysCron()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSysCron 获取内部系统定时器服务单例
|
||||||
|
func GetSysCron() *SysCronManager {
|
||||||
|
sysCronOnce.Do(func() {
|
||||||
|
// 使用秒级精度,指定为东八区
|
||||||
|
c := cron.New(cron.WithSeconds(), cron.WithLocation(systime.CST))
|
||||||
|
c.Start()
|
||||||
|
sysCronInstance = &SysCronManager{
|
||||||
|
cron: c,
|
||||||
|
}
|
||||||
|
logger.Infof("[SysCron] 内部系统定时管理器已启动")
|
||||||
|
})
|
||||||
|
return sysCronInstance
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddJob 添加内部系统任务,spec为cron表达式(支持 @every 30s 这种快捷方式)
|
||||||
|
func (s *SysCronManager) AddJob(spec string, cmd func()) (cron.EntryID, error) {
|
||||||
|
id, err := s.cron.AddFunc(spec, cmd)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("[SysCron] 无法添加系统任务: %s, err: %v", spec, err)
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddJobWithRun 立即开启一个协程异步执行一次任务,随后将其加入到系统定时任务中
|
||||||
|
func (s *SysCronManager) AddJobWithRun(spec string, cmd func()) (cron.EntryID, error) {
|
||||||
|
// 立即异步执行一次
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
logger.Errorf("[SysCron] 立即执行任务时发生 panic: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
cmd()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 然后加入定时器
|
||||||
|
return s.AddJob(spec, cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveJob 动态移除指定的系统定时任务
|
||||||
|
func (s *SysCronManager) RemoveJob(id cron.EntryID) {
|
||||||
|
s.cron.Remove(id)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/engigu/baihu-panel/internal/constant"
|
"github.com/engigu/baihu-panel/internal/constant"
|
||||||
@@ -34,6 +35,47 @@ func AuthRequired() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否携带互联 Token(支持跨面板远程全接口调用)
|
||||||
|
authHeader := c.GetHeader("Authorization")
|
||||||
|
if authHeader != "" {
|
||||||
|
tokenStr := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
|
||||||
|
if tokenStr != "" {
|
||||||
|
settingsSvc := services.NewSettingsService()
|
||||||
|
interconnectToken := settingsSvc.Get(constant.SectionSite, constant.KeyInterconnectToken)
|
||||||
|
parentToken := settingsSvc.Get(constant.SectionInterconnect, constant.KeyInterconnectParentToken)
|
||||||
|
|
||||||
|
isMatched := false
|
||||||
|
h1 := sha256.Sum256([]byte(tokenStr))
|
||||||
|
|
||||||
|
if interconnectToken != "" {
|
||||||
|
h2 := sha256.Sum256([]byte(interconnectToken))
|
||||||
|
if subtle.ConstantTimeCompare(h1[:], h2[:]) == 1 {
|
||||||
|
isMatched = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isMatched && parentToken != "" {
|
||||||
|
h2 := sha256.Sum256([]byte(parentToken))
|
||||||
|
if subtle.ConstantTimeCompare(h1[:], h2[:]) == 1 {
|
||||||
|
isMatched = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isMatched {
|
||||||
|
// 模拟 Admin 角色
|
||||||
|
var adminUser models.User
|
||||||
|
res := database.DB.Where("role = ?", constant.AdminRole).Limit(1).Find(&adminUser)
|
||||||
|
if res.Error == nil && res.RowsAffected > 0 {
|
||||||
|
c.Set("userID", adminUser.ID)
|
||||||
|
c.Set("username", adminUser.Username)
|
||||||
|
c.Set("role", adminUser.Role)
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
token, err := c.Cookie(constant.CookieName)
|
token, err := c.Cookie(constant.CookieName)
|
||||||
if err != nil || token == "" {
|
if err != nil || token == "" {
|
||||||
utils.Unauthorized(c, "请先登录")
|
utils.Unauthorized(c, "请先登录")
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/engigu/baihu-panel/internal/constant"
|
||||||
|
"github.com/engigu/baihu-panel/internal/database"
|
||||||
|
"github.com/engigu/baihu-panel/internal/models"
|
||||||
|
"github.com/engigu/baihu-panel/internal/services"
|
||||||
|
"github.com/engigu/baihu-panel/internal/tunnel"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
var travelProxyClient = &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
func TravelProxyMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// 1. 检查是否存在 active_interconnect_node_id Cookie
|
||||||
|
nodeID, err := c.Cookie(constant.CookieActiveInterconnectNodeID)
|
||||||
|
if err != nil || nodeID == "" {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 白名单放行:反向隧道建立连接端点必须直达本机,不能二次代理
|
||||||
|
path := c.Request.URL.Path
|
||||||
|
if strings.HasPrefix(path, "/api/v1/interconnect/tunnel") {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 查询数据库中节点信息
|
||||||
|
var node models.InterconnectNode
|
||||||
|
if err := database.DB.Where("id = ?", nodeID).First(&node).Error; err != nil {
|
||||||
|
// 节点不存在,说明 Cookie 无效,清除并放行
|
||||||
|
c.SetCookie(constant.CookieActiveInterconnectNodeID, "", -1, "/", "", false, false)
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 准备代理的路径(若主节点配置了 URLPrefix,需要剥离)
|
||||||
|
cfg := services.GetConfig()
|
||||||
|
urlPrefix := strings.TrimSuffix(cfg.Server.URLPrefix, "/")
|
||||||
|
targetPath := path
|
||||||
|
if urlPrefix != "" {
|
||||||
|
targetPath = strings.TrimPrefix(targetPath, urlPrefix)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(targetPath, "/") {
|
||||||
|
targetPath = "/" + targetPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 执行代理转发
|
||||||
|
if strings.HasPrefix(node.URL, "tunnel://") {
|
||||||
|
// 走 WebSocket 逆向 Yamux 隧道
|
||||||
|
err := tunnel.ProxyHTTP(nodeID, c, targetPath)
|
||||||
|
if err != nil {
|
||||||
|
// 如果是网页 HTML 导航请求,提供友好降级返回主节点
|
||||||
|
if strings.Contains(c.GetHeader("Accept"), "text/html") {
|
||||||
|
c.SetCookie(constant.CookieActiveInterconnectNodeID, "", -1, "/", "", false, false)
|
||||||
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
c.String(200, `<p>与子节点连接失败,正在自动返回主节点...</p><script>document.cookie="` + constant.CookieActiveInterconnectNodeID + `=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; window.location.href="/";</script>`)
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(502, gin.H{"code": 502, "msg": "与子节点逆向隧道通信异常: " + err.Error()})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 走普通 HTTP 直连代理
|
||||||
|
targetURL := strings.TrimRight(node.URL, "/") + targetPath
|
||||||
|
if c.Request.URL.RawQuery != "" {
|
||||||
|
targetURL += "?" + c.Request.URL.RawQuery
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(c.Request.Method, targetURL, c.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(500, gin.H{"code": 500, "msg": "Failed to create proxy request"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制请求头
|
||||||
|
req.Header = c.Request.Header.Clone()
|
||||||
|
|
||||||
|
// 覆盖认证授权 Header 确保子节点鉴权通过
|
||||||
|
if node.Token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+node.Token)
|
||||||
|
}
|
||||||
|
// 移除 Cookie 头部防干扰
|
||||||
|
req.Header.Del("Cookie")
|
||||||
|
req.Header.Set("X-Tunnel-Proxy", "true")
|
||||||
|
|
||||||
|
resp, err := travelProxyClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
// 如果是客户端自己主动取消了请求(例如连续刷新、关闭网页等),直接退出,不应视为子节点离线而执行退回主节点的操作
|
||||||
|
if c.Request.Context().Err() == context.Canceled || strings.Contains(err.Error(), "context canceled") {
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.Contains(c.GetHeader("Accept"), "text/html") {
|
||||||
|
c.SetCookie(constant.CookieActiveInterconnectNodeID, "", -1, "/", "", false, false)
|
||||||
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
c.String(200, `<p>与子节点连接失败,正在自动返回主节点...</p><script>document.cookie="` + constant.CookieActiveInterconnectNodeID + `=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; window.location.href="/";</script>`)
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(502, gin.H{"code": 502, "msg": "无法连接至目标子节点: " + err.Error()})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// 复制响应头
|
||||||
|
for k, v := range resp.Header {
|
||||||
|
for _, vv := range v {
|
||||||
|
c.Writer.Header().Add(k, vv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Status(resp.StatusCode)
|
||||||
|
io.Copy(c.Writer, resp.Body)
|
||||||
|
c.Abort()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/engigu/baihu-panel/internal/constant"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NodeMetrics 表示互联节点的性能指标,使用 JSON 存储
|
||||||
|
type NodeMetrics struct {
|
||||||
|
CPUPercent float64 `json:"cpu_percent"`
|
||||||
|
MemPercent float64 `json:"mem_percent"`
|
||||||
|
DiskPercent float64 `json:"disk_percent"`
|
||||||
|
TxBytes uint64 `json:"tx_bytes,omitempty"`
|
||||||
|
RxBytes uint64 `json:"rx_bytes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InterconnectNode represents a connected remote panel
|
||||||
|
type InterconnectNode struct {
|
||||||
|
ID string `json:"id" gorm:"primaryKey;size:20"`
|
||||||
|
Name string `json:"name" gorm:"size:255;not null"`
|
||||||
|
URL string `json:"url" gorm:"size:255;not null"`
|
||||||
|
Token string `json:"token" gorm:"size:255"`
|
||||||
|
Remark string `json:"remark" gorm:"size:500"`
|
||||||
|
CreatedAt LocalTime `json:"created_at"`
|
||||||
|
UpdatedAt LocalTime `json:"updated_at"`
|
||||||
|
Status string `json:"status" gorm:"size:50"` // online / offline
|
||||||
|
Metrics NodeMetrics `json:"metrics" gorm:"serializer:json"`
|
||||||
|
LastHeartbeatAt *LocalTime `json:"last_heartbeat_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (InterconnectNode) TableName() string {
|
||||||
|
return constant.TablePrefix + "interconnect_nodes"
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
// "github.com/engigu/baihu-panel/internal/controllers"
|
"runtime"
|
||||||
"github.com/engigu/baihu-panel/internal/middleware"
|
|
||||||
|
|
||||||
|
"github.com/engigu/baihu-panel/internal/middleware"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,6 +13,12 @@ func initPublicAPIRoutes(api *gin.RouterGroup, c *Controllers) {
|
|||||||
ctx.JSON(200, gin.H{"message": "pong"})
|
ctx.JSON(200, gin.H{"message": "pong"})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
api.GET("/debug/goroutines", func(ctx *gin.Context) {
|
||||||
|
buf := make([]byte, 1024*1024)
|
||||||
|
n := runtime.Stack(buf, true)
|
||||||
|
ctx.Data(200, "text/plain; charset=utf-8", buf[:n])
|
||||||
|
})
|
||||||
|
|
||||||
// Authentication routes (无需认证)
|
// Authentication routes (无需认证)
|
||||||
auth := api.Group("/auth")
|
auth := api.Group("/auth")
|
||||||
{
|
{
|
||||||
@@ -24,6 +30,11 @@ func initPublicAPIRoutes(api *gin.RouterGroup, c *Controllers) {
|
|||||||
// 公开的站点设置(无需认证)
|
// 公开的站点设置(无需认证)
|
||||||
api.GET("/settings/public", c.Settings.GetPublicSiteSettings)
|
api.GET("/settings/public", c.Settings.GetPublicSiteSettings)
|
||||||
|
|
||||||
|
// 隧道模式 (被控端反向连入,使用独立 Token 做 WebSocket 鉴权)
|
||||||
|
api.GET("/interconnect/tunnel", c.Interconnect.HandleTunnel)
|
||||||
|
// 子节点主动上报监控数据 (无中间件鉴权,内部鉴权)
|
||||||
|
api.POST("/interconnect/report", c.Interconnect.ReportMonitorData)
|
||||||
|
|
||||||
// 内部使用的 API(仅限本地调用,无需 Bearer 认证)
|
// 内部使用的 API(仅限本地调用,无需 Bearer 认证)
|
||||||
internalAPI := api.Group("/internal")
|
internalAPI := api.Group("/internal")
|
||||||
internalAPI.Use(middleware.LocalhostOnly())
|
internalAPI.Use(middleware.LocalhostOnly())
|
||||||
@@ -66,6 +77,7 @@ func initAuthorizedAPIRoutes(api *gin.RouterGroup, c *Controllers) {
|
|||||||
registerSystemWSRoutes(adminOnly, c)
|
registerSystemWSRoutes(adminOnly, c)
|
||||||
registerWebUIRoutes(adminOnly, c)
|
registerWebUIRoutes(adminOnly, c)
|
||||||
registerMonitorRoutes(adminOnly, c)
|
registerMonitorRoutes(adminOnly, c)
|
||||||
|
registerInterconnectRoutes(adminOnly, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +95,7 @@ func registerTaskRoutes(g *gin.RouterGroup, c *Controllers) {
|
|||||||
tasks.POST("", c.Task.CreateTask)
|
tasks.POST("", c.Task.CreateTask)
|
||||||
tasks.GET("", c.Task.GetTasks)
|
tasks.GET("", c.Task.GetTasks)
|
||||||
tasks.GET("/:id", c.Task.GetTask)
|
tasks.GET("/:id", c.Task.GetTask)
|
||||||
|
tasks.POST("/bulk_save", c.Task.BulkSaveTask)
|
||||||
tasks.PUT("/:id", c.Task.UpdateTask)
|
tasks.PUT("/:id", c.Task.UpdateTask)
|
||||||
tasks.DELETE("/:id", c.Task.DeleteTask)
|
tasks.DELETE("/:id", c.Task.DeleteTask)
|
||||||
tasks.POST("/batch-delete", c.Task.BatchDeleteTasks)
|
tasks.POST("/batch-delete", c.Task.BatchDeleteTasks)
|
||||||
@@ -273,7 +286,7 @@ func registerMonitorRoutes(g *gin.RouterGroup, c *Controllers) {
|
|||||||
monitor := g.Group("/monitor")
|
monitor := g.Group("/monitor")
|
||||||
{
|
{
|
||||||
monitor.GET("", c.Monitor.GetSystemMonitor)
|
monitor.GET("", c.Monitor.GetSystemMonitor)
|
||||||
monitor.GET("/ws", c.Monitor.MonitorWS)
|
monitor.GET("/sse", c.Monitor.MonitorSSE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,3 +311,22 @@ func registerWebUIRoutes(g *gin.RouterGroup, c *Controllers) {
|
|||||||
webuiGroup.DELETE("/:name", c.WebUI.DeleteWebUI)
|
webuiGroup.DELETE("/:name", c.WebUI.DeleteWebUI)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func registerInterconnectRoutes(g *gin.RouterGroup, c *Controllers) {
|
||||||
|
interconnect := g.Group("/interconnect")
|
||||||
|
{
|
||||||
|
interconnect.GET("/nodes", c.Interconnect.GetNodes)
|
||||||
|
interconnect.POST("/nodes", c.Interconnect.CreateNode)
|
||||||
|
interconnect.PUT("/nodes/:id", c.Interconnect.UpdateNode)
|
||||||
|
interconnect.DELETE("/nodes/:id", c.Interconnect.DeleteNode)
|
||||||
|
interconnect.GET("/nodes/:id/status", c.Interconnect.GetNodeStatus)
|
||||||
|
interconnect.POST("/sync/script", c.Interconnect.SyncScript)
|
||||||
|
interconnect.POST("/sync/env", c.Interconnect.SyncEnv)
|
||||||
|
interconnect.POST("/sync/task", c.Interconnect.SyncTask)
|
||||||
|
|
||||||
|
interconnect.GET("/child/status", c.Interconnect.GetChildStatus)
|
||||||
|
|
||||||
|
// 代理模式 (面板穿越)
|
||||||
|
interconnect.Any("/proxy/:node_id/*path", c.Interconnect.ProxyRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ package router
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
// "fmt"
|
// "fmt"
|
||||||
"time"
|
|
||||||
|
|
||||||
// "github.com/engigu/baihu-panel/internal/constant"
|
// "github.com/engigu/baihu-panel/internal/constant"
|
||||||
"github.com/engigu/baihu-panel/internal/eventbus"
|
"github.com/engigu/baihu-panel/internal/eventbus"
|
||||||
// "github.com/engigu/baihu-panel/internal/logger"
|
// "github.com/engigu/baihu-panel/internal/logger"
|
||||||
// "github.com/engigu/baihu-panel/internal/models"
|
// "github.com/engigu/baihu-panel/internal/models"
|
||||||
"github.com/engigu/baihu-panel/internal/services"
|
"github.com/engigu/baihu-panel/internal/services"
|
||||||
|
"github.com/engigu/baihu-panel/internal/executor"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupEventHandlers(subscribers ...eventbus.Subscriber) {
|
func setupEventHandlers(subscribers ...eventbus.Subscriber) {
|
||||||
@@ -21,12 +21,8 @@ func setupEventHandlers(subscribers ...eventbus.Subscriber) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func startAppLogCleanup(appLogSvc *services.AppLogService) {
|
func startAppLogCleanup(appLogSvc *services.AppLogService) {
|
||||||
// 初始化时执行一次清理
|
// 注册到内部系统定时器(并立即执行第一次)
|
||||||
appLogSvc.CleanUp()
|
executor.GetSysCron().AddJobWithRun("@every 1h", func() {
|
||||||
|
|
||||||
// 定期清理(每隔1小时执行一次巡检)
|
|
||||||
ticker := time.NewTicker(1 * time.Hour)
|
|
||||||
for range ticker.C {
|
|
||||||
appLogSvc.CleanUp()
|
appLogSvc.CleanUp()
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ func RegisterControllers() *Controllers {
|
|||||||
// 创建任务执行服务(需要依赖注入)
|
// 创建任务执行服务(需要依赖注入)
|
||||||
notifyService := services.NewNotificationService()
|
notifyService := services.NewNotificationService()
|
||||||
appLogService := services.NewAppLogService()
|
appLogService := services.NewAppLogService()
|
||||||
|
interconnectService := services.NewInterconnectService()
|
||||||
|
|
||||||
// 清理 task 运行状态的任务可以直接由 executorService 承担或在此处通过 Database 直接清理
|
// 清理 task 运行状态的任务可以直接由 executorService 承担或在此处通过 Database 直接清理
|
||||||
// 简单期间,我们使用一个新方法 tasks.CleanupRunningTasks() 或者让 executorService 启动时清理
|
// 简单期间,我们使用一个新方法 tasks.CleanupRunningTasks() 或者让 executorService 启动时清理
|
||||||
@@ -42,7 +43,7 @@ func RegisterControllers() *Controllers {
|
|||||||
|
|
||||||
// 初始化所有关注系统总线的服务
|
// 初始化所有关注系统总线的服务
|
||||||
setupEventHandlers(appLogService, notifyService, loginLogService, systemWSManager)
|
setupEventHandlers(appLogService, notifyService, loginLogService, systemWSManager)
|
||||||
go startAppLogCleanup(appLogService)
|
startAppLogCleanup(appLogService)
|
||||||
|
|
||||||
// 初始化并返回控制器
|
// 初始化并返回控制器
|
||||||
return &Controllers{
|
return &Controllers{
|
||||||
@@ -65,6 +66,7 @@ func RegisterControllers() *Controllers {
|
|||||||
SystemWS: controllers.NewSystemWSController(),
|
SystemWS: controllers.NewSystemWSController(),
|
||||||
WebUI: controllers.NewWebUIController(services.NewWebUIService(settingsService)),
|
WebUI: controllers.NewWebUIController(services.NewWebUIService(settingsService)),
|
||||||
Monitor: controllers.NewMonitorController(executorService),
|
Monitor: controllers.NewMonitorController(executorService),
|
||||||
|
Interconnect: controllers.NewInterconnectController(interconnectService),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type Controllers struct {
|
|||||||
SystemWS *controllers.SystemWSController
|
SystemWS *controllers.SystemWSController
|
||||||
WebUI *controllers.WebUIController
|
WebUI *controllers.WebUIController
|
||||||
Monitor *controllers.MonitorController
|
Monitor *controllers.MonitorController
|
||||||
|
Interconnect *controllers.InterconnectController
|
||||||
}
|
}
|
||||||
|
|
||||||
func Setup(c *Controllers) *gin.Engine {
|
func Setup(c *Controllers) *gin.Engine {
|
||||||
@@ -40,6 +41,7 @@ func Setup(c *Controllers) *gin.Engine {
|
|||||||
}
|
}
|
||||||
router := gin.New()
|
router := gin.New()
|
||||||
router.Use(middleware.GinLogger(), middleware.GinRecovery())
|
router.Use(middleware.GinLogger(), middleware.GinRecovery())
|
||||||
|
router.Use(middleware.TravelProxyMiddleware())
|
||||||
|
|
||||||
// 获取 URL 前缀
|
// 获取 URL 前缀
|
||||||
cfg := services.GetConfig()
|
cfg := services.GetConfig()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/engigu/baihu-panel/internal/constant"
|
"github.com/engigu/baihu-panel/internal/constant"
|
||||||
"github.com/engigu/baihu-panel/internal/database"
|
"github.com/engigu/baihu-panel/internal/database"
|
||||||
|
"github.com/engigu/baihu-panel/internal/executor"
|
||||||
"github.com/engigu/baihu-panel/internal/logger"
|
"github.com/engigu/baihu-panel/internal/logger"
|
||||||
"github.com/engigu/baihu-panel/internal/models"
|
"github.com/engigu/baihu-panel/internal/models"
|
||||||
|
|
||||||
@@ -78,7 +79,11 @@ func GetAgentWSManager() *AgentWSManager {
|
|||||||
ipFailCount: make(map[string]int),
|
ipFailCount: make(map[string]int),
|
||||||
remoteWaiters: make(map[string]chan *models.AgentTaskResult),
|
remoteWaiters: make(map[string]chan *models.AgentTaskResult),
|
||||||
}
|
}
|
||||||
go agentWSManager.cleanupLoop()
|
// 启动时,先将所有 "online" 状态的 Agent 重置为 "offline"
|
||||||
|
NewAgentService().ResetAllAgentsToOffline()
|
||||||
|
|
||||||
|
// 将清理任务注册到系统内部 Cron,每 30 秒执行一次
|
||||||
|
executor.GetSysCron().AddJob("@every 30s", agentWSManager.cleanupLoop)
|
||||||
})
|
})
|
||||||
return agentWSManager
|
return agentWSManager
|
||||||
}
|
}
|
||||||
@@ -282,66 +287,51 @@ func (m *AgentWSManager) OnlineCount() int {
|
|||||||
return len(m.connections)
|
return len(m.connections)
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanupLoop 清理超时连接
|
// cleanupLoop 清理超时连接 (由 SysCron 每 30 秒调用一次)
|
||||||
func (m *AgentWSManager) cleanupLoop() {
|
func (m *AgentWSManager) cleanupLoop() {
|
||||||
ticker := time.NewTicker(30 * time.Second)
|
defer func() {
|
||||||
defer ticker.Stop()
|
if r := recover(); r != nil {
|
||||||
|
logger.Errorf("[AgentWS] cleanupLoop panic: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// 启动时,先将所有 "online" 状态的 Agent 重置为 "offline"
|
m.mu.Lock()
|
||||||
// 因为 WebSocket 连接在应用启动时是空的,所有 Agent 客观上都是离线状态
|
defer m.mu.Unlock()
|
||||||
// 等它们重新连接上来后,会变为 "online"
|
now := time.Now()
|
||||||
NewAgentService().ResetAllAgentsToOffline()
|
|
||||||
|
|
||||||
for range ticker.C {
|
// 清理超时连接
|
||||||
func() {
|
for agentID, conn := range m.connections {
|
||||||
defer func() {
|
if now.Sub(conn.LastPing) > 2*time.Minute {
|
||||||
if r := recover(); r != nil {
|
// 减少 IP 连接计数
|
||||||
logger.Errorf("[AgentWS] cleanupLoop panic: %v", r)
|
if conn.IP != "" {
|
||||||
}
|
if count, ok := m.ipConnections[conn.IP]; ok && count > 0 {
|
||||||
}()
|
m.ipConnections[conn.IP] = count - 1
|
||||||
m.mu.Lock()
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
// 清理超时连接
|
|
||||||
for agentID, conn := range m.connections {
|
|
||||||
if now.Sub(conn.LastPing) > 2*time.Minute {
|
|
||||||
// 减少 IP 连接计数
|
|
||||||
if conn.IP != "" {
|
|
||||||
if count, ok := m.ipConnections[conn.IP]; ok && count > 0 {
|
|
||||||
m.ipConnections[conn.IP] = count - 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
conn.Close()
|
|
||||||
delete(m.connections, agentID)
|
|
||||||
// 更新数据库状态
|
|
||||||
database.DB.Model(&models.Agent{}).Where("id = ?", agentID).Update("status", constant.AgentStatusOffline)
|
|
||||||
logger.Infof("[AgentWS] Agent #%s 心跳超时,已断开", agentID)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
conn.Close()
|
||||||
|
delete(m.connections, agentID)
|
||||||
|
// 更新数据库状态
|
||||||
|
database.DB.Model(&models.Agent{}).Where("id = ?", agentID).Update("status", constant.AgentStatusOffline)
|
||||||
|
logger.Infof("[AgentWS] Agent #%s 心跳超时,已断开", agentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 定期清理数据库中的过期状态(处理服务重启或异常终止的情况)
|
// 定期清理数据库中的过期状态(处理服务重启或异常终止的情况)
|
||||||
// 有些 Agent 虽然没有连接,但数据库状态可能是 "online"
|
cutoff := now.Add(-2 * time.Minute)
|
||||||
cutoff := now.Add(-2 * time.Minute)
|
database.DB.Model(&models.Agent{}).
|
||||||
database.DB.Model(&models.Agent{}).
|
Where("status = ? AND last_seen < ?", constant.AgentStatusOnline, cutoff).
|
||||||
Where("status = ? AND last_seen < ?", constant.AgentStatusOnline, cutoff).
|
Update("status", constant.AgentStatusOffline)
|
||||||
Update("status", constant.AgentStatusOffline)
|
|
||||||
|
|
||||||
// 清理过期的限流记录(超过 10 分钟未活动)
|
// 清理过期的限流记录(超过 10 分钟未活动)
|
||||||
|
for ip, lastAttempt := range m.ipLastAttempt {
|
||||||
// 清理过期的限流记录(超过 10 分钟未活动)
|
if now.Sub(lastAttempt) > 10*time.Minute {
|
||||||
for ip, lastAttempt := range m.ipLastAttempt {
|
delete(m.ipLastAttempt, ip)
|
||||||
if now.Sub(lastAttempt) > 10*time.Minute {
|
delete(m.ipFailCount, ip)
|
||||||
delete(m.ipLastAttempt, ip)
|
// 只清理没有活跃连接的 IP 计数
|
||||||
delete(m.ipFailCount, ip)
|
if m.ipConnections[ip] == 0 {
|
||||||
// 只清理没有活跃连接的 IP 计数
|
delete(m.ipConnections, ip)
|
||||||
if m.ipConnections[ip] == 0 {
|
|
||||||
delete(m.ipConnections, ip)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
m.mu.Unlock()
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/engigu/baihu-panel/internal/database"
|
||||||
|
"github.com/engigu/baihu-panel/internal/models"
|
||||||
|
"github.com/engigu/baihu-panel/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type InterconnectService struct{}
|
||||||
|
|
||||||
|
func NewInterconnectService() *InterconnectService {
|
||||||
|
return &InterconnectService{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InterconnectService) GetNodes() ([]*models.InterconnectNode, error) {
|
||||||
|
var nodes []*models.InterconnectNode
|
||||||
|
err := database.DB.Find(&nodes).Error
|
||||||
|
return nodes, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InterconnectService) GetNodeByID(id string) (*models.InterconnectNode, error) {
|
||||||
|
var node models.InterconnectNode
|
||||||
|
err := database.DB.Where("id = ?", id).First(&node).Error
|
||||||
|
return &node, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InterconnectService) CreateNode(name, url, token, remark string) (*models.InterconnectNode, error) {
|
||||||
|
nodeID := utils.GenerateID()
|
||||||
|
if url == "" {
|
||||||
|
url = "tunnel://" + nodeID
|
||||||
|
}
|
||||||
|
node := &models.InterconnectNode{
|
||||||
|
ID: nodeID,
|
||||||
|
Name: name,
|
||||||
|
URL: url,
|
||||||
|
Token: strings.ToLower(token),
|
||||||
|
Remark: remark,
|
||||||
|
CreatedAt: models.Now(),
|
||||||
|
UpdatedAt: models.Now(),
|
||||||
|
}
|
||||||
|
err := database.DB.Create(node).Error
|
||||||
|
return node, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InterconnectService) UpdateNode(id, name, url, token, remark string) (*models.InterconnectNode, error) {
|
||||||
|
node, err := s.GetNodeByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
node.Name = name
|
||||||
|
if url != "" {
|
||||||
|
node.URL = url
|
||||||
|
}
|
||||||
|
node.Token = token
|
||||||
|
node.Remark = remark
|
||||||
|
node.UpdatedAt = models.Now()
|
||||||
|
|
||||||
|
err = database.DB.Save(node).Error
|
||||||
|
return node, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InterconnectService) DeleteNode(id string) error {
|
||||||
|
return database.DB.Where("id = ?", id).Delete(&models.InterconnectNode{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InterconnectService) GetNodeByToken(token string) (*models.InterconnectNode, error) {
|
||||||
|
var node models.InterconnectNode
|
||||||
|
err := database.DB.Where("token = ?", token).First(&node).Error
|
||||||
|
return &node, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InterconnectService) UpdateNodeMonitorData(id string, metrics models.NodeMetrics) error {
|
||||||
|
now := models.Now()
|
||||||
|
return database.DB.Model(&models.InterconnectNode{}).
|
||||||
|
Where("id = ?", id).
|
||||||
|
Select("status", "metrics", "last_heartbeat_at", "updated_at").
|
||||||
|
Updates(models.InterconnectNode{
|
||||||
|
Status: "online",
|
||||||
|
Metrics: metrics,
|
||||||
|
LastHeartbeatAt: &now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/engigu/baihu-panel/internal/constant"
|
||||||
|
"github.com/shirou/gopsutil/v3/cpu"
|
||||||
|
"github.com/shirou/gopsutil/v3/disk"
|
||||||
|
"github.com/shirou/gopsutil/v3/host"
|
||||||
|
"github.com/shirou/gopsutil/v3/mem"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HostMetrics struct {
|
||||||
|
CPUPercent float64
|
||||||
|
VMem *mem.VirtualMemoryStat
|
||||||
|
DiskUsage *disk.UsageStat
|
||||||
|
HostInfo *host.InfoStat
|
||||||
|
}
|
||||||
|
|
||||||
|
type MonitorService struct {
|
||||||
|
hostMu sync.RWMutex
|
||||||
|
lastUpdate time.Time
|
||||||
|
metrics HostMetrics
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
monitorServiceInstance *MonitorService
|
||||||
|
monitorServiceOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetMonitorService 获取系统监控服务单例
|
||||||
|
func GetMonitorService() *MonitorService {
|
||||||
|
monitorServiceOnce.Do(func() {
|
||||||
|
monitorServiceInstance = &MonitorService{}
|
||||||
|
})
|
||||||
|
return monitorServiceInstance
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHostMetrics 获取并返回物理机状态(带有缓存和演示模式伪装)
|
||||||
|
func (ms *MonitorService) GetHostMetrics() HostMetrics {
|
||||||
|
ms.hostMu.Lock()
|
||||||
|
defer ms.hostMu.Unlock()
|
||||||
|
|
||||||
|
// 缓存 2 秒
|
||||||
|
if time.Since(ms.lastUpdate) < 2*time.Second && ms.metrics.VMem != nil {
|
||||||
|
return ms.metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
if constant.DemoMode {
|
||||||
|
ms.updateDemoMetrics()
|
||||||
|
return ms.metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
cpuPercents, _ := cpu.Percent(0, false)
|
||||||
|
if len(cpuPercents) > 0 {
|
||||||
|
ms.metrics.CPUPercent = cpuPercents[0]
|
||||||
|
}
|
||||||
|
ms.metrics.VMem, _ = mem.VirtualMemory()
|
||||||
|
ms.metrics.DiskUsage, _ = disk.Usage("/")
|
||||||
|
ms.metrics.HostInfo, _ = host.Info()
|
||||||
|
ms.lastUpdate = time.Now()
|
||||||
|
|
||||||
|
// 提供默认值防空指针
|
||||||
|
if ms.metrics.VMem == nil {
|
||||||
|
ms.metrics.VMem = &mem.VirtualMemoryStat{}
|
||||||
|
}
|
||||||
|
if ms.metrics.DiskUsage == nil {
|
||||||
|
ms.metrics.DiskUsage = &disk.UsageStat{}
|
||||||
|
}
|
||||||
|
if ms.metrics.HostInfo == nil {
|
||||||
|
ms.metrics.HostInfo = &host.InfoStat{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ms.metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
type RuntimeMetrics struct {
|
||||||
|
NumGoroutine int
|
||||||
|
MemStats runtime.MemStats
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
runtimeMu sync.RWMutex
|
||||||
|
lastRuntime time.Time
|
||||||
|
cachedRuntime RuntimeMetrics
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetRuntimeMetrics 获取 Go 运行时指标(缓存 2 秒,防止高并发下频繁触发 STW)
|
||||||
|
func (ms *MonitorService) GetRuntimeMetrics() RuntimeMetrics {
|
||||||
|
runtimeMu.Lock()
|
||||||
|
defer runtimeMu.Unlock()
|
||||||
|
|
||||||
|
if time.Since(lastRuntime) < 2*time.Second && cachedRuntime.NumGoroutine > 0 {
|
||||||
|
return cachedRuntime
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedRuntime.NumGoroutine = runtime.NumGoroutine()
|
||||||
|
runtime.ReadMemStats(&cachedRuntime.MemStats)
|
||||||
|
lastRuntime = time.Now()
|
||||||
|
|
||||||
|
return cachedRuntime
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ms *MonitorService) updateDemoMetrics() {
|
||||||
|
ms.metrics.CPUPercent = 10 + rand.Float64()*40 // 10% - 50% 的随机 CPU 波动
|
||||||
|
|
||||||
|
totalMem := uint64(8 * 1024 * 1024 * 1024) // 8GB 内存
|
||||||
|
usedMem := uint64(float64(totalMem) * (0.3 + rand.Float64()*0.3)) // 30% - 60% 随机使用率
|
||||||
|
ms.metrics.VMem = &mem.VirtualMemoryStat{
|
||||||
|
Total: totalMem,
|
||||||
|
Used: usedMem,
|
||||||
|
UsedPercent: float64(usedMem) / float64(totalMem) * 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
totalDisk := uint64(500 * 1024 * 1024 * 1024) // 500GB 硬盘
|
||||||
|
usedDisk := uint64(float64(totalDisk) * 0.45) // 固定 45% 使用率
|
||||||
|
ms.metrics.DiskUsage = &disk.UsageStat{
|
||||||
|
Total: totalDisk,
|
||||||
|
Used: usedDisk,
|
||||||
|
UsedPercent: float64(usedDisk) / float64(totalDisk) * 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
ms.metrics.HostInfo = &host.InfoStat{
|
||||||
|
Platform: "Demo Environment",
|
||||||
|
OS: "linux",
|
||||||
|
Uptime: uint64(time.Now().Unix() - 1700000000), // 生成一个较长且持续增加的运行时间
|
||||||
|
}
|
||||||
|
ms.lastUpdate = time.Now()
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
package tunnel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/engigu/baihu-panel/internal/constant"
|
||||||
|
"github.com/engigu/baihu-panel/internal/logger"
|
||||||
|
"github.com/engigu/baihu-panel/internal/services"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/hashicorp/yamux"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
tunnelActive bool
|
||||||
|
clientConn *yamux.Session
|
||||||
|
clientMu sync.Mutex
|
||||||
|
stopCh chan struct{}
|
||||||
|
|
||||||
|
// LocalEngine 保存全局的 Gin 引擎或 HTTP 处理器,用于接收主节点的隧道请求并进行纯内存函数路由
|
||||||
|
LocalEngine http.Handler
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetLocalEngine 由 bootstrap 层注入
|
||||||
|
func SetLocalEngine(engine http.Handler) {
|
||||||
|
LocalEngine = engine
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartClient 尝试连接到父节点面板(如果已配置)
|
||||||
|
func StartClient() {
|
||||||
|
clientMu.Lock()
|
||||||
|
if tunnelActive {
|
||||||
|
clientMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tunnelActive = true
|
||||||
|
stopCh = make(chan struct{})
|
||||||
|
clientMu.Unlock()
|
||||||
|
|
||||||
|
go runClientLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopClient 停止后台的隧道客户端
|
||||||
|
func StopClient() {
|
||||||
|
clientMu.Lock()
|
||||||
|
if !tunnelActive {
|
||||||
|
clientMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tunnelActive = false
|
||||||
|
if stopCh != nil {
|
||||||
|
close(stopCh)
|
||||||
|
}
|
||||||
|
if clientConn != nil {
|
||||||
|
clientConn.Close()
|
||||||
|
}
|
||||||
|
clientMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init 从数据库加载配置并初始化对应的后台隧道服务
|
||||||
|
func Init() {
|
||||||
|
siteConfig := services.NewSettingsService().GetSection(constant.SectionInterconnect)
|
||||||
|
role := siteConfig[constant.KeyInterconnectRole]
|
||||||
|
ApplyRole(role)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyRole 根据互联角色动态启停背景协程服务
|
||||||
|
func ApplyRole(role string) {
|
||||||
|
switch role {
|
||||||
|
case constant.InterconnectRoleMaster:
|
||||||
|
// 主控角色:无需后台轮询,等待子节点上报即可
|
||||||
|
StopClient()
|
||||||
|
case constant.InterconnectRoleChild:
|
||||||
|
// 子节点:关闭可能存在的主控会话,启动连接守护
|
||||||
|
CloseAllSessions()
|
||||||
|
StartClient()
|
||||||
|
default:
|
||||||
|
// 未开启或离线
|
||||||
|
StopClient()
|
||||||
|
CloseAllSessions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsTunnelConnected 返回当前子节点隧道是否已连接到主节点
|
||||||
|
func IsTunnelConnected() bool {
|
||||||
|
clientMu.Lock()
|
||||||
|
defer clientMu.Unlock()
|
||||||
|
if !tunnelActive || clientConn == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !clientConn.IsClosed()
|
||||||
|
}
|
||||||
|
|
||||||
|
func runClientLoop() {
|
||||||
|
settingsSvc := services.NewSettingsService()
|
||||||
|
|
||||||
|
const (
|
||||||
|
initialBackoff = 5 * time.Second
|
||||||
|
maxBackoff = 300 * time.Second
|
||||||
|
fatalBackoff = 60 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
backoff := initialBackoff
|
||||||
|
retryCount := 0
|
||||||
|
var lastErrorMsg string
|
||||||
|
|
||||||
|
for {
|
||||||
|
clientMu.Lock()
|
||||||
|
if !tunnelActive {
|
||||||
|
clientMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clientMu.Unlock()
|
||||||
|
|
||||||
|
siteConfig := settingsSvc.GetSection(constant.SectionInterconnect)
|
||||||
|
|
||||||
|
role := siteConfig[constant.KeyInterconnectRole]
|
||||||
|
if role != constant.InterconnectRoleChild {
|
||||||
|
time.Sleep(10 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
parentURL := siteConfig[constant.KeyInterconnectParentURL]
|
||||||
|
parentToken := siteConfig[constant.KeyInterconnectParentToken]
|
||||||
|
|
||||||
|
if parentURL == "" || parentToken == "" {
|
||||||
|
time.Sleep(10 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse(parentURL)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("[Tunnel] 无效的主节点 URL: %v", err)
|
||||||
|
time.Sleep(10 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
scheme := "ws"
|
||||||
|
if u.Scheme == "https" {
|
||||||
|
scheme = "wss"
|
||||||
|
}
|
||||||
|
|
||||||
|
wsURL := fmt.Sprintf("%s://%s/api/v1/interconnect/tunnel", scheme, u.Host)
|
||||||
|
|
||||||
|
header := http.Header{}
|
||||||
|
header.Set("Authorization", "Bearer "+parentToken)
|
||||||
|
|
||||||
|
dialer := &websocket.Dialer{
|
||||||
|
Proxy: http.ProxyFromEnvironment,
|
||||||
|
HandshakeTimeout: 45 * time.Second,
|
||||||
|
}
|
||||||
|
dialer.EnableCompression = true
|
||||||
|
if u.Scheme == "https" {
|
||||||
|
dialer.TLSClientConfig = &tls.Config{
|
||||||
|
InsecureSkipVerify: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, resp, err := dialer.Dial(wsURL, header)
|
||||||
|
if err != nil {
|
||||||
|
retryCount++
|
||||||
|
errMsg := err.Error()
|
||||||
|
|
||||||
|
// 特殊处理致命的认证或配置错误 (仅401作为致命错误,403使用指数退避)
|
||||||
|
isFatal := resp != nil && resp.StatusCode == 401
|
||||||
|
if isFatal {
|
||||||
|
backoff = fatalBackoff
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先计算退避时间 (加上 0-8 秒随机抖动防止风暴)
|
||||||
|
jitter := time.Duration(rand.Intn(8000)) * time.Millisecond
|
||||||
|
sleepDuration := backoff + jitter
|
||||||
|
|
||||||
|
// 日志记录
|
||||||
|
if retryCount == 1 || errMsg != lastErrorMsg {
|
||||||
|
logger.Errorf("[Tunnel] 连接主节点隧道失败: %v, 下次重试将在 %v 后", err, sleepDuration.Round(time.Second))
|
||||||
|
} else {
|
||||||
|
logger.Warnf("[Tunnel] 仍无法连接主节点,已尝试 %d 次, 下次重试将在 %v 后", retryCount, sleepDuration.Round(time.Second))
|
||||||
|
}
|
||||||
|
|
||||||
|
lastErrorMsg = errMsg
|
||||||
|
|
||||||
|
time.Sleep(sleepDuration)
|
||||||
|
|
||||||
|
// 指数级增加退避时间 (最大不超过 maxBackoff)
|
||||||
|
if !isFatal && backoff < maxBackoff {
|
||||||
|
backoff *= 2
|
||||||
|
if backoff > maxBackoff {
|
||||||
|
backoff = maxBackoff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 连接成功,重置退避状态
|
||||||
|
retryCount = 0
|
||||||
|
lastErrorMsg = ""
|
||||||
|
backoff = initialBackoff
|
||||||
|
|
||||||
|
logger.Infof("[Tunnel] 已成功连接到主节点隧道: %s", wsURL)
|
||||||
|
|
||||||
|
netConn := NetConn(conn)
|
||||||
|
trackedConn := &trafficCounterConn{Conn: netConn}
|
||||||
|
conf := yamux.DefaultConfig()
|
||||||
|
conf.EnableKeepAlive = true
|
||||||
|
|
||||||
|
// 我们是子节点,接受来自主节点的请求。
|
||||||
|
// 因此在这里,我们是 Yamux Server,负责监听传入的流。
|
||||||
|
session, err := yamux.Server(trackedConn, conf)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("[Tunnel] 启动 Yamux Server 失败: %v", err)
|
||||||
|
trackedConn.Close()
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已经存在上一个活跃的隧道连接,在重建前必须将其显式 Close(),以释放该会话在后台运行的 HTTP 监听与监听协程
|
||||||
|
clientMu.Lock()
|
||||||
|
if clientConn != nil {
|
||||||
|
clientConn.Close()
|
||||||
|
}
|
||||||
|
clientConn = session
|
||||||
|
clientMu.Unlock()
|
||||||
|
|
||||||
|
services.GetSystemWSManager().Broadcast(constant.EventInterconnectChildStatus, map[string]interface{}{
|
||||||
|
"connected": true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 启动子节点主动上报服务
|
||||||
|
StartReporter(parentURL, parentToken)
|
||||||
|
|
||||||
|
// 启动本地 HTTP 代理服务 (阻塞直到会话关闭)
|
||||||
|
serveLocalProxy(session)
|
||||||
|
|
||||||
|
// 断开时停止上报
|
||||||
|
StopReporter()
|
||||||
|
|
||||||
|
session.Close()
|
||||||
|
services.GetSystemWSManager().Broadcast(constant.EventInterconnectChildStatus, map[string]interface{}{
|
||||||
|
"connected": false,
|
||||||
|
})
|
||||||
|
logger.Infof("[Tunnel] 已从主节点隧道断开连接")
|
||||||
|
|
||||||
|
// 断开后稍微缓冲一下再去重试
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package tunnel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// wsConn 包装 gorilla websocket 以实现 net.Conn 接口
|
||||||
|
type wsConn struct {
|
||||||
|
conn *websocket.Conn
|
||||||
|
reader io.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
// NetConn 将 WebSocket 连接转换为标准 net.Conn
|
||||||
|
func NetConn(conn *websocket.Conn) net.Conn {
|
||||||
|
return &wsConn{
|
||||||
|
conn: conn,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *wsConn) Read(b []byte) (n int, err error) {
|
||||||
|
for {
|
||||||
|
if c.reader == nil {
|
||||||
|
_, r, err := c.conn.NextReader()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
c.reader = r
|
||||||
|
}
|
||||||
|
n, err = c.reader.Read(b)
|
||||||
|
if err == io.EOF {
|
||||||
|
c.reader = nil
|
||||||
|
if n > 0 {
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *wsConn) Write(b []byte) (n int, err error) {
|
||||||
|
// Yamux 保证了对底层连接的序列化写入(不会出现并发写),
|
||||||
|
// 因此我们这里不需要加互斥锁 (Mutex)。
|
||||||
|
w, err := c.conn.NextWriter(websocket.BinaryMessage)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
n, err = w.Write(b)
|
||||||
|
|
||||||
|
// 必须关闭 writer 以触发 WebSocket 的消息帧封包发送
|
||||||
|
closeErr := w.Close()
|
||||||
|
if err == nil {
|
||||||
|
err = closeErr
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *wsConn) Close() error {
|
||||||
|
return c.conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *wsConn) LocalAddr() net.Addr {
|
||||||
|
return c.conn.LocalAddr()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *wsConn) RemoteAddr() net.Addr {
|
||||||
|
return c.conn.RemoteAddr()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *wsConn) SetDeadline(t time.Time) error {
|
||||||
|
if err := c.conn.SetReadDeadline(t); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.conn.SetWriteDeadline(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *wsConn) SetReadDeadline(t time.Time) error {
|
||||||
|
return c.conn.SetReadDeadline(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *wsConn) SetWriteDeadline(t time.Time) error {
|
||||||
|
return c.conn.SetWriteDeadline(t)
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package tunnel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/engigu/baihu-panel/internal/logger"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/hashicorp/yamux"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProxyHTTP 以完全透明的流式转发将 HTTP 请求代理到远程节点 (主节点调用)
|
||||||
|
func ProxyHTTP(nodeID string, c *gin.Context, targetPath string) error {
|
||||||
|
sess := GetSession(nodeID)
|
||||||
|
if sess == nil {
|
||||||
|
return errors.New("node is offline or tunnel not established")
|
||||||
|
}
|
||||||
|
|
||||||
|
if sess.Token == "" {
|
||||||
|
return errors.New("node token not found in session cache")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取真实客户端 IP,传递给子节点用于审计日志等
|
||||||
|
clientIP := c.ClientIP()
|
||||||
|
|
||||||
|
director := func(req *http.Request) {
|
||||||
|
req.URL.Scheme = "http"
|
||||||
|
req.URL.Host = "tunnel.local"
|
||||||
|
req.URL.Path = targetPath
|
||||||
|
// gin 和 httputil 默认会保留并透传 Query 参数
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+sess.Token)
|
||||||
|
// 删除父节点的 Cookie 避免干扰子节点的会话验证
|
||||||
|
req.Header.Del("Cookie")
|
||||||
|
|
||||||
|
// 强制加上该 Header,防止出现节点相互代理的死循环
|
||||||
|
req.Header.Set("X-Tunnel-Proxy", "true")
|
||||||
|
|
||||||
|
// 传递真实的客户端 IP 给子节点
|
||||||
|
if clientIP != "" {
|
||||||
|
req.Header.Set("X-Forwarded-For", clientIP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy := &httputil.ReverseProxy{
|
||||||
|
Director: director,
|
||||||
|
Transport: sess.Transport, // 直接复用 AddSession 时创建的全局连接池,由原生库处理 Context 取消
|
||||||
|
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
logger.Errorf("[Tunnel] 节点 %s 代理隧道请求发生错误: %v", nodeID, err)
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
w.Write([]byte("Tunnel connection error: " + err.Error()))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy.ServeHTTP(c.Writer, c.Request)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveLocalProxy 本地服务代理逻辑 (子节点调用)
|
||||||
|
func serveLocalProxy(session *yamux.Session) {
|
||||||
|
if LocalEngine == nil {
|
||||||
|
logger.Errorf("[Tunnel] 无法启动本地代理服务,因为 LocalEngine 尚未注入。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// 安全规则 1:防止互相调用的死循环
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/api/v1/interconnect/proxy/") {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
w.Write([]byte("Recursive proxy loops are not allowed via tunnel"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 安全规则 2:严格限制只能访问本机的 /api/v1/ 接口
|
||||||
|
if !strings.HasPrefix(r.URL.Path, "/api/v1/") {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
w.Write([]byte("Tunnel access is strictly restricted to /api/v1/ endpoints"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保保留 X-Tunnel-Proxy 请求头,以防后续逻辑需要判定
|
||||||
|
r.Header.Set("X-Tunnel-Proxy", "true")
|
||||||
|
|
||||||
|
// 纯内存函数调用,直接扔给 Gin 引擎,零网络开销!
|
||||||
|
LocalEngine.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
|
||||||
|
server := &http.Server{
|
||||||
|
Handler: handler,
|
||||||
|
ReadTimeout: 15 * time.Second,
|
||||||
|
IdleTimeout: 15 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听会话关闭信号,主动关闭 http.Server 以彻底释放协程
|
||||||
|
go func() {
|
||||||
|
<-session.CloseChan()
|
||||||
|
server.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// yamux.Session 实现了标准的 net.Listener 接口!
|
||||||
|
err := server.Serve(session)
|
||||||
|
if err != nil && err != http.ErrServerClosed && err != yamux.ErrSessionShutdown {
|
||||||
|
logger.Errorf("[Tunnel] Yamux 代理服务意外停止: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package tunnel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/engigu/baihu-panel/internal/executor"
|
||||||
|
"github.com/engigu/baihu-panel/internal/logger"
|
||||||
|
"github.com/engigu/baihu-panel/internal/services"
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
reporterCronID cron.EntryID
|
||||||
|
isReporting bool
|
||||||
|
localTunnelURL string
|
||||||
|
localTunnelURLMutex sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetLocalTunnelURL 返回当前子节点被分配的隧道地址
|
||||||
|
func GetLocalTunnelURL() string {
|
||||||
|
localTunnelURLMutex.RLock()
|
||||||
|
defer localTunnelURLMutex.RUnlock()
|
||||||
|
return localTunnelURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartReporter 启动子节点上报守护进程
|
||||||
|
func StartReporter(parentURL, token string) {
|
||||||
|
if isReporting {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isReporting = true
|
||||||
|
|
||||||
|
// 修整 parentURL 确保正确指向 /api/v1/interconnect/report
|
||||||
|
// 如果用户填的是 http://192.168.1.100:8000,我们要加上路径
|
||||||
|
baseURL := strings.TrimRight(parentURL, "/")
|
||||||
|
// 注意兼容如果用户带了 /api/v1
|
||||||
|
if !strings.HasSuffix(baseURL, "/api/v1") && !strings.Contains(baseURL, "/api/v1") {
|
||||||
|
baseURL = baseURL + "/api/v1"
|
||||||
|
}
|
||||||
|
reportURL := baseURL + "/interconnect/report"
|
||||||
|
|
||||||
|
// 使用全局 SysCron 并立即上报一次,随后每 45 秒循环一次
|
||||||
|
id, err := executor.GetSysCron().AddJobWithRun("@every 45s", func() {
|
||||||
|
reportMonitorData(reportURL, token)
|
||||||
|
})
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
reporterCronID = id
|
||||||
|
} else {
|
||||||
|
logger.Warnf("[Tunnel] 无法将上报任务加入 SysCron: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopReporter 停止子节点上报守护进程
|
||||||
|
func StopReporter() {
|
||||||
|
if isReporting {
|
||||||
|
if reporterCronID != 0 {
|
||||||
|
executor.GetSysCron().RemoveJob(reporterCronID)
|
||||||
|
reporterCronID = 0
|
||||||
|
}
|
||||||
|
isReporting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reportMonitorData(reportURL, token string) {
|
||||||
|
// 调用单例监控服务获取底层系统状态
|
||||||
|
metrics := services.GetMonitorService().GetHostMetrics()
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"cpu_percent": metrics.CPUPercent,
|
||||||
|
"mem_percent": metrics.VMem.UsedPercent,
|
||||||
|
"disk_percent": metrics.DiskUsage.UsedPercent,
|
||||||
|
"tx_bytes": GetTxBytes(),
|
||||||
|
"rx_bytes": GetRxBytes(),
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", reportURL, bytes.NewBuffer(data))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warnf("[Tunnel] 上报监控数据失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
logger.Warnf("[Tunnel] 上报监控数据返回异常状态码: %d", resp.StatusCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var res struct {
|
||||||
|
Data struct {
|
||||||
|
TunnelURL string `json:"tunnel_url"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&res); err == nil {
|
||||||
|
if res.Data.TunnelURL != "" {
|
||||||
|
localTunnelURLMutex.Lock()
|
||||||
|
localTunnelURL = res.Data.TunnelURL
|
||||||
|
localTunnelURLMutex.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package tunnel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/engigu/baihu-panel/internal/constant"
|
||||||
|
"github.com/engigu/baihu-panel/internal/database"
|
||||||
|
"github.com/engigu/baihu-panel/internal/logger"
|
||||||
|
"github.com/engigu/baihu-panel/internal/models"
|
||||||
|
"github.com/engigu/baihu-panel/internal/services"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"net/http"
|
||||||
|
"github.com/hashicorp/yamux"
|
||||||
|
)
|
||||||
|
|
||||||
|
var upgrader = websocket.Upgrader{
|
||||||
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
EnableCompression: true, // 启用协议级别的 Deflate 数据压缩
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleTunnel(c *gin.Context) {
|
||||||
|
authHeader := c.GetHeader("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
c.JSON(401, gin.H{"error": "missing authorization"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenStr := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
|
||||||
|
if tokenStr == "" {
|
||||||
|
c.JSON(401, gin.H{"error": "invalid token format"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsSvc := services.NewSettingsService()
|
||||||
|
siteConfig := settingsSvc.GetSection(constant.SectionInterconnect)
|
||||||
|
role := siteConfig[constant.KeyInterconnectRole]
|
||||||
|
|
||||||
|
if role != constant.InterconnectRoleMaster {
|
||||||
|
c.JSON(403, gin.H{"error": "interconnect master role not enabled on this server"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var node models.InterconnectNode
|
||||||
|
if err := database.DB.Where("token = ?", tokenStr).First(&node).Error; err != nil {
|
||||||
|
c.JSON(401, gin.H{"error": "invalid interconnect token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeID := node.ID
|
||||||
|
|
||||||
|
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("[Tunnel] 升级隧道连接失败 (NodeID: %s): %v", nodeID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将 WebSocket 包装为标准的 net.Conn
|
||||||
|
netConn := NetConn(conn)
|
||||||
|
|
||||||
|
// 获取客户端真实 IP 和端口以更新连接地址,方便面板显示
|
||||||
|
clientIP := c.ClientIP()
|
||||||
|
port := "unknown"
|
||||||
|
if parts := strings.Split(netConn.RemoteAddr().String(), ":"); len(parts) > 1 {
|
||||||
|
port = parts[len(parts)-1]
|
||||||
|
}
|
||||||
|
realURL := "tunnel://" + clientIP + ":" + port
|
||||||
|
|
||||||
|
if node.URL != realURL {
|
||||||
|
database.DB.Model(&node).Update("url", realURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 我们是主节点(父节点),主动发起对子节点的请求。
|
||||||
|
// 因此在 Yamux 协议中我们扮演 Client,而子节点扮演 Server。
|
||||||
|
conf := yamux.DefaultConfig()
|
||||||
|
conf.EnableKeepAlive = true
|
||||||
|
|
||||||
|
session, err := yamux.Client(netConn, conf)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("[Tunnel] 启动 Yamux Client 失败 (NodeID: %s): %v", nodeID, err)
|
||||||
|
netConn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
AddSession(nodeID, tokenStr, session)
|
||||||
|
logger.Infof("[Tunnel] 已建立来自子节点的隧道连接: %s (%s)", nodeID, node.Name)
|
||||||
|
|
||||||
|
go func(s *yamux.Session) {
|
||||||
|
<-s.CloseChan()
|
||||||
|
logger.Infof("[Tunnel] 子节点隧道连接已断开 (NodeID: %s, IsClosed: %v)", nodeID, s.IsClosed())
|
||||||
|
RemoveSession(nodeID, s)
|
||||||
|
}(session)
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package tunnel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/engigu/baihu-panel/internal/database"
|
||||||
|
"github.com/engigu/baihu-panel/internal/models"
|
||||||
|
"github.com/hashicorp/yamux"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TunnelSession 表示一个基于 WebSocket 的 Yamux 活跃会话
|
||||||
|
type TunnelSession struct {
|
||||||
|
NodeID string
|
||||||
|
Token string
|
||||||
|
Session *yamux.Session
|
||||||
|
Transport *http.Transport
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
sessions = make(map[string]*TunnelSession)
|
||||||
|
sessionsMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
func AddSession(nodeID string, token string, session *yamux.Session) *TunnelSession {
|
||||||
|
sessionsMu.Lock()
|
||||||
|
defer sessionsMu.Unlock()
|
||||||
|
|
||||||
|
// 关闭已经存在的旧会话
|
||||||
|
if old, exists := sessions[nodeID]; exists {
|
||||||
|
old.Session.Close()
|
||||||
|
old.Transport.CloseIdleConnections()
|
||||||
|
}
|
||||||
|
|
||||||
|
tr := &http.Transport{
|
||||||
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return session.Open()
|
||||||
|
},
|
||||||
|
DisableKeepAlives: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := &TunnelSession{
|
||||||
|
NodeID: nodeID,
|
||||||
|
Token: token,
|
||||||
|
Session: session,
|
||||||
|
Transport: tr,
|
||||||
|
}
|
||||||
|
sessions[nodeID] = sess
|
||||||
|
return sess
|
||||||
|
}
|
||||||
|
|
||||||
|
func RemoveSession(nodeID string, session *yamux.Session) {
|
||||||
|
sessionsMu.Lock()
|
||||||
|
defer sessionsMu.Unlock()
|
||||||
|
|
||||||
|
if sess, exists := sessions[nodeID]; exists && sess.Session == session {
|
||||||
|
sess.Session.Close()
|
||||||
|
sess.Transport.CloseIdleConnections()
|
||||||
|
delete(sessions, nodeID)
|
||||||
|
|
||||||
|
// 节点下线时实时更新数据库状态
|
||||||
|
database.DB.Model(&models.InterconnectNode{}).
|
||||||
|
Where("id = ?", nodeID).
|
||||||
|
Update("status", "offline")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseAllSessions 强制关闭所有现存的子节点会话(用于主控角色被取消时)
|
||||||
|
func CloseAllSessions() {
|
||||||
|
sessionsMu.Lock()
|
||||||
|
defer sessionsMu.Unlock()
|
||||||
|
|
||||||
|
for _, sess := range sessions {
|
||||||
|
if sess.Session != nil {
|
||||||
|
sess.Session.Close()
|
||||||
|
}
|
||||||
|
if sess.Transport != nil {
|
||||||
|
sess.Transport.CloseIdleConnections()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 清空所有的会话
|
||||||
|
sessions = make(map[string]*TunnelSession)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSession(nodeID string) *TunnelSession {
|
||||||
|
sessionsMu.RLock()
|
||||||
|
defer sessionsMu.RUnlock()
|
||||||
|
return sessions[nodeID]
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package tunnel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
txBytes uint64
|
||||||
|
rxBytes uint64
|
||||||
|
)
|
||||||
|
|
||||||
|
// trafficCounterConn 包装了原生的 net.Conn,用于统计底层收发的真实物理字节数
|
||||||
|
type trafficCounterConn struct {
|
||||||
|
net.Conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *trafficCounterConn) Read(b []byte) (n int, err error) {
|
||||||
|
n, err = c.Conn.Read(b)
|
||||||
|
if n > 0 {
|
||||||
|
atomic.AddUint64(&rxBytes, uint64(n))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *trafficCounterConn) Write(b []byte) (n int, err error) {
|
||||||
|
n, err = c.Conn.Write(b)
|
||||||
|
if n > 0 {
|
||||||
|
atomic.AddUint64(&txBytes, uint64(n))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTxBytes 返回隧道累计发送的字节数
|
||||||
|
func GetTxBytes() uint64 {
|
||||||
|
return atomic.LoadUint64(&txBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRxBytes 返回隧道累计接收的字节数
|
||||||
|
func GetRxBytes() uint64 {
|
||||||
|
return atomic.LoadUint64(&rxBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetTrafficBytes 重置流量统计计数器
|
||||||
|
func ResetTrafficBytes() {
|
||||||
|
atomic.StoreUint64(&txBytes, 0)
|
||||||
|
atomic.StoreUint64(&rxBytes, 0)
|
||||||
|
}
|
||||||
@@ -2,6 +2,13 @@
|
|||||||
import { onMounted } from 'vue'
|
import { onMounted } from 'vue'
|
||||||
import { RouterView } from 'vue-router'
|
import { RouterView } from 'vue-router'
|
||||||
import { Toaster } from '@/components/ui/sonner'
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
|
import { LogOut } from 'lucide-vue-next'
|
||||||
|
import { activeInterconnectNodeId, setActiveInterconnectNodeId } from '@/api'
|
||||||
|
|
||||||
|
function exitTravel() {
|
||||||
|
setActiveInterconnectNodeId('')
|
||||||
|
window.location.href = '/'
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 全局应用设备差异化垂直抗锯齿
|
// 全局应用设备差异化垂直抗锯齿
|
||||||
@@ -19,4 +26,18 @@ onMounted(() => {
|
|||||||
<template>
|
<template>
|
||||||
<RouterView />
|
<RouterView />
|
||||||
<Toaster position="bottom-right" :duration="2000" />
|
<Toaster position="bottom-right" :duration="2000" />
|
||||||
|
|
||||||
|
<!-- 穿越子节点时显示的悬浮“返回主节点”控制条 -->
|
||||||
|
<div v-if="activeInterconnectNodeId" class="fixed bottom-4 left-4 z-[9999] group">
|
||||||
|
<button
|
||||||
|
@click="exitTravel"
|
||||||
|
class="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-medium
|
||||||
|
bg-amber-500/85 hover:bg-amber-500 text-white shadow-md shadow-amber-500/10
|
||||||
|
transition-all duration-300 border border-amber-400/10 backdrop-blur-sm
|
||||||
|
hover:scale-105 active:scale-95 cursor-pointer"
|
||||||
|
>
|
||||||
|
<LogOut class="h-3 w-3 transition-transform group-hover:-translate-x-0.5" />
|
||||||
|
<span>返回主节点</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+27
-9
@@ -65,16 +65,34 @@ export interface MonitorStats {
|
|||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export let activeInterconnectNodeId = localStorage.getItem('activeInterconnectNodeId') || ''
|
||||||
|
|
||||||
async function request<T>(url: string, options?: RequestInit): Promise<T> {
|
export function setActiveInterconnectNodeId(id: string) {
|
||||||
const res = await fetch(`${API_BASE_URL}${url}`, {
|
activeInterconnectNodeId = id
|
||||||
...options,
|
localStorage.removeItem('site_settings_cache')
|
||||||
credentials: 'include', // 携带 Cookie
|
if (id) {
|
||||||
headers: {
|
localStorage.setItem('activeInterconnectNodeId', id)
|
||||||
'Content-Type': 'application/json',
|
document.cookie = `active_interconnect_node_id=${id}; path=/; max-age=${7 * 24 * 3600}`
|
||||||
...options?.headers
|
} else {
|
||||||
}
|
localStorage.removeItem('activeInterconnectNodeId')
|
||||||
})
|
document.cookie = `active_interconnect_node_id=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function request<T>(url: string, options?: RequestInit): Promise<T> {
|
||||||
|
let res: Response
|
||||||
|
try {
|
||||||
|
res = await fetch(`${API_BASE_URL}${url}`, {
|
||||||
|
...options,
|
||||||
|
credentials: 'include', // 携带 Cookie
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options?.headers
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
const json: ApiResponse<T> = await res.json()
|
const json: ApiResponse<T> = await res.json()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { request } from '@/api'
|
||||||
|
|
||||||
|
export interface InterconnectNode {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
token: string
|
||||||
|
remark: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
status?: string
|
||||||
|
metrics?: {
|
||||||
|
cpu_percent: number
|
||||||
|
mem_percent: number
|
||||||
|
disk_percent: number
|
||||||
|
}
|
||||||
|
last_heartbeat_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNodes() {
|
||||||
|
return request<InterconnectNode[]>('/interconnect/nodes', { method: 'GET' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNode(data: Partial<InterconnectNode>) {
|
||||||
|
return request<InterconnectNode>('/interconnect/nodes', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateNode(id: string, data: Partial<InterconnectNode>) {
|
||||||
|
return request<InterconnectNode>(`/interconnect/nodes/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteNode(id: string) {
|
||||||
|
return request<void>(`/interconnect/nodes/${id}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNodeStatus(id: string) {
|
||||||
|
return request<any>(`/interconnect/nodes/${id}/status`, { method: 'GET' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncScript(node_ids: string[], filename: string, content: string) {
|
||||||
|
return request<any[]>('/interconnect/sync/script', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ node_ids, filename, content })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncEnv(node_ids: string[], envs: any[]) {
|
||||||
|
return request<any[]>('/interconnect/sync/env', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ node_ids, envs })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncTask(node_ids: string[], tasks: any[]) {
|
||||||
|
return request<any[]>('/interconnect/sync/task', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ node_ids, tasks })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChildStatus() {
|
||||||
|
return request<{ parent_url: string; parent_token: string; connected: boolean }>('/interconnect/child/status', { method: 'GET' })
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import * as interconnectApi from '@/api/interconnect'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
actionLabel?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:open', val: boolean): void
|
||||||
|
(e: 'confirm', nodeIds: string[]): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const nodes = ref<interconnectApi.InterconnectNode[]>([])
|
||||||
|
const selectedNodeIds = ref<Set<string>>(new Set())
|
||||||
|
const loading = ref(false)
|
||||||
|
const syncing = ref(false)
|
||||||
|
|
||||||
|
watch(() => props.open, async (val) => {
|
||||||
|
if (val) {
|
||||||
|
selectedNodeIds.value.clear()
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
nodes.value = await interconnectApi.getNodes()
|
||||||
|
} catch {
|
||||||
|
toast.error('获取互联节点失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleNode(id: string) {
|
||||||
|
if (selectedNodeIds.value.has(id)) {
|
||||||
|
selectedNodeIds.value.delete(id)
|
||||||
|
} else {
|
||||||
|
selectedNodeIds.value.add(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
if (selectedNodeIds.value.size === 0) {
|
||||||
|
toast.error('请至少选择一个目标节点')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('confirm', Array.from(selectedNodeIds.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
setSyncing: (val: boolean) => syncing.value = val,
|
||||||
|
close: () => emit('update:open', false)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog :open="open" @update:open="emit('update:open', $event)">
|
||||||
|
<DialogContent class="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{ title }}</DialogTitle>
|
||||||
|
<DialogDescription v-if="description">{{ description }}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div class="py-4 max-h-[300px] overflow-y-auto">
|
||||||
|
<div v-if="loading" class="text-center text-sm text-muted-foreground py-4">
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="nodes.length === 0" class="text-center text-sm text-muted-foreground py-4">
|
||||||
|
暂无互联节点,请先在「互联管理」中添加节点。
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="node in nodes"
|
||||||
|
:key="node.id"
|
||||||
|
class="flex items-center space-x-3 p-2 rounded-md border cursor-pointer hover:bg-secondary/50 transition-colors"
|
||||||
|
@click="toggleNode(node.id)"
|
||||||
|
>
|
||||||
|
<Checkbox :checked="selectedNodeIds.has(node.id)" @update:checked="toggleNode(node.id)" />
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm font-medium leading-none">{{ node.name }}</p>
|
||||||
|
<p class="text-xs text-muted-foreground mt-1">{{ node.url }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" @click="emit('update:open', false)" :disabled="syncing">取消</Button>
|
||||||
|
<Button @click="handleConfirm" :disabled="nodes.length === 0 || syncing">
|
||||||
|
<span v-if="syncing">处理中...</span>
|
||||||
|
<span v-else>{{ actionLabel || '确认分发' }}</span>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger } from '@/components/ui/select'
|
||||||
|
import { MonitorDot } from 'lucide-vue-next'
|
||||||
|
import { request, activeInterconnectNodeId, setActiveInterconnectNodeId, api } from '@/api'
|
||||||
|
import type { InterconnectNode } from '@/api/interconnect'
|
||||||
|
import { useEventBus } from '@vueuse/core'
|
||||||
|
|
||||||
|
const nodes = ref<InterconnectNode[]>([])
|
||||||
|
const selectedNodeId = ref<string>('local')
|
||||||
|
const isMaster = ref(false)
|
||||||
|
|
||||||
|
let timer: any = null
|
||||||
|
|
||||||
|
async function loadNodes() {
|
||||||
|
try {
|
||||||
|
const res = await request<InterconnectNode[]>('/interconnect/nodes')
|
||||||
|
nodes.value = res || []
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load interconnect nodes', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNodeChange(val: any) {
|
||||||
|
if (!val) return
|
||||||
|
const strVal = String(val)
|
||||||
|
selectedNodeId.value = strVal
|
||||||
|
if (strVal === 'local') {
|
||||||
|
setActiveInterconnectNodeId('')
|
||||||
|
} else {
|
||||||
|
setActiveInterconnectNodeId(strVal)
|
||||||
|
}
|
||||||
|
// Refresh the current page to reload data from the new node
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
stopPolling()
|
||||||
|
loadNodes()
|
||||||
|
timer = setInterval(loadNodes, 30000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkRoleAndInit() {
|
||||||
|
try {
|
||||||
|
const role = await api.settings.get('interconnect', 'interconnect_role')
|
||||||
|
updateRoleState(role)
|
||||||
|
} catch (error) {
|
||||||
|
updateRoleState('none')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRoleState(role: string) {
|
||||||
|
const master = role === 'master'
|
||||||
|
isMaster.value = master
|
||||||
|
if (master) {
|
||||||
|
if (activeInterconnectNodeId) {
|
||||||
|
selectedNodeId.value = activeInterconnectNodeId
|
||||||
|
} else {
|
||||||
|
selectedNodeId.value = 'local'
|
||||||
|
}
|
||||||
|
startPolling()
|
||||||
|
} else {
|
||||||
|
stopPolling()
|
||||||
|
nodes.value = []
|
||||||
|
selectedNodeId.value = 'local'
|
||||||
|
// 只有在非“穿越状态”下,才可以因为角色改变而清除 LocalStorage/Cookie
|
||||||
|
const isTraveling = !!document.cookie.match(new RegExp('(^| )active_interconnect_node_id=([^;]*)'))
|
||||||
|
if (activeInterconnectNodeId && !isTraveling) {
|
||||||
|
setActiveInterconnectNodeId('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleBus = useEventBus<string>('interconnect-role-changed')
|
||||||
|
roleBus.on((newRole) => {
|
||||||
|
updateRoleState(newRole)
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await checkRoleAndInit()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopPolling()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="isMaster" class="flex items-center gap-2">
|
||||||
|
<Select :model-value="selectedNodeId" @update:model-value="handleNodeChange">
|
||||||
|
<SelectTrigger class="h-6 px-1.5 py-0 text-xs font-medium rounded-md border border-input/60 bg-background/50 hover:bg-accent hover:text-accent-foreground shadow-sm transition-all focus:ring-0 focus:ring-offset-0 w-auto gap-0.5 min-w-[70px] max-w-[120px]" :class="{'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/25 hover:bg-amber-500/15': selectedNodeId !== 'local'}">
|
||||||
|
<div class="flex items-center gap-0.5 min-w-0">
|
||||||
|
<MonitorDot class="h-2.5 w-2.5 shrink-0" />
|
||||||
|
<span class="truncate pr-0.5 text-[10px] leading-none">
|
||||||
|
{{ selectedNodeId === 'local' ? '本机节点' : (nodes.find(n => n.id === selectedNodeId)?.name || '未知子节点') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="local" class="text-xs">
|
||||||
|
本机节点
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem v-for="node in nodes" :key="node.id" :value="node.id" class="text-xs">
|
||||||
|
{{ node.name }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -34,7 +34,7 @@ const siteSettings = ref<SiteSettings>(cachedSettings || {
|
|||||||
cookie_days: '7'
|
cookie_days: '7'
|
||||||
})
|
})
|
||||||
|
|
||||||
// 立即应用缓存的设置
|
// 立即应用缓存的设置,防止标题闪烁或空白
|
||||||
if (cachedSettings) {
|
if (cachedSettings) {
|
||||||
if (cachedSettings.title) {
|
if (cachedSettings.title) {
|
||||||
document.title = cachedSettings.title
|
document.title = cachedSettings.title
|
||||||
@@ -42,6 +42,8 @@ if (cachedSettings) {
|
|||||||
if (cachedSettings.icon) {
|
if (cachedSettings.icon) {
|
||||||
updateFavicon(cachedSettings.icon)
|
updateFavicon(cachedSettings.icon)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
document.title = siteSettings.value.title
|
||||||
}
|
}
|
||||||
|
|
||||||
let loaded = false
|
let loaded = false
|
||||||
@@ -72,10 +74,16 @@ export function useSiteSettings() {
|
|||||||
siteSettings.value = res
|
siteSettings.value = res
|
||||||
saveToCache(res) // 保存到缓存
|
saveToCache(res) // 保存到缓存
|
||||||
document.title = res.title || '白虎面板'
|
document.title = res.title || '白虎面板'
|
||||||
if (res.icon) updateFavicon(res.icon)
|
if (res.icon) {
|
||||||
|
updateFavicon(res.icon)
|
||||||
|
}
|
||||||
loaded = true
|
loaded = true
|
||||||
} catch {
|
} catch (error) {
|
||||||
// 使用默认值或缓存值
|
// 使用默认值或缓存值
|
||||||
|
document.title = siteSettings.value.title || '白虎面板'
|
||||||
|
if (siteSettings.value.icon) {
|
||||||
|
updateFavicon(siteSettings.value.icon)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,3 +77,8 @@ export const TASK_EVENTS = {
|
|||||||
export const LOG_EVENTS = {
|
export const LOG_EVENTS = {
|
||||||
ADDED: 'app_log_added',
|
ADDED: 'app_log_added',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
// 系统事件类型 (对应后端的 system_ws_service.go)
|
||||||
|
export const SYSTEM_EVENTS = {
|
||||||
|
INTERCONNECT_CHILD_STATUS: 'interconnect_child_status',
|
||||||
|
} as const
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { RouterLink, RouterView, useRoute } from 'vue-router'
|
import { RouterLink, RouterView, useRoute } from 'vue-router'
|
||||||
import { resetAuthCache } from '@/router'
|
import { resetAuthCache } from '@/router'
|
||||||
import { LayoutDashboard, ListTodo, FileCode, Settings, LogOut, ScrollText, Terminal, Variable, KeyRound, Menu, X, Server, Globe, Bell, Activity } from 'lucide-vue-next'
|
import { LayoutDashboard, ListTodo, FileCode, Settings, LogOut, ScrollText, Terminal, Variable, KeyRound, Menu, X, Server, Globe, Bell, Activity, Network } from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import ThemeToggle from '@/components/ThemeToggle.vue'
|
import ThemeToggle from '@/components/ThemeToggle.vue'
|
||||||
import SystemNotice from '@/components/SystemNotice.vue'
|
import SystemNotice from '@/components/SystemNotice.vue'
|
||||||
|
import NodeSwitcher from '@/components/NodeSwitcher.vue'
|
||||||
import { api } from '@/api'
|
import { api } from '@/api'
|
||||||
import { useSiteSettings } from '@/composables/useSiteSettings'
|
import { useSiteSettings } from '@/composables/useSiteSettings'
|
||||||
|
|
||||||
@@ -64,6 +65,7 @@ const navItems = [
|
|||||||
{ to: '/notify', icon: Bell, label: '消息推送', exact: true },
|
{ to: '/notify', icon: Bell, label: '消息推送', exact: true },
|
||||||
{ to: '/logs', icon: KeyRound, label: '运行日志', exact: true },
|
{ to: '/logs', icon: KeyRound, label: '运行日志', exact: true },
|
||||||
{ to: '/monitor', icon: Activity, label: '系统监控', exact: true },
|
{ to: '/monitor', icon: Activity, label: '系统监控', exact: true },
|
||||||
|
{ to: '/interconnect', icon: Network, label: '互联管理', exact: true },
|
||||||
{ to: '/settings', icon: Settings, label: '系统设置', exact: true },
|
{ to: '/settings', icon: Settings, label: '系统设置', exact: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -164,7 +166,7 @@ onMounted(() => {
|
|||||||
<Button variant="ghost" size="icon" class="h-9 w-9 lg:hidden shrink-0" @click="mobileMenuOpen = true">
|
<Button variant="ghost" size="icon" class="h-9 w-9 lg:hidden shrink-0" @click="mobileMenuOpen = true">
|
||||||
<Menu class="h-5 w-5 text-muted-foreground" />
|
<Menu class="h-5 w-5 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
<div class="flex flex-col sm:flex-row sm:items-baseline sm:gap-2 truncate">
|
<div class="flex flex-col sm:flex-row sm:items-baseline sm:gap-2 truncate min-w-0 flex-1">
|
||||||
<span class="text-sm text-muted-foreground truncate font-normal poem-sentence" :title="sentence">
|
<span class="text-sm text-muted-foreground truncate font-normal poem-sentence" :title="sentence">
|
||||||
<span class="hidden sm:inline">{{ sentence }}</span>
|
<span class="hidden sm:inline">{{ sentence }}</span>
|
||||||
<span class="sm:hidden">{{ sentenceContent }}</span>
|
<span class="sm:hidden">{{ sentenceContent }}</span>
|
||||||
@@ -172,6 +174,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1 sm:gap-2.5 shrink-0">
|
<div class="flex items-center gap-1 sm:gap-2.5 shrink-0">
|
||||||
|
<NodeSwitcher />
|
||||||
<SystemNotice />
|
<SystemNotice />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ const router = createRouter({
|
|||||||
{ path: 'terminal', name: 'terminal', component: () => import('@/views/terminal/Terminal.vue') },
|
{ path: 'terminal', name: 'terminal', component: () => import('@/views/terminal/Terminal.vue') },
|
||||||
{ path: 'notify', name: 'notify', component: () => import('@/views/notify/Notify.vue') },
|
{ path: 'notify', name: 'notify', component: () => import('@/views/notify/Notify.vue') },
|
||||||
{ path: 'monitor', name: 'monitor', component: () => import('@/views/monitor/Monitor.vue') },
|
{ path: 'monitor', name: 'monitor', component: () => import('@/views/monitor/Monitor.vue') },
|
||||||
|
{ path: 'interconnect', name: 'interconnect', component: () => import('@/views/interconnect/index.vue') },
|
||||||
{ path: 'settings', name: 'settings', component: () => import('@/views/settings/Settings.vue') }
|
{ path: 'settings', name: 'settings', component: () => import('@/views/settings/Settings.vue') }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ const showTerminalDialog = ref(false)
|
|||||||
const runCommand = ref('')
|
const runCommand = ref('')
|
||||||
const scriptsDir = ref('')
|
const scriptsDir = ref('')
|
||||||
|
|
||||||
|
|
||||||
async function fetchInstalledLangs() {
|
async function fetchInstalledLangs() {
|
||||||
try {
|
try {
|
||||||
installedLangs.value = await api.mise.list()
|
installedLangs.value = await api.mise.list()
|
||||||
|
|||||||
@@ -272,9 +272,6 @@ onMounted(() => {
|
|||||||
<Button variant="ghost" size="icon" class="h-6 w-6" @click="openEdit(env)" title="编辑">
|
<Button variant="ghost" size="icon" class="h-6 w-6" @click="openEdit(env)" title="编辑">
|
||||||
<Pencil class="h-3 w-3" />
|
<Pencil class="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" class="h-6 w-6 text-destructive" @click="confirmDelete(env.id)" title="删除">
|
|
||||||
<Trash2 class="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -381,7 +378,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-4 items-center pt-2 mt-2 border-t border-border/40 -mx-1">
|
<div class="grid grid-cols-5 items-center pt-2 mt-2 border-t border-border/40 -mx-1">
|
||||||
<Button variant="ghost" class="h-9 px-0 text-xs gap-1.5 hover:bg-primary/5 rounded-none" @click="toggleShow(env.id)">
|
<Button variant="ghost" class="h-9 px-0 text-xs gap-1.5 hover:bg-primary/5 rounded-none" @click="toggleShow(env.id)">
|
||||||
<Eye v-if="!showValues[env.id]" class="h-3.5 w-3.5" />
|
<Eye v-if="!showValues[env.id]" class="h-3.5 w-3.5" />
|
||||||
<EyeOff v-else class="h-3.5 w-3.5" />
|
<EyeOff v-else class="h-3.5 w-3.5" />
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { Network, RefreshCw, CheckCircle2, AlertTriangle, ChevronDown, ChevronUp } from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { api } from '@/api'
|
||||||
|
import { getChildStatus } from '@/api/interconnect'
|
||||||
|
import { eventBus } from '@/utils/event-bus'
|
||||||
|
import { SYSTEM_EVENTS } from '@/constants'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'cancel'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const parentConfig = ref({ url: '', token: '' })
|
||||||
|
const savingSetting = ref(false)
|
||||||
|
const connectionStatus = ref<{ parent_url: string; parent_token: string; connected: boolean; tunnel_url?: string; tx_bytes?: number; rx_bytes?: number } | null>(null)
|
||||||
|
const statusLoading = ref(false)
|
||||||
|
const configExpanded = ref(false)
|
||||||
|
let unsubEventBus: (() => void) | null = null
|
||||||
|
|
||||||
|
function formatBytes(bytes?: number): string {
|
||||||
|
if (bytes === undefined || bytes === 0) return '0 B'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStatus() {
|
||||||
|
statusLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getChildStatus()
|
||||||
|
connectionStatus.value = res
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore error
|
||||||
|
} finally {
|
||||||
|
statusLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
parentConfig.value.url = await api.settings.get('interconnect', 'interconnect_parent_url') || ''
|
||||||
|
parentConfig.value.token = await api.settings.get('interconnect', 'interconnect_parent_token') || ''
|
||||||
|
await fetchStatus()
|
||||||
|
configExpanded.value = !connectionStatus.value?.parent_url
|
||||||
|
|
||||||
|
unsubEventBus = eventBus.subscribe((msg) => {
|
||||||
|
if (msg.type === SYSTEM_EVENTS.INTERCONNECT_CHILD_STATUS) {
|
||||||
|
if (connectionStatus.value) {
|
||||||
|
connectionStatus.value.connected = msg.payload.connected
|
||||||
|
} else {
|
||||||
|
fetchStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore error
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (unsubEventBus) unsubEventBus()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleSaveParentConfig() {
|
||||||
|
if (!parentConfig.value.url || !parentConfig.value.token) {
|
||||||
|
toast.error('请填写完整的主面板地址 and 互联密钥')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
savingSetting.value = true
|
||||||
|
try {
|
||||||
|
await api.settings.setSection('interconnect', {
|
||||||
|
interconnect_parent_url: parentConfig.value.url,
|
||||||
|
interconnect_parent_token: parentConfig.value.token
|
||||||
|
})
|
||||||
|
toast.success('配置已保存,正在主动建立反向安全隧道')
|
||||||
|
await fetchStatus()
|
||||||
|
configExpanded.value = false
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || '保存失败')
|
||||||
|
} finally {
|
||||||
|
savingSetting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="max-w-2xl mx-auto pt-2 space-y-4">
|
||||||
|
<!-- 顶部状态标题 -->
|
||||||
|
<div class="flex flex-col items-center justify-center text-center space-y-2 mb-2">
|
||||||
|
<div class="inline-flex h-10 w-10 items-center justify-center rounded-full bg-green-500/10 text-green-500">
|
||||||
|
<Network class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-bold tracking-tight">本机作为子节点运行</h2>
|
||||||
|
<p class="text-muted-foreground text-xs mt-1 max-w-lg">本机正在受控模式下运行,将定期向主节点汇报状态,并允许主节点穿越到本面板。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 连接状态展示面板 (仅在配置存在时显示) -->
|
||||||
|
<div v-if="connectionStatus?.parent_url" class="rounded-xl border p-5 md:p-6 bg-card shadow-sm space-y-3.5 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||||
|
<div class="flex items-center justify-between border-b pb-2">
|
||||||
|
<h3 class="text-xs font-bold text-muted-foreground uppercase tracking-wider">连接状态</h3>
|
||||||
|
<Button variant="ghost" size="icon" class="h-7 w-7 rounded-full" @click="fetchStatus" :disabled="statusLoading" title="刷新状态">
|
||||||
|
<RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': statusLoading }" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 在线状态 -->
|
||||||
|
<div v-if="connectionStatus.connected" class="flex items-start gap-3 p-3.5 rounded-lg bg-green-500/5 border border-green-500/10 text-green-600 dark:text-green-400">
|
||||||
|
<CheckCircle2 class="h-4.5 w-4.5 shrink-0 mt-0.5" />
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-xs font-bold">已成功连接至主控端</div>
|
||||||
|
<div class="text-[10px] opacity-90 leading-relaxed">
|
||||||
|
与主控端的反向安全物理隧道已打通,网络路径畅通。主控端现可无缝“穿越”并集中监控本机。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 离线状态 -->
|
||||||
|
<div v-else class="flex items-start gap-3 p-3.5 rounded-lg bg-amber-500/5 border border-amber-500/10 text-amber-600 dark:text-amber-400">
|
||||||
|
<AlertTriangle class="h-4.5 w-4.5 shrink-0 mt-0.5" />
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-xs font-bold">未建立与主控的物理连接</div>
|
||||||
|
<div class="text-[10px] opacity-90 leading-relaxed">
|
||||||
|
物理隧道连接中断,系统正在后台进行自动重连。可能原因为:主控地址配置错误、专属密钥与主控不匹配,或者主控节点未处于在线状态。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 信息详情 -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-2 text-xs pt-1">
|
||||||
|
<div class="flex justify-between py-1 border-b border-border/40">
|
||||||
|
<span class="text-muted-foreground">主控地址</span>
|
||||||
|
<span class="font-medium truncate max-w-[180px]" :title="connectionStatus.parent_url">{{ connectionStatus.parent_url }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between py-1 border-b border-border/40" v-if="connectionStatus.tunnel_url">
|
||||||
|
<span class="text-muted-foreground">物理隧道地址</span>
|
||||||
|
<span class="font-medium truncate max-w-[180px]" :title="connectionStatus.tunnel_url">{{ connectionStatus.tunnel_url }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between py-1 border-b border-border/40">
|
||||||
|
<span class="text-muted-foreground">物理隧道状态</span>
|
||||||
|
<span class="font-bold flex items-center gap-1.5" :class="connectionStatus.connected ? 'text-green-500' : 'text-amber-500'">
|
||||||
|
<span class="h-1.5 w-1.5 rounded-full" :class="connectionStatus.connected ? 'bg-green-500' : 'bg-amber-500 animate-pulse'"></span>
|
||||||
|
{{ connectionStatus.connected ? '正常在线' : '离线 / 尝试重连' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between py-1 border-b border-border/40" v-if="connectionStatus.tx_bytes !== undefined || connectionStatus.rx_bytes !== undefined">
|
||||||
|
<span class="text-muted-foreground">物理隧道流量</span>
|
||||||
|
<span class="font-medium">
|
||||||
|
<span class="text-green-500" title="发送 (TX)">{{ formatBytes(connectionStatus.tx_bytes) }}</span>
|
||||||
|
<span class="mx-1 text-muted-foreground">/</span>
|
||||||
|
<span class="text-blue-500" title="接收 (RX)">{{ formatBytes(connectionStatus.rx_bytes) }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 连接配置面板 -->
|
||||||
|
<div class="rounded-xl border bg-card p-5 md:p-6 shadow-sm space-y-4">
|
||||||
|
<!-- 头部:如果已配置且未展开,显示简化的“修改配置”触发栏 -->
|
||||||
|
<div v-if="connectionStatus?.parent_url && !configExpanded" class="flex items-center justify-between">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-sm font-semibold">主控连接配置</span>
|
||||||
|
<span class="text-[10px] text-muted-foreground mt-0.5">配置已保存,若需更新连接凭证请点击展开。</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" class="text-xs h-8 px-3" @click="configExpanded = true">
|
||||||
|
展开配置
|
||||||
|
<ChevronDown class="h-3.5 w-3.5 ml-1" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" class="text-destructive hover:bg-destructive/10 text-xs px-2.5 h-8" @click="emit('cancel')">取消子节点</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 展开状态的完整配置面板 -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex items-center justify-between border-b pb-2.5">
|
||||||
|
<h3 class="text-sm font-semibold">主控连接配置</h3>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button v-if="connectionStatus?.parent_url" variant="ghost" size="sm" class="text-xs h-7 px-2.5" @click="configExpanded = false">
|
||||||
|
收起
|
||||||
|
<ChevronUp class="h-3.5 w-3.5 ml-1" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" class="text-destructive hover:bg-destructive/10 text-xs px-2.5 h-7" @click="emit('cancel')">取消子节点角色</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="grid gap-1.5">
|
||||||
|
<Label for="parentUrl" class="text-xs">主面板地址 (URL) <span class="text-destructive">*</span></Label>
|
||||||
|
<Input id="parentUrl" v-model="parentConfig.url" placeholder="例如:http://main-panel.com:8052" autocomplete="off" class="h-8 text-xs" />
|
||||||
|
<p class="text-[10px] text-muted-foreground">填写主面板的访问地址(包含协议和端口)。</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-1.5">
|
||||||
|
<Label for="parentToken" class="text-xs">专属接入密钥 (Token) <span class="text-destructive">*</span></Label>
|
||||||
|
<Input id="parentToken" v-model="parentConfig.token" type="password" placeholder="粘贴从主面板生成的专属接入密钥" autocomplete="new-password" class="h-8 text-xs" />
|
||||||
|
<p class="text-[10px] text-muted-foreground">主面板添加节点时自动生成的随机高强度密钥。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-3 border-t">
|
||||||
|
<Button @click="handleSaveParentConfig" :disabled="savingSetting" class="w-full h-9 text-xs">
|
||||||
|
<RefreshCw v-if="savingSetting" class="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||||
|
保存并连接主节点
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,603 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import StatusDot from '@/components/StatusDot.vue'
|
||||||
|
import { Plus, Edit2, Trash2, RefreshCw, ExternalLink, Eye, Copy } from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import * as interconnectApi from '@/api/interconnect'
|
||||||
|
import { copyToClipboard } from '@/utils/clipboard'
|
||||||
|
import { setActiveInterconnectNodeId } from '@/api'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
nodes: interconnectApi.InterconnectNode[]
|
||||||
|
loading: boolean
|
||||||
|
searchQuery: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'refresh'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const copied = ref(false)
|
||||||
|
const detailOpen = ref(false)
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const selectedNodeDetail = ref<any>(null)
|
||||||
|
const selectedNodeName = ref('')
|
||||||
|
const nodeStatuses = ref<Record<string, any>>({})
|
||||||
|
|
||||||
|
const filteredNodes = computed(() => {
|
||||||
|
if (!props.searchQuery) return props.nodes
|
||||||
|
const lowerKeyword = props.searchQuery.toLowerCase()
|
||||||
|
return props.nodes.filter(node =>
|
||||||
|
node.name.toLowerCase().includes(lowerKeyword) ||
|
||||||
|
node.url.toLowerCase().includes(lowerKeyword) ||
|
||||||
|
(node.remark && node.remark.toLowerCase().includes(lowerKeyword))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const dialogOpen = ref(false)
|
||||||
|
const isEditing = ref(false)
|
||||||
|
const currentForm = ref<Partial<interconnectApi.InterconnectNode>>({
|
||||||
|
name: '',
|
||||||
|
url: '',
|
||||||
|
token: '',
|
||||||
|
remark: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const showDeleteConfirm = ref(false)
|
||||||
|
const deleteId = ref('')
|
||||||
|
|
||||||
|
function formatUptime(seconds: number | undefined): string {
|
||||||
|
if (!seconds) return '-'
|
||||||
|
const days = Math.floor(seconds / (24 * 3600))
|
||||||
|
const hours = Math.floor((seconds % (24 * 3600)) / 3600)
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60)
|
||||||
|
|
||||||
|
const parts = []
|
||||||
|
if (days > 0) parts.push(`${days}天`)
|
||||||
|
if (hours > 0) parts.push(`${hours}小时`)
|
||||||
|
if (minutes > 0 || parts.length === 0) parts.push(`${minutes}分钟`)
|
||||||
|
return parts.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number | undefined): string {
|
||||||
|
if (bytes === undefined) return '-'
|
||||||
|
const gb = bytes / (1024 * 1024 * 1024)
|
||||||
|
if (gb >= 1) return `${gb.toFixed(2)} GB`
|
||||||
|
const mb = bytes / (1024 * 1024)
|
||||||
|
return `${mb.toFixed(2)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLoadColor(percent: number | undefined): string {
|
||||||
|
if (percent === undefined) return 'text-muted-foreground'
|
||||||
|
if (percent < 50) return 'text-green-500'
|
||||||
|
if (percent < 80) return 'text-yellow-500'
|
||||||
|
return 'text-destructive'
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateRandomToken(length = 32) {
|
||||||
|
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
|
let res = ""
|
||||||
|
for (let i = 0; i < length; ++i) {
|
||||||
|
res += charset[Math.floor(Math.random() * charset.length)]
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopy(text: string) {
|
||||||
|
const success = await copyToClipboard(text)
|
||||||
|
if (success) {
|
||||||
|
copied.value = true
|
||||||
|
toast.success('已复制到剪贴板')
|
||||||
|
setTimeout(() => {
|
||||||
|
copied.value = false
|
||||||
|
}, 2000)
|
||||||
|
} else {
|
||||||
|
toast.error('复制失败,请手动复制')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchNodeStatus(id: string) {
|
||||||
|
try {
|
||||||
|
nodeStatuses.value[id] = { status: 'loading' }
|
||||||
|
const res = await interconnectApi.getNodeStatus(id)
|
||||||
|
nodeStatuses.value[id] = Object.assign({ status: 'online' }, res)
|
||||||
|
} catch (error: any) {
|
||||||
|
nodeStatuses.value[id] = { status: 'offline', error: error.message || '离线' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.nodes, (newNodes) => {
|
||||||
|
newNodes.forEach(node => {
|
||||||
|
if (!nodeStatuses.value[node.id] || nodeStatuses.value[node.id].status === 'offline') {
|
||||||
|
fetchNodeStatus(node.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
async function showDetail(node: interconnectApi.InterconnectNode) {
|
||||||
|
selectedNodeName.value = node.name
|
||||||
|
selectedNodeDetail.value = null
|
||||||
|
detailLoading.value = true
|
||||||
|
detailOpen.value = true
|
||||||
|
try {
|
||||||
|
const res = await interconnectApi.getNodeStatus(node.id)
|
||||||
|
selectedNodeDetail.value = res
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error('获取子节点详细信息失败')
|
||||||
|
} finally {
|
||||||
|
detailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTravel(nodeId: string) {
|
||||||
|
setActiveInterconnectNodeId(nodeId)
|
||||||
|
window.location.href = '/'
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddDialog() {
|
||||||
|
isEditing.value = false
|
||||||
|
currentForm.value = { name: '', url: '', token: generateRandomToken(), remark: '' }
|
||||||
|
dialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(node: interconnectApi.InterconnectNode) {
|
||||||
|
isEditing.value = true
|
||||||
|
currentForm.value = { ...node }
|
||||||
|
dialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!currentForm.value.name || (!isEditing.value && !currentForm.value.token)) {
|
||||||
|
toast.error('请填写必要信息')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEditing.value && currentForm.value.id) {
|
||||||
|
await interconnectApi.updateNode(currentForm.value.id, currentForm.value)
|
||||||
|
toast.success('更新成功')
|
||||||
|
} else {
|
||||||
|
await interconnectApi.createNode(currentForm.value)
|
||||||
|
toast.success('添加成功')
|
||||||
|
}
|
||||||
|
dialogOpen.value = false
|
||||||
|
emit('refresh')
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(isEditing.value ? '更新失败' : '添加失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(id: string) {
|
||||||
|
deleteId.value = id
|
||||||
|
showDeleteConfirm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!deleteId.value) return
|
||||||
|
showDeleteConfirm.value = false
|
||||||
|
try {
|
||||||
|
await interconnectApi.deleteNode(deleteId.value)
|
||||||
|
toast.success('删除成功')
|
||||||
|
emit('refresh')
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
openAddDialog
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="rounded-lg border bg-card overflow-hidden">
|
||||||
|
<!-- ========== 1. 大屏布局 (Large >= 1280px) ========== -->
|
||||||
|
<div class="hidden xl:block">
|
||||||
|
<div class="flex items-center gap-4 px-4 py-1.5 border-b bg-muted/20 text-xs text-muted-foreground font-medium">
|
||||||
|
<span class="w-12 shrink-0 pl-1">序号</span>
|
||||||
|
<span class="w-56 shrink-0">节点名称</span>
|
||||||
|
<span class="flex-1 min-w-0">隧道地址 </span>
|
||||||
|
<span class="w-48 shrink-0">运行状态 & 负载</span>
|
||||||
|
<span class="w-48 shrink-0">备注</span>
|
||||||
|
<span class="w-40 shrink-0 text-center">操作</span>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y text-sm">
|
||||||
|
<div v-if="loading" class="py-8 text-center text-muted-foreground">加载中...</div>
|
||||||
|
<div v-else-if="filteredNodes.length === 0" class="py-8 text-center text-muted-foreground">暂无连接的子节点</div>
|
||||||
|
<div v-for="(node, index) in filteredNodes" :key="`large-${node.id}`" class="flex items-center gap-2 px-4 py-2 hover:bg-muted/30 transition-colors">
|
||||||
|
<StatusDot
|
||||||
|
:state="nodeStatuses[node.id]?.status === 'loading' ? 'pending' : (nodeStatuses[node.id]?.status === 'online' || node.status === 'online' ? 'online' : 'failed')"
|
||||||
|
:title="nodeStatuses[node.id]?.status === 'loading' ? '检测中' : (nodeStatuses[node.id]?.status === 'online' || node.status === 'online' ? '在线' : '离线')"
|
||||||
|
/>
|
||||||
|
<div class="w-12 shrink-0 text-muted-foreground tabular-nums text-[11px]">#{{ index + 1 }}</div>
|
||||||
|
<div class="w-56 shrink-0 flex items-center">
|
||||||
|
<span class="font-medium truncate" :title="node.name">{{ node.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0 text-muted-foreground truncate text-xs bg-muted/40 px-2 py-1 rounded" style="font-family: Inter, sans-serif;" :title="node.url">
|
||||||
|
{{ node.url || '-' }}
|
||||||
|
</div>
|
||||||
|
<div class="w-48 shrink-0 flex flex-col justify-center cursor-pointer gap-1" @click="fetchNodeStatus(node.id)" title="点击刷新状态">
|
||||||
|
<template v-if="nodeStatuses[node.id]?.status === 'loading'">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<RefreshCw class="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||||
|
<span class="text-xs text-muted-foreground">检测中...</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="nodeStatuses[node.id]?.status === 'online' || node.status === 'online'">
|
||||||
|
<div class="flex items-center gap-1.5" v-if="nodeStatuses[node.id]?.version">
|
||||||
|
<span class="text-xs text-muted-foreground font-normal">v{{ nodeStatuses[node.id].version }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-[10px]" v-if="node.metrics?.cpu_percent !== undefined && node.metrics?.mem_percent !== undefined && node.metrics?.cpu_percent !== 0">
|
||||||
|
<span :class="getLoadColor(node.metrics?.cpu_percent)">CPU: {{ node.metrics?.cpu_percent.toFixed(1) }}%</span>
|
||||||
|
<span :class="getLoadColor(node.metrics?.mem_percent)">Mem: {{ node.metrics?.mem_percent.toFixed(1) }}%</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="text-xs text-destructive font-medium">离线</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="w-48 shrink-0 truncate text-xs text-muted-foreground" :title="node.remark">
|
||||||
|
{{ node.remark || '-' }}
|
||||||
|
</div>
|
||||||
|
<div class="w-40 shrink-0 flex justify-center gap-1">
|
||||||
|
<Button variant="ghost" size="icon" class="h-7 w-7" @click="showDetail(node)" title="查看详情">
|
||||||
|
<Eye class="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" class="h-7 w-7" @click="handleTravel(node.id)" title="穿越到此子节点">
|
||||||
|
<ExternalLink class="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" class="h-7 w-7" @click="openEditDialog(node)" title="编辑">
|
||||||
|
<Edit2 class="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" class="h-7 w-7" @click="confirmDelete(node.id)" title="删除">
|
||||||
|
<Trash2 class="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ========== 2. 中屏布局 (Medium 640px - 1280px) ========== -->
|
||||||
|
<div class="hidden sm:block xl:hidden">
|
||||||
|
<div class="flex items-center gap-4 px-4 py-1.5 border-b bg-muted/20 text-xs text-muted-foreground font-medium">
|
||||||
|
<span class="w-12 shrink-0 pl-1">序号</span>
|
||||||
|
<span class="w-48 shrink-0">节点信息</span>
|
||||||
|
<span class="flex-1 min-w-0">隧道地址 </span>
|
||||||
|
<span class="w-32 shrink-0">状态与负载</span>
|
||||||
|
<span class="w-36 shrink-0 text-center">操作</span>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y text-sm">
|
||||||
|
<div v-if="loading" class="py-8 text-center text-muted-foreground">加载中...</div>
|
||||||
|
<div v-else-if="filteredNodes.length === 0" class="py-8 text-center text-muted-foreground">暂无连接的子节点</div>
|
||||||
|
<div v-for="(node, index) in filteredNodes" :key="`medium-${node.id}`" class="flex items-center gap-2 px-4 py-2.5 hover:bg-muted/30 transition-colors">
|
||||||
|
<StatusDot
|
||||||
|
:state="nodeStatuses[node.id]?.status === 'loading' ? 'pending' : (nodeStatuses[node.id]?.status === 'online' || node.status === 'online' ? 'online' : 'failed')"
|
||||||
|
/>
|
||||||
|
<div class="w-12 shrink-0 text-muted-foreground tabular-nums text-[10px]">#{{ index + 1 }}</div>
|
||||||
|
<div class="w-48 shrink-0 flex items-center overflow-hidden">
|
||||||
|
<div class="flex flex-col min-w-0">
|
||||||
|
<span class="font-medium truncate text-sm">{{ node.name }}</span>
|
||||||
|
<span v-if="node.remark" class="text-[10px] text-muted-foreground truncate">{{ node.remark }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0 text-[11px] text-muted-foreground bg-muted/20 px-2 py-1 rounded truncate" style="font-family: Inter, sans-serif;" :title="node.url">
|
||||||
|
{{ node.url || '-' }}
|
||||||
|
</div>
|
||||||
|
<div class="w-32 shrink-0 flex flex-col justify-center cursor-pointer gap-0.5" @click="fetchNodeStatus(node.id)">
|
||||||
|
<template v-if="nodeStatuses[node.id]?.status === 'loading'">
|
||||||
|
<RefreshCw class="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="nodeStatuses[node.id]?.status === 'online' || node.status === 'online'">
|
||||||
|
<div class="flex items-center gap-1.5" v-if="nodeStatuses[node.id]?.version">
|
||||||
|
<span class="text-[10px] text-muted-foreground font-normal">v{{ nodeStatuses[node.id].version }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 text-[9px]" v-if="node.metrics?.cpu_percent !== undefined && node.metrics?.mem_percent !== undefined && node.metrics?.cpu_percent !== 0">
|
||||||
|
<span :class="getLoadColor(node.metrics?.cpu_percent)">C:{{ node.metrics?.cpu_percent.toFixed(0) }}%</span>
|
||||||
|
<span :class="getLoadColor(node.metrics?.mem_percent)">M:{{ node.metrics?.mem_percent.toFixed(0) }}%</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="text-xs text-destructive font-medium">离线</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="w-36 shrink-0 flex justify-center gap-0.5">
|
||||||
|
<Button variant="ghost" size="icon" class="h-7 w-7" @click="showDetail(node)" title="查看详情"><Eye class="h-3.5 w-3.5" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" class="h-7 w-7" @click="handleTravel(node.id)" title="穿越到此子节点"><ExternalLink class="h-3.5 w-3.5" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" class="h-7 w-7" @click="openEditDialog(node)" title="编辑"><Edit2 class="h-3.5 w-3.5" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" class="h-7 w-7" @click="confirmDelete(node.id)" title="删除"><Trash2 class="h-3.5 w-3.5" /></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ========== 3. 小屏布局 (Small < 640px) ========== -->
|
||||||
|
<div class="divide-y sm:hidden">
|
||||||
|
<div v-if="loading" class="text-sm text-muted-foreground text-center py-8">加载中...</div>
|
||||||
|
<div v-else-if="filteredNodes.length === 0" class="text-sm text-muted-foreground text-center py-8">暂无连接的子节点</div>
|
||||||
|
<div v-for="(node, index) in filteredNodes" :key="`small-${node.id}`" class="p-3 hover:bg-muted/50 transition-colors">
|
||||||
|
<div class="flex items-start justify-between mb-3 border-b border-border/40 pb-2">
|
||||||
|
<div class="flex items-center gap-2 flex-1 min-w-0 pr-2">
|
||||||
|
<StatusDot
|
||||||
|
:state="nodeStatuses[node.id]?.status === 'loading' ? 'pending' : (nodeStatuses[node.id]?.status === 'online' || node.status === 'online' ? 'online' : 'failed')"
|
||||||
|
class="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span class="text-[10px] text-muted-foreground tabular-nums flex-shrink-0">#{{ index + 1 }}</span>
|
||||||
|
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
||||||
|
<span class="font-bold text-sm truncate">{{ node.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5 text-xs text-muted-foreground mb-3 px-1">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="w-10 shrink-0 font-medium mt-0.5 opacity-70">隧道地址:</span>
|
||||||
|
<div class="flex-1 min-w-0 overflow-hidden text-foreground">
|
||||||
|
<div class="text-[11px] bg-muted/40 px-1 py-0.5 rounded break-all" style="font-family: Inter, sans-serif;">{{ node.url || '-' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="node.remark" class="flex items-start gap-3">
|
||||||
|
<span class="w-10 shrink-0 font-medium mt-0.5 opacity-70">备注:</span>
|
||||||
|
<span class="flex-1 text-[11px] truncate">{{ node.remark }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-3 cursor-pointer" @click="fetchNodeStatus(node.id)">
|
||||||
|
<span class="w-10 shrink-0 font-medium mt-0.5 opacity-70">状态:</span>
|
||||||
|
<div class="flex-1 text-[11px] flex items-center gap-2 flex-wrap">
|
||||||
|
<template v-if="nodeStatuses[node.id]?.status === 'loading'">
|
||||||
|
<RefreshCw class="h-3 w-3 animate-spin" />检测中...
|
||||||
|
</template>
|
||||||
|
<template v-else-if="nodeStatuses[node.id]?.status === 'online' || node.status === 'online'">
|
||||||
|
<span v-if="nodeStatuses[node.id]?.version" class="text-muted-foreground">v{{ nodeStatuses[node.id].version }}</span>
|
||||||
|
<span v-if="node.metrics?.cpu_percent !== undefined && node.metrics?.cpu_percent !== 0" :class="getLoadColor(node.metrics?.cpu_percent)">CPU: {{ node.metrics?.cpu_percent.toFixed(1) }}%</span>
|
||||||
|
<span v-if="node.metrics?.mem_percent !== undefined && node.metrics?.mem_percent !== 0" :class="getLoadColor(node.metrics?.mem_percent)">Mem: {{ node.metrics?.mem_percent.toFixed(1) }}%</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="text-destructive font-medium">离线</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-4 items-center pt-2 mt-2 border-t border-border/40 -mx-1">
|
||||||
|
<Button variant="ghost" class="h-9 px-0 text-xs gap-1 hover:bg-primary/5 rounded-none" @click="showDetail(node)">
|
||||||
|
<Eye class="h-3.5 w-3.5" />详情
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" class="h-9 px-0 text-xs gap-1 hover:bg-primary/5 rounded-none border-l border-border/10" @click="handleTravel(node.id)">
|
||||||
|
<ExternalLink class="h-3.5 w-3.5" />穿越
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" class="h-9 px-0 text-xs gap-1 hover:bg-primary/5 rounded-none border-l border-border/10" @click="openEditDialog(node)">
|
||||||
|
<Edit2 class="h-3.5 w-3.5" />编辑
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" class="h-9 px-0 text-xs gap-1 hover:bg-primary/5 rounded-none border-l border-border/10" @click="confirmDelete(node.id)">
|
||||||
|
<Trash2 class="h-3.5 w-3.5" />删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 添加/编辑弹窗 -->
|
||||||
|
<Dialog :open="dialogOpen" @update:open="dialogOpen = $event">
|
||||||
|
<DialogContent class="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{ isEditing ? '编辑子节点' : '生成子节点专属接入密钥' }}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{{ isEditing ? '修改节点的备注名称信息。' : '保存后请将此密钥粘贴到子节点的配置界面中建立连接。' }}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="grid gap-4 py-4">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="name">节点名称 <span class="text-destructive">*</span></Label>
|
||||||
|
<Input id="name" v-model="currentForm.name" placeholder="例如:海外节点-洛杉矶" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2" v-if="!isEditing">
|
||||||
|
<Label>专属互联密钥 (Token) <span class="text-destructive">*</span></Label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Input v-model="currentForm.token" readonly class="font-mono text-sm bg-muted/30 placeholder:font-sans" />
|
||||||
|
<Button variant="outline" size="icon" @click="currentForm.token = generateRandomToken()" title="重新生成">
|
||||||
|
<RefreshCw class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="icon" @click="handleCopy(currentForm.token || '')" :title="copied ? '已复制' : '复制'">
|
||||||
|
<Copy class="h-4 w-4" :class="{ 'text-green-500': copied }" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-orange-500 mt-1">请务必复制上方密钥,一旦关闭窗口将无法再次查看完整密钥。</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="remark">备注</Label>
|
||||||
|
<Input id="remark" v-model="currentForm.remark" placeholder="选填,关于该节点的附加说明" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" @click="dialogOpen = false">取消</Button>
|
||||||
|
<Button @click="handleSave">保存</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- 删除确认弹窗 -->
|
||||||
|
<AlertDialog :open="showDeleteConfirm" @update:open="showDeleteConfirm = $event">
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>确定删除该互联节点?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
此操作不可恢复,节点连接信息将被永久删除。
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction @click="handleDelete" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">确认删除</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
<!-- 详情弹窗 -->
|
||||||
|
<Dialog :open="detailOpen" @update:open="detailOpen = $event">
|
||||||
|
<DialogContent class="sm:max-w-[600px] max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle class="flex items-center gap-2">
|
||||||
|
<span>节点详情</span>
|
||||||
|
<span class="text-sm font-normal text-muted-foreground">({{ selectedNodeName }})</span>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
查看子节点的系统环境、硬件指标和任务调度器运行状态。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<!-- 加载中 -->
|
||||||
|
<div v-if="detailLoading" class="py-12 flex flex-col items-center justify-center space-y-4 animate-in fade-in duration-300">
|
||||||
|
<div class="relative flex items-center justify-center">
|
||||||
|
<!-- 外圈渐变呼吸环 -->
|
||||||
|
<div class="absolute h-10 w-10 rounded-full border border-primary/25 animate-ping"></div>
|
||||||
|
<!-- 旋转环 -->
|
||||||
|
<div class="h-10 w-10 rounded-full border-2 border-primary/10 border-t-primary animate-spin"></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs font-medium text-muted-foreground/80 tracking-wider animate-pulse">正在获取远程数据...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 加载失败或无数据 -->
|
||||||
|
<div v-else-if="!selectedNodeDetail" class="py-12 flex flex-col items-center justify-center gap-2 text-destructive">
|
||||||
|
<p class="text-sm font-medium">获取数据失败</p>
|
||||||
|
<p class="text-xs text-muted-foreground">子节点可能处于离线状态,或者网络连接超时。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 数据展示 -->
|
||||||
|
<div v-else class="space-y-6 py-2">
|
||||||
|
<!-- 1. 硬件状态 -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<h3 class="text-sm font-semibold border-b pb-1">系统资源负载</h3>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<!-- CPU -->
|
||||||
|
<div class="rounded-lg border p-3 bg-muted/10 space-y-2">
|
||||||
|
<div class="flex items-center justify-between text-xs">
|
||||||
|
<span class="text-muted-foreground">CPU 使用率</span>
|
||||||
|
<span class="font-medium" :class="getLoadColor(selectedNodeDetail.host?.cpu_percent)">{{ selectedNodeDetail.host?.cpu_percent?.toFixed(1) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-muted rounded-full h-2">
|
||||||
|
<div class="h-2 rounded-full transition-all duration-300" :class="selectedNodeDetail.host?.cpu_percent >= 80 ? 'bg-destructive' : selectedNodeDetail.host?.cpu_percent >= 50 ? 'bg-yellow-500' : 'bg-green-500'" :style="{ width: `${selectedNodeDetail.host?.cpu_percent || 0}%` }"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 内存 -->
|
||||||
|
<div class="rounded-lg border p-3 bg-muted/10 space-y-2">
|
||||||
|
<div class="flex items-center justify-between text-xs">
|
||||||
|
<span class="text-muted-foreground">内存使用率</span>
|
||||||
|
<span class="font-medium" :class="getLoadColor(selectedNodeDetail.host?.mem_percent)">{{ selectedNodeDetail.host?.mem_percent?.toFixed(1) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-muted rounded-full h-2">
|
||||||
|
<div class="h-2 rounded-full transition-all duration-300" :class="selectedNodeDetail.host?.mem_percent >= 80 ? 'bg-destructive' : selectedNodeDetail.host?.mem_percent >= 50 ? 'bg-yellow-500' : 'bg-green-500'" :style="{ width: `${selectedNodeDetail.host?.mem_percent || 0}%` }"></div>
|
||||||
|
</div>
|
||||||
|
<div class="text-[10px] text-muted-foreground flex justify-between">
|
||||||
|
<span>{{ formatBytes(selectedNodeDetail.host?.mem_used) }}</span>
|
||||||
|
<span>{{ formatBytes(selectedNodeDetail.host?.mem_total) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 磁盘 -->
|
||||||
|
<div class="rounded-lg border p-3 bg-muted/10 space-y-2">
|
||||||
|
<div class="flex items-center justify-between text-xs">
|
||||||
|
<span class="text-muted-foreground">磁盘使用率</span>
|
||||||
|
<span class="font-medium" :class="getLoadColor(selectedNodeDetail.host?.disk_percent)">{{ selectedNodeDetail.host?.disk_percent?.toFixed(1) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-muted rounded-full h-2">
|
||||||
|
<div class="h-2 rounded-full transition-all duration-300" :class="selectedNodeDetail.host?.disk_percent >= 80 ? 'bg-destructive' : selectedNodeDetail.host?.disk_percent >= 50 ? 'bg-yellow-500' : 'bg-green-500'" :style="{ width: `${selectedNodeDetail.host?.disk_percent || 0}%` }"></div>
|
||||||
|
</div>
|
||||||
|
<div class="text-[10px] text-muted-foreground flex justify-between">
|
||||||
|
<span>{{ formatBytes(selectedNodeDetail.host?.disk_used) }}</span>
|
||||||
|
<span>{{ formatBytes(selectedNodeDetail.host?.disk_total) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. 环境信息 -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<h3 class="text-sm font-semibold border-b pb-1">运行环境与系统</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
|
||||||
|
<div class="flex justify-between py-1 border-b border-border/40">
|
||||||
|
<span class="text-muted-foreground">操作系统</span>
|
||||||
|
<span class="font-medium uppercase">{{ selectedNodeDetail.env?.os }} ({{ selectedNodeDetail.env?.arch }})</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between py-1 border-b border-border/40">
|
||||||
|
<span class="text-muted-foreground">系统平台</span>
|
||||||
|
<span class="font-medium truncate max-w-[160px]" :title="selectedNodeDetail.host?.platform">{{ selectedNodeDetail.host?.platform || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between py-1 border-b border-border/40">
|
||||||
|
<span class="text-muted-foreground">CPU 核心数</span>
|
||||||
|
<span class="font-medium">{{ selectedNodeDetail.env?.num_cpu }} 核</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between py-1 border-b border-border/40">
|
||||||
|
<span class="text-muted-foreground">Goroutine 数量</span>
|
||||||
|
<span class="font-medium">{{ selectedNodeDetail.env?.goroutines }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between py-1 border-b border-border/40">
|
||||||
|
<span class="text-muted-foreground">Go 编译版本</span>
|
||||||
|
<span class="font-medium">{{ selectedNodeDetail.env?.go_version }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between py-1 border-b border-border/40">
|
||||||
|
<span class="text-muted-foreground">节点运行时间</span>
|
||||||
|
<span class="font-medium">{{ formatUptime(selectedNodeDetail.host?.uptime) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3. 物理隧道连接状态 -->
|
||||||
|
<div class="space-y-3" v-if="selectedNodeDetail.tunnel_connected">
|
||||||
|
<h3 class="text-sm font-semibold border-b pb-1">物理隧道连接状态</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
|
||||||
|
<div class="flex justify-between py-1 border-b border-border/40 col-span-2">
|
||||||
|
<span class="text-muted-foreground">隧道内部通讯地址</span>
|
||||||
|
<span class="font-medium truncate max-w-[280px]" :title="selectedNodeDetail.tunnel_url">{{ selectedNodeDetail.tunnel_url }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between py-1 border-b border-border/40 col-span-2" v-if="selectedNodeDetail.host?.tx_bytes !== undefined || selectedNodeDetail.host?.rx_bytes !== undefined">
|
||||||
|
<span class="text-muted-foreground">实时累加隧道流量</span>
|
||||||
|
<span class="font-medium">
|
||||||
|
<span class="text-green-500" title="发送 (TX)">{{ formatBytes(selectedNodeDetail.host?.tx_bytes) }}</span>
|
||||||
|
<span class="mx-1 text-muted-foreground">/</span>
|
||||||
|
<span class="text-blue-500" title="接收 (RX)">{{ formatBytes(selectedNodeDetail.host?.rx_bytes) }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 4. 调度器与任务统计 -->
|
||||||
|
<div class="space-y-3" v-if="selectedNodeDetail.scheduler">
|
||||||
|
<h3 class="text-sm font-semibold border-b pb-1">任务调度统计</h3>
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-center">
|
||||||
|
<div class="rounded border p-2 bg-muted/5">
|
||||||
|
<div class="text-[10px] text-muted-foreground">计划中任务</div>
|
||||||
|
<div class="text-lg font-bold tabular-nums mt-0.5">{{ selectedNodeDetail.scheduler.scheduled ?? 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded border p-2 bg-muted/5">
|
||||||
|
<div class="text-[10px] text-muted-foreground">正在运行任务</div>
|
||||||
|
<div class="text-lg font-bold tabular-nums mt-0.5 text-green-500">{{ selectedNodeDetail.scheduler.running ?? 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded border p-2 bg-muted/5">
|
||||||
|
<div class="text-[10px] text-muted-foreground">队列任务积压</div>
|
||||||
|
<div class="text-lg font-bold tabular-nums mt-0.5">{{ selectedNodeDetail.scheduler.queue_size ?? 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded border p-2 bg-muted/5">
|
||||||
|
<div class="text-[10px] text-muted-foreground">并发工作协程</div>
|
||||||
|
<div class="text-lg font-bold tabular-nums mt-0.5">{{ selectedNodeDetail.scheduler.worker_count ?? 0 }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { Plus, RefreshCw, Search, Server, ArrowRightLeft } from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import * as interconnectApi from '@/api/interconnect'
|
||||||
|
|
||||||
|
import SyncPanel from './SyncPanel.vue'
|
||||||
|
import MasterList from './MasterList.vue'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'cancel'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const nodes = ref<interconnectApi.InterconnectNode[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const activeTab = ref('nodes')
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const masterListRef = ref<InstanceType<typeof MasterList> | null>(null)
|
||||||
|
|
||||||
|
async function fetchNodes() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
nodes.value = await interconnectApi.getNodes()
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error('获取节点列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchNodes()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div class="flex flex-col shrink-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h2 class="text-xl sm:text-2xl font-bold tracking-tight">互联管理</h2>
|
||||||
|
<span class="px-2 py-0.5 rounded text-xs font-medium bg-amber-500/10 text-amber-500 border border-amber-500/20">主节点 (Master)</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground text-xs mt-0.5 ml-0.5">集中监控其他面板的状态,并可无缝穿越到子节点进行管理</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col sm:flex-row items-center gap-2 w-full md:w-auto md:ml-auto md:justify-end">
|
||||||
|
<!-- 搜索框 (仅节点管理时显示) -->
|
||||||
|
<div class="relative w-full sm:w-[200px] md:w-[240px] group shrink-0" v-if="activeTab === 'nodes'">
|
||||||
|
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground group-focus-within:text-primary transition-colors" />
|
||||||
|
<Input v-model="searchQuery" placeholder="搜索节点..." class="h-9 pl-9 w-full bg-muted/20 border-muted-foreground/10 focus:bg-background text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 按钮控制组 -->
|
||||||
|
<div class="flex items-center gap-2 w-full sm:w-auto sm:justify-end overflow-x-auto scrollbar-none pb-1 -mb-1 sm:pb-0 sm:mb-0">
|
||||||
|
<!-- 刷新按钮 -->
|
||||||
|
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" @click="fetchNodes" :disabled="loading" title="刷新">
|
||||||
|
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': loading }" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- 取消主控角色按钮 -->
|
||||||
|
<Button variant="outline" class="shrink-0 px-2 md:px-3 h-9 shadow-sm text-destructive border-destructive/20 hover:bg-destructive/10" @click="emit('cancel')">
|
||||||
|
取消主控
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- 添加子节点按钮 -->
|
||||||
|
<Button v-if="activeTab === 'nodes'" @click="masterListRef?.openAddDialog()" class="shrink-0 px-2 md:px-3 h-9 shadow-sm font-medium" title="添加子节点">
|
||||||
|
<Plus class="h-4 w-4 md:mr-1.5" /> <span class="hidden md:inline">添加子节点</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- Tabs 切换 (暂时隐藏同步功能) -->
|
||||||
|
<!--
|
||||||
|
<Tabs :model-value="activeTab" @update:model-value="(v: string | number) => activeTab = String(v)" class="shrink-0 flex-1 sm:flex-none min-w-[120px]">
|
||||||
|
<TabsList class="h-9 p-0.5 bg-muted/20 border border-border/40 rounded-lg w-full flex">
|
||||||
|
<TabsTrigger value="nodes" class="flex-1 sm:flex-none px-3 h-8 text-xs gap-1.5 font-medium transition-all">
|
||||||
|
<Server class="w-3.5 h-3.5 opacity-70" />
|
||||||
|
<span>节点</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="sync" class="flex-1 sm:flex-none px-3 h-8 text-xs gap-1.5 font-medium transition-all">
|
||||||
|
<ArrowRightLeft class="w-3.5 h-3.5 opacity-70" />
|
||||||
|
<span>同步</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
-->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 内容区域 -->
|
||||||
|
<MasterList v-if="activeTab === 'nodes'" ref="masterListRef" :nodes="nodes" :loading="loading" :search-query="searchQuery" @refresh="fetchNodes" />
|
||||||
|
<!-- <SyncPanel v-if="activeTab === 'sync'" :nodes="nodes" /> -->
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Network } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'select', role: 'master' | 'child'): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col items-center justify-center py-6 gap-4">
|
||||||
|
<div class="text-center space-y-1.5">
|
||||||
|
<div class="inline-flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary mb-1">
|
||||||
|
<Network class="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<h2 class="text-lg font-bold tracking-tight">选择面板的互联角色</h2>
|
||||||
|
<p class="text-muted-foreground text-xs max-w-md mx-auto">
|
||||||
|
请根据集群架构分配互斥角色,避免循环嵌套。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 w-full max-w-3xl px-4 mt-2">
|
||||||
|
<!-- Master Card -->
|
||||||
|
<div class="border rounded-xl p-5 hover:border-amber-500 hover:ring-1 hover:ring-amber-500/30 cursor-pointer transition-all hover:shadow-sm bg-card space-y-2.5 group" @click="emit('select', 'master')">
|
||||||
|
<div class="h-10 w-10 rounded-lg bg-amber-500/10 flex items-center justify-center text-amber-500 group-hover:scale-110 transition-transform duration-300">
|
||||||
|
<Network class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-base font-semibold">我是主节点 (Master)</h3>
|
||||||
|
<p class="text-xs text-muted-foreground leading-relaxed">
|
||||||
|
集中监控其他面板的状态,并可无缝穿越到子节点进行管理。<strong class="font-medium text-foreground">即使子节点处于无公网 IP 的深层内网,主节点依然可以通过反向隧道直连穿透。</strong>选择此项后,您将可以生成专属密钥并添加多个子节点。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- Child Card -->
|
||||||
|
<div class="border rounded-xl p-5 hover:border-green-500 hover:ring-1 hover:ring-green-500/30 cursor-pointer transition-all hover:shadow-sm bg-card space-y-2.5 group" @click="emit('select', 'child')">
|
||||||
|
<div class="h-10 w-10 rounded-lg bg-green-500/10 flex items-center justify-center text-green-500 group-hover:scale-110 transition-transform duration-300">
|
||||||
|
<Network class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-base font-semibold">我是子节点 (Child)</h3>
|
||||||
|
<p class="text-xs text-muted-foreground leading-relaxed">
|
||||||
|
向主节点报告运行状态,并允许主节点穿越到本面板进行管理。<strong class="font-medium text-foreground">非常适合部署在家庭宽带或企业内网等无公网 IP 环境中。</strong>选择此项后,本机将主动连接到主节点建立安全的反向穿透隧道。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import * as interconnectApi from '@/api/interconnect'
|
||||||
|
import { api, type EnvVar, type Task } from '@/api'
|
||||||
|
import { Network, Search, HardDrive } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
nodes: interconnectApi.InterconnectNode[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const activeSyncType = ref<'task' | 'env'>('task')
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const syncing = ref(false)
|
||||||
|
|
||||||
|
// Data
|
||||||
|
const localTasks = ref<Task[]>([])
|
||||||
|
const localEnvs = ref<EnvVar[]>([])
|
||||||
|
|
||||||
|
// Selection
|
||||||
|
const selectedNodes = ref<string[]>([])
|
||||||
|
const selectedTasks = ref<string[]>([])
|
||||||
|
const selectedEnvsIds = ref<string[]>([])
|
||||||
|
|
||||||
|
// Search
|
||||||
|
const taskSearch = ref('')
|
||||||
|
const envSearch = ref('')
|
||||||
|
|
||||||
|
// Load local data
|
||||||
|
async function loadLocalData() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const taskRes = await api.tasks.list({ page: 1, page_size: 9999 })
|
||||||
|
localTasks.value = taskRes.data
|
||||||
|
|
||||||
|
const envRes = await api.env.list({ page: 1, page_size: 9999 })
|
||||||
|
localEnvs.value = envRes.data
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('获取本地数据失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadLocalData()
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredTasks = computed(() => {
|
||||||
|
if (!taskSearch.value) return localTasks.value
|
||||||
|
const kw = taskSearch.value.toLowerCase()
|
||||||
|
return localTasks.value.filter((t: Task) => t.name.toLowerCase().includes(kw) || t.command.toLowerCase().includes(kw))
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredEnvs = computed(() => {
|
||||||
|
if (!envSearch.value) return localEnvs.value
|
||||||
|
const kw = envSearch.value.toLowerCase()
|
||||||
|
return localEnvs.value.filter((e: EnvVar) => e.name.toLowerCase().includes(kw) || e.value.toLowerCase().includes(kw))
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleNode(nodeId: string) {
|
||||||
|
const index = selectedNodes.value.indexOf(nodeId)
|
||||||
|
if (index > -1) {
|
||||||
|
selectedNodes.value.splice(index, 1)
|
||||||
|
} else {
|
||||||
|
selectedNodes.value.push(nodeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAllNodes() {
|
||||||
|
if (selectedNodes.value.length === props.nodes.length && props.nodes.length > 0) {
|
||||||
|
selectedNodes.value = []
|
||||||
|
} else {
|
||||||
|
selectedNodes.value = props.nodes.map(n => n.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTask(id: string) {
|
||||||
|
const index = selectedTasks.value.indexOf(id)
|
||||||
|
if (index > -1) {
|
||||||
|
selectedTasks.value.splice(index, 1)
|
||||||
|
} else {
|
||||||
|
selectedTasks.value.push(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAllTasks() {
|
||||||
|
if (selectedTasks.value.length === filteredTasks.value.length && filteredTasks.value.length > 0) {
|
||||||
|
selectedTasks.value = []
|
||||||
|
} else {
|
||||||
|
selectedTasks.value = filteredTasks.value.map((t: Task) => t.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleEnv(id: string) {
|
||||||
|
const index = selectedEnvsIds.value.indexOf(id)
|
||||||
|
if (index > -1) {
|
||||||
|
selectedEnvsIds.value.splice(index, 1)
|
||||||
|
} else {
|
||||||
|
selectedEnvsIds.value.push(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAllEnvs() {
|
||||||
|
if (selectedEnvsIds.value.length === filteredEnvs.value.length && filteredEnvs.value.length > 0) {
|
||||||
|
selectedEnvsIds.value = []
|
||||||
|
} else {
|
||||||
|
selectedEnvsIds.value = filteredEnvs.value.map((e: EnvVar) => e.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSync() {
|
||||||
|
if (selectedNodes.value.length === 0) {
|
||||||
|
toast.warning('请先选择目标节点')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetNodeIds = selectedNodes.value
|
||||||
|
|
||||||
|
syncing.value = true
|
||||||
|
try {
|
||||||
|
if (activeSyncType.value === 'task') {
|
||||||
|
if (selectedTasks.value.length === 0) {
|
||||||
|
toast.warning('请先选择要同步的任务')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const tasksToSync = localTasks.value.filter((t: Task) => selectedTasks.value.includes(t.id))
|
||||||
|
const res = await interconnectApi.syncTask(targetNodeIds, tasksToSync)
|
||||||
|
showSyncResult(res)
|
||||||
|
} else {
|
||||||
|
if (selectedEnvsIds.value.length === 0) {
|
||||||
|
toast.warning('请先选择要同步的变量')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const envsToSync = localEnvs.value.filter((e: EnvVar) => selectedEnvsIds.value.includes(e.id))
|
||||||
|
const res = await interconnectApi.syncEnv(targetNodeIds, envsToSync)
|
||||||
|
showSyncResult(res)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('下发请求失败')
|
||||||
|
} finally {
|
||||||
|
syncing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSyncResult(res: any[]) {
|
||||||
|
const targetNodeIds = selectedNodes.value
|
||||||
|
const successCount = res.filter((r: any) => r.success).length
|
||||||
|
if (successCount === targetNodeIds.length) {
|
||||||
|
toast.success('全部分发成功')
|
||||||
|
} else {
|
||||||
|
toast.warning(`成功: ${successCount}, 失败: ${targetNodeIds.length - successCount}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col lg:flex-row gap-6 h-[calc(100vh-16rem)] min-h-[500px]">
|
||||||
|
<!-- 左侧:节点选择 -->
|
||||||
|
<div class="w-full lg:w-1/3 flex flex-col border rounded-lg bg-card overflow-hidden">
|
||||||
|
<div class="p-3 border-b bg-muted/20 flex items-center justify-between">
|
||||||
|
<h3 class="font-medium text-sm flex items-center gap-2">
|
||||||
|
<HardDrive class="w-4 h-4 text-muted-foreground" />
|
||||||
|
目标节点 ({{ selectedNodes.length }}/{{ nodes.length }})
|
||||||
|
</h3>
|
||||||
|
<Button variant="ghost" size="sm" class="h-6 text-xs px-2" @click="toggleAllNodes">
|
||||||
|
{{ selectedNodes.length === nodes.length && nodes.length > 0 ? '全不选' : '全选' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-y-auto p-2">
|
||||||
|
<div v-if="nodes.length === 0" class="text-center py-8 text-sm text-muted-foreground">
|
||||||
|
暂无子节点
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-1">
|
||||||
|
<div v-for="node in nodes" :key="node.id" class="flex items-center gap-3 p-2 hover:bg-muted/50 rounded-md cursor-pointer" @click="toggleNode(node.id)">
|
||||||
|
<Checkbox :checked="selectedNodes.includes(node.id)" @update:checked="toggleNode(node.id)" @click.stop />
|
||||||
|
<div class="flex flex-col flex-1 min-w-0">
|
||||||
|
<span class="text-sm font-medium truncate">{{ node.name }}</span>
|
||||||
|
<span class="text-xs text-muted-foreground truncate">{{ node.url }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧:数据选择 -->
|
||||||
|
<div class="w-full lg:w-2/3 flex flex-col border rounded-lg bg-card overflow-hidden">
|
||||||
|
<div class="p-3 border-b bg-muted/20 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
|
<div class="flex bg-muted p-1 rounded-md">
|
||||||
|
<button
|
||||||
|
class="px-3 py-1.5 text-xs font-medium rounded-sm transition-colors"
|
||||||
|
:class="activeSyncType === 'task' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'"
|
||||||
|
@click="activeSyncType = 'task'"
|
||||||
|
>
|
||||||
|
任务配置
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-3 py-1.5 text-xs font-medium rounded-sm transition-colors"
|
||||||
|
:class="activeSyncType === 'env' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'"
|
||||||
|
@click="activeSyncType = 'env'"
|
||||||
|
>
|
||||||
|
变量配置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="relative w-48 hidden sm:block">
|
||||||
|
<Search class="w-3.5 h-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input v-model="taskSearch" v-if="activeSyncType === 'task'" placeholder="搜索任务..." class="h-8 pl-7 text-xs" />
|
||||||
|
<Input v-model="envSearch" v-if="activeSyncType === 'env'" placeholder="搜索变量..." class="h-8 pl-7 text-xs" />
|
||||||
|
</div>
|
||||||
|
<Button class="h-8 gap-1.5" size="sm" @click="handleSync" :disabled="syncing">
|
||||||
|
<Network class="w-3.5 h-3.5" :class="{'animate-pulse': syncing}" />
|
||||||
|
{{ syncing ? '正在下发...' : '执行下发' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto p-2">
|
||||||
|
<!-- 任务列表 -->
|
||||||
|
<div v-if="activeSyncType === 'task'" class="space-y-1">
|
||||||
|
<div class="flex items-center justify-between px-2 py-1 mb-2">
|
||||||
|
<span class="text-xs text-muted-foreground">已选 {{ selectedTasks.length }} 项</span>
|
||||||
|
<Button variant="ghost" size="sm" class="h-6 text-xs px-2" @click="toggleAllTasks">
|
||||||
|
{{ selectedTasks.length === filteredTasks.length && filteredTasks.length > 0 ? '全不选' : '全选' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="text-center py-8 text-sm text-muted-foreground">加载中...</div>
|
||||||
|
<div v-else-if="filteredTasks.length === 0" class="text-center py-8 text-sm text-muted-foreground">暂无任务</div>
|
||||||
|
<div v-for="task in filteredTasks" :key="task.id" class="flex items-start gap-3 p-2 hover:bg-muted/50 rounded-md cursor-pointer" @click="toggleTask(task.id)">
|
||||||
|
<Checkbox class="mt-1" :checked="selectedTasks.includes(task.id)" @update:checked="toggleTask(task.id)" @click.stop />
|
||||||
|
<div class="flex flex-col flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm font-medium truncate">{{ task.name }}</span>
|
||||||
|
<span class="text-[10px] bg-muted px-1.5 py-0.5 rounded text-muted-foreground">{{ task.type }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-muted-foreground truncate font-mono mt-0.5">{{ task.command }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 变量列表 -->
|
||||||
|
<div v-if="activeSyncType === 'env'" class="space-y-1">
|
||||||
|
<div class="flex items-center justify-between px-2 py-1 mb-2">
|
||||||
|
<span class="text-xs text-muted-foreground">已选 {{ selectedEnvsIds.length }} 项</span>
|
||||||
|
<Button variant="ghost" size="sm" class="h-6 text-xs px-2" @click="toggleAllEnvs">
|
||||||
|
{{ selectedEnvsIds.length === filteredEnvs.length && filteredEnvs.length > 0 ? '全不选' : '全选' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="text-center py-8 text-sm text-muted-foreground">加载中...</div>
|
||||||
|
<div v-else-if="filteredEnvs.length === 0" class="text-center py-8 text-sm text-muted-foreground">暂无变量</div>
|
||||||
|
<div v-for="env in filteredEnvs" :key="env.id" class="flex items-start gap-3 p-2 hover:bg-muted/50 rounded-md cursor-pointer" @click="toggleEnv(env.id)">
|
||||||
|
<Checkbox class="mt-1" :checked="selectedEnvsIds.includes(env.id)" @update:checked="toggleEnv(env.id)" @click.stop />
|
||||||
|
<div class="flex flex-col flex-1 min-w-0">
|
||||||
|
<span class="text-sm font-medium truncate font-mono">{{ env.name }}</span>
|
||||||
|
<span class="text-xs text-muted-foreground truncate font-mono mt-0.5">{{ env.value }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { api } from '@/api'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
|
import RoleSelector from './components/RoleSelector.vue'
|
||||||
|
import MasterView from './components/MasterView.vue'
|
||||||
|
import ChildView from './components/ChildView.vue'
|
||||||
|
import { useEventBus } from '@vueuse/core'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
|
||||||
|
const interconnectRole = ref<'master' | 'child' | 'none'>('none')
|
||||||
|
const loadingRole = ref(true)
|
||||||
|
const roleBus = useEventBus<string>('interconnect-role-changed')
|
||||||
|
|
||||||
|
async function fetchRole() {
|
||||||
|
loadingRole.value = true
|
||||||
|
try {
|
||||||
|
const role = await api.settings.get('interconnect', 'interconnect_role') as string
|
||||||
|
interconnectRole.value = (role === 'master' || role === 'child') ? role : 'none'
|
||||||
|
} catch (error) {
|
||||||
|
interconnectRole.value = 'none'
|
||||||
|
} finally {
|
||||||
|
loadingRole.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setRole(role: 'master' | 'child' | 'none') {
|
||||||
|
try {
|
||||||
|
await api.settings.setSection('interconnect', { interconnect_role: role })
|
||||||
|
interconnectRole.value = role
|
||||||
|
roleBus.emit(role)
|
||||||
|
|
||||||
|
if (role === 'none') {
|
||||||
|
await api.settings.setSection('interconnect', {
|
||||||
|
interconnect_parent_url: '',
|
||||||
|
interconnect_parent_token: ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error('角色设置失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showCancelConfirm = ref(false)
|
||||||
|
|
||||||
|
function handleCancelRole() {
|
||||||
|
showCancelConfirm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmCancelRole() {
|
||||||
|
showCancelConfirm.value = false
|
||||||
|
await setRole('none')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchRole()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div v-if="loadingRole" class="h-[60vh] w-full flex flex-col items-center justify-center space-y-4 animate-in fade-in duration-300">
|
||||||
|
<div class="relative flex items-center justify-center">
|
||||||
|
<!-- 外圈渐变呼吸环 -->
|
||||||
|
<div class="absolute h-10 w-10 rounded-full border border-primary/25 animate-ping"></div>
|
||||||
|
<!-- 旋转环 -->
|
||||||
|
<div class="h-10 w-10 rounded-full border-2 border-primary/10 border-t-primary animate-spin"></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs font-medium text-muted-foreground/80 tracking-wider animate-pulse">正在载入配置...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 状态一:未选择角色 -->
|
||||||
|
<RoleSelector v-else-if="interconnectRole === 'none'" @select="setRole" />
|
||||||
|
|
||||||
|
<!-- 状态二:主节点视图 -->
|
||||||
|
<MasterView v-else-if="interconnectRole === 'master'" @cancel="handleCancelRole" />
|
||||||
|
|
||||||
|
<!-- 状态三:子节点视图 -->
|
||||||
|
<ChildView v-else-if="interconnectRole === 'child'" @cancel="handleCancelRole" />
|
||||||
|
|
||||||
|
<AlertDialog :open="showCancelConfirm" @update:open="showCancelConfirm = $event">
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>确认取消角色?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
<template v-if="interconnectRole === 'master'">
|
||||||
|
切换为子节点或取消角色将导致所有连接的子节点失去控制,是否继续?
|
||||||
|
</template>
|
||||||
|
<template v-else-if="interconnectRole === 'child'">
|
||||||
|
取消配置将断开与主节点的连接,是否继续?
|
||||||
|
</template>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction @click="confirmCancelRole" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">确认</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -41,7 +41,6 @@ watch(activeTab, (newVal) => {
|
|||||||
|
|
||||||
const stats = ref<MonitorStats | null>(null)
|
const stats = ref<MonitorStats | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
let timer: any = null
|
|
||||||
|
|
||||||
// --- 时序数据池 ---
|
// --- 时序数据池 ---
|
||||||
const historySize = 60 // 保存最近60次请求(约3分钟@3s)
|
const historySize = 60 // 保存最近60次请求(约3分钟@3s)
|
||||||
@@ -55,23 +54,25 @@ const runningData = ref<number[]>([])
|
|||||||
const queueData = ref<number[]>([])
|
const queueData = ref<number[]>([])
|
||||||
let lastPauseNs = 0
|
let lastPauseNs = 0
|
||||||
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
let ws: EventSource | null = null
|
||||||
|
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:'
|
||||||
const baseUrl = (window as any).__BASE_URL__ || ''
|
const baseUrl = (window as any).__BASE_URL__ || ''
|
||||||
const apiVersion = (window as any).__API_VERSION__ || '/api/v1'
|
const apiVersion = (window as any).__API_VERSION__ || '/api/v1'
|
||||||
|
|
||||||
const connectWS = () => {
|
const connectWS = () => {
|
||||||
if (timer) return
|
if (ws) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
const host = window.location.host
|
const host = window.location.host
|
||||||
const wsUrl = `${protocol}//${host}${baseUrl}${apiVersion}/monitor/ws`
|
const sseUrl = `${protocol}//${host}${baseUrl}${apiVersion}/monitor/sse`
|
||||||
|
|
||||||
timer = new WebSocket(wsUrl)
|
ws = new EventSource(sseUrl, { withCredentials: true })
|
||||||
|
|
||||||
timer.onopen = () => {
|
ws.onopen = () => {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
timer.onmessage = (event: MessageEvent) => {
|
ws.onmessage = (event: MessageEvent) => {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(event.data)
|
const payload = JSON.parse(event.data)
|
||||||
if (payload.code === 200 && payload.data) {
|
if (payload.code === 200 && payload.data) {
|
||||||
@@ -110,24 +111,16 @@ const connectWS = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
timer.onclose = () => {
|
ws.onerror = () => {
|
||||||
loading.value = false
|
|
||||||
timer = null
|
|
||||||
// 断线后2秒自动重连
|
|
||||||
setTimeout(connectWS, 2000)
|
|
||||||
}
|
|
||||||
|
|
||||||
timer.onerror = () => {
|
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
// EventSource 会自动指数退避重连,无需我们手动控制
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const disconnectWS = () => {
|
const disconnectWS = () => {
|
||||||
if (timer) {
|
if (ws) {
|
||||||
// 置空 onclose 避免触发自动重连
|
ws.close()
|
||||||
timer.onclose = null
|
ws = null
|
||||||
timer.close()
|
|
||||||
timer = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -120,12 +120,12 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8052',
|
target: process.env.VITE_PROXY_TARGET || 'http://localhost:8052',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
ws: true
|
ws: true
|
||||||
},
|
},
|
||||||
'/openapi': {
|
'/openapi': {
|
||||||
target: 'http://localhost:8052',
|
target: process.env.VITE_PROXY_TARGET || 'http://localhost:8052',
|
||||||
changeOrigin: true
|
changeOrigin: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user