修复流量统计

This commit is contained in:
qaq
2025-06-27 11:56:59 +08:00
parent a5e7062c15
commit a5d7be8405
24 changed files with 431 additions and 685 deletions
+2 -1
View File
@@ -103,7 +103,7 @@ require (
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/vishvananda/netlink v1.1.0 // indirect
github.com/vishvananda/netlink v1.1.1-0.20211118161826-650dca95af54 // indirect
github.com/vishvananda/netns v0.0.4 // indirect
github.com/vulcand/predicate v1.2.0 // indirect
github.com/xtaci/kcp-go/v5 v5.6.5 // indirect
@@ -133,6 +133,7 @@ require (
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 // indirect
)
replace github.com/go-gost/x => ./x
+5 -4
View File
@@ -266,9 +266,9 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/vishvananda/netlink v1.1.0 h1:1iyaYNBLmP6L0220aDnYQpo1QEV4t4hJ+xEEhhJH8j0=
github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE=
github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU=
github.com/vishvananda/netlink v1.1.1-0.20211118161826-650dca95af54 h1:8mhqcHPqTMhSPoslhGYihEgSfc77+7La1P6kiB6+9So=
github.com/vishvananda/netlink v1.1.1-0.20211118161826-650dca95af54/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho=
github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/vulcand/predicate v1.2.0 h1:uFsW1gcnnR7R+QTID+FVcs0sSYlIGntoGOTb3rQJt50=
@@ -340,9 +340,10 @@ golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+5 -12
View File
@@ -14,8 +14,8 @@ import (
"github.com/go-gost/core/logger"
xlogger "github.com/go-gost/x/logger"
"github.com/go-gost/x/service"
"github.com/go-gost/x/socket"
"github.com/go-gost/x/traffic"
"github.com/judwhite/go-svc"
)
@@ -109,28 +109,21 @@ func main() {
// 加载配置文件
config, err := LoadConfig("config.json")
if err != nil {
fmt.Printf("❌ 配置加载失败: %v\n", err)
fmt.Println("❌ 配置加载失败: %v\n", err)
fmt.Println("请确保当前目录存在 config.json 文件")
os.Exit(1)
}
fmt.Printf("✅ 配置加载成功 - addr: %s", config.Addr)
fmt.Println("✅ 配置加载成功 - addr: %s", config.Addr)
log := xlogger.NewLogger()
logger.SetDefault(log)
trafficMgr := traffic.GetGlobalManager()
defer trafficMgr.Close()
fmt.Println("✅ 使用内存流量管理器")
logger.Default().Info("Using memory traffic manager")
traffic.SetHTTPReportURL(config.Addr, config.Secret)
traffic.StartTrafficReporter(trafficMgr)
wsReporter := socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret)
defer wsReporter.Stop()
service.SetHTTPReportURL(config.Addr, config.Secret)
p := &program{}
if err := svc.Run(p); err != nil {
+12
View File
@@ -8,6 +8,7 @@ import (
"os/signal"
"strings"
"syscall"
"time"
"github.com/go-gost/core/auth"
"github.com/go-gost/core/logger"
@@ -21,6 +22,7 @@ import (
xmetrics "github.com/go-gost/x/metrics"
metrics "github.com/go-gost/x/metrics/service"
"github.com/go-gost/x/registry"
xservice "github.com/go-gost/x/service"
"github.com/judwhite/go-svc"
)
@@ -73,6 +75,16 @@ func (p *program) Start() error {
p.cancel = cancel
go p.reload(ctx)
// 延迟启动配置定时上报器(等待30秒让WebSocket连接稳定)
go func() {
select {
case <-time.After(10 * time.Second):
xservice.StartConfigReporter(ctx)
case <-ctx.Done():
return
}
}()
return nil
}
+33 -4
View File
@@ -12,6 +12,7 @@ import (
"github.com/go-gost/core/hop"
"github.com/go-gost/core/listener"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/observer"
"github.com/go-gost/core/observer/stats"
"github.com/go-gost/core/recorder"
"github.com/go-gost/core/selector"
@@ -112,6 +113,9 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
var limiterCleanupInterval time.Duration
var limiterScope string
enableStats := true
observerPeriod = 5 * time.Second
if cfg.Metadata != nil {
md := metadata.NewMetadata(cfg.Metadata)
ppv = mdutil.GetInt(md, parsing.MDKeyProxyProtocol)
@@ -129,10 +133,13 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
postDown = mdutil.GetStrings(md, parsing.MDKeyPostDown)
ignoreChain = mdutil.GetBool(md, parsing.MDKeyIgnoreChain)
if mdutil.GetBool(md, parsing.MDKeyEnableStats) {
pStats = xstats.NewStats(mdutil.GetBool(md, parsing.MDKeyObserverResetTraffic))
if md.IsExists(parsing.MDKeyEnableStats) {
enableStats = mdutil.GetBool(md, parsing.MDKeyEnableStats)
}
if period := mdutil.GetDuration(md, parsing.MDKeyObserverPeriod, "observePeriod"); period > 0 {
observerPeriod = period
}
observerPeriod = mdutil.GetDuration(md, parsing.MDKeyObserverPeriod, "observePeriod")
netnsIn = mdutil.GetString(md, parsing.MDKeyNetns)
netnsOut = mdutil.GetString(md, parsing.MDKeyNetnsOut)
@@ -144,6 +151,17 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
limiterScope = mdutil.GetString(md, parsing.MDKeyLimiterScope)
}
if enableStats {
resetTraffic := true
if cfg.Metadata != nil {
md := metadata.NewMetadata(cfg.Metadata)
if md.IsExists(parsing.MDKeyObserverResetTraffic) {
resetTraffic = mdutil.GetBool(md, parsing.MDKeyObserverResetTraffic)
}
}
pStats = xstats.NewStats(resetTraffic)
}
listenerLogger := serviceLogger.WithFields(map[string]any{
"kind": "listener",
})
@@ -329,6 +347,17 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
return nil, err
}
var observer observer.Observer
// 如果服务名以_tls结尾,则不启用观察器
if strings.HasSuffix(cfg.Name, "_tls") {
observer = nil
fmt.Println("服务名以_tls结尾,跳过观察器启用")
} else if cfg.Observer != "" {
observer = registry.ObserverRegistry().Get(cfg.Observer)
} else if pStats != nil {
observer = registry.ObserverRegistry().Get("console")
}
s := xservice.NewService(cfg.Name, ln, h,
xservice.AdmissionOption(xadmission.AdmissionGroup(admissions...)),
xservice.PreUpOption(preUp),
@@ -337,7 +366,7 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
xservice.PostDownOption(postDown),
xservice.RecordersOption(recorders...),
xservice.StatsOption(pStats),
xservice.ObserverOption(registry.ObserverRegistry().Get(cfg.Observer)),
xservice.ObserverOption(observer),
xservice.ObserverPeriodOption(observerPeriod),
xservice.LoggerOption(serviceLogger),
)
+16 -75
View File
@@ -24,7 +24,6 @@ import (
stats_wrapper "github.com/go-gost/x/observer/stats/wrapper"
xrecorder "github.com/go-gost/x/recorder"
"github.com/go-gost/x/registry"
"github.com/go-gost/x/traffic"
)
func init() {
@@ -34,12 +33,11 @@ func init() {
}
type forwardHandler struct {
hop hop.Hop
md metadata
options handler.Options
recorder recorder.RecorderObject
certPool tls_util.CertPool
trafficManager traffic.Manager
hop hop.Hop
md metadata
options handler.Options
recorder recorder.RecorderObject
certPool tls_util.CertPool
}
func NewHandler(opts ...handler.Option) handler.Handler {
@@ -49,8 +47,7 @@ func NewHandler(opts ...handler.Option) handler.Handler {
}
return &forwardHandler{
options: options,
trafficManager: traffic.GetGlobalManager(),
options: options,
}
}
@@ -103,75 +100,23 @@ func (h *forwardHandler) Handle(ctx context.Context, conn net.Conn, opts ...hand
ro.ClientIP = h
}
log := h.options.Logger.WithFields(map[string]any{
"remote": conn.RemoteAddr().String(),
"local": conn.LocalAddr().String(),
"sid": ro.SID,
"client": ro.ClientIP,
})
network := "tcp"
if _, ok := conn.(net.PacketConn); ok {
network = "udp"
}
ro.Network = network
connStats := xstats.NewStats(false) // false表示不在Get时清零
ccStats := xstats.NewStats(false) // false表示不在Get时清零
conn = stats_wrapper.WrapConn(conn, connStats)
// 启动定期上报流量的goroutine
trafficCtx, trafficCancel := context.WithCancel(context.Background())
defer trafficCancel()
if h.trafficManager != nil {
go func() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
// 记录上次的流量值
var lastConnOutput, lastConnInput, lastCCOutput, lastCCInput uint64
for {
select {
case <-ticker.C:
// 获取当前流量统计(不清零)
connOutput := connStats.Get(stats.KindInputBytes)
connInput := connStats.Get(stats.KindOutputBytes)
ccOutput := ccStats.Get(stats.KindOutputBytes)
ccInput := ccStats.Get(stats.KindInputBytes)
// 计算增量
connOutputDelta := connOutput - lastConnOutput
connInputDelta := connInput - lastConnInput
ccOutputDelta := ccOutput - lastCCOutput
ccInputDelta := ccInput - lastCCInput
if connInputDelta > 0 || connOutputDelta > 0 {
h.trafficManager.RecordTraffic(ctx, ro.Service+":conn", int64(connInputDelta), int64(connOutputDelta))
}
if ccOutputDelta > 0 || ccInputDelta > 0 {
h.trafficManager.RecordTraffic(ctx, ro.Service+":cc", int64(ccOutputDelta), int64(ccInputDelta))
}
// 更新上次的值
lastConnOutput = connOutput
lastConnInput = connInput
lastCCOutput = ccOutput
lastCCInput = ccInput
case <-trafficCtx.Done():
return
}
}
}()
}
pStats := xstats.Stats{}
conn = stats_wrapper.WrapConn(conn, &pStats)
defer func() {
if err != nil {
ro.Err = err.Error()
}
ro.InputBytes = pStats.Get(stats.KindInputBytes)
ro.OutputBytes = pStats.Get(stats.KindOutputBytes)
ro.Duration = time.Since(start)
// 流量统计已经在定期上报中处理,这里不需要再次记录
}()
if !h.checkRateLimit(conn.RemoteAddr()) {
@@ -196,9 +141,6 @@ func (h *forwardHandler) Handle(ctx context.Context, conn net.Conn, opts ...hand
var buf bytes.Buffer
cc, err := h.options.Router.Dial(ctxvalue.ContextWithBuffer(ctx, &buf), "tcp", address)
ro.Route = buf.String()
cc = stats_wrapper.WrapConn(cc, ccStats)
return cc, err
}
sniffer := &forwarder.Sniffer{
@@ -223,7 +165,6 @@ func (h *forwardHandler) Handle(ctx context.Context, conn net.Conn, opts ...hand
forwarder.WithBypass(h.options.Bypass),
forwarder.WithHTTPKeepalive(h.md.httpKeepalive),
forwarder.WithRecorderObject(ro),
forwarder.WithLog(log),
)
case sniffing.ProtoTLS:
return sniffer.HandleTLS(ctx, conn,
@@ -231,14 +172,15 @@ func (h *forwardHandler) Handle(ctx context.Context, conn net.Conn, opts ...hand
forwarder.WithHop(h.hop),
forwarder.WithBypass(h.options.Bypass),
forwarder.WithRecorderObject(ro),
forwarder.WithLog(log),
)
}
}
target := &chain.Node{}
if h.hop != nil {
target = h.hop.Select(ctx, hop.ProtocolSelectOption(proto))
target = h.hop.Select(ctx,
hop.ProtocolSelectOption(proto),
)
}
if target == nil {
err := errors.New("node not available")
@@ -264,6 +206,8 @@ func (h *forwardHandler) Handle(ctx context.Context, conn net.Conn, opts ...hand
cc, err := h.options.Router.Dial(ctxvalue.ContextWithBuffer(ctx, &buf), network, addr)
ro.Route = buf.String()
if err != nil {
// TODO: the router itself may be failed due to the failed node in the router,
// the dead marker may be a wrong operation.
if marker := target.Marker(); marker != nil {
marker.Mark()
}
@@ -272,9 +216,6 @@ func (h *forwardHandler) Handle(ctx context.Context, conn net.Conn, opts ...hand
if marker := target.Marker(); marker != nil {
marker.Reset()
}
cc = stats_wrapper.WrapConn(cc, ccStats)
defer cc.Close()
xnet.Transport(conn, cc)
+7 -8
View File
@@ -57,21 +57,20 @@ func (s *Stats) Get(kind stats.Kind) uint64 {
case stats.KindCurrentConns:
return s.currentConns.Load()
case stats.KindInputBytes:
if s.resetTraffic {
return s.inputBytes.Swap(0)
}
return s.inputBytes.Load()
return s.inputBytes.Load() // 只获取,不自动清零
case stats.KindOutputBytes:
if s.resetTraffic {
return s.outputBytes.Swap(0)
}
return s.outputBytes.Load()
return s.outputBytes.Load() // 只获取,不自动清零
case stats.KindTotalErrs:
return s.totalErrs.Load()
}
return 0
}
func (s *Stats) ResetTraffic(reportedInputBytes, reportedOutputBytes uint64) {
s.inputBytes.Store(reportedInputBytes)
s.outputBytes.Store(reportedOutputBytes)
}
func (s *Stats) Reset() {
s.updated.Store(false)
s.totalConns.Store(0)
+2 -1
View File
@@ -35,5 +35,6 @@ func (w *observerWrapper) Observe(ctx context.Context, events []observer.Event,
if v == nil {
return nil
}
return v.Observe(ctx, events, opts...)
err := v.Observe(ctx, events, opts...)
return err
}
+50 -16
View File
@@ -338,31 +338,65 @@ func (s *defaultService) observeStats(ctx context.Context) {
for {
select {
case <-ticker.C:
// First, try to send any pending events
if len(events) > 0 {
if err := s.options.observer.Observe(ctx, events); err == nil {
events = nil
}
break
continue
}
st := s.status.Stats()
if st == nil || !st.IsUpdated() {
break
if st == nil {
continue
}
evs := []observer.Event{
xstats.StatsEvent{
Kind: "service",
Service: s.name,
TotalConns: st.Get(stats.KindTotalConns),
CurrentConns: st.Get(stats.KindCurrentConns),
InputBytes: st.Get(stats.KindInputBytes),
OutputBytes: st.Get(stats.KindOutputBytes),
TotalErrs: st.Get(stats.KindTotalErrs),
},
}
if err := s.options.observer.Observe(ctx, evs); err != nil {
events = evs
isUpdated := st.IsUpdated()
if isUpdated {
inputBytes := st.Get(stats.KindInputBytes)
outputBytes := st.Get(stats.KindOutputBytes)
evs := []observer.Event{
xstats.StatsEvent{
Kind: "service",
Service: s.name,
TotalConns: st.Get(stats.KindTotalConns),
CurrentConns: st.Get(stats.KindCurrentConns),
InputBytes: inputBytes,
OutputBytes: outputBytes,
TotalErrs: st.Get(stats.KindTotalErrs),
},
}
if outputBytes > 0 || inputBytes > 0 {
reportItems := []TrafficReportItem{
{
N: s.name,
T: "cc",
U: int64(outputBytes),
D: int64(inputBytes),
},
{
N: s.name,
T: "conn",
U: int64(inputBytes),
D: int64(outputBytes),
},
}
success, err := sendTrafficReport(ctx, reportItems)
if err != nil {
fmt.Printf("发送流量报告失败: %v", err)
} else if success {
if xstats, ok := st.(*xstats.Stats); ok {
xstats.ResetTraffic(st.Get(stats.KindInputBytes)-inputBytes, st.Get(stats.KindOutputBytes)-outputBytes)
}
}
}
if err := s.options.observer.Observe(ctx, evs); err != nil {
fmt.Printf("发送观察器事件失败: %v", err)
events = evs
}
}
case <-ctx.Done():
+226
View File
@@ -0,0 +1,226 @@
package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/go-gost/core/observer/stats"
"github.com/go-gost/x/config"
"github.com/go-gost/x/registry"
)
var httpReportURL string
var configReportURL string
// TrafficReportItem 流量报告项(压缩格式)
type TrafficReportItem struct {
N string `json:"n"` // 服务名(name缩写)
T string `json:"t"` // 连接类型:conn, cc(type缩写)
U int64 `json:"u"` // 上行流量(up缩写)
D int64 `json:"d"` // 下行流量(down缩写)
}
func SetHTTPReportURL(addr string, secret string) {
httpReportURL = "http://" + addr + "/flow/upload?secret=" + secret
configReportURL = "http://" + addr + "/flow/config?secret=" + secret
}
// sendTrafficReport 发送流量报告到HTTP接口
func sendTrafficReport(ctx context.Context, reportItems []TrafficReportItem) (bool, error) {
jsonData, err := json.Marshal(reportItems)
if err != nil {
return false, fmt.Errorf("序列化报告数据失败: %v", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", httpReportURL, bytes.NewBuffer(jsonData))
if err != nil {
return false, fmt.Errorf("创建HTTP请求失败: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "GOST-Traffic-Reporter/1.0")
client := &http.Client{
Timeout: 5 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
return false, fmt.Errorf("发送HTTP请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false, fmt.Errorf("HTTP响应错误: %d %s", resp.StatusCode, resp.Status)
}
// 读取响应内容
var responseBytes bytes.Buffer
_, err = responseBytes.ReadFrom(resp.Body)
if err != nil {
return false, fmt.Errorf("读取响应内容失败: %v", err)
}
responseText := strings.TrimSpace(responseBytes.String())
// 检查响应是否为"ok"
if responseText == "ok" {
return true, nil
} else {
return false, fmt.Errorf("服务器响应: %s (期望: ok)", responseText)
}
}
// sendConfigReport 发送配置报告到HTTP接口
func sendConfigReport(ctx context.Context) (bool, error) {
if configReportURL == "" {
return false, fmt.Errorf("配置上报URL未设置")
}
// 获取配置数据
configData, err := getConfigData()
if err != nil {
return false, fmt.Errorf("获取配置数据失败: %v", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", configReportURL, bytes.NewBuffer(configData))
if err != nil {
return false, fmt.Errorf("创建HTTP请求失败: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "GOST-Config-Reporter/1.0")
client := &http.Client{
Timeout: 10 * time.Second, // 配置上报可以稍长一些
}
resp, err := client.Do(req)
if err != nil {
return false, fmt.Errorf("发送HTTP请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false, fmt.Errorf("HTTP响应错误: %d %s", resp.StatusCode, resp.Status)
}
// 读取响应内容
var responseBytes bytes.Buffer
_, err = responseBytes.ReadFrom(resp.Body)
if err != nil {
return false, fmt.Errorf("读取响应内容失败: %v", err)
}
responseText := strings.TrimSpace(responseBytes.String())
// 检查响应是否为"ok"
if responseText == "ok" {
return true, nil
} else {
return false, fmt.Errorf("服务器响应: %s (期望: ok)", responseText)
}
}
// StartConfigReporter 启动配置定时上报器(每10分钟上报一次)
func StartConfigReporter(ctx context.Context) {
if configReportURL == "" {
fmt.Printf("⚠️ 配置上报URL未设置,跳过定时上报\n")
return
}
fmt.Printf("🚀 配置定时上报器已启动,每10分钟上报一次(WebSocket连接稳定后启动)\n")
// 创建10分钟定时器
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
// 立即执行一次配置上报
go func() {
success, err := sendConfigReport(ctx)
if err != nil {
fmt.Printf("❌ 初始配置上报失败: %v\n", err)
} else if success {
fmt.Printf("✅ 初始配置上报成功\n")
}
}()
// 定时上报循环
for {
select {
case <-ticker.C:
go func() {
success, err := sendConfigReport(ctx)
if err != nil {
fmt.Printf("❌ 定时配置上报失败: %v\n", err)
} else if success {
fmt.Printf("✅ 定时配置上报成功\n")
}
}()
case <-ctx.Done():
fmt.Printf("⏹️ 配置定时上报器已停止\n")
return
}
}
}
// serviceStatus 接口定义
type serviceStatus interface {
Status() *Status
}
// getConfigResponse 配置响应结构
type getConfigResponse struct {
Config *config.Config `json:"config"`
}
// getConfigData 获取配置数据(避免循环依赖)
func getConfigData() ([]byte, error) {
config.OnUpdate(func(c *config.Config) error {
for _, svc := range c.Services {
if svc == nil {
continue
}
s := registry.ServiceRegistry().Get(svc.Name)
ss, ok := s.(serviceStatus)
if ok && ss != nil {
status := ss.Status()
svc.Status = &config.ServiceStatus{
CreateTime: status.CreateTime().Unix(),
State: string(status.State()),
}
if st := status.Stats(); st != nil {
svc.Status.Stats = &config.ServiceStats{
TotalConns: st.Get(stats.KindTotalConns),
CurrentConns: st.Get(stats.KindCurrentConns),
TotalErrs: st.Get(stats.KindTotalErrs),
InputBytes: st.Get(stats.KindInputBytes),
OutputBytes: st.Get(stats.KindOutputBytes),
}
}
for _, ev := range status.Events() {
if !ev.Time.IsZero() {
svc.Status.Events = append(svc.Status.Events, config.ServiceEvent{
Time: ev.Time.Unix(),
Msg: ev.Message,
})
}
}
}
}
return nil
})
var resp getConfigResponse
resp.Config = config.Global()
buf := &bytes.Buffer{}
resp.Config.Write(buf, "json")
return buf.Bytes(), nil
}
-58
View File
@@ -1,13 +1,9 @@
package socket
import (
"bytes"
"os"
"github.com/go-gost/core/observer/stats"
"github.com/go-gost/x/config"
"github.com/go-gost/x/registry"
"github.com/go-gost/x/service"
)
func saveConfig() {
@@ -27,57 +23,3 @@ func saveConfig() {
return
}
type serviceStatus interface {
Status() *service.Status
}
type getConfigResponse struct {
Config *config.Config
}
func getConfig() ([]byte, error) {
config.OnUpdate(func(c *config.Config) error {
for _, svc := range c.Services {
if svc == nil {
continue
}
s := registry.ServiceRegistry().Get(svc.Name)
ss, ok := s.(serviceStatus)
if ok && ss != nil {
status := ss.Status()
svc.Status = &config.ServiceStatus{
CreateTime: status.CreateTime().Unix(),
State: string(status.State()),
}
if st := status.Stats(); st != nil {
svc.Status.Stats = &config.ServiceStats{
TotalConns: st.Get(stats.KindTotalConns),
CurrentConns: st.Get(stats.KindCurrentConns),
TotalErrs: st.Get(stats.KindTotalErrs),
InputBytes: st.Get(stats.KindInputBytes),
OutputBytes: st.Get(stats.KindOutputBytes),
}
}
for _, ev := range status.Events() {
if !ev.Time.IsZero() {
svc.Status.Events = append(svc.Status.Events, config.ServiceEvent{
Time: ev.Time.Unix(),
Msg: ev.Message,
})
}
}
}
}
return nil
})
var resp getConfigResponse
resp.Config = config.Global()
buf := &bytes.Buffer{}
resp.Config.Write(buf, "json")
return buf.Bytes(), nil
}
+8 -124
View File
@@ -160,9 +160,6 @@ func (w *WebSocketReporter) connect() error {
w.conn = conn
w.connected = true
// 设置最大消息大小为 10MB (默认是 1024 * 1024)
w.conn.SetReadLimit(10 * 1024 * 1024)
// 设置关闭处理器来检测连接状态
w.conn.SetCloseHandler(func(code int, text string) error {
w.connected = false
@@ -185,9 +182,6 @@ func (w *WebSocketReporter) handleConnection() {
// 启动消息接收goroutine
go w.receiveMessages()
// 启动配置上报goroutine
go w.reportConfig()
// 主发送循环
ticker := time.NewTicker(w.pingInterval)
defer ticker.Stop()
@@ -347,14 +341,14 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
// routeCommand 路由命令到对应的处理函数
func (w *WebSocketReporter) routeCommand(cmd CommandMessage) {
jsonBytes, errs := json.MarshalIndent(cmd, "", " ")
if errs != nil {
fmt.Println("Error marshaling JSON:", errs)
return
}
// 打印 JSON 字符串
fmt.Println("🔔 收到命令: ", string(jsonBytes))
//jsonBytes, errs := json.MarshalIndent(cmd, "", " ")
//if errs != nil {
// fmt.Println("Error marshaling JSON:", errs)
// return
//}
//
//// 打印 JSON 字符串
//fmt.Println("🔔 收到命令: ", string(jsonBytes))
var err error
var response CommandResponse
@@ -674,116 +668,6 @@ func (w *WebSocketReporter) handleCall(data interface{}) error {
return nil
}
// reportConfig 定时上报配置信息
func (w *WebSocketReporter) reportConfig() {
// 立即发送一次配置
w.sendConfigReport()
// 启动定时器
ticker := time.NewTicker(w.configInterval)
defer ticker.Stop()
for {
select {
case <-w.ctx.Done():
return
case <-ticker.C:
if w.connected {
w.sendConfigReport()
}
}
}
}
// sendConfigReport 发送配置报告
func (w *WebSocketReporter) sendConfigReport() {
if w.conn == nil || !w.connected {
return
}
// 获取配置数据
configData, err := getConfig()
if err != nil {
fmt.Printf("❌ 获取配置失败: %v\n", err)
return
}
// 检查数据大小,如果超过1MB则压缩
if len(configData) > 1024*1024 {
fmt.Printf("📦 配置数据较大 (%.2f MB),进行压缩处理\n", float64(len(configData))/(1024*1024))
// 压缩配置数据
var compressedBuf bytes.Buffer
gzipWriter := gzip.NewWriter(&compressedBuf)
if _, err := gzipWriter.Write(configData); err != nil {
fmt.Printf("❌ 压缩配置数据失败: %v\n", err)
return
}
gzipWriter.Close()
// 构建压缩后的配置报告消息
configMsg := struct {
Type string `json:"type"`
Compressed bool `json:"compressed"`
Data []byte `json:"data"`
}{
Type: "config_report",
Compressed: true,
Data: compressedBuf.Bytes(),
}
// 转换为JSON
jsonData, err := json.Marshal(configMsg)
if err != nil {
fmt.Printf("❌ 序列化压缩配置报告失败: %v\n", err)
return
}
fmt.Printf("✅ 压缩后大小: %.2f MB -> %.2f MB (压缩率: %.1f%%)\n",
float64(len(configData))/(1024*1024),
float64(len(jsonData))/(1024*1024),
100.0-float64(len(jsonData))/float64(len(configData))*100)
// 设置写入超时
w.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := w.conn.WriteMessage(websocket.TextMessage, jsonData); err != nil {
fmt.Printf("❌ 发送压缩配置报告失败: %v\n", err)
w.connected = false
return
}
} else {
// 数据较小,直接发送
// 构建配置报告消息
configMsg := struct {
Type string `json:"type"`
Compressed bool `json:"compressed"`
Data interface{} `json:"data"`
}{
Type: "config_report",
Compressed: false,
Data: json.RawMessage(configData),
}
// 转换为JSON
jsonData, err := json.Marshal(configMsg)
if err != nil {
fmt.Printf("❌ 序列化配置报告失败: %v\n", err)
return
}
// 设置写入超时
w.conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
if err := w.conn.WriteMessage(websocket.TextMessage, jsonData); err != nil {
fmt.Printf("❌ 发送配置报告失败: %v\n", err)
w.connected = false
return
}
}
}
// sendResponse 发送响应消息到服务端
func (w *WebSocketReporter) sendResponse(response CommandResponse) {
if w.conn == nil || !w.connected {
-118
View File
@@ -1,118 +0,0 @@
package traffic
import (
"context"
"sync"
"sync/atomic"
)
// MemoryManager 内存流量管理器
type MemoryManager struct {
mu sync.RWMutex
stats map[string]*trafficStats
}
// trafficStats 流量统计
type trafficStats struct {
upload atomic.Int64
download atomic.Int64
}
// NewMemoryManager 创建内存流量管理器
func NewMemoryManager() *MemoryManager {
return &MemoryManager{
stats: make(map[string]*trafficStats),
}
}
// RecordTraffic 记录流量
func (m *MemoryManager) RecordTraffic(ctx context.Context, service string, upload, download int64) error {
m.mu.RLock()
stats, exists := m.stats[service]
m.mu.RUnlock()
if !exists {
m.mu.Lock()
stats, exists = m.stats[service]
if !exists {
stats = &trafficStats{}
m.stats[service] = stats
}
m.mu.Unlock()
}
if upload > 0 {
stats.upload.Add(upload)
}
if download > 0 {
stats.download.Add(download)
}
return nil
}
// GetAllServicesStats 获取所有服务的流量统计
func (m *MemoryManager) GetAllServicesStats(ctx context.Context) (map[string]map[string]int64, error) {
m.mu.RLock()
defer m.mu.RUnlock()
result := make(map[string]map[string]int64)
for service, stats := range m.stats {
result[service] = map[string]int64{
"upload": stats.upload.Load(),
"download": stats.download.Load(),
}
}
return result, nil
}
// ClearAllTrafficStats 清零所有流量统计
func (m *MemoryManager) ClearAllTrafficStats(ctx context.Context) error {
m.mu.RLock()
defer m.mu.RUnlock()
for _, stats := range m.stats {
stats.upload.Store(0)
stats.download.Store(0)
}
return nil
}
// SubtractTrafficStats 从指定服务的流量统计中减去给定的值
func (m *MemoryManager) SubtractTrafficStats(ctx context.Context, stats map[string]map[string]int64) error {
m.mu.RLock()
defer m.mu.RUnlock()
for service, serviceStats := range stats {
if managerStats, exists := m.stats[service]; exists {
if upload, ok := serviceStats["upload"]; ok && upload > 0 {
managerStats.upload.Add(-upload)
// 确保不会变成负数
if managerStats.upload.Load() < 0 {
managerStats.upload.Store(0)
}
}
if download, ok := serviceStats["download"]; ok && download > 0 {
managerStats.download.Add(-download)
// 确保不会变成负数
if managerStats.download.Load() < 0 {
managerStats.download.Store(0)
}
}
}
}
return nil
}
// Close 关闭管理器(内存管理器无需特殊清理)
func (m *MemoryManager) Close() error {
return nil
}
// TestConnection 测试连接(内存管理器总是返回成功)
func (m *MemoryManager) TestConnection(ctx context.Context) error {
return nil
}
-34
View File
@@ -1,34 +0,0 @@
package traffic
import (
"context"
"sync"
)
// Manager 流量管理器接口
type Manager interface {
RecordTraffic(ctx context.Context, service string, upload, download int64) error
GetAllServicesStats(ctx context.Context) (map[string]map[string]int64, error)
ClearAllTrafficStats(ctx context.Context) error
SubtractTrafficStats(ctx context.Context, stats map[string]map[string]int64) error
Close() error
TestConnection(ctx context.Context) error
}
var (
globalManager Manager
once sync.Once
)
// GetGlobalManager 获取全局流量管理器实例
func GetGlobalManager() Manager {
once.Do(func() {
globalManager = NewMemoryManager()
})
return globalManager
}
// SetGlobalManager 设置全局流量管理器(用于测试)
func SetGlobalManager(m Manager) {
globalManager = m
}
-190
View File
@@ -1,190 +0,0 @@
package traffic
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// 全局变量存储HTTP地址
var httpReportURL string
// TrafficReportItem 流量报告项(压缩格式)
type TrafficReportItem struct {
N string `json:"n"` // 服务名(name缩写)
T string `json:"t"` // 连接类型:conn, cc(type缩写)
U int64 `json:"u"` // 上行流量(up缩写)
D int64 `json:"d"` // 下行流量(down缩写)
}
// SetHTTPReportURL 设置HTTP报告地址
func SetHTTPReportURL(addr string, secret string) {
httpReportURL = "http://" + addr + "/flow/upload?secret=" + secret
}
// StartTrafficReporter 启动流量报告任务
func StartTrafficReporter(trafficMgr Manager) {
// 检查是否设置了HTTP地址
if httpReportURL == "" {
fmt.Println("❌ HTTP报告地址未设置,无法启动流量报告任务")
return
}
ticker := time.NewTicker(5 * time.Second)
go func() {
defer ticker.Stop()
for range ticker.C {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
// 先获取流量统计(不清零)
stats, err := trafficMgr.GetAllServicesStats(ctx)
if err != nil {
fmt.Printf("获取流量统计失败: %v\n", err)
cancel()
continue
}
if len(stats) == 0 {
cancel()
continue
}
// 构建报告数据为数组格式,分别处理不同类型的流量
var reportItems []TrafficReportItem
totalServices := 0
totalTraffic := int64(0)
// 解析服务和类型,分组处理
serviceGroups := make(map[string]map[string]map[string]int64) // service -> type -> direction -> bytes
for serviceKey, serviceStats := range stats {
upload := serviceStats["upload"]
download := serviceStats["download"]
// 解析服务名和类型 (format: service:type)
var serviceName, serviceType string
parts := strings.Split(serviceKey, ":")
if len(parts) >= 2 {
serviceName = parts[0]
serviceType = parts[1]
} else {
serviceName = serviceKey
serviceType = "unknown"
}
if serviceGroups[serviceName] == nil {
serviceGroups[serviceName] = make(map[string]map[string]int64)
}
if serviceGroups[serviceName][serviceType] == nil {
serviceGroups[serviceName][serviceType] = make(map[string]int64)
}
serviceGroups[serviceName][serviceType]["upload"] = upload
serviceGroups[serviceName][serviceType]["download"] = download
totalTraffic += upload + download
}
// 为每个服务的每种类型创建报告项
for serviceName, types := range serviceGroups {
for serviceType, trafficData := range types {
// 过滤掉total类型,只保留conn和cc
if serviceType == "total" {
continue
}
upload := trafficData["upload"]
download := trafficData["download"]
// 只有当有流量时才报告
if upload > 0 || download > 0 {
reportItems = append(reportItems, TrafficReportItem{
N: serviceName,
T: serviceType,
U: upload,
D: download,
})
totalServices++
}
}
}
// 只有当有流量数据时才发送
if len(reportItems) > 0 && totalTraffic > 0 {
// 发送到HTTP接口
success, err := sendTrafficReport(ctx, reportItems)
if err != nil {
fmt.Printf("发送流量报告失败: %v\n", err)
} else if success {
// 只有收到"ok"响应才减去已上报的流量
err = trafficMgr.SubtractTrafficStats(ctx, stats)
if err != nil {
fmt.Printf("减去已上报流量失败: %v\n", err)
} else {
fmt.Printf("✅ 流量报告已发送并减去已上报流量: %d个记录, 总流量: %d bytes\n",
totalServices, totalTraffic)
}
} else {
fmt.Printf("⚠️ 服务器未确认(非ok响应),保留流量数据: %d个记录, 总流量: %d bytes\n",
totalServices, totalTraffic)
}
}
cancel()
}
}()
fmt.Printf("🚀 流量报告任务已启动 (5秒间隔),目标地址: %s\n", httpReportURL)
}
// sendTrafficReport 发送流量报告到HTTP接口
func sendTrafficReport(ctx context.Context, reportItems []TrafficReportItem) (bool, error) {
jsonData, err := json.Marshal(reportItems)
if err != nil {
return false, fmt.Errorf("序列化报告数据失败: %v", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", httpReportURL, bytes.NewBuffer(jsonData))
if err != nil {
return false, fmt.Errorf("创建HTTP请求失败: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "GOST-Traffic-Reporter/1.0")
client := &http.Client{
Timeout: 5 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
return false, fmt.Errorf("发送HTTP请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false, fmt.Errorf("HTTP响应错误: %d %s", resp.StatusCode, resp.Status)
}
// 读取响应内容
var responseBytes bytes.Buffer
_, err = responseBytes.ReadFrom(resp.Body)
if err != nil {
return false, fmt.Errorf("读取响应内容失败: %v", err)
}
responseText := strings.TrimSpace(responseBytes.String())
// 检查响应是否为"ok"
if responseText == "ok" {
return true, nil
} else {
return false, fmt.Errorf("服务器响应: %s (期望: ok)", responseText)
}
}
@@ -12,9 +12,6 @@ public class UserUpdateDto {
@NotNull(message = "用户ID不能为空")
private Long id;
@NotBlank(message = "姓名不能为空")
private String name;
@NotBlank(message = "用户名不能为空")
private String user;
@@ -2,6 +2,7 @@ package com.admin.common.task;
import com.admin.common.dto.ConfigItem;
import com.admin.common.dto.GostConfigDto;
import com.admin.common.dto.GostDto;
import com.admin.common.utils.GostUtil;
import com.admin.entity.Forward;
import com.admin.entity.Node;
@@ -74,7 +75,8 @@ public class CheckGostConfigAsync {
Forward forward = forwardService.getById(forwardId);
if (forward == null) {
log.warn("删除孤立的服务: {} (节点: {})", service.getName(), node.getId());
GostUtil.DeleteService(node.getId(), forwardId+"_"+userId+"_"+userTunnelId);
GostDto gostDto = GostUtil.DeleteService(node.getId(), forwardId + "_" + userId + "_" + userTunnelId);
System.out.println(gostDto);
}
}
if (Objects.equals(type, "tls")) {
@@ -30,9 +30,6 @@ public class WebSocketServer extends TextWebSocketHandler {
@Resource
NodeService nodeService;
@Resource
CheckGostConfigAsync checkGostConfigAsync;
// 存储所有活跃的 WebSocket 连接(
private static final CopyOnWriteArraySet<WebSocketSession> activeSessions = new CopyOnWriteArraySet<>();
@@ -57,13 +54,7 @@ public class WebSocketServer extends TextWebSocketHandler {
if (message.getPayload().contains("memory_usage")){
// 先发送确认消息
sendToUser(session, "{\"type\":\"call\"}");
} else if (message.getPayload().contains("config_report")) {
log.info("收到消息: {}", message.getPayload());
JSONObject jsonObject = JSONObject.parseObject(message.getPayload());
String string = jsonObject.getString("data");
GostConfigDto gostConfigDto = JSONObject.parseObject(string, GostConfigDto.class);
checkGostConfigAsync.cleanNodeConfigs(id, gostConfigDto);
} else if (message.getPayload().contains("requestId")) {
}else if (message.getPayload().contains("requestId")) {
log.info("收到消息: {}", message.getPayload());
// 处理命令响应消息
try {
@@ -23,17 +23,6 @@ public class WebSocketConfig implements WebSocketConfigurer {
@Resource
private WebSocketInterceptor webSocketInterceptor;
@Bean
public ServletContextInitializer websocketBufferConfig() {
return new ServletContextInitializer() {
@Override
public void onStartup(ServletContext servletContext) throws ServletException {
servletContext.setInitParameter("org.apache.tomcat.websocket.textBufferSize", String.valueOf(10 * 1024 * 1024));
servletContext.setInitParameter("org.apache.tomcat.websocket.binaryBufferSize", String.valueOf(10 * 1024 * 1024));
}
};
}
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry webSocketHandlerRegistry) {
webSocketHandlerRegistry
@@ -2,16 +2,16 @@ package com.admin.controller;
import com.admin.common.aop.LogAnnotation;
import com.admin.common.dto.FlowDto;
import com.admin.common.dto.GostConfigDto;
import com.admin.common.lang.R;
import com.admin.common.task.CheckGostConfigAsync;
import com.admin.common.utils.GostUtil;
import com.admin.entity.*;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.util.List;
import java.util.Objects;
@@ -56,6 +56,19 @@ public class FlowController extends BaseController {
private static final ConcurrentHashMap<String, Object> FORWARD_LOCKS = new ConcurrentHashMap<>();
@Resource
CheckGostConfigAsync checkGostConfigAsync;
@PostMapping("/config")
@LogAnnotation
public String config(@RequestBody GostConfigDto gostConfigDto, String secret) {
Node node = nodeService.getOne(new QueryWrapper<Node>().eq("secret", secret));
if (node == null) return SUCCESS_RESPONSE;
checkGostConfigAsync.cleanNodeConfigs(node.getId().toString(), gostConfigDto);
return SUCCESS_RESPONSE;
}
@RequestMapping("/test")
@LogAnnotation
public String test() {
@@ -11,10 +11,12 @@ import com.admin.entity.Tunnel;
import com.admin.mapper.NodeMapper;
import com.admin.mapper.TunnelMapper;
import com.admin.service.NodeService;
import com.admin.service.TunnelService;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.beans.BeanUtils;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
@@ -58,6 +60,10 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
@Resource
private TunnelMapper tunnelMapper;
@Resource
@Lazy
private TunnelService tunnelService;
@Value("${server-addr}")
private String serverAddr;
@@ -107,7 +113,25 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
// 2. 构建更新对象并执行更新
Node updateNode = buildUpdateNode(nodeUpdateDto);
boolean result = this.updateById(updateNode);
// 更新隧道入口ip
List<Tunnel> inNodeId = tunnelService.list(new QueryWrapper<Tunnel>().eq("in_node_id", updateNode.getId()));
if (!inNodeId.isEmpty()) {
for (Tunnel tunnel : inNodeId) {
tunnel.setInIp(updateNode.getIp());
}
tunnelService.updateBatchById(inNodeId);
}
// 更新服务器出口ip
List<Tunnel> outNodeId = tunnelService.list(new QueryWrapper<Tunnel>().eq("out_node_id", updateNode.getId()));
if (!outNodeId.isEmpty()) {
for (Tunnel tunnel : outNodeId) {
tunnel.setOutIp(updateNode.getServerIp());
}
tunnelService.updateBatchById(outNodeId);
}
return result ? R.ok(SUCCESS_UPDATE_MSG) : R.err(ERROR_UPDATE_MSG);
}
@@ -26,7 +26,6 @@ server:
uri-encoding: UTF-8
max-thread: 800
max-connections: 2000
max-swallow-size: 10MB
shutdown: graceful
mybatis-plus:
+6 -7
View File
@@ -92,7 +92,7 @@
<div v-if="userTunnels.length === 0 && !loading" class="tunnel-card empty-card">
<EmptyState
description="暂无隧道权限"
size="small"
size="large"
/>
</div>
@@ -185,7 +185,7 @@
<EmptyState
v-if="groupedForwards.length === 0 && !loading"
description="暂无转发配置"
size="small"
size="large"
/>
<div v-for="group in groupedForwards" :key="group.tunnelName" class="forward-group">
@@ -1085,12 +1085,11 @@ export default {
/* 隧道权限空状态卡片样式 */
.tunnel-card.empty-card {
grid-column: 1 / -1;
justify-self: center;
max-width: 400px;
min-height: 160px;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
/* 转发详情的空状态样式由EmptyState组件内置处理 */
@@ -1172,9 +1171,9 @@ export default {
}
.tunnel-card.empty-card {
max-width: 100%;
min-height: 140px;
width: 100%;
margin-bottom: 12px;
padding: 16px;
}
.tunnel-header {
+13 -2
View File
@@ -1652,13 +1652,24 @@ export default {
/* 移动端分页优化 */
.mobile-pagination {
justify-content: center;
display: flex;
justify-content: space-between;
align-items: center;
}
.mobile-pagination >>> .btn-prev {
flex-shrink: 0;
}
.mobile-pagination >>> .el-pager {
display: flex;
flex-wrap: wrap;
flex: 1;
justify-content: center;
margin: 0 8px;
}
.mobile-pagination >>> .btn-next {
flex-shrink: 0;
}
.mobile-pagination >>> .el-pager li {