diff --git a/web/controller/server.go b/web/controller/server.go index 3b93afd9..169a1ae7 100644 --- a/web/controller/server.go +++ b/web/controller/server.go @@ -21,17 +21,14 @@ type ServerController struct { serverService service.ServerService settingService service.SettingService - lastStatus *service.Status - lastGetStatusTime time.Time + lastStatus *service.Status lastVersions []string - lastGetVersionsTime time.Time + lastGetVersionsTime int64 // unix seconds } func NewServerController(g *gin.RouterGroup) *ServerController { - a := &ServerController{ - lastGetStatusTime: time.Now(), - } + a := &ServerController{} a.initRouter(g) a.startTask() return a @@ -40,7 +37,7 @@ func NewServerController(g *gin.RouterGroup) *ServerController { func (a *ServerController) initRouter(g *gin.RouterGroup) { g.GET("/status", a.status) - g.GET("/cpuHistory", a.getCpuHistory) + g.GET("/cpuHistory/:bucket", a.getCpuHistoryBucket) g.GET("/getXrayVersion", a.getXrayVersion) g.GET("/getConfigJson", a.getConfigJson) g.GET("/getDb", a.getDb) @@ -79,35 +76,34 @@ func (a *ServerController) startTask() { }) } -func (a *ServerController) status(c *gin.Context) { - a.lastGetStatusTime = time.Now() +func (a *ServerController) status(c *gin.Context) { jsonObj(c, a.lastStatus, nil) } - jsonObj(c, a.lastStatus, nil) -} - -// getCpuHistory returns recent CPU utilization points. -// Query param q=minutes (int). Bounds: 1..360 (6 hours). Defaults to 60. -func (a *ServerController) getCpuHistory(c *gin.Context) { - minsStr := c.Query("q") - mins := 60 - if minsStr != "" { - if v, err := strconv.Atoi(minsStr); err == nil { - mins = v - } +func (a *ServerController) getCpuHistoryBucket(c *gin.Context) { + bucketStr := c.Param("bucket") + bucket, err := strconv.Atoi(bucketStr) + if err != nil || bucket <= 0 { + jsonMsg(c, "invalid bucket", fmt.Errorf("bad bucket")) + return } - if mins < 1 { - mins = 1 + allowed := map[int]bool{ + 2: true, // Real-time view + 30: true, // 30s intervals + 60: true, // 1m intervals + 120: true, // 2m intervals + 180: true, // 3m intervals + 300: true, // 5m intervals } - if mins > 360 { - mins = 360 + if !allowed[bucket] { + jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket")) + return } - res := a.serverService.GetCpuHistory(mins) - jsonObj(c, res, nil) + points := a.serverService.AggregateCpuHistory(bucket, 60) + jsonObj(c, points, nil) } func (a *ServerController) getXrayVersion(c *gin.Context) { - now := time.Now() - if now.Sub(a.lastGetVersionsTime) <= time.Minute { + now := time.Now().Unix() + if now-a.lastGetVersionsTime <= 60 { // 1 minute cache jsonObj(c, a.lastVersions, nil) return } @@ -119,7 +115,7 @@ func (a *ServerController) getXrayVersion(c *gin.Context) { } a.lastVersions = versions - a.lastGetVersionsTime = time.Now() + a.lastGetVersionsTime = now jsonObj(c, versions, nil) } @@ -137,7 +133,6 @@ func (a *ServerController) updateGeofile(c *gin.Context) { } func (a *ServerController) stopXrayService(c *gin.Context) { - a.lastGetStatusTime = time.Now() err := a.serverService.StopXrayService() if err != nil { jsonMsg(c, I18nWeb(c, "pages.xray.stopError"), err) @@ -253,9 +248,7 @@ func (a *ServerController) importDB(c *gin.Context) { defer file.Close() // Always restart Xray before return defer a.serverService.RestartXrayService() - defer func() { - a.lastGetStatusTime = time.Now() - }() + // lastGetStatusTime removed; no longer needed // Import it err = a.serverService.ImportDB(file) if err != nil { diff --git a/web/controller/xray_setting.go b/web/controller/xray_setting.go index 5b2d1036..2b5e0db1 100644 --- a/web/controller/xray_setting.go +++ b/web/controller/xray_setting.go @@ -23,13 +23,13 @@ func NewXraySettingController(g *gin.RouterGroup) *XraySettingController { func (a *XraySettingController) initRouter(g *gin.RouterGroup) { g = g.Group("/xray") + g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig) + g.GET("/getOutboundsTraffic", a.getOutboundsTraffic) + g.GET("/getXrayResult", a.getXrayResult) g.POST("/", a.getXraySetting) - g.POST("/update", a.updateSetting) - g.GET("/getXrayResult", a.getXrayResult) - g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig) g.POST("/warp/:action", a.warp) - g.GET("/getOutboundsTraffic", a.getOutboundsTraffic) + g.POST("/update", a.updateSetting) g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic) } diff --git a/web/html/index.html b/web/html/index.html index b50d0189..2466ba58 100644 --- a/web/html/index.html +++ b/web/html/index.html @@ -863,7 +863,6 @@ this.visible = false; }, }; - const backupModal = { visible: false, show() { diff --git a/web/service/inbound.go b/web/service/inbound.go index 4abc88c3..b0aed169 100644 --- a/web/service/inbound.go +++ b/web/service/inbound.go @@ -1951,8 +1951,8 @@ func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.Cl return nil, err } if t != nil && client != nil { - // Ensure enable mirrors the client's current enable flag in settings t.Enable = client.Enable + t.SubId = client.SubID return t, nil } return nil, nil @@ -1993,6 +1993,7 @@ func (s *InboundService) GetClientTrafficByID(id string) ([]xray.ClientTraffic, for i := range traffics { if ct, client, e := s.GetClientByEmail(traffics[i].Email); e == nil && ct != nil && client != nil { traffics[i].Enable = client.Enable + traffics[i].SubId = client.SubID } } return traffics, err diff --git a/web/service/server.go b/web/service/server.go index de9e7469..670e622e 100644 --- a/web/service/server.go +++ b/web/service/server.go @@ -94,22 +94,81 @@ type Release struct { } type ServerService struct { - xrayService XrayService - inboundService InboundService - cachedIPv4 string - cachedIPv6 string - noIPv6 bool - // CPU utilization smoothing state - mu sync.Mutex - lastCPUTimes cpu.TimesStat - hasLastCPUSample bool - emaCPU float64 - // CPU history buffer (in-memory, protected by mu) - cpuHistory []CPUSample - cpuCapacity int + xrayService XrayService + inboundService InboundService + cachedIPv4 string + cachedIPv6 string + noIPv6 bool + mu sync.Mutex + lastCPUTimes cpu.TimesStat + hasLastCPUSample bool + emaCPU float64 + cpuHistory []CPUSample + cachedCpuSpeedMhz float64 + lastCpuInfoAttempt time.Time } -// CPUSample represents a single CPU utilization sample with timestamp +// AggregateCpuHistory returns up to maxPoints averaged buckets of size bucketSeconds over recent data. +func (s *ServerService) AggregateCpuHistory(bucketSeconds int, maxPoints int) []map[string]any { + if bucketSeconds <= 0 || maxPoints <= 0 { + return nil + } + cutoff := time.Now().Add(-time.Duration(bucketSeconds*maxPoints) * time.Second).Unix() + s.mu.Lock() + // find start index (history sorted ascending) + hist := s.cpuHistory + // binary-ish scan (simple linear from end since size capped ~10800 is fine) + startIdx := 0 + for i := len(hist) - 1; i >= 0; i-- { + if hist[i].T < cutoff { + startIdx = i + 1 + break + } + } + if startIdx >= len(hist) { + s.mu.Unlock() + return []map[string]any{} + } + slice := hist[startIdx:] + // copy for unlock + tmp := make([]CPUSample, len(slice)) + copy(tmp, slice) + s.mu.Unlock() + if len(tmp) == 0 { + return []map[string]any{} + } + var out []map[string]any + var acc []float64 + bSize := int64(bucketSeconds) + curBucket := (tmp[0].T / bSize) * bSize + flush := func(ts int64) { + if len(acc) == 0 { + return + } + sum := 0.0 + for _, v := range acc { + sum += v + } + avg := sum / float64(len(acc)) + out = append(out, map[string]any{"t": ts, "cpu": avg}) + acc = acc[:0] + } + for _, p := range tmp { + b := (p.T / bSize) * bSize + if b != curBucket { + flush(curBucket) + curBucket = b + } + acc = append(acc, p.Cpu) + } + flush(curBucket) + if len(out) > maxPoints { + out = out[len(out)-maxPoints:] + } + return out +} + +// CPUSample single CPU utilization sample type CPUSample struct { T int64 `json:"t"` // unix seconds Cpu float64 `json:"cpu"` // percent 0..100 @@ -178,13 +237,30 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status { status.LogicalPro = runtime.NumCPU() - cpuInfos, err := cpu.Info() - if err != nil { - logger.Warning("get cpu info failed:", err) - } else if len(cpuInfos) > 0 { - status.CpuSpeedMhz = cpuInfos[0].Mhz - } else { - logger.Warning("could not find cpu info") + if status.CpuSpeedMhz = s.cachedCpuSpeedMhz; s.cachedCpuSpeedMhz == 0 && time.Since(s.lastCpuInfoAttempt) > 5*time.Minute { + s.lastCpuInfoAttempt = time.Now() + done := make(chan struct{}) + go func() { + defer close(done) + cpuInfos, err := cpu.Info() + if err != nil { + logger.Warning("get cpu info failed:", err) + return + } + if len(cpuInfos) > 0 { + s.cachedCpuSpeedMhz = cpuInfos[0].Mhz + status.CpuSpeedMhz = s.cachedCpuSpeedMhz + } else { + logger.Warning("could not find cpu info") + } + }() + select { + case <-done: + case <-time.After(1500 * time.Millisecond): + logger.Warning("cpu info query timed out; will retry later") + } + } else if s.cachedCpuSpeedMhz != 0 { + status.CpuSpeedMhz = s.cachedCpuSpeedMhz } // Uptime @@ -332,55 +408,21 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status { return status } -// AppendCpuSample appends a CPU sample into the in-memory history with capacity trimming. func (s *ServerService) AppendCpuSample(t time.Time, v float64) { + const capacity = 9000 // ~5 hours @ 2s interval s.mu.Lock() defer s.mu.Unlock() - if s.cpuCapacity == 0 { - s.cpuCapacity = 10800 // ~6 hours at 2s per sample - } p := CPUSample{T: t.Unix(), Cpu: v} - s.cpuHistory = append(s.cpuHistory, p) - if len(s.cpuHistory) > s.cpuCapacity { - drop := len(s.cpuHistory) - s.cpuCapacity - s.cpuHistory = s.cpuHistory[drop:] + if n := len(s.cpuHistory); n > 0 && s.cpuHistory[n-1].T == p.T { + s.cpuHistory[n-1] = p + } else { + s.cpuHistory = append(s.cpuHistory, p) + } + if len(s.cpuHistory) > capacity { + s.cpuHistory = s.cpuHistory[len(s.cpuHistory)-capacity:] } } -// GetCpuHistory returns samples from the last 'mins' minutes (bounded 1..360). -func (s *ServerService) GetCpuHistory(mins int) []CPUSample { - if mins < 1 { - mins = 1 - } - if mins > 360 { - mins = 360 - } - cutoff := time.Now().Add(-time.Duration(mins) * time.Minute).Unix() - s.mu.Lock() - defer s.mu.Unlock() - if len(s.cpuHistory) == 0 { - return nil - } - // find first index >= cutoff (linear scan from end is fine for these sizes) - i := len(s.cpuHistory) - 1 - for ; i >= 0; i-- { - if s.cpuHistory[i].T < cutoff { - i++ - break - } - } - if i < 0 { - i = 0 - } - // copy to avoid exposing internal slice - out := make([]CPUSample, len(s.cpuHistory)-i) - copy(out, s.cpuHistory[i:]) - return out -} - -// sampleCPUUtilization returns a smoothed total CPU utilization percentage across all logical processors. -// It computes utilization from CPU time deltas (non-blocking) and applies an exponential moving average -// to reduce spikes similar to Task Manager's smoothing. func (s *ServerService) sampleCPUUtilization() (float64, error) { // Prefer native Windows API to avoid external deps for CPU percent if runtime.GOOS == "windows" { diff --git a/xray/client_traffic.go b/xray/client_traffic.go index fe527d55..84c509ac 100644 --- a/xray/client_traffic.go +++ b/xray/client_traffic.go @@ -5,6 +5,7 @@ type ClientTraffic struct { InboundId int `json:"inboundId" form:"inboundId"` Enable bool `json:"enable" form:"enable"` Email string `json:"email" form:"email" gorm:"unique"` + SubId string `json:"subId" form:"subId" gorm:"-"` Up int64 `json:"up" form:"up"` Down int64 `json:"down" form:"down"` AllTime int64 `json:"allTime" form:"allTime"`