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 | 	github.com/xtls/xray-core v1.250911.0 | ||||||
| 	go.uber.org/atomic v1.11.0 | 	go.uber.org/atomic v1.11.0 | ||||||
| 	golang.org/x/crypto v0.42.0 | 	golang.org/x/crypto v0.42.0 | ||||||
| 	golang.org/x/sys v0.36.0 |  | ||||||
| 	golang.org/x/text v0.29.0 | 	golang.org/x/text v0.29.0 | ||||||
| 	google.golang.org/grpc v1.75.1 | 	google.golang.org/grpc v1.75.1 | ||||||
| 	gorm.io/driver/sqlite v1.6.0 | 	gorm.io/driver/sqlite v1.6.0 | ||||||
|  | @ -91,6 +90,7 @@ require ( | ||||||
| 	golang.org/x/mod v0.28.0 // indirect | 	golang.org/x/mod v0.28.0 // indirect | ||||||
| 	golang.org/x/net v0.44.0 // indirect | 	golang.org/x/net v0.44.0 // indirect | ||||||
| 	golang.org/x/sync v0.17.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/time v0.13.0 // indirect | ||||||
| 	golang.org/x/tools v0.36.0 // indirect | 	golang.org/x/tools v0.36.0 // indirect | ||||||
| 	golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // 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" | 	"os" | ||||||
| 	"path/filepath" | 	"path/filepath" | ||||||
| 	"strconv" | 	"strconv" | ||||||
| 	"strings" |  | ||||||
| 
 | 
 | ||||||
| 	"x-ui/logger" | 	"x-ui/logger" | ||||||
| 	"x-ui/util/common" | 	"x-ui/util/common" | ||||||
|  | @ -75,6 +74,11 @@ func (s *Server) initRouter() (*gin.Engine, error) { | ||||||
| 		engine.Use(middleware.DomainValidatorMiddleware(subDomain)) | 		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() | 	LinksPath, err := s.settingService.GetSubPath() | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, err | 		return nil, err | ||||||
|  | @ -85,11 +89,6 @@ func (s *Server) initRouter() (*gin.Engine, error) { | ||||||
| 		return nil, err | 		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() | 	Encrypt, err := s.settingService.GetSubEncrypt() | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, err | 		return nil, err | ||||||
|  | @ -155,29 +154,11 @@ func (s *Server) initRouter() (*gin.Engine, error) { | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Assets: use disk if present, fallback to embedded
 | 	// 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 { | 	if _, err := os.Stat("web/assets"); err == nil { | ||||||
| 		engine.StaticFS("/assets", http.FS(os.DirFS("web/assets"))) | 		engine.StaticFS("/assets", http.FS(os.DirFS("web/assets"))) | ||||||
| 		if linksPathForAssets != "/assets" { |  | ||||||
| 			engine.StaticFS(linksPathForAssets, http.FS(os.DirFS("web/assets"))) |  | ||||||
| 		} |  | ||||||
| 	} else { | 	} else { | ||||||
| 		if subFS, err := fs.Sub(webpkg.EmbeddedAssets(), "assets"); err == nil { | 		if subFS, err := fs.Sub(webpkg.EmbeddedAssets(), "assets"); err == nil { | ||||||
| 			engine.StaticFS("/assets", http.FS(subFS)) | 			engine.StaticFS("/assets", http.FS(subFS)) | ||||||
| 			if linksPathForAssets != "/assets" { |  | ||||||
| 				engine.StaticFS(linksPathForAssets, http.FS(subFS)) |  | ||||||
| 			} |  | ||||||
| 		} else { | 		} else { | ||||||
| 			logger.Error("sub: failed to mount embedded assets:", err) | 			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 { | func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, encryption string) json_util.RawMessage { | ||||||
| 	outbound := Outbound{} | 	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.Protocol = string(inbound.Protocol) | ||||||
| 	outbound.Tag = "proxy" | 	outbound.Tag = "proxy" | ||||||
| 	if s.mux != "" { | 	if s.mux != "" { | ||||||
| 		outbound.Mux = json_util.RawMessage(s.mux) | 		outbound.Mux = json_util.RawMessage(s.mux) | ||||||
| 	} | 	} | ||||||
| 	outbound.StreamSettings = streamSettings | 	outbound.StreamSettings = streamSettings | ||||||
| 	// Emit flattened settings inside Settings to match new Xray format
 | 	outbound.Settings = OutboundSettings{ | ||||||
| 	settings := make(map[string]any) | 		Vnext: vnextData, | ||||||
| 	settings["address"] = inbound.Listen |  | ||||||
| 	settings["port"] = inbound.Port |  | ||||||
| 	settings["id"] = client.ID |  | ||||||
| 	if inbound.Protocol == model.VLESS { |  | ||||||
| 		settings["flow"] = client.Flow |  | ||||||
| 		settings["encryption"] = encryption |  | ||||||
| 	} | 	} | ||||||
| 	if inbound.Protocol == model.VMESS { |  | ||||||
| 		settings["security"] = client.Security |  | ||||||
| 	} |  | ||||||
| 	outbound.Settings = settings |  | ||||||
| 
 | 
 | ||||||
| 	result, _ := json.MarshalIndent(outbound, "", "  ") | 	result, _ := json.MarshalIndent(outbound, "", "  ") | ||||||
| 	return result | 	return result | ||||||
|  | @ -347,8 +356,8 @@ func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_u | ||||||
| 		outbound.Mux = json_util.RawMessage(s.mux) | 		outbound.Mux = json_util.RawMessage(s.mux) | ||||||
| 	} | 	} | ||||||
| 	outbound.StreamSettings = streamSettings | 	outbound.StreamSettings = streamSettings | ||||||
| 	outbound.Settings = map[string]any{ | 	outbound.Settings = OutboundSettings{ | ||||||
| 		"servers": serverData, | 		Servers: serverData, | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	result, _ := json.MarshalIndent(outbound, "", "  ") | 	result, _ := json.MarshalIndent(outbound, "", "  ") | ||||||
|  | @ -360,10 +369,28 @@ type Outbound struct { | ||||||
| 	Tag            string               `json:"tag"` | 	Tag            string               `json:"tag"` | ||||||
| 	StreamSettings json_util.RawMessage `json:"streamSettings"` | 	StreamSettings json_util.RawMessage `json:"streamSettings"` | ||||||
| 	Mux            json_util.RawMessage `json:"mux,omitempty"` | 	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 { | type ServerSetting struct { | ||||||
| 	Password string `json:"password"` | 	Password string `json:"password"` | ||||||
|  |  | ||||||
|  | @ -5,6 +5,7 @@ import ( | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"net" | 	"net" | ||||||
| 	"net/url" | 	"net/url" | ||||||
|  | 	"strconv" | ||||||
| 	"strings" | 	"strings" | ||||||
| 	"time" | 	"time" | ||||||
| 
 | 
 | ||||||
|  | @ -1130,7 +1131,7 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray | ||||||
| 
 | 
 | ||||||
| 	return PageData{ | 	return PageData{ | ||||||
| 		Host:         hostHeader, | 		Host:         hostHeader, | ||||||
| 		BasePath:     "/", // kept as "/"; templates now use context base_path injected from router
 | 		BasePath:     "/", | ||||||
| 		SId:          subId, | 		SId:          subId, | ||||||
| 		Download:     download, | 		Download:     download, | ||||||
| 		Upload:       upload, | 		Upload:       upload, | ||||||
|  | @ -1159,3 +1160,10 @@ func getHostFromXFH(s string) (string, error) { | ||||||
| 	} | 	} | ||||||
| 	return s, nil | 	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 | package sys | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"encoding/binary" |  | ||||||
| 	"fmt" |  | ||||||
| 	"sync" |  | ||||||
| 
 |  | ||||||
| 	"github.com/shirou/gopsutil/v4/net" | 	"github.com/shirou/gopsutil/v4/net" | ||||||
| 	"golang.org/x/sys/unix" |  | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| func GetTCPCount() (int, error) { | func GetTCPCount() (int, error) { | ||||||
|  | @ -27,69 +22,3 @@ func GetUDPCount() (int, error) { | ||||||
| 	} | 	} | ||||||
| 	return len(stats), nil | 	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 | package sys | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"bufio" |  | ||||||
| 	"bytes" | 	"bytes" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"io" | 	"io" | ||||||
| 	"os" | 	"os" | ||||||
| 	"strconv" |  | ||||||
| 	"strings" |  | ||||||
| 	"sync" |  | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| func getLinesNum(filename string) (int, error) { | func getLinesNum(filename string) (int, error) { | ||||||
|  | @ -83,99 +79,3 @@ func safeGetLinesNum(path string) (int, error) { | ||||||
| 	} | 	} | ||||||
| 	return getLinesNum(path) | 	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 ( | import ( | ||||||
| 	"errors" | 	"errors" | ||||||
| 	"sync" |  | ||||||
| 	"syscall" |  | ||||||
| 	"unsafe" |  | ||||||
| 
 | 
 | ||||||
| 	"github.com/shirou/gopsutil/v4/net" | 	"github.com/shirou/gopsutil/v4/net" | ||||||
| ) | ) | ||||||
|  | @ -31,81 +28,3 @@ func GetTCPCount() (int, error) { | ||||||
| func GetUDPCount() (int, error) { | func GetUDPCount() (int, error) { | ||||||
| 	return GetConnectionCount("udp") | 	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 { | class WsStreamSettings extends CommonClass { | ||||||
|     constructor( |     constructor( | ||||||
|         path = '/', |         path = '/',  | ||||||
|         host = '', |         host = '', | ||||||
|         heartbeatPeriod = 0, |         heartbeatPeriod = 0, | ||||||
| 
 | 
 | ||||||
|  | @ -647,6 +647,10 @@ class Outbound extends CommonClass { | ||||||
|         ].includes(this.protocol); |         ].includes(this.protocol); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     hasVnext() { | ||||||
|  |         return [Protocols.VMess, Protocols.VLESS].includes(this.protocol); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     hasServers() { |     hasServers() { | ||||||
|         return [Protocols.Trojan, Protocols.Shadowsocks, Protocols.Socks, Protocols.HTTP].includes(this.protocol); |         return [Protocols.Trojan, Protocols.Shadowsocks, Protocols.Socks, Protocols.HTTP].includes(this.protocol); | ||||||
|     } |     } | ||||||
|  | @ -686,22 +690,13 @@ class Outbound extends CommonClass { | ||||||
|             if (this.stream?.sockopt) |             if (this.stream?.sockopt) | ||||||
|                 stream = { sockopt: this.stream.sockopt.toJson() }; |                 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 { |         return { | ||||||
|  |             tag: this.tag == '' ? undefined : this.tag, | ||||||
|             protocol: this.protocol, |             protocol: this.protocol, | ||||||
|             settings: settingsOut, |             settings: this.settings instanceof CommonClass ? this.settings.toJson() : this.settings, | ||||||
|             // Only include tag, streamSettings, sendThrough, mux if present and not empty
 |             streamSettings: stream, | ||||||
|             ...(this.tag ? { tag: this.tag } : {}), |             sendThrough: this.sendThrough != "" ? this.sendThrough : undefined, | ||||||
|             ...(stream ? { streamSettings: stream } : {}), |             mux: this.mux?.enabled ? this.mux : undefined, | ||||||
|             ...(this.sendThrough ? { sendThrough: this.sendThrough } : {}), |  | ||||||
|             ...(this.mux?.enabled ? { mux: this.mux } : {}), |  | ||||||
|         }; |         }; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -913,7 +908,7 @@ Outbound.FreedomSettings = class extends CommonClass { | ||||||
|     toJson() { |     toJson() { | ||||||
|         return { |         return { | ||||||
|             domainStrategy: ObjectUtil.isEmpty(this.domainStrategy) ? undefined : this.domainStrategy, |             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, |             fragment: Object.keys(this.fragment).length === 0 ? undefined : this.fragment, | ||||||
|             noises: this.noises.length === 0 ? undefined : Outbound.FreedomSettings.Noise.toJsonArray(this.noises), |             noises: this.noises.length === 0 ? undefined : Outbound.FreedomSettings.Noise.toJsonArray(this.noises), | ||||||
|         }; |         }; | ||||||
|  | @ -1031,21 +1026,22 @@ Outbound.VmessSettings = class extends CommonClass { | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     static fromJson(json = {}) { |     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( |         return new Outbound.VmessSettings( | ||||||
|             json.address, |             json.vnext[0].address, | ||||||
|             json.port, |             json.vnext[0].port, | ||||||
|             json.id, |             json.vnext[0].users[0].id, | ||||||
|             json.security, |             json.vnext[0].users[0].security, | ||||||
|         ); |         ); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     toJson() { |     toJson() { | ||||||
|         return { |         return { | ||||||
|             address: this.address, |             vnext: [{ | ||||||
|             port: this.port, |                 address: this.address, | ||||||
|             id: this.id, |                 port: this.port, | ||||||
|             security: this.security, |                 users: [{ id: this.id, security: this.security }], | ||||||
|  |             }], | ||||||
|         }; |         }; | ||||||
|     } |     } | ||||||
| }; | }; | ||||||
|  | @ -1060,23 +1056,23 @@ Outbound.VLESSSettings = class extends CommonClass { | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     static fromJson(json = {}) { |     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( |         return new Outbound.VLESSSettings( | ||||||
|             json.address, |             json.vnext[0].address, | ||||||
|             json.port, |             json.vnext[0].port, | ||||||
|             json.id, |             json.vnext[0].users[0].id, | ||||||
|             json.flow, |             json.vnext[0].users[0].flow, | ||||||
|             json.encryption |             json.vnext[0].users[0].encryption, | ||||||
|         ); |         ); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     toJson() { |     toJson() { | ||||||
|         return { |         return { | ||||||
|             address: this.address, |             vnext: [{ | ||||||
|             port: this.port, |                 address: this.address, | ||||||
|             id: this.id, |                 port: this.port, | ||||||
|             flow: this.flow, |                 users: [{ id: this.id, flow: this.flow, encryption: this.encryption }], | ||||||
|             encryption: this.encryption, |             }], | ||||||
|         }; |         }; | ||||||
|     } |     } | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | @ -4,7 +4,6 @@ import ( | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"net/http" | 	"net/http" | ||||||
| 	"regexp" | 	"regexp" | ||||||
| 	"strconv" |  | ||||||
| 	"time" | 	"time" | ||||||
| 
 | 
 | ||||||
| 	"x-ui/web/global" | 	"x-ui/web/global" | ||||||
|  | @ -21,14 +20,17 @@ type ServerController struct { | ||||||
| 	serverService  service.ServerService | 	serverService  service.ServerService | ||||||
| 	settingService service.SettingService | 	settingService service.SettingService | ||||||
| 
 | 
 | ||||||
| 	lastStatus *service.Status | 	lastStatus        *service.Status | ||||||
|  | 	lastGetStatusTime time.Time | ||||||
| 
 | 
 | ||||||
| 	lastVersions        []string | 	lastVersions        []string | ||||||
| 	lastGetVersionsTime int64 // unix seconds
 | 	lastGetVersionsTime time.Time | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func NewServerController(g *gin.RouterGroup) *ServerController { | func NewServerController(g *gin.RouterGroup) *ServerController { | ||||||
| 	a := &ServerController{} | 	a := &ServerController{ | ||||||
|  | 		lastGetStatusTime: time.Now(), | ||||||
|  | 	} | ||||||
| 	a.initRouter(g) | 	a.initRouter(g) | ||||||
| 	a.startTask() | 	a.startTask() | ||||||
| 	return a | 	return a | ||||||
|  | @ -37,7 +39,6 @@ func NewServerController(g *gin.RouterGroup) *ServerController { | ||||||
| func (a *ServerController) initRouter(g *gin.RouterGroup) { | func (a *ServerController) initRouter(g *gin.RouterGroup) { | ||||||
| 
 | 
 | ||||||
| 	g.GET("/status", a.status) | 	g.GET("/status", a.status) | ||||||
| 	g.GET("/cpuHistory/:bucket", a.getCpuHistoryBucket) |  | ||||||
| 	g.GET("/getXrayVersion", a.getXrayVersion) | 	g.GET("/getXrayVersion", a.getXrayVersion) | ||||||
| 	g.GET("/getConfigJson", a.getConfigJson) | 	g.GET("/getConfigJson", a.getConfigJson) | ||||||
| 	g.GET("/getDb", a.getDb) | 	g.GET("/getDb", a.getDb) | ||||||
|  | @ -60,50 +61,29 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) { | ||||||
| 
 | 
 | ||||||
| func (a *ServerController) refreshStatus() { | func (a *ServerController) refreshStatus() { | ||||||
| 	a.lastStatus = a.serverService.GetStatus(a.lastStatus) | 	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() { | func (a *ServerController) startTask() { | ||||||
| 	webServer := global.GetWebServer() | 	webServer := global.GetWebServer() | ||||||
| 	c := webServer.GetCron() | 	c := webServer.GetCron() | ||||||
| 	c.AddFunc("@every 2s", func() { | 	c.AddFunc("@every 2s", func() { | ||||||
| 		// Always refresh to keep CPU history collected continuously.
 | 		now := time.Now() | ||||||
| 		// Sampling is lightweight and capped to ~6 hours in memory.
 | 		if now.Sub(a.lastGetStatusTime) > time.Minute*3 { | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
| 		a.refreshStatus() | 		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) { | 	jsonObj(c, a.lastStatus, nil) | ||||||
| 	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) |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (a *ServerController) getXrayVersion(c *gin.Context) { | func (a *ServerController) getXrayVersion(c *gin.Context) { | ||||||
| 	now := time.Now().Unix() | 	now := time.Now() | ||||||
| 	if now-a.lastGetVersionsTime <= 60 { // 1 minute cache
 | 	if now.Sub(a.lastGetVersionsTime) <= time.Minute { | ||||||
| 		jsonObj(c, a.lastVersions, nil) | 		jsonObj(c, a.lastVersions, nil) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  | @ -115,7 +95,7 @@ func (a *ServerController) getXrayVersion(c *gin.Context) { | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	a.lastVersions = versions | 	a.lastVersions = versions | ||||||
| 	a.lastGetVersionsTime = now | 	a.lastGetVersionsTime = time.Now() | ||||||
| 
 | 
 | ||||||
| 	jsonObj(c, versions, nil) | 	jsonObj(c, versions, nil) | ||||||
| } | } | ||||||
|  | @ -133,6 +113,7 @@ func (a *ServerController) updateGeofile(c *gin.Context) { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (a *ServerController) stopXrayService(c *gin.Context) { | func (a *ServerController) stopXrayService(c *gin.Context) { | ||||||
|  | 	a.lastGetStatusTime = time.Now() | ||||||
| 	err := a.serverService.StopXrayService() | 	err := a.serverService.StopXrayService() | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		jsonMsg(c, I18nWeb(c, "pages.xray.stopError"), err) | 		jsonMsg(c, I18nWeb(c, "pages.xray.stopError"), err) | ||||||
|  | @ -248,7 +229,9 @@ func (a *ServerController) importDB(c *gin.Context) { | ||||||
| 	defer file.Close() | 	defer file.Close() | ||||||
| 	// Always restart Xray before return
 | 	// Always restart Xray before return
 | ||||||
| 	defer a.serverService.RestartXrayService() | 	defer a.serverService.RestartXrayService() | ||||||
| 	// lastGetStatusTime removed; no longer needed
 | 	defer func() { | ||||||
|  | 		a.lastGetStatusTime = time.Now() | ||||||
|  | 	}() | ||||||
| 	// Import it
 | 	// Import it
 | ||||||
| 	err = a.serverService.ImportDB(file) | 	err = a.serverService.ImportDB(file) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
|  |  | ||||||
|  | @ -23,13 +23,13 @@ func NewXraySettingController(g *gin.RouterGroup) *XraySettingController { | ||||||
| 
 | 
 | ||||||
| func (a *XraySettingController) initRouter(g *gin.RouterGroup) { | func (a *XraySettingController) initRouter(g *gin.RouterGroup) { | ||||||
| 	g = g.Group("/xray") | 	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("/", a.getXraySetting) | ||||||
| 	g.POST("/warp/:action", a.warp) |  | ||||||
| 	g.POST("/update", a.updateSetting) | 	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) | 	g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -37,7 +37,7 @@ | ||||||
|     <template slot="content" > |     <template slot="content" > | ||||||
|       {{ i18n "lastOnline" }}: [[ formatLastOnline(client.email) ]] |       {{ i18n "lastOnline" }}: [[ formatLastOnline(client.email) ]] | ||||||
|     </template> |     </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> |       <a-tag color="green">{{ i18n "online" }}</a-tag> | ||||||
|     </template> |     </template> | ||||||
|     <template v-else> |     <template v-else> | ||||||
|  | @ -49,9 +49,9 @@ | ||||||
|   <a-space direction="horizontal" :size="2"> |   <a-space direction="horizontal" :size="2"> | ||||||
|     <a-tooltip> |     <a-tooltip> | ||||||
|       <template slot="title"> |       <template slot="title"> | ||||||
|         <template v-if="isClientDepleted">{{ i18n "depleted" }}</template> |         <template v-if="!isClientEnabled(record, client.email)">{{ i18n "depleted" }}</template> | ||||||
|         <template v-if="!isClientDepleted && !client.enable">{{ i18n "disabled" }}</template> |         <template v-else-if="!client.enable">{{ i18n "disabled" }}</template> | ||||||
|         <template v-if="!isClientDepleted && client.enable && isClientOnline(client.email)">{{ i18n "online" }}</template> |         <template v-else-if="client.enable && isClientOnline(client.email)">{{ i18n "online" }}</template> | ||||||
|       </template> |       </template> | ||||||
|       <a-badge :class="isClientOnline(client.email)? 'online-animation' : ''" :color="client.enable ? statsExpColor(record, client.email) : themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc'"></a-badge> |       <a-badge :class="isClientOnline(client.email)? 'online-animation' : ''" :color="client.enable ? statsExpColor(record, client.email) : themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc'"></a-badge> | ||||||
|     </a-tooltip> |     </a-tooltip> | ||||||
|  |  | ||||||
|  | @ -210,7 +210,7 @@ | ||||||
|         </a-form-item> |         </a-form-item> | ||||||
|       </template> |       </template> | ||||||
| 
 | 
 | ||||||
|   <!-- VLESS/VMess user settings --> |       <!-- Vnext (vless/vmess) settings --> | ||||||
|       <template v-if="[Protocols.VMess, Protocols.VLESS].includes(outbound.protocol)"> |       <template v-if="[Protocols.VMess, Protocols.VLESS].includes(outbound.protocol)"> | ||||||
|         <a-form-item label='ID'> |         <a-form-item label='ID'> | ||||||
|           <a-input v-model.trim="outbound.settings.id"></a-input> |           <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-input-number v-model.number="inbound.stream.reality.maxTimediff" :min="0"></a-input-number> | ||||||
|     </a-form-item> |     </a-form-item> | ||||||
|     <a-form-item label='Min Client Ver'> |     <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> | ||||||
|     <a-form-item label='Max Client Ver'> |     <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> | ||||||
|     <a-form-item> |     <a-form-item> | ||||||
|         <template slot="label"> |         <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> |         <tr> | ||||||
|           <td>{{ i18n "status" }}</td> |           <td>{{ i18n "status" }}</td> | ||||||
|           <td> |           <td> | ||||||
|             <a-tag v-if="isEnable && isActive && !isDepleted" color="green">{{ i18n "enabled" }}</a-tag> |             <a-tag v-if="isEnable" color="green">{{ i18n "enabled" }}</a-tag> | ||||||
|             <a-tag v-if="!isEnable && !isDepleted">{{ i18n "disabled" }}</a-tag> |             <a-tag v-else>{{ i18n "disabled" }}</a-tag> | ||||||
|             <a-tag v-if="isDepleted" color="red">{{ i18n "depleted" }}</a-tag> |             <a-tag v-if="!isActive" color="red">{{ i18n "depleted" }}</a-tag> | ||||||
|           </td> |           </td> | ||||||
|         </tr> |         </tr> | ||||||
|         <tr v-if="infoModal.clientStats"> |         <tr v-if="infoModal.clientStats"> | ||||||
|  | @ -587,14 +587,6 @@ | ||||||
|         } |         } | ||||||
|         return infoModal.dbInbound.isEnable; |         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: { |     methods: { | ||||||
|       copy(content) { |       copy(content) { | ||||||
|  |  | ||||||
|  | @ -535,9 +535,7 @@ | ||||||
|         switch (o.protocol) { |         switch (o.protocol) { | ||||||
|           case Protocols.VMess: |           case Protocols.VMess: | ||||||
|           case Protocols.VLESS: |           case Protocols.VLESS: | ||||||
|             if (o.settings && o.settings.address && o.settings.port) { |             serverObj = o.settings.vnext; | ||||||
|               return [o.settings.address + ':' + o.settings.port]; |  | ||||||
|             } |  | ||||||
|             break; |             break; | ||||||
|           case Protocols.HTTP: |           case Protocols.HTTP: | ||||||
|           case Protocols.Mixed: |           case Protocols.Mixed: | ||||||
|  |  | ||||||
|  | @ -1291,7 +1291,7 @@ func (s *InboundService) AddClientStat(tx *gorm.DB, inboundId int, client *model | ||||||
| 	clientTraffic.Email = client.Email | 	clientTraffic.Email = client.Email | ||||||
| 	clientTraffic.Total = client.TotalGB | 	clientTraffic.Total = client.TotalGB | ||||||
| 	clientTraffic.ExpiryTime = client.ExpiryTime | 	clientTraffic.ExpiryTime = client.ExpiryTime | ||||||
| 	clientTraffic.Enable = client.Enable | 	clientTraffic.Enable = true | ||||||
| 	clientTraffic.Up = 0 | 	clientTraffic.Up = 0 | ||||||
| 	clientTraffic.Down = 0 | 	clientTraffic.Down = 0 | ||||||
| 	clientTraffic.Reset = client.Reset | 	clientTraffic.Reset = client.Reset | ||||||
|  | @ -1304,7 +1304,7 @@ func (s *InboundService) UpdateClientStat(tx *gorm.DB, email string, client *mod | ||||||
| 	result := tx.Model(xray.ClientTraffic{}). | 	result := tx.Model(xray.ClientTraffic{}). | ||||||
| 		Where("email = ?", email). | 		Where("email = ?", email). | ||||||
| 		Updates(map[string]any{ | 		Updates(map[string]any{ | ||||||
| 			"enable":      client.Enable, | 			"enable":      true, | ||||||
| 			"email":       client.Email, | 			"email":       client.Email, | ||||||
| 			"total":       client.TotalGB, | 			"total":       client.TotalGB, | ||||||
| 			"expiry_time": client.ExpiryTime, | 			"expiry_time": client.ExpiryTime, | ||||||
|  | @ -1856,14 +1856,8 @@ func (s *InboundService) DelDepletedClients(id int) (err error) { | ||||||
| 		whereText += "= ?" | 		whereText += "= ?" | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Only consider truly depleted clients: expired OR traffic exhausted
 |  | ||||||
| 	now := time.Now().Unix() * 1000 |  | ||||||
| 	depletedClients := []xray.ClientTraffic{} | 	depletedClients := []xray.ClientTraffic{} | ||||||
| 	err = db.Model(xray.ClientTraffic{}). | 	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 | ||||||
| 		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 |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		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 enable = ?", id, false).Delete(xray.ClientTraffic{}).Error | ||||||
| 	err = tx.Where(whereText+" and ((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?))", id, now).Delete(xray.ClientTraffic{}).Error |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		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) { | func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.ClientTraffic, err error) { | ||||||
| 	// Prefer retrieving along with client to reflect actual enabled state from inbound settings
 | 	db := database.GetDB() | ||||||
| 	t, client, err := s.GetClientByEmail(email) | 	var traffics []*xray.ClientTraffic | ||||||
|  | 
 | ||||||
|  | 	err = db.Model(xray.ClientTraffic{}).Where("email = ?", email).Find(&traffics).Error | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		logger.Warningf("Error retrieving ClientTraffic with email %s: %v", email, err) | 		logger.Warningf("Error retrieving ClientTraffic with email %s: %v", email, err) | ||||||
| 		return nil, err | 		return nil, err | ||||||
| 	} | 	} | ||||||
| 	if t != nil && client != nil { | 	if len(traffics) > 0 { | ||||||
| 		// Ensure enable mirrors the client's current enable flag in settings
 | 		return traffics[0], nil | ||||||
| 		t.Enable = client.Enable |  | ||||||
| 		return t, nil |  | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
| 	return nil, nil | 	return nil, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -2008,12 +2002,6 @@ func (s *InboundService) GetClientTrafficByID(id string) ([]xray.ClientTraffic, | ||||||
| 		logger.Debug(err) | 		logger.Debug(err) | ||||||
| 		return nil, 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 | 	return traffics, err | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -16,7 +16,6 @@ import ( | ||||||
| 	"runtime" | 	"runtime" | ||||||
| 	"strconv" | 	"strconv" | ||||||
| 	"strings" | 	"strings" | ||||||
| 	"sync" |  | ||||||
| 	"time" | 	"time" | ||||||
| 
 | 
 | ||||||
| 	"x-ui/config" | 	"x-ui/config" | ||||||
|  | @ -94,84 +93,11 @@ type Release struct { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type ServerService struct { | type ServerService struct { | ||||||
| 	xrayService        XrayService | 	xrayService    XrayService | ||||||
| 	inboundService     InboundService | 	inboundService InboundService | ||||||
| 	cachedIPv4         string | 	cachedIPv4     string | ||||||
| 	cachedIPv6         string | 	cachedIPv6     string | ||||||
| 	noIPv6             bool | 	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
 |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func getPublicIP(url string) string { | func getPublicIP(url string) string { | ||||||
|  | @ -213,11 +139,11 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status { | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// CPU stats
 | 	// CPU stats
 | ||||||
| 	util, err := s.sampleCPUUtilization() | 	percents, err := cpu.Percent(0, false) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		logger.Warning("get cpu percent failed:", err) | 		logger.Warning("get cpu percent failed:", err) | ||||||
| 	} else { | 	} else { | ||||||
| 		status.Cpu = util | 		status.Cpu = percents[0] | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	status.CpuCores, err = cpu.Counts(false) | 	status.CpuCores, err = cpu.Counts(false) | ||||||
|  | @ -227,30 +153,13 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status { | ||||||
| 
 | 
 | ||||||
| 	status.LogicalPro = runtime.NumCPU() | 	status.LogicalPro = runtime.NumCPU() | ||||||
| 
 | 
 | ||||||
| 	if status.CpuSpeedMhz = s.cachedCpuSpeedMhz; s.cachedCpuSpeedMhz == 0 && time.Since(s.lastCpuInfoAttempt) > 5*time.Minute { | 	cpuInfos, err := cpu.Info() | ||||||
| 		s.lastCpuInfoAttempt = time.Now() | 	if err != nil { | ||||||
| 		done := make(chan struct{}) | 		logger.Warning("get cpu info failed:", err) | ||||||
| 		go func() { | 	} else if len(cpuInfos) > 0 { | ||||||
| 			defer close(done) | 		status.CpuSpeedMhz = cpuInfos[0].Mhz | ||||||
| 			cpuInfos, err := cpu.Info() | 	} else { | ||||||
| 			if err != nil { | 		logger.Warning("could not find cpu info") | ||||||
| 				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
 | 	// Uptime
 | ||||||
|  | @ -398,103 +307,6 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status { | ||||||
| 	return 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) { | func (s *ServerService) GetXrayVersions() ([]string, error) { | ||||||
| 	const ( | 	const ( | ||||||
| 		XrayURL    = "https://api.github.com/repos/XTLS/Xray-core/releases" | 		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 { | 		if len(dataArray) >= 2 && len(dataArray[1]) > 0 { | ||||||
| 			email := dataArray[1] | 			email := dataArray[1] | ||||||
| 			switch dataArray[0] { | 			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": | 			case "client_get_usage": | ||||||
| 				t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.messages.email", "Email=="+email)) | 				t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.messages.email", "Email=="+email)) | ||||||
| 				t.searchClient(chatId, 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.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.allClients")) | ||||||
| 				t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseInbound"), inbounds) | 				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.allClients")).WithCallbackData(t.encodeQuery("get_inbounds")), | ||||||
| 			tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.addClient")).WithCallbackData(t.encodeQuery("add_client")), | 			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.
 | 		// TODOOOOOOOOOOOOOO: Add restart button here.
 | ||||||
| 	) | 	) | ||||||
| 	numericKeyboardClient := tu.InlineKeyboard( | 	numericKeyboardClient := tu.InlineKeyboard( | ||||||
|  | @ -2150,10 +2073,7 @@ func (t *Tgbot) sendClientSubLinks(chatId int64, email string) { | ||||||
| 		"JSON URL:\r\n<code>" + subJsonURL + "</code>" | 		"JSON URL:\r\n<code>" + subJsonURL + "</code>" | ||||||
| 	inlineKeyboard := tu.InlineKeyboard( | 	inlineKeyboard := tu.InlineKeyboard( | ||||||
| 		tu.InlineKeyboardRow( | 		tu.InlineKeyboardRow( | ||||||
| 			tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links "+email)), | 			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)), |  | ||||||
| 		), | 		), | ||||||
| 	) | 	) | ||||||
| 	t.SendMsgToTgbot(chatId, msg, inlineKeyboard) | 	t.SendMsgToTgbot(chatId, msg, inlineKeyboard) | ||||||
|  | @ -2539,74 +2459,6 @@ func (t *Tgbot) getInbounds() (*telego.InlineKeyboardMarkup, error) { | ||||||
| 	return keyboard, nil | 	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) { | func (t *Tgbot) getInboundsAddClient() (*telego.InlineKeyboardMarkup, error) { | ||||||
| 	inbounds, err := t.inboundService.GetAllInbounds() | 	inbounds, err := t.inboundService.GetAllInbounds() | ||||||
| 	if err != nil { | 	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
 | 	// check client ips from log file every day
 | ||||||
| 	s.cron.AddJob("@daily", job.NewClearLogsJob()) | 	s.cron.AddJob("@daily", job.NewClearLogsJob()) | ||||||
| 
 | 
 | ||||||
| 	// Inbound traffic reset jobs
 | 	// Periodic traffic resets
 | ||||||
| 	// Run once a day, midnight
 | 	logger.Info("Scheduling periodic traffic reset jobs") | ||||||
| 	s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("daily")) | 	{ | ||||||
| 	// Run once a week, midnight between Sat/Sun
 | 		// Inbound traffic reset jobs
 | ||||||
| 	s.cron.AddJob("@weekly", job.NewPeriodicTrafficResetJob("weekly")) | 		// Run once a day, midnight
 | ||||||
| 	// Run once a month, midnight, first of month
 | 		s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("daily")) | ||||||
| 	s.cron.AddJob("@monthly", job.NewPeriodicTrafficResetJob("monthly")) | 		// 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
 | 	// Make a traffic condition every day, 8:30
 | ||||||
| 	var entry cron.EntryID | 	var entry cron.EntryID | ||||||
|  |  | ||||||
		Loading…
	
		Reference in a new issue