diff --git a/go.mod b/go.mod index 78b6f09c..41b9fee9 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/xtls/xray-core v1.250911.0 go.uber.org/atomic v1.11.0 golang.org/x/crypto v0.42.0 + golang.org/x/sys v0.36.0 golang.org/x/text v0.29.0 google.golang.org/grpc v1.75.1 gorm.io/driver/sqlite v1.6.0 @@ -89,7 +90,6 @@ require ( golang.org/x/mod v0.28.0 // indirect golang.org/x/net v0.44.0 // indirect golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.36.0 // indirect golang.org/x/time v0.13.0 // indirect golang.org/x/tools v0.36.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect diff --git a/util/sys/sys_darwin.go b/util/sys/sys_darwin.go index 3f5b2072..6ecc78c0 100644 --- a/util/sys/sys_darwin.go +++ b/util/sys/sys_darwin.go @@ -4,7 +4,12 @@ package sys import ( + "encoding/binary" + "fmt" + "sync" + "github.com/shirou/gopsutil/v4/net" + "golang.org/x/sys/unix" ) func GetTCPCount() (int, error) { @@ -22,3 +27,69 @@ func GetUDPCount() (int, error) { } return len(stats), nil } + +// --- CPU Utilization (macOS native) --- + +// sysctl kern.cp_time returns an array of 5 longs: user, nice, sys, idle, intr. +// We compute utilization deltas without cgo. +var ( + cpuMu sync.Mutex + lastTotals [5]uint64 + hasLastCPUT bool +) + +func CPUPercentRaw() (float64, error) { + raw, err := unix.SysctlRaw("kern.cp_time") + if err != nil { + return 0, err + } + // Expect either 5*8 bytes (uint64) or 5*4 bytes (uint32) + var out [5]uint64 + switch len(raw) { + case 5 * 8: + for i := 0; i < 5; i++ { + out[i] = binary.LittleEndian.Uint64(raw[i*8 : (i+1)*8]) + } + case 5 * 4: + for i := 0; i < 5; i++ { + out[i] = uint64(binary.LittleEndian.Uint32(raw[i*4 : (i+1)*4])) + } + default: + return 0, fmt.Errorf("unexpected kern.cp_time size: %d", len(raw)) + } + + // user, nice, sys, idle, intr + user := out[0] + nice := out[1] + sysv := out[2] + idle := out[3] + intr := out[4] + + cpuMu.Lock() + defer cpuMu.Unlock() + + if !hasLastCPUT { + lastTotals = out + hasLastCPUT = true + return 0, nil + } + + dUser := user - lastTotals[0] + dNice := nice - lastTotals[1] + dSys := sysv - lastTotals[2] + dIdle := idle - lastTotals[3] + dIntr := intr - lastTotals[4] + + lastTotals = out + + totald := dUser + dNice + dSys + dIdle + dIntr + if totald == 0 { + return 0, nil + } + busy := totald - dIdle + pct := float64(busy) / float64(totald) * 100.0 + if pct > 100 { + pct = 100 + } + return pct, nil +} diff --git a/util/sys/sys_linux.go b/util/sys/sys_linux.go index d4e6e8ae..8a494d62 100644 --- a/util/sys/sys_linux.go +++ b/util/sys/sys_linux.go @@ -4,10 +4,14 @@ package sys import ( + "bufio" "bytes" "fmt" "io" "os" + "strconv" + "strings" + "sync" ) func getLinesNum(filename string) (int, error) { @@ -79,3 +83,99 @@ func safeGetLinesNum(path string) (int, error) { } return getLinesNum(path) } + +// --- CPU Utilization (Linux native) --- + +var ( + cpuMu sync.Mutex + lastTotal uint64 + lastIdleAll uint64 + hasLast bool +) + +// CPUPercentRaw returns instantaneous total CPU utilization by reading /proc/stat. +// First call initializes and returns 0; subsequent calls return busy/total * 100. +func CPUPercentRaw() (float64, error) { + f, err := os.Open("/proc/stat") + if err != nil { + return 0, err + } + defer f.Close() + + rd := bufio.NewReader(f) + line, err := rd.ReadString('\n') + if err != nil && err != io.EOF { + return 0, err + } + // Expect line like: cpu user nice system idle iowait irq softirq steal guest guest_nice + fields := strings.Fields(line) + if len(fields) < 5 || fields[0] != "cpu" { + return 0, fmt.Errorf("unexpected /proc/stat format") + } + + var nums []uint64 + for i := 1; i < len(fields); i++ { + v, err := strconv.ParseUint(fields[i], 10, 64) + if err != nil { + break + } + nums = append(nums, v) + } + if len(nums) < 4 { // need at least user,nice,system,idle + return 0, fmt.Errorf("insufficient cpu fields") + } + + // Conform with standard Linux CPU accounting + var user, nice, system, idle, iowait, irq, softirq, steal uint64 + user = nums[0] + if len(nums) > 1 { + nice = nums[1] + } + if len(nums) > 2 { + system = nums[2] + } + if len(nums) > 3 { + idle = nums[3] + } + if len(nums) > 4 { + iowait = nums[4] + } + if len(nums) > 5 { + irq = nums[5] + } + if len(nums) > 6 { + softirq = nums[6] + } + if len(nums) > 7 { + steal = nums[7] + } + + idleAll := idle + iowait + nonIdle := user + nice + system + irq + softirq + steal + total := idleAll + nonIdle + + cpuMu.Lock() + defer cpuMu.Unlock() + + if !hasLast { + lastTotal = total + lastIdleAll = idleAll + hasLast = true + return 0, nil + } + + totald := total - lastTotal + idled := idleAll - lastIdleAll + lastTotal = total + lastIdleAll = idleAll + + if totald == 0 { + return 0, nil + } + busy := totald - idled + pct := float64(busy) / float64(totald) * 100.0 + if pct > 100 { + pct = 100 + } + return pct, nil +} diff --git a/util/sys/sys_windows.go b/util/sys/sys_windows.go index fd51d470..f3eae076 100644 --- a/util/sys/sys_windows.go +++ b/util/sys/sys_windows.go @@ -5,6 +5,9 @@ package sys import ( "errors" + "sync" + "syscall" + "unsafe" "github.com/shirou/gopsutil/v4/net" ) @@ -28,3 +31,81 @@ func GetTCPCount() (int, error) { func GetUDPCount() (int, error) { return GetConnectionCount("udp") } + +// --- CPU Utilization (Windows native) --- + +var ( + modKernel32 = syscall.NewLazyDLL("kernel32.dll") + procGetSystemTimes = modKernel32.NewProc("GetSystemTimes") + + cpuMu sync.Mutex + lastIdle uint64 + lastKernel uint64 + lastUser uint64 + hasLast bool +) + +type filetime struct { + LowDateTime uint32 + HighDateTime uint32 +} + +func ftToUint64(ft filetime) uint64 { + return (uint64(ft.HighDateTime) << 32) | uint64(ft.LowDateTime) +} + +// CPUPercentRaw returns the instantaneous total CPU utilization percentage using +// Windows GetSystemTimes across all logical processors. The first call returns 0 +// as it initializes the baseline. Subsequent calls compute deltas. +func CPUPercentRaw() (float64, error) { + var idleFT, kernelFT, userFT filetime + r1, _, e1 := procGetSystemTimes.Call( + uintptr(unsafe.Pointer(&idleFT)), + uintptr(unsafe.Pointer(&kernelFT)), + uintptr(unsafe.Pointer(&userFT)), + ) + if r1 == 0 { // failure + if e1 != nil { + return 0, e1 + } + return 0, syscall.GetLastError() + } + + idle := ftToUint64(idleFT) + kernel := ftToUint64(kernelFT) + user := ftToUint64(userFT) + + cpuMu.Lock() + defer cpuMu.Unlock() + + if !hasLast { + lastIdle = idle + lastKernel = kernel + lastUser = user + hasLast = true + return 0, nil + } + + idleDelta := idle - lastIdle + kernelDelta := kernel - lastKernel + userDelta := user - lastUser + + // Update for next call + lastIdle = idle + lastKernel = kernel + lastUser = user + + total := kernelDelta + userDelta + if total == 0 { + return 0, nil + } + // On Windows, kernel time includes idle time; busy = total - idle + busy := total - idleDelta + + pct := float64(busy) / float64(total) * 100.0 + // lower bound not needed; ratios of uint64 are non-negative + if pct > 100 { + pct = 100 + } + return pct, nil +} diff --git a/web/controller/server.go b/web/controller/server.go index d5ae688b..3b93afd9 100644 --- a/web/controller/server.go +++ b/web/controller/server.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" "regexp" + "strconv" "time" "x-ui/web/global" @@ -39,6 +40,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("/getXrayVersion", a.getXrayVersion) g.GET("/getConfigJson", a.getConfigJson) g.GET("/getDb", a.getDb) @@ -61,16 +63,18 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) { func (a *ServerController) refreshStatus() { a.lastStatus = a.serverService.GetStatus(a.lastStatus) + // collect cpu history when status is fresh + if a.lastStatus != nil { + a.serverService.AppendCpuSample(time.Now(), a.lastStatus.Cpu) + } } func (a *ServerController) startTask() { webServer := global.GetWebServer() c := webServer.GetCron() c.AddFunc("@every 2s", func() { - now := time.Now() - if now.Sub(a.lastGetStatusTime) > time.Minute*3 { - return - } + // Always refresh to keep CPU history collected continuously. + // Sampling is lightweight and capped to ~6 hours in memory. a.refreshStatus() }) } @@ -81,6 +85,26 @@ func (a *ServerController) status(c *gin.Context) { 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 + } + } + if mins < 1 { + mins = 1 + } + if mins > 360 { + mins = 360 + } + res := a.serverService.GetCpuHistory(mins) + jsonObj(c, res, nil) +} + func (a *ServerController) getXrayVersion(c *gin.Context) { now := time.Now() if now.Sub(a.lastGetVersionsTime) <= time.Minute { diff --git a/web/html/index.html b/web/html/index.html index 2ff8db3e..b6a9993e 100644 --- a/web/html/index.html +++ b/web/html/index.html @@ -41,6 +41,11 @@