feat: add openconnect

This commit is contained in:
duorameng
2026-06-25 20:06:38 +08:00
parent fac1582fe0
commit 90f06de298
49 changed files with 3854 additions and 969 deletions
+21
View File
@@ -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
+1 -144
View File
@@ -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
+2 -596
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -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
}) })
+22
View File
@@ -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
}
+31 -111
View File
@@ -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,
+39 -3
View File
@@ -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, "保存成功")
} }
+82
View File
@@ -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, &param)
} else {
savedTask = tc.taskService.CreateTask(&param)
// 如果原始有 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、标签、类型筛选
+12 -1
View File
@@ -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()
} }
+1
View File
@@ -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 {
+69
View File
@@ -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)
}
+42
View File
@@ -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, "请先登录")
+133
View File
@@ -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()
}
}
+32
View File
@@ -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"
}
+35 -3
View File
@@ -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)
}
}
+4 -8
View File
@@ -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()
} })
} }
+3 -1
View File
@@ -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),
} }
} }
+2
View File
@@ -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()
+9 -19
View File
@@ -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,24 +287,16 @@ 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 ticker.Stop()
// 启动时,先将所有 "online" 状态的 Agent 重置为 "offline"
// 因为 WebSocket 连接在应用启动时是空的,所有 Agent 客观上都是离线状态
// 等它们重新连接上来后,会变为 "online"
NewAgentService().ResetAllAgentsToOffline()
for range ticker.C {
func() {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
logger.Errorf("[AgentWS] cleanupLoop panic: %v", r) logger.Errorf("[AgentWS] cleanupLoop panic: %v", r)
} }
}() }()
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock()
now := time.Now() now := time.Now()
// 清理超时连接 // 清理超时连接
@@ -320,14 +317,11 @@ func (m *AgentWSManager) cleanupLoop() {
} }
// 定期清理数据库中的过期状态(处理服务重启或异常终止的情况) // 定期清理数据库中的过期状态(处理服务重启或异常终止的情况)
// 有些 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 分钟未活动) // 清理过期的限流记录(超过 10 分钟未活动)
for ip, lastAttempt := range m.ipLastAttempt { for ip, lastAttempt := range m.ipLastAttempt {
if now.Sub(lastAttempt) > 10*time.Minute { if now.Sub(lastAttempt) > 10*time.Minute {
@@ -339,10 +333,6 @@ func (m *AgentWSManager) cleanupLoop() {
} }
} }
} }
m.mu.Unlock()
}()
}
} }
// Close 关闭连接 // Close 关闭连接
+85
View File
@@ -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
}
+132
View File
@@ -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()
}
+254
View File
@@ -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)
}
}
+87
View File
@@ -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)
}
+109
View File
@@ -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)
}
}
+117
View File
@@ -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()
}
}
}
+96
View File
@@ -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)
}
+91
View File
@@ -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]
}
+48
View File
@@ -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)
}
+21
View File
@@ -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>
+20 -2
View File
@@ -65,9 +65,24 @@ 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
localStorage.removeItem('site_settings_cache')
if (id) {
localStorage.setItem('activeInterconnectNodeId', id)
document.cookie = `active_interconnect_node_id=${id}; path=/; max-age=${7 * 24 * 3600}`
} 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, ...options,
credentials: 'include', // 携带 Cookie credentials: 'include', // 携带 Cookie
headers: { headers: {
@@ -75,6 +90,9 @@ async function request<T>(url: string, options?: RequestInit): Promise<T> {
...options?.headers ...options?.headers
} }
}) })
} catch (err: any) {
throw err
}
const json: ApiResponse<T> = await res.json() const json: ApiResponse<T> = await res.json()
+69
View File
@@ -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>
+118
View File
@@ -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>
+11 -3
View File
@@ -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)
}
} }
} }
+5
View File
@@ -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
+5 -2
View File
@@ -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>
+1
View File
@@ -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') }
] ]
}, },
+1
View File
@@ -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()
+1 -4
View File
@@ -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>
+111
View File
@@ -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>
+13 -20
View File
@@ -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
View File
@@ -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
} }
} }