mirror of
				https://github.com/MHSanaei/3x-ui.git
				synced 2025-10-27 10:30:08 +00:00 
			
		
		
		
	Compare commits
	
		
			1 commit
		
	
	
		
			5d93eae438
			...
			7a57cdbc98
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 7a57cdbc98 | 
					 21 changed files with 1689 additions and 2626 deletions
				
			
		
							
								
								
									
										2
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								go.mod
									
									
									
									
									
								
							|  | @ -21,7 +21,6 @@ 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 | ||||
|  | @ -91,6 +90,7 @@ 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 | ||||
|  |  | |||
							
								
								
									
										29
									
								
								sub/sub.go
									
									
									
									
									
								
							
							
						
						
									
										29
									
								
								sub/sub.go
									
									
									
									
									
								
							|  | @ -11,7 +11,6 @@ import ( | |||
| 	"os" | ||||
| 	"path/filepath" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 
 | ||||
| 	"x-ui/logger" | ||||
| 	"x-ui/util/common" | ||||
|  | @ -75,6 +74,11 @@ func (s *Server) initRouter() (*gin.Engine, error) { | |||
| 		engine.Use(middleware.DomainValidatorMiddleware(subDomain)) | ||||
| 	} | ||||
| 
 | ||||
| 	// Provide base_path in context for templates
 | ||||
| 	engine.Use(func(c *gin.Context) { | ||||
| 		c.Set("base_path", "/") | ||||
| 	}) | ||||
| 
 | ||||
| 	LinksPath, err := s.settingService.GetSubPath() | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
|  | @ -85,11 +89,6 @@ func (s *Server) initRouter() (*gin.Engine, error) { | |||
| 		return nil, err | ||||
| 	} | ||||
| 
 | ||||
| 	// Set base_path based on LinksPath for template rendering
 | ||||
| 	engine.Use(func(c *gin.Context) { | ||||
| 		c.Set("base_path", LinksPath) | ||||
| 	}) | ||||
| 
 | ||||
| 	Encrypt, err := s.settingService.GetSubEncrypt() | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
|  | @ -155,29 +154,11 @@ func (s *Server) initRouter() (*gin.Engine, error) { | |||
| 	} | ||||
| 
 | ||||
| 	// Assets: use disk if present, fallback to embedded
 | ||||
| 	// Serve under both root (/assets) and under the subscription path prefix (LinksPath + "assets")
 | ||||
| 	// so reverse proxies with a URI prefix can load assets correctly.
 | ||||
| 	// Determine LinksPath earlier to compute prefixed assets mount.
 | ||||
| 	// Note: LinksPath always starts and ends with "/" (validated in settings).
 | ||||
| 	var linksPathForAssets string | ||||
| 	if LinksPath == "/" { | ||||
| 		linksPathForAssets = "/assets" | ||||
| 	} else { | ||||
| 		// ensure single slash join
 | ||||
| 		linksPathForAssets = strings.TrimRight(LinksPath, "/") + "/assets" | ||||
| 	} | ||||
| 
 | ||||
| 	if _, err := os.Stat("web/assets"); err == nil { | ||||
| 		engine.StaticFS("/assets", http.FS(os.DirFS("web/assets"))) | ||||
| 		if linksPathForAssets != "/assets" { | ||||
| 			engine.StaticFS(linksPathForAssets, http.FS(os.DirFS("web/assets"))) | ||||
| 		} | ||||
| 	} else { | ||||
| 		if subFS, err := fs.Sub(webpkg.EmbeddedAssets(), "assets"); err == nil { | ||||
| 			engine.StaticFS("/assets", http.FS(subFS)) | ||||
| 			if linksPathForAssets != "/assets" { | ||||
| 				engine.StaticFS(linksPathForAssets, http.FS(subFS)) | ||||
| 			} | ||||
| 		} else { | ||||
| 			logger.Error("sub: failed to mount embedded assets:", err) | ||||
| 		} | ||||
|  |  | |||
|  | @ -292,25 +292,34 @@ func (s *SubJsonService) realityData(rData map[string]any) map[string]any { | |||
| 
 | ||||
| func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, encryption string) json_util.RawMessage { | ||||
| 	outbound := Outbound{} | ||||
| 	usersData := make([]UserVnext, 1) | ||||
| 
 | ||||
| 	usersData[0].ID = client.ID | ||||
| 	usersData[0].Level = 8 | ||||
| 	if inbound.Protocol == model.VMESS { | ||||
| 		usersData[0].Security = client.Security | ||||
| 	} | ||||
| 	if inbound.Protocol == model.VLESS { | ||||
| 		usersData[0].Flow = client.Flow | ||||
| 		usersData[0].Encryption = encryption | ||||
| 	} | ||||
| 
 | ||||
| 	vnextData := make([]VnextSetting, 1) | ||||
| 	vnextData[0] = VnextSetting{ | ||||
| 		Address: inbound.Listen, | ||||
| 		Port:    inbound.Port, | ||||
| 		Users:   usersData, | ||||
| 	} | ||||
| 
 | ||||
| 	outbound.Protocol = string(inbound.Protocol) | ||||
| 	outbound.Tag = "proxy" | ||||
| 	if s.mux != "" { | ||||
| 		outbound.Mux = json_util.RawMessage(s.mux) | ||||
| 	} | ||||
| 	outbound.StreamSettings = streamSettings | ||||
| 	// Emit flattened settings inside Settings to match new Xray format
 | ||||
| 	settings := make(map[string]any) | ||||
| 	settings["address"] = inbound.Listen | ||||
| 	settings["port"] = inbound.Port | ||||
| 	settings["id"] = client.ID | ||||
| 	if inbound.Protocol == model.VLESS { | ||||
| 		settings["flow"] = client.Flow | ||||
| 		settings["encryption"] = encryption | ||||
| 	outbound.Settings = OutboundSettings{ | ||||
| 		Vnext: vnextData, | ||||
| 	} | ||||
| 	if inbound.Protocol == model.VMESS { | ||||
| 		settings["security"] = client.Security | ||||
| 	} | ||||
| 	outbound.Settings = settings | ||||
| 
 | ||||
| 	result, _ := json.MarshalIndent(outbound, "", "  ") | ||||
| 	return result | ||||
|  | @ -347,8 +356,8 @@ func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_u | |||
| 		outbound.Mux = json_util.RawMessage(s.mux) | ||||
| 	} | ||||
| 	outbound.StreamSettings = streamSettings | ||||
| 	outbound.Settings = map[string]any{ | ||||
| 		"servers": serverData, | ||||
| 	outbound.Settings = OutboundSettings{ | ||||
| 		Servers: serverData, | ||||
| 	} | ||||
| 
 | ||||
| 	result, _ := json.MarshalIndent(outbound, "", "  ") | ||||
|  | @ -360,10 +369,28 @@ type Outbound struct { | |||
| 	Tag            string               `json:"tag"` | ||||
| 	StreamSettings json_util.RawMessage `json:"streamSettings"` | ||||
| 	Mux            json_util.RawMessage `json:"mux,omitempty"` | ||||
| 	Settings       map[string]any       `json:"settings,omitempty"` | ||||
| 	ProxySettings  map[string]any       `json:"proxySettings,omitempty"` | ||||
| 	Settings       OutboundSettings     `json:"settings,omitempty"` | ||||
| } | ||||
| 
 | ||||
| // Legacy vnext-related structs removed for flattened schema
 | ||||
| type OutboundSettings struct { | ||||
| 	Vnext   []VnextSetting  `json:"vnext,omitempty"` | ||||
| 	Servers []ServerSetting `json:"servers,omitempty"` | ||||
| } | ||||
| 
 | ||||
| type VnextSetting struct { | ||||
| 	Address string      `json:"address"` | ||||
| 	Port    int         `json:"port"` | ||||
| 	Users   []UserVnext `json:"users"` | ||||
| } | ||||
| 
 | ||||
| type UserVnext struct { | ||||
| 	Encryption string `json:"encryption,omitempty"` | ||||
| 	Flow       string `json:"flow,omitempty"` | ||||
| 	ID         string `json:"id"` | ||||
| 	Security   string `json:"security,omitempty"` | ||||
| 	Level      int    `json:"level"` | ||||
| } | ||||
| 
 | ||||
| type ServerSetting struct { | ||||
| 	Password string `json:"password"` | ||||
|  |  | |||
|  | @ -5,6 +5,7 @@ import ( | |||
| 	"fmt" | ||||
| 	"net" | ||||
| 	"net/url" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 	"time" | ||||
| 
 | ||||
|  | @ -1130,7 +1131,7 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray | |||
| 
 | ||||
| 	return PageData{ | ||||
| 		Host:         hostHeader, | ||||
| 		BasePath:     "/", // kept as "/"; templates now use context base_path injected from router
 | ||||
| 		BasePath:     "/", | ||||
| 		SId:          subId, | ||||
| 		Download:     download, | ||||
| 		Upload:       upload, | ||||
|  | @ -1159,3 +1160,10 @@ func getHostFromXFH(s string) (string, error) { | |||
| 	} | ||||
| 	return s, nil | ||||
| } | ||||
| 
 | ||||
| func parseInt64(s string) (int64, error) { | ||||
| 	// handle potential quotes
 | ||||
| 	s = strings.Trim(s, "\"'") | ||||
| 	n, err := strconv.ParseInt(s, 10, 64) | ||||
| 	return n, err | ||||
| } | ||||
|  |  | |||
|  | @ -4,12 +4,7 @@ | |||
| package sys | ||||
| 
 | ||||
| import ( | ||||
| 	"encoding/binary" | ||||
| 	"fmt" | ||||
| 	"sync" | ||||
| 
 | ||||
| 	"github.com/shirou/gopsutil/v4/net" | ||||
| 	"golang.org/x/sys/unix" | ||||
| ) | ||||
| 
 | ||||
| func GetTCPCount() (int, error) { | ||||
|  | @ -27,69 +22,3 @@ 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 | ||||
| } | ||||
|  |  | |||
|  | @ -4,14 +4,10 @@ | |||
| package sys | ||||
| 
 | ||||
| import ( | ||||
| 	"bufio" | ||||
| 	"bytes" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"os" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| ) | ||||
| 
 | ||||
| func getLinesNum(filename string) (int, error) { | ||||
|  | @ -83,99 +79,3 @@ 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 | ||||
| } | ||||
|  |  | |||
|  | @ -5,9 +5,6 @@ package sys | |||
| 
 | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"sync" | ||||
| 	"syscall" | ||||
| 	"unsafe" | ||||
| 
 | ||||
| 	"github.com/shirou/gopsutil/v4/net" | ||||
| ) | ||||
|  | @ -31,81 +28,3 @@ 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 | ||||
| } | ||||
|  |  | |||
|  | @ -219,7 +219,7 @@ class KcpStreamSettings extends CommonClass { | |||
| 
 | ||||
| class WsStreamSettings extends CommonClass { | ||||
|     constructor( | ||||
|         path = '/', | ||||
|         path = '/',  | ||||
|         host = '', | ||||
|         heartbeatPeriod = 0, | ||||
| 
 | ||||
|  | @ -647,6 +647,10 @@ class Outbound extends CommonClass { | |||
|         ].includes(this.protocol); | ||||
|     } | ||||
| 
 | ||||
|     hasVnext() { | ||||
|         return [Protocols.VMess, Protocols.VLESS].includes(this.protocol); | ||||
|     } | ||||
| 
 | ||||
|     hasServers() { | ||||
|         return [Protocols.Trojan, Protocols.Shadowsocks, Protocols.Socks, Protocols.HTTP].includes(this.protocol); | ||||
|     } | ||||
|  | @ -686,22 +690,13 @@ class Outbound extends CommonClass { | |||
|             if (this.stream?.sockopt) | ||||
|                 stream = { sockopt: this.stream.sockopt.toJson() }; | ||||
|         } | ||||
|         // For VMess/VLESS, emit settings as a flat object
 | ||||
|         let settingsOut = this.settings instanceof CommonClass ? this.settings.toJson() : this.settings; | ||||
|         // Remove undefined/null keys
 | ||||
|         if (settingsOut && typeof settingsOut === 'object') { | ||||
|             Object.keys(settingsOut).forEach(k => { | ||||
|                 if (settingsOut[k] === undefined || settingsOut[k] === null) delete settingsOut[k]; | ||||
|             }); | ||||
|         } | ||||
|         return { | ||||
|             tag: this.tag == '' ? undefined : this.tag, | ||||
|             protocol: this.protocol, | ||||
|             settings: settingsOut, | ||||
|             // Only include tag, streamSettings, sendThrough, mux if present and not empty
 | ||||
|             ...(this.tag ? { tag: this.tag } : {}), | ||||
|             ...(stream ? { streamSettings: stream } : {}), | ||||
|             ...(this.sendThrough ? { sendThrough: this.sendThrough } : {}), | ||||
|             ...(this.mux?.enabled ? { mux: this.mux } : {}), | ||||
|             settings: this.settings instanceof CommonClass ? this.settings.toJson() : this.settings, | ||||
|             streamSettings: stream, | ||||
|             sendThrough: this.sendThrough != "" ? this.sendThrough : undefined, | ||||
|             mux: this.mux?.enabled ? this.mux : undefined, | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|  | @ -913,7 +908,7 @@ Outbound.FreedomSettings = class extends CommonClass { | |||
|     toJson() { | ||||
|         return { | ||||
|             domainStrategy: ObjectUtil.isEmpty(this.domainStrategy) ? undefined : this.domainStrategy, | ||||
|             redirect: ObjectUtil.isEmpty(this.redirect) ? undefined : this.redirect, | ||||
|             redirect: ObjectUtil.isEmpty(this.redirect) ? undefined: this.redirect, | ||||
|             fragment: Object.keys(this.fragment).length === 0 ? undefined : this.fragment, | ||||
|             noises: this.noises.length === 0 ? undefined : Outbound.FreedomSettings.Noise.toJsonArray(this.noises), | ||||
|         }; | ||||
|  | @ -1031,21 +1026,22 @@ Outbound.VmessSettings = class extends CommonClass { | |||
|     } | ||||
| 
 | ||||
|     static fromJson(json = {}) { | ||||
|         if (ObjectUtil.isEmpty(json.address) || ObjectUtil.isEmpty(json.port)) return new Outbound.VmessSettings(); | ||||
|         if (ObjectUtil.isArrEmpty(json.vnext)) return new Outbound.VmessSettings(); | ||||
|         return new Outbound.VmessSettings( | ||||
|             json.address, | ||||
|             json.port, | ||||
|             json.id, | ||||
|             json.security, | ||||
|             json.vnext[0].address, | ||||
|             json.vnext[0].port, | ||||
|             json.vnext[0].users[0].id, | ||||
|             json.vnext[0].users[0].security, | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     toJson() { | ||||
|         return { | ||||
|             address: this.address, | ||||
|             port: this.port, | ||||
|             id: this.id, | ||||
|             security: this.security, | ||||
|             vnext: [{ | ||||
|                 address: this.address, | ||||
|                 port: this.port, | ||||
|                 users: [{ id: this.id, security: this.security }], | ||||
|             }], | ||||
|         }; | ||||
|     } | ||||
| }; | ||||
|  | @ -1060,23 +1056,23 @@ Outbound.VLESSSettings = class extends CommonClass { | |||
|     } | ||||
| 
 | ||||
|     static fromJson(json = {}) { | ||||
|         if (ObjectUtil.isEmpty(json.address) || ObjectUtil.isEmpty(json.port)) return new Outbound.VLESSSettings(); | ||||
|         if (ObjectUtil.isArrEmpty(json.vnext)) return new Outbound.VLESSSettings(); | ||||
|         return new Outbound.VLESSSettings( | ||||
|             json.address, | ||||
|             json.port, | ||||
|             json.id, | ||||
|             json.flow, | ||||
|             json.encryption | ||||
|             json.vnext[0].address, | ||||
|             json.vnext[0].port, | ||||
|             json.vnext[0].users[0].id, | ||||
|             json.vnext[0].users[0].flow, | ||||
|             json.vnext[0].users[0].encryption, | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     toJson() { | ||||
|         return { | ||||
|             address: this.address, | ||||
|             port: this.port, | ||||
|             id: this.id, | ||||
|             flow: this.flow, | ||||
|             encryption: this.encryption, | ||||
|             vnext: [{ | ||||
|                 address: this.address, | ||||
|                 port: this.port, | ||||
|                 users: [{ id: this.id, flow: this.flow, encryption: this.encryption }], | ||||
|             }], | ||||
|         }; | ||||
|     } | ||||
| }; | ||||
|  |  | |||
|  | @ -4,7 +4,6 @@ import ( | |||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"regexp" | ||||
| 	"strconv" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"x-ui/web/global" | ||||
|  | @ -21,14 +20,17 @@ type ServerController struct { | |||
| 	serverService  service.ServerService | ||||
| 	settingService service.SettingService | ||||
| 
 | ||||
| 	lastStatus *service.Status | ||||
| 	lastStatus        *service.Status | ||||
| 	lastGetStatusTime time.Time | ||||
| 
 | ||||
| 	lastVersions        []string | ||||
| 	lastGetVersionsTime int64 // unix seconds
 | ||||
| 	lastGetVersionsTime time.Time | ||||
| } | ||||
| 
 | ||||
| func NewServerController(g *gin.RouterGroup) *ServerController { | ||||
| 	a := &ServerController{} | ||||
| 	a := &ServerController{ | ||||
| 		lastGetStatusTime: time.Now(), | ||||
| 	} | ||||
| 	a.initRouter(g) | ||||
| 	a.startTask() | ||||
| 	return a | ||||
|  | @ -37,7 +39,6 @@ func NewServerController(g *gin.RouterGroup) *ServerController { | |||
| func (a *ServerController) initRouter(g *gin.RouterGroup) { | ||||
| 
 | ||||
| 	g.GET("/status", a.status) | ||||
| 	g.GET("/cpuHistory/:bucket", a.getCpuHistoryBucket) | ||||
| 	g.GET("/getXrayVersion", a.getXrayVersion) | ||||
| 	g.GET("/getConfigJson", a.getConfigJson) | ||||
| 	g.GET("/getDb", a.getDb) | ||||
|  | @ -60,50 +61,29 @@ 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() { | ||||
| 		// Always refresh to keep CPU history collected continuously.
 | ||||
| 		// Sampling is lightweight and capped to ~6 hours in memory.
 | ||||
| 		now := time.Now() | ||||
| 		if now.Sub(a.lastGetStatusTime) > time.Minute*3 { | ||||
| 			return | ||||
| 		} | ||||
| 		a.refreshStatus() | ||||
| 	}) | ||||
| } | ||||
| 
 | ||||
| func (a *ServerController) status(c *gin.Context) { jsonObj(c, a.lastStatus, nil) } | ||||
| func (a *ServerController) status(c *gin.Context) { | ||||
| 	a.lastGetStatusTime = time.Now() | ||||
| 
 | ||||
| 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 | ||||
| 	} | ||||
| 	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 !allowed[bucket] { | ||||
| 		jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket")) | ||||
| 		return | ||||
| 	} | ||||
| 	points := a.serverService.AggregateCpuHistory(bucket, 60) | ||||
| 	jsonObj(c, points, nil) | ||||
| 	jsonObj(c, a.lastStatus, nil) | ||||
| } | ||||
| 
 | ||||
| func (a *ServerController) getXrayVersion(c *gin.Context) { | ||||
| 	now := time.Now().Unix() | ||||
| 	if now-a.lastGetVersionsTime <= 60 { // 1 minute cache
 | ||||
| 	now := time.Now() | ||||
| 	if now.Sub(a.lastGetVersionsTime) <= time.Minute { | ||||
| 		jsonObj(c, a.lastVersions, nil) | ||||
| 		return | ||||
| 	} | ||||
|  | @ -115,7 +95,7 @@ func (a *ServerController) getXrayVersion(c *gin.Context) { | |||
| 	} | ||||
| 
 | ||||
| 	a.lastVersions = versions | ||||
| 	a.lastGetVersionsTime = now | ||||
| 	a.lastGetVersionsTime = time.Now() | ||||
| 
 | ||||
| 	jsonObj(c, versions, nil) | ||||
| } | ||||
|  | @ -133,6 +113,7 @@ 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) | ||||
|  | @ -248,7 +229,9 @@ func (a *ServerController) importDB(c *gin.Context) { | |||
| 	defer file.Close() | ||||
| 	// Always restart Xray before return
 | ||||
| 	defer a.serverService.RestartXrayService() | ||||
| 	// lastGetStatusTime removed; no longer needed
 | ||||
| 	defer func() { | ||||
| 		a.lastGetStatusTime = time.Now() | ||||
| 	}() | ||||
| 	// Import it
 | ||||
| 	err = a.serverService.ImportDB(file) | ||||
| 	if err != nil { | ||||
|  |  | |||
|  | @ -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("/warp/:action", a.warp) | ||||
| 	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("/resetOutboundsTraffic", a.resetOutboundsTraffic) | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -37,7 +37,7 @@ | |||
|     <template slot="content" > | ||||
|       {{ i18n "lastOnline" }}: [[ formatLastOnline(client.email) ]] | ||||
|     </template> | ||||
|     <template v-if="client.enable && isClientOnline(client.email) && !isClientDepleted"> | ||||
|     <template v-if="client.enable && isClientOnline(client.email)"> | ||||
|       <a-tag color="green">{{ i18n "online" }}</a-tag> | ||||
|     </template> | ||||
|     <template v-else> | ||||
|  | @ -49,9 +49,9 @@ | |||
|   <a-space direction="horizontal" :size="2"> | ||||
|     <a-tooltip> | ||||
|       <template slot="title"> | ||||
|         <template v-if="isClientDepleted">{{ i18n "depleted" }}</template> | ||||
|         <template v-if="!isClientDepleted && !client.enable">{{ i18n "disabled" }}</template> | ||||
|         <template v-if="!isClientDepleted && client.enable && isClientOnline(client.email)">{{ i18n "online" }}</template> | ||||
|         <template v-if="!isClientEnabled(record, client.email)">{{ i18n "depleted" }}</template> | ||||
|         <template v-else-if="!client.enable">{{ i18n "disabled" }}</template> | ||||
|         <template v-else-if="client.enable && isClientOnline(client.email)">{{ i18n "online" }}</template> | ||||
|       </template> | ||||
|       <a-badge :class="isClientOnline(client.email)? 'online-animation' : ''" :color="client.enable ? statsExpColor(record, client.email) : themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc'"></a-badge> | ||||
|     </a-tooltip> | ||||
|  |  | |||
|  | @ -210,7 +210,7 @@ | |||
|         </a-form-item> | ||||
|       </template> | ||||
| 
 | ||||
|   <!-- VLESS/VMess user settings --> | ||||
|       <!-- Vnext (vless/vmess) settings --> | ||||
|       <template v-if="[Protocols.VMess, Protocols.VLESS].includes(outbound.protocol)"> | ||||
|         <a-form-item label='ID'> | ||||
|           <a-input v-model.trim="outbound.settings.id"></a-input> | ||||
|  |  | |||
|  | @ -22,10 +22,10 @@ | |||
|         <a-input-number v-model.number="inbound.stream.reality.maxTimediff" :min="0"></a-input-number> | ||||
|     </a-form-item> | ||||
|     <a-form-item label='Min Client Ver'> | ||||
|         <a-input v-model.trim="inbound.stream.reality.minClientVer" placeholder='25.9.11'></a-input> | ||||
|         <a-input v-model.trim="inbound.stream.reality.minClientVer"></a-input> | ||||
|     </a-form-item> | ||||
|     <a-form-item label='Max Client Ver'> | ||||
|         <a-input v-model.trim="inbound.stream.reality.maxClientVer" placeholder='25.9.11'></a-input> | ||||
|         <a-input v-model.trim="inbound.stream.reality.maxClientVer"></a-input> | ||||
|     </a-form-item> | ||||
|     <a-form-item> | ||||
|         <template slot="label"> | ||||
|  |  | |||
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
							
								
								
									
										1169
									
								
								web/html/index.html
									
									
									
									
									
								
							
							
						
						
									
										1169
									
								
								web/html/index.html
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							|  | @ -180,9 +180,9 @@ | |||
|         <tr> | ||||
|           <td>{{ i18n "status" }}</td> | ||||
|           <td> | ||||
|             <a-tag v-if="isEnable && isActive && !isDepleted" color="green">{{ i18n "enabled" }}</a-tag> | ||||
|             <a-tag v-if="!isEnable && !isDepleted">{{ i18n "disabled" }}</a-tag> | ||||
|             <a-tag v-if="isDepleted" color="red">{{ i18n "depleted" }}</a-tag> | ||||
|             <a-tag v-if="isEnable" color="green">{{ i18n "enabled" }}</a-tag> | ||||
|             <a-tag v-else>{{ i18n "disabled" }}</a-tag> | ||||
|             <a-tag v-if="!isActive" color="red">{{ i18n "depleted" }}</a-tag> | ||||
|           </td> | ||||
|         </tr> | ||||
|         <tr v-if="infoModal.clientStats"> | ||||
|  | @ -587,14 +587,6 @@ | |||
|         } | ||||
|         return infoModal.dbInbound.isEnable; | ||||
|       }, | ||||
|       get isDepleted() { | ||||
|         const stats = this.infoModal.clientStats; | ||||
|         if (!stats) return false; | ||||
|         const now = new Date().getTime(); | ||||
|         const expired = stats.expiryTime > 0 && now >= stats.expiryTime; | ||||
|         const exhausted = stats.total > 0 && (stats.up + stats.down) >= stats.total; | ||||
|         return expired || exhausted; | ||||
|       }, | ||||
|     }, | ||||
|     methods: { | ||||
|       copy(content) { | ||||
|  |  | |||
|  | @ -535,9 +535,7 @@ | |||
|         switch (o.protocol) { | ||||
|           case Protocols.VMess: | ||||
|           case Protocols.VLESS: | ||||
|             if (o.settings && o.settings.address && o.settings.port) { | ||||
|               return [o.settings.address + ':' + o.settings.port]; | ||||
|             } | ||||
|             serverObj = o.settings.vnext; | ||||
|             break; | ||||
|           case Protocols.HTTP: | ||||
|           case Protocols.Mixed: | ||||
|  |  | |||
|  | @ -1291,7 +1291,7 @@ func (s *InboundService) AddClientStat(tx *gorm.DB, inboundId int, client *model | |||
| 	clientTraffic.Email = client.Email | ||||
| 	clientTraffic.Total = client.TotalGB | ||||
| 	clientTraffic.ExpiryTime = client.ExpiryTime | ||||
| 	clientTraffic.Enable = client.Enable | ||||
| 	clientTraffic.Enable = true | ||||
| 	clientTraffic.Up = 0 | ||||
| 	clientTraffic.Down = 0 | ||||
| 	clientTraffic.Reset = client.Reset | ||||
|  | @ -1304,7 +1304,7 @@ func (s *InboundService) UpdateClientStat(tx *gorm.DB, email string, client *mod | |||
| 	result := tx.Model(xray.ClientTraffic{}). | ||||
| 		Where("email = ?", email). | ||||
| 		Updates(map[string]any{ | ||||
| 			"enable":      client.Enable, | ||||
| 			"enable":      true, | ||||
| 			"email":       client.Email, | ||||
| 			"total":       client.TotalGB, | ||||
| 			"expiry_time": client.ExpiryTime, | ||||
|  | @ -1856,14 +1856,8 @@ func (s *InboundService) DelDepletedClients(id int) (err error) { | |||
| 		whereText += "= ?" | ||||
| 	} | ||||
| 
 | ||||
| 	// Only consider truly depleted clients: expired OR traffic exhausted
 | ||||
| 	now := time.Now().Unix() * 1000 | ||||
| 	depletedClients := []xray.ClientTraffic{} | ||||
| 	err = db.Model(xray.ClientTraffic{}). | ||||
| 		Where(whereText+" and ((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?))", id, now). | ||||
| 		Select("inbound_id, GROUP_CONCAT(email) as email"). | ||||
| 		Group("inbound_id"). | ||||
| 		Find(&depletedClients).Error | ||||
| 	err = db.Model(xray.ClientTraffic{}).Where(whereText+" and enable = ?", id, false).Select("inbound_id, GROUP_CONCAT(email) as email").Group("inbound_id").Find(&depletedClients).Error | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | @ -1914,8 +1908,7 @@ func (s *InboundService) DelDepletedClients(id int) (err error) { | |||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// Delete stats only for truly depleted clients
 | ||||
| 	err = tx.Where(whereText+" and ((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?))", id, now).Delete(xray.ClientTraffic{}).Error | ||||
| 	err = tx.Where(whereText+" and enable = ?", id, false).Delete(xray.ClientTraffic{}).Error | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | @ -1963,17 +1956,18 @@ func (s *InboundService) GetClientTrafficTgBot(tgId int64) ([]*xray.ClientTraffi | |||
| } | ||||
| 
 | ||||
| func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.ClientTraffic, err error) { | ||||
| 	// Prefer retrieving along with client to reflect actual enabled state from inbound settings
 | ||||
| 	t, client, err := s.GetClientByEmail(email) | ||||
| 	db := database.GetDB() | ||||
| 	var traffics []*xray.ClientTraffic | ||||
| 
 | ||||
| 	err = db.Model(xray.ClientTraffic{}).Where("email = ?", email).Find(&traffics).Error | ||||
| 	if err != nil { | ||||
| 		logger.Warningf("Error retrieving ClientTraffic with email %s: %v", email, err) | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	if t != nil && client != nil { | ||||
| 		// Ensure enable mirrors the client's current enable flag in settings
 | ||||
| 		t.Enable = client.Enable | ||||
| 		return t, nil | ||||
| 	if len(traffics) > 0 { | ||||
| 		return traffics[0], nil | ||||
| 	} | ||||
| 
 | ||||
| 	return nil, nil | ||||
| } | ||||
| 
 | ||||
|  | @ -2008,12 +2002,6 @@ func (s *InboundService) GetClientTrafficByID(id string) ([]xray.ClientTraffic, | |||
| 		logger.Debug(err) | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	// Reconcile enable flag with client settings per email to avoid stale DB value
 | ||||
| 	for i := range traffics { | ||||
| 		if ct, client, e := s.GetClientByEmail(traffics[i].Email); e == nil && ct != nil && client != nil { | ||||
| 			traffics[i].Enable = client.Enable | ||||
| 		} | ||||
| 	} | ||||
| 	return traffics, err | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -16,7 +16,6 @@ import ( | |||
| 	"runtime" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"x-ui/config" | ||||
|  | @ -94,84 +93,11 @@ type Release struct { | |||
| } | ||||
| 
 | ||||
| type ServerService struct { | ||||
| 	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 | ||||
| } | ||||
| 
 | ||||
| // 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
 | ||||
| 	xrayService    XrayService | ||||
| 	inboundService InboundService | ||||
| 	cachedIPv4     string | ||||
| 	cachedIPv6     string | ||||
| 	noIPv6         bool | ||||
| } | ||||
| 
 | ||||
| func getPublicIP(url string) string { | ||||
|  | @ -213,11 +139,11 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status { | |||
| 	} | ||||
| 
 | ||||
| 	// CPU stats
 | ||||
| 	util, err := s.sampleCPUUtilization() | ||||
| 	percents, err := cpu.Percent(0, false) | ||||
| 	if err != nil { | ||||
| 		logger.Warning("get cpu percent failed:", err) | ||||
| 	} else { | ||||
| 		status.Cpu = util | ||||
| 		status.Cpu = percents[0] | ||||
| 	} | ||||
| 
 | ||||
| 	status.CpuCores, err = cpu.Counts(false) | ||||
|  | @ -227,30 +153,13 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status { | |||
| 
 | ||||
| 	status.LogicalPro = runtime.NumCPU() | ||||
| 
 | ||||
| 	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 | ||||
| 	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") | ||||
| 	} | ||||
| 
 | ||||
| 	// Uptime
 | ||||
|  | @ -398,103 +307,6 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status { | |||
| 	return status | ||||
| } | ||||
| 
 | ||||
| func (s *ServerService) AppendCpuSample(t time.Time, v float64) { | ||||
| 	const capacity = 9000 // ~5 hours @ 2s interval
 | ||||
| 	s.mu.Lock() | ||||
| 	defer s.mu.Unlock() | ||||
| 	p := CPUSample{T: t.Unix(), Cpu: v} | ||||
| 	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:] | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func (s *ServerService) sampleCPUUtilization() (float64, error) { | ||||
| 	// Prefer native Windows API to avoid external deps for CPU percent
 | ||||
| 	if runtime.GOOS == "windows" { | ||||
| 		if pct, err := sys.CPUPercentRaw(); err == nil { | ||||
| 			s.mu.Lock() | ||||
| 			// Smooth with EMA
 | ||||
| 			const alpha = 0.3 | ||||
| 			if s.emaCPU == 0 { | ||||
| 				s.emaCPU = pct | ||||
| 			} else { | ||||
| 				s.emaCPU = alpha*pct + (1-alpha)*s.emaCPU | ||||
| 			} | ||||
| 			val := s.emaCPU | ||||
| 			s.mu.Unlock() | ||||
| 			return val, nil | ||||
| 		} | ||||
| 		// If native call fails, fall back to gopsutil times
 | ||||
| 	} | ||||
| 	// Read aggregate CPU times (all CPUs combined)
 | ||||
| 	times, err := cpu.Times(false) | ||||
| 	if err != nil { | ||||
| 		return 0, err | ||||
| 	} | ||||
| 	if len(times) == 0 { | ||||
| 		return 0, fmt.Errorf("no cpu times available") | ||||
| 	} | ||||
| 
 | ||||
| 	cur := times[0] | ||||
| 
 | ||||
| 	s.mu.Lock() | ||||
| 	defer s.mu.Unlock() | ||||
| 
 | ||||
| 	// If this is the first sample, initialize and return current EMA (0 by default)
 | ||||
| 	if !s.hasLastCPUSample { | ||||
| 		s.lastCPUTimes = cur | ||||
| 		s.hasLastCPUSample = true | ||||
| 		return s.emaCPU, nil | ||||
| 	} | ||||
| 
 | ||||
| 	// Compute busy and total deltas
 | ||||
| 	idleDelta := cur.Idle - s.lastCPUTimes.Idle | ||||
| 	// Sum of busy deltas (exclude Idle)
 | ||||
| 	busyDelta := (cur.User - s.lastCPUTimes.User) + | ||||
| 		(cur.System - s.lastCPUTimes.System) + | ||||
| 		(cur.Nice - s.lastCPUTimes.Nice) + | ||||
| 		(cur.Iowait - s.lastCPUTimes.Iowait) + | ||||
| 		(cur.Irq - s.lastCPUTimes.Irq) + | ||||
| 		(cur.Softirq - s.lastCPUTimes.Softirq) + | ||||
| 		(cur.Steal - s.lastCPUTimes.Steal) + | ||||
| 		(cur.Guest - s.lastCPUTimes.Guest) + | ||||
| 		(cur.GuestNice - s.lastCPUTimes.GuestNice) | ||||
| 
 | ||||
| 	totalDelta := busyDelta + idleDelta | ||||
| 
 | ||||
| 	// Update last sample for next time
 | ||||
| 	s.lastCPUTimes = cur | ||||
| 
 | ||||
| 	// Guard against division by zero or negative deltas (e.g., counter resets)
 | ||||
| 	if totalDelta <= 0 { | ||||
| 		return s.emaCPU, nil | ||||
| 	} | ||||
| 
 | ||||
| 	raw := 100.0 * (busyDelta / totalDelta) | ||||
| 	if raw < 0 { | ||||
| 		raw = 0 | ||||
| 	} | ||||
| 	if raw > 100 { | ||||
| 		raw = 100 | ||||
| 	} | ||||
| 
 | ||||
| 	// Exponential moving average to smooth spikes
 | ||||
| 	const alpha = 0.3 // smoothing factor (0<alpha<=1). Higher = more responsive, lower = smoother
 | ||||
| 	if s.emaCPU == 0 { | ||||
| 		// Initialize EMA with the first real reading to avoid long warm-up from zero
 | ||||
| 		s.emaCPU = raw | ||||
| 	} else { | ||||
| 		s.emaCPU = alpha*raw + (1-alpha)*s.emaCPU | ||||
| 	} | ||||
| 
 | ||||
| 	return s.emaCPU, nil | ||||
| } | ||||
| 
 | ||||
| func (s *ServerService) GetXrayVersions() ([]string, error) { | ||||
| 	const ( | ||||
| 		XrayURL    = "https://api.github.com/repos/XTLS/Xray-core/releases" | ||||
|  |  | |||
|  | @ -548,57 +548,6 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool | |||
| 		if len(dataArray) >= 2 && len(dataArray[1]) > 0 { | ||||
| 			email := dataArray[1] | ||||
| 			switch dataArray[0] { | ||||
| 			case "get_clients_for_sub": | ||||
| 				inboundId := dataArray[1] | ||||
| 				inboundIdInt, err := strconv.Atoi(inboundId) | ||||
| 				if err != nil { | ||||
| 					t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) | ||||
| 					return | ||||
| 				} | ||||
| 				clientsKB, err := t.getInboundClientsFor(inboundIdInt, "client_sub_links") | ||||
| 				if err != nil { | ||||
| 					t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) | ||||
| 					return | ||||
| 				} | ||||
| 				inbound, _ := t.inboundService.GetInbound(inboundIdInt) | ||||
| 				t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseClient", "Inbound=="+inbound.Remark), clientsKB) | ||||
| 			case "get_clients_for_individual": | ||||
| 				inboundId := dataArray[1] | ||||
| 				inboundIdInt, err := strconv.Atoi(inboundId) | ||||
| 				if err != nil { | ||||
| 					t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) | ||||
| 					return | ||||
| 				} | ||||
| 				clientsKB, err := t.getInboundClientsFor(inboundIdInt, "client_individual_links") | ||||
| 				if err != nil { | ||||
| 					t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) | ||||
| 					return | ||||
| 				} | ||||
| 				inbound, _ := t.inboundService.GetInbound(inboundIdInt) | ||||
| 				t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseClient", "Inbound=="+inbound.Remark), clientsKB) | ||||
| 			case "get_clients_for_qr": | ||||
| 				inboundId := dataArray[1] | ||||
| 				inboundIdInt, err := strconv.Atoi(inboundId) | ||||
| 				if err != nil { | ||||
| 					t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) | ||||
| 					return | ||||
| 				} | ||||
| 				clientsKB, err := t.getInboundClientsFor(inboundIdInt, "client_qr_links") | ||||
| 				if err != nil { | ||||
| 					t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) | ||||
| 					return | ||||
| 				} | ||||
| 				inbound, _ := t.inboundService.GetInbound(inboundIdInt) | ||||
| 				t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseClient", "Inbound=="+inbound.Remark), clientsKB) | ||||
| 			case "client_sub_links": | ||||
| 				t.sendClientSubLinks(chatId, email) | ||||
| 				return | ||||
| 			case "client_individual_links": | ||||
| 				t.sendClientIndividualLinks(chatId, email) | ||||
| 				return | ||||
| 			case "client_qr_links": | ||||
| 				t.sendClientQRLinks(chatId, email) | ||||
| 				return | ||||
| 			case "client_get_usage": | ||||
| 				t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.messages.email", "Email=="+email)) | ||||
| 				t.searchClient(chatId, email) | ||||
|  | @ -1378,27 +1327,6 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool | |||
| 				} | ||||
| 				t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.allClients")) | ||||
| 				t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseInbound"), inbounds) | ||||
| 			case "admin_client_sub_links": | ||||
| 				inbounds, err := t.getInboundsFor("get_clients_for_sub") | ||||
| 				if err != nil { | ||||
| 					t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) | ||||
| 					return | ||||
| 				} | ||||
| 				t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseInbound"), inbounds) | ||||
| 			case "admin_client_individual_links": | ||||
| 				inbounds, err := t.getInboundsFor("get_clients_for_individual") | ||||
| 				if err != nil { | ||||
| 					t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) | ||||
| 					return | ||||
| 				} | ||||
| 				t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseInbound"), inbounds) | ||||
| 			case "admin_client_qr_links": | ||||
| 				inbounds, err := t.getInboundsFor("get_clients_for_qr") | ||||
| 				if err != nil { | ||||
| 					t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) | ||||
| 					return | ||||
| 				} | ||||
| 				t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseInbound"), inbounds) | ||||
| 			} | ||||
| 
 | ||||
| 		} | ||||
|  | @ -1999,11 +1927,6 @@ func (t *Tgbot) SendAnswer(chatId int64, msg string, isAdmin bool) { | |||
| 			tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.allClients")).WithCallbackData(t.encodeQuery("get_inbounds")), | ||||
| 			tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.addClient")).WithCallbackData(t.encodeQuery("add_client")), | ||||
| 		), | ||||
| 		tu.InlineKeyboardRow( | ||||
| 			tu.InlineKeyboardButton(t.I18nBot("pages.settings.subSettings")).WithCallbackData(t.encodeQuery("admin_client_sub_links")), | ||||
| 			tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("admin_client_individual_links")), | ||||
| 			tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("admin_client_qr_links")), | ||||
| 		), | ||||
| 		// TODOOOOOOOOOOOOOO: Add restart button here.
 | ||||
| 	) | ||||
| 	numericKeyboardClient := tu.InlineKeyboard( | ||||
|  | @ -2150,10 +2073,7 @@ func (t *Tgbot) sendClientSubLinks(chatId int64, email string) { | |||
| 		"JSON URL:\r\n<code>" + subJsonURL + "</code>" | ||||
| 	inlineKeyboard := tu.InlineKeyboard( | ||||
| 		tu.InlineKeyboardRow( | ||||
| 			tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links "+email)), | ||||
| 		), | ||||
| 		tu.InlineKeyboardRow( | ||||
| 			tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("client_qr_links "+email)), | ||||
| 			tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links " + email)), | ||||
| 		), | ||||
| 	) | ||||
| 	t.SendMsgToTgbot(chatId, msg, inlineKeyboard) | ||||
|  | @ -2539,74 +2459,6 @@ func (t *Tgbot) getInbounds() (*telego.InlineKeyboardMarkup, error) { | |||
| 	return keyboard, nil | ||||
| } | ||||
| 
 | ||||
| // getInboundsFor builds an inline keyboard of inbounds where each button leads to a custom next action
 | ||||
| // nextAction should be one of: get_clients_for_sub|get_clients_for_individual|get_clients_for_qr
 | ||||
| func (t *Tgbot) getInboundsFor(nextAction string) (*telego.InlineKeyboardMarkup, error) { | ||||
| 	inbounds, err := t.inboundService.GetAllInbounds() | ||||
| 	if err != nil { | ||||
| 		logger.Warning("GetAllInbounds run failed:", err) | ||||
| 		return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) | ||||
| 	} | ||||
| 
 | ||||
| 	if len(inbounds) == 0 { | ||||
| 		logger.Warning("No inbounds found") | ||||
| 		return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) | ||||
| 	} | ||||
| 
 | ||||
| 	var buttons []telego.InlineKeyboardButton | ||||
| 	for _, inbound := range inbounds { | ||||
| 		status := "❌" | ||||
| 		if inbound.Enable { | ||||
| 			status = "✅" | ||||
| 		} | ||||
| 		callbackData := t.encodeQuery(fmt.Sprintf("%s %d", nextAction, inbound.Id)) | ||||
| 		buttons = append(buttons, tu.InlineKeyboardButton(fmt.Sprintf("%v - %v", inbound.Remark, status)).WithCallbackData(callbackData)) | ||||
| 	} | ||||
| 
 | ||||
| 	cols := 1 | ||||
| 	if len(buttons) >= 6 { | ||||
| 		cols = 2 | ||||
| 	} | ||||
| 
 | ||||
| 	keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...)) | ||||
| 	return keyboard, nil | ||||
| } | ||||
| 
 | ||||
| // getInboundClientsFor lists clients of an inbound with a specific action prefix to be appended with email
 | ||||
| func (t *Tgbot) getInboundClientsFor(inboundID int, action string) (*telego.InlineKeyboardMarkup, error) { | ||||
| 	inbound, err := t.inboundService.GetInbound(inboundID) | ||||
| 	if err != nil { | ||||
| 		logger.Warning("getInboundClientsFor run failed:", err) | ||||
| 		return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) | ||||
| 	} | ||||
| 	clients, err := t.inboundService.GetClients(inbound) | ||||
| 	var buttons []telego.InlineKeyboardButton | ||||
| 
 | ||||
| 	if err != nil { | ||||
| 		logger.Warning("GetInboundClients run failed:", err) | ||||
| 		return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) | ||||
| 	} else { | ||||
| 		if len(clients) > 0 { | ||||
| 			for _, client := range clients { | ||||
| 				buttons = append(buttons, tu.InlineKeyboardButton(client.Email).WithCallbackData(t.encodeQuery(action+" "+client.Email))) | ||||
| 			} | ||||
| 
 | ||||
| 		} else { | ||||
| 			return nil, errors.New(t.I18nBot("tgbot.answers.getClientsFailed")) | ||||
| 		} | ||||
| 
 | ||||
| 	} | ||||
| 	cols := 0 | ||||
| 	if len(buttons) < 6 { | ||||
| 		cols = 3 | ||||
| 	} else { | ||||
| 		cols = 2 | ||||
| 	} | ||||
| 	keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...)) | ||||
| 
 | ||||
| 	return keyboard, nil | ||||
| } | ||||
| 
 | ||||
| func (t *Tgbot) getInboundsAddClient() (*telego.InlineKeyboardMarkup, error) { | ||||
| 	inbounds, err := t.inboundService.GetAllInbounds() | ||||
| 	if err != nil { | ||||
|  |  | |||
							
								
								
									
										19
									
								
								web/web.go
									
									
									
									
									
								
							
							
						
						
									
										19
									
								
								web/web.go
									
									
									
									
									
								
							|  | @ -289,13 +289,18 @@ func (s *Server) startTask() { | |||
| 	// check client ips from log file every day
 | ||||
| 	s.cron.AddJob("@daily", job.NewClearLogsJob()) | ||||
| 
 | ||||
| 	// Inbound traffic reset jobs
 | ||||
| 	// Run once a day, midnight
 | ||||
| 	s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("daily")) | ||||
| 	// Run once a week, midnight between Sat/Sun
 | ||||
| 	s.cron.AddJob("@weekly", job.NewPeriodicTrafficResetJob("weekly")) | ||||
| 	// Run once a month, midnight, first of month
 | ||||
| 	s.cron.AddJob("@monthly", job.NewPeriodicTrafficResetJob("monthly")) | ||||
| 	// Periodic traffic resets
 | ||||
| 	logger.Info("Scheduling periodic traffic reset jobs") | ||||
| 	{ | ||||
| 		// Inbound traffic reset jobs
 | ||||
| 		// Run once a day, midnight
 | ||||
| 		s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("daily")) | ||||
| 		// Run once a week, midnight between Sat/Sun
 | ||||
| 		s.cron.AddJob("@weekly", job.NewPeriodicTrafficResetJob("weekly")) | ||||
| 		// Run once a month, midnight, first of month
 | ||||
| 		s.cron.AddJob("@monthly", job.NewPeriodicTrafficResetJob("monthly")) | ||||
| 
 | ||||
| 	} | ||||
| 
 | ||||
| 	// Make a traffic condition every day, 8:30
 | ||||
| 	var entry cron.EntryID | ||||
|  |  | |||
		Loading…
	
		Reference in a new issue