Compare commits

...

8 commits

Author SHA1 Message Date
mhsanaei
22afa50901
fix CPU History intervals
Some checks are pending
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (push) Waiting to run
2025-09-17 01:08:59 +02:00
mhsanaei
bc274d1e1f
Reality: placeholder for min, max 2025-09-16 18:57:27 +02:00
mhsanaei
dc21f41932
bug fix: del Depleted 2025-09-16 18:28:02 +02:00
mhsanaei
f137b1af76
bug fix: enable 2025-09-16 14:57:31 +02:00
mhsanaei
c4871ef8fe
sub page: improved 2025-09-16 14:38:18 +02:00
mhsanaei
ecfffa882a
CPU History, CPU Utilization 2025-09-16 14:15:18 +02:00
mhsanaei
3af5026abe
tgbot: subscription, qrcode, link - for admin 2025-09-16 13:41:48 +02:00
mhsanaei
1de7accd7c
vnext removed 2025-09-16 13:41:05 +02:00
21 changed files with 2621 additions and 1684 deletions

2
go.mod
View file

@ -21,6 +21,7 @@ require (
github.com/xtls/xray-core v1.250911.0
go.uber.org/atomic v1.11.0
golang.org/x/crypto v0.42.0
golang.org/x/sys v0.36.0
golang.org/x/text v0.29.0
google.golang.org/grpc v1.75.1
gorm.io/driver/sqlite v1.6.0
@ -89,7 +90,6 @@ require (
golang.org/x/mod v0.28.0 // indirect
golang.org/x/net v0.44.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/time v0.13.0 // indirect
golang.org/x/tools v0.36.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect

View file

@ -11,6 +11,7 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"x-ui/logger"
"x-ui/util/common"
@ -74,11 +75,6 @@ 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
@ -89,6 +85,11 @@ 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
@ -154,11 +155,29 @@ 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)
}

View file

@ -292,34 +292,25 @@ 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
outbound.Settings = OutboundSettings{
Vnext: vnextData,
// 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
}
if inbound.Protocol == model.VMESS {
settings["security"] = client.Security
}
outbound.Settings = settings
result, _ := json.MarshalIndent(outbound, "", " ")
return result
@ -356,8 +347,8 @@ func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_u
outbound.Mux = json_util.RawMessage(s.mux)
}
outbound.StreamSettings = streamSettings
outbound.Settings = OutboundSettings{
Servers: serverData,
outbound.Settings = map[string]any{
"servers": serverData,
}
result, _ := json.MarshalIndent(outbound, "", " ")
@ -369,28 +360,10 @@ type Outbound struct {
Tag string `json:"tag"`
StreamSettings json_util.RawMessage `json:"streamSettings"`
Mux json_util.RawMessage `json:"mux,omitempty"`
ProxySettings map[string]any `json:"proxySettings,omitempty"`
Settings OutboundSettings `json:"settings,omitempty"`
Settings map[string]any `json:"settings,omitempty"`
}
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"`
}
// Legacy vnext-related structs removed for flattened schema
type ServerSetting struct {
Password string `json:"password"`

View file

@ -5,7 +5,6 @@ import (
"fmt"
"net"
"net/url"
"strconv"
"strings"
"time"
@ -1110,7 +1109,7 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray
return PageData{
Host: hostHeader,
BasePath: "/",
BasePath: "/", // kept as "/"; templates now use context base_path injected from router
SId: subId,
Download: download,
Upload: upload,
@ -1139,10 +1138,3 @@ 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
}

View file

@ -4,7 +4,12 @@
package sys
import (
"encoding/binary"
"fmt"
"sync"
"github.com/shirou/gopsutil/v4/net"
"golang.org/x/sys/unix"
)
func GetTCPCount() (int, error) {
@ -22,3 +27,69 @@ func GetUDPCount() (int, error) {
}
return len(stats), nil
}
// --- CPU Utilization (macOS native) ---
// sysctl kern.cp_time returns an array of 5 longs: user, nice, sys, idle, intr.
// We compute utilization deltas without cgo.
var (
cpuMu sync.Mutex
lastTotals [5]uint64
hasLastCPUT bool
)
func CPUPercentRaw() (float64, error) {
raw, err := unix.SysctlRaw("kern.cp_time")
if err != nil {
return 0, err
}
// Expect either 5*8 bytes (uint64) or 5*4 bytes (uint32)
var out [5]uint64
switch len(raw) {
case 5 * 8:
for i := 0; i < 5; i++ {
out[i] = binary.LittleEndian.Uint64(raw[i*8 : (i+1)*8])
}
case 5 * 4:
for i := 0; i < 5; i++ {
out[i] = uint64(binary.LittleEndian.Uint32(raw[i*4 : (i+1)*4]))
}
default:
return 0, fmt.Errorf("unexpected kern.cp_time size: %d", len(raw))
}
// user, nice, sys, idle, intr
user := out[0]
nice := out[1]
sysv := out[2]
idle := out[3]
intr := out[4]
cpuMu.Lock()
defer cpuMu.Unlock()
if !hasLastCPUT {
lastTotals = out
hasLastCPUT = true
return 0, nil
}
dUser := user - lastTotals[0]
dNice := nice - lastTotals[1]
dSys := sysv - lastTotals[2]
dIdle := idle - lastTotals[3]
dIntr := intr - lastTotals[4]
lastTotals = out
totald := dUser + dNice + dSys + dIdle + dIntr
if totald == 0 {
return 0, nil
}
busy := totald - dIdle
pct := float64(busy) / float64(totald) * 100.0
if pct > 100 {
pct = 100
}
return pct, nil
}

View file

@ -4,10 +4,14 @@
package sys
import (
"bufio"
"bytes"
"fmt"
"io"
"os"
"strconv"
"strings"
"sync"
)
func getLinesNum(filename string) (int, error) {
@ -79,3 +83,99 @@ func safeGetLinesNum(path string) (int, error) {
}
return getLinesNum(path)
}
// --- CPU Utilization (Linux native) ---
var (
cpuMu sync.Mutex
lastTotal uint64
lastIdleAll uint64
hasLast bool
)
// CPUPercentRaw returns instantaneous total CPU utilization by reading /proc/stat.
// First call initializes and returns 0; subsequent calls return busy/total * 100.
func CPUPercentRaw() (float64, error) {
f, err := os.Open("/proc/stat")
if err != nil {
return 0, err
}
defer f.Close()
rd := bufio.NewReader(f)
line, err := rd.ReadString('\n')
if err != nil && err != io.EOF {
return 0, err
}
// Expect line like: cpu user nice system idle iowait irq softirq steal guest guest_nice
fields := strings.Fields(line)
if len(fields) < 5 || fields[0] != "cpu" {
return 0, fmt.Errorf("unexpected /proc/stat format")
}
var nums []uint64
for i := 1; i < len(fields); i++ {
v, err := strconv.ParseUint(fields[i], 10, 64)
if err != nil {
break
}
nums = append(nums, v)
}
if len(nums) < 4 { // need at least user,nice,system,idle
return 0, fmt.Errorf("insufficient cpu fields")
}
// Conform with standard Linux CPU accounting
var user, nice, system, idle, iowait, irq, softirq, steal uint64
user = nums[0]
if len(nums) > 1 {
nice = nums[1]
}
if len(nums) > 2 {
system = nums[2]
}
if len(nums) > 3 {
idle = nums[3]
}
if len(nums) > 4 {
iowait = nums[4]
}
if len(nums) > 5 {
irq = nums[5]
}
if len(nums) > 6 {
softirq = nums[6]
}
if len(nums) > 7 {
steal = nums[7]
}
idleAll := idle + iowait
nonIdle := user + nice + system + irq + softirq + steal
total := idleAll + nonIdle
cpuMu.Lock()
defer cpuMu.Unlock()
if !hasLast {
lastTotal = total
lastIdleAll = idleAll
hasLast = true
return 0, nil
}
totald := total - lastTotal
idled := idleAll - lastIdleAll
lastTotal = total
lastIdleAll = idleAll
if totald == 0 {
return 0, nil
}
busy := totald - idled
pct := float64(busy) / float64(totald) * 100.0
if pct > 100 {
pct = 100
}
return pct, nil
}

View file

@ -5,6 +5,9 @@ package sys
import (
"errors"
"sync"
"syscall"
"unsafe"
"github.com/shirou/gopsutil/v4/net"
)
@ -28,3 +31,81 @@ func GetTCPCount() (int, error) {
func GetUDPCount() (int, error) {
return GetConnectionCount("udp")
}
// --- CPU Utilization (Windows native) ---
var (
modKernel32 = syscall.NewLazyDLL("kernel32.dll")
procGetSystemTimes = modKernel32.NewProc("GetSystemTimes")
cpuMu sync.Mutex
lastIdle uint64
lastKernel uint64
lastUser uint64
hasLast bool
)
type filetime struct {
LowDateTime uint32
HighDateTime uint32
}
func ftToUint64(ft filetime) uint64 {
return (uint64(ft.HighDateTime) << 32) | uint64(ft.LowDateTime)
}
// CPUPercentRaw returns the instantaneous total CPU utilization percentage using
// Windows GetSystemTimes across all logical processors. The first call returns 0
// as it initializes the baseline. Subsequent calls compute deltas.
func CPUPercentRaw() (float64, error) {
var idleFT, kernelFT, userFT filetime
r1, _, e1 := procGetSystemTimes.Call(
uintptr(unsafe.Pointer(&idleFT)),
uintptr(unsafe.Pointer(&kernelFT)),
uintptr(unsafe.Pointer(&userFT)),
)
if r1 == 0 { // failure
if e1 != nil {
return 0, e1
}
return 0, syscall.GetLastError()
}
idle := ftToUint64(idleFT)
kernel := ftToUint64(kernelFT)
user := ftToUint64(userFT)
cpuMu.Lock()
defer cpuMu.Unlock()
if !hasLast {
lastIdle = idle
lastKernel = kernel
lastUser = user
hasLast = true
return 0, nil
}
idleDelta := idle - lastIdle
kernelDelta := kernel - lastKernel
userDelta := user - lastUser
// Update for next call
lastIdle = idle
lastKernel = kernel
lastUser = user
total := kernelDelta + userDelta
if total == 0 {
return 0, nil
}
// On Windows, kernel time includes idle time; busy = total - idle
busy := total - idleDelta
pct := float64(busy) / float64(total) * 100.0
// lower bound not needed; ratios of uint64 are non-negative
if pct > 100 {
pct = 100
}
return pct, nil
}

View file

@ -219,7 +219,7 @@ class KcpStreamSettings extends CommonClass {
class WsStreamSettings extends CommonClass {
constructor(
path = '/',
path = '/',
host = '',
heartbeatPeriod = 0,
@ -647,10 +647,6 @@ 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);
}
@ -690,13 +686,22 @@ 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: this.settings instanceof CommonClass ? this.settings.toJson() : this.settings,
streamSettings: stream,
sendThrough: this.sendThrough != "" ? this.sendThrough : undefined,
mux: this.mux?.enabled ? this.mux : undefined,
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 } : {}),
};
}
@ -908,7 +913,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),
};
@ -1026,22 +1031,21 @@ Outbound.VmessSettings = class extends CommonClass {
}
static fromJson(json = {}) {
if (ObjectUtil.isArrEmpty(json.vnext)) return new Outbound.VmessSettings();
if (ObjectUtil.isEmpty(json.address) || ObjectUtil.isEmpty(json.port)) return new Outbound.VmessSettings();
return new Outbound.VmessSettings(
json.vnext[0].address,
json.vnext[0].port,
json.vnext[0].users[0].id,
json.vnext[0].users[0].security,
json.address,
json.port,
json.id,
json.security,
);
}
toJson() {
return {
vnext: [{
address: this.address,
port: this.port,
users: [{ id: this.id, security: this.security }],
}],
address: this.address,
port: this.port,
id: this.id,
security: this.security,
};
}
};
@ -1056,23 +1060,23 @@ Outbound.VLESSSettings = class extends CommonClass {
}
static fromJson(json = {}) {
if (ObjectUtil.isArrEmpty(json.vnext)) return new Outbound.VLESSSettings();
if (ObjectUtil.isEmpty(json.address) || ObjectUtil.isEmpty(json.port)) return new Outbound.VLESSSettings();
return new Outbound.VLESSSettings(
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,
json.address,
json.port,
json.id,
json.flow,
json.encryption
);
}
toJson() {
return {
vnext: [{
address: this.address,
port: this.port,
users: [{ id: this.id, flow: this.flow, encryption: this.encryption }],
}],
address: this.address,
port: this.port,
id: this.id,
flow: this.flow,
encryption: this.encryption,
};
}
};

View file

@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"regexp"
"strconv"
"time"
"x-ui/web/global"
@ -20,17 +21,14 @@ type ServerController struct {
serverService service.ServerService
settingService service.SettingService
lastStatus *service.Status
lastGetStatusTime time.Time
lastStatus *service.Status
lastVersions []string
lastGetVersionsTime time.Time
lastGetVersionsTime int64 // unix seconds
}
func NewServerController(g *gin.RouterGroup) *ServerController {
a := &ServerController{
lastGetStatusTime: time.Now(),
}
a := &ServerController{}
a.initRouter(g)
a.startTask()
return a
@ -39,6 +37,7 @@ 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)
@ -61,29 +60,50 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
func (a *ServerController) refreshStatus() {
a.lastStatus = a.serverService.GetStatus(a.lastStatus)
// collect cpu history when status is fresh
if a.lastStatus != nil {
a.serverService.AppendCpuSample(time.Now(), a.lastStatus.Cpu)
}
}
func (a *ServerController) startTask() {
webServer := global.GetWebServer()
c := webServer.GetCron()
c.AddFunc("@every 2s", func() {
now := time.Now()
if now.Sub(a.lastGetStatusTime) > time.Minute*3 {
return
}
// Always refresh to keep CPU history collected continuously.
// Sampling is lightweight and capped to ~6 hours in memory.
a.refreshStatus()
})
}
func (a *ServerController) status(c *gin.Context) {
a.lastGetStatusTime = time.Now()
func (a *ServerController) status(c *gin.Context) { jsonObj(c, a.lastStatus, nil) }
jsonObj(c, a.lastStatus, nil)
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)
}
func (a *ServerController) getXrayVersion(c *gin.Context) {
now := time.Now()
if now.Sub(a.lastGetVersionsTime) <= time.Minute {
now := time.Now().Unix()
if now-a.lastGetVersionsTime <= 60 { // 1 minute cache
jsonObj(c, a.lastVersions, nil)
return
}
@ -95,7 +115,7 @@ func (a *ServerController) getXrayVersion(c *gin.Context) {
}
a.lastVersions = versions
a.lastGetVersionsTime = time.Now()
a.lastGetVersionsTime = now
jsonObj(c, versions, nil)
}
@ -113,7 +133,6 @@ func (a *ServerController) updateGeofile(c *gin.Context) {
}
func (a *ServerController) stopXrayService(c *gin.Context) {
a.lastGetStatusTime = time.Now()
err := a.serverService.StopXrayService()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.xray.stopError"), err)
@ -229,9 +248,7 @@ func (a *ServerController) importDB(c *gin.Context) {
defer file.Close()
// Always restart Xray before return
defer a.serverService.RestartXrayService()
defer func() {
a.lastGetStatusTime = time.Now()
}()
// lastGetStatusTime removed; no longer needed
// Import it
err = a.serverService.ImportDB(file)
if err != nil {

View file

@ -23,13 +23,13 @@ func NewXraySettingController(g *gin.RouterGroup) *XraySettingController {
func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
g = g.Group("/xray")
g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
g.GET("/getOutboundsTraffic", a.getOutboundsTraffic)
g.GET("/getXrayResult", a.getXrayResult)
g.POST("/", a.getXraySetting)
g.POST("/update", a.updateSetting)
g.GET("/getXrayResult", a.getXrayResult)
g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
g.POST("/warp/:action", a.warp)
g.GET("/getOutboundsTraffic", a.getOutboundsTraffic)
g.POST("/update", a.updateSetting)
g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
}

View file

@ -37,7 +37,7 @@
<template slot="content" >
{{ i18n "lastOnline" }}: [[ formatLastOnline(client.email) ]]
</template>
<template v-if="client.enable && isClientOnline(client.email)">
<template v-if="client.enable && isClientOnline(client.email) && !isClientDepleted">
<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="!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 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>
<a-badge :class="isClientOnline(client.email)? 'online-animation' : ''" :color="client.enable ? statsExpColor(record, client.email) : themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc'"></a-badge>
</a-tooltip>

View file

@ -210,7 +210,7 @@
</a-form-item>
</template>
<!-- Vnext (vless/vmess) settings -->
<!-- VLESS/VMess user 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>

View file

@ -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"></a-input>
<a-input v-model.trim="inbound.stream.reality.minClientVer" placeholder='25.9.11'></a-input>
</a-form-item>
<a-form-item label='Max Client Ver'>
<a-input v-model.trim="inbound.stream.reality.maxClientVer"></a-input>
<a-input v-model.trim="inbound.stream.reality.maxClientVer" placeholder='25.9.11'></a-input>
</a-form-item>
<a-form-item>
<template slot="label">

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -180,9 +180,9 @@
<tr>
<td>{{ i18n "status" }}</td>
<td>
<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>
<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>
</td>
</tr>
<tr v-if="infoModal.clientStats">
@ -587,6 +587,14 @@
}
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) {

View file

@ -535,7 +535,9 @@
switch (o.protocol) {
case Protocols.VMess:
case Protocols.VLESS:
serverObj = o.settings.vnext;
if (o.settings && o.settings.address && o.settings.port) {
return [o.settings.address + ':' + o.settings.port];
}
break;
case Protocols.HTTP:
case Protocols.Mixed:

View file

@ -1272,7 +1272,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 = true
clientTraffic.Enable = client.Enable
clientTraffic.Up = 0
clientTraffic.Down = 0
clientTraffic.Reset = client.Reset
@ -1285,7 +1285,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": true,
"enable": client.Enable,
"email": client.Email,
"total": client.TotalGB,
"expiry_time": client.ExpiryTime,
@ -1837,8 +1837,14 @@ 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 enable = ?", id, false).Select("inbound_id, GROUP_CONCAT(email) as email").Group("inbound_id").Find(&depletedClients).Error
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
if err != nil {
return err
}
@ -1889,7 +1895,8 @@ func (s *InboundService) DelDepletedClients(id int) (err error) {
}
}
err = tx.Where(whereText+" and enable = ?", id, false).Delete(xray.ClientTraffic{}).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
if err != nil {
return err
}
@ -1937,18 +1944,17 @@ func (s *InboundService) GetClientTrafficTgBot(tgId int64) ([]*xray.ClientTraffi
}
func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.ClientTraffic, err error) {
db := database.GetDB()
var traffics []*xray.ClientTraffic
err = db.Model(xray.ClientTraffic{}).Where("email = ?", email).Find(&traffics).Error
// Prefer retrieving along with client to reflect actual enabled state from inbound settings
t, client, err := s.GetClientByEmail(email)
if err != nil {
logger.Warningf("Error retrieving ClientTraffic with email %s: %v", email, err)
return nil, err
}
if len(traffics) > 0 {
return traffics[0], nil
if t != nil && client != nil {
// Ensure enable mirrors the client's current enable flag in settings
t.Enable = client.Enable
return t, nil
}
return nil, nil
}
@ -1983,6 +1989,12 @@ 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
}

View file

@ -16,6 +16,7 @@ import (
"runtime"
"strconv"
"strings"
"sync"
"time"
"x-ui/config"
@ -93,11 +94,84 @@ type Release struct {
}
type ServerService struct {
xrayService XrayService
inboundService InboundService
cachedIPv4 string
cachedIPv6 string
noIPv6 bool
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
}
func getPublicIP(url string) string {
@ -139,11 +213,11 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
}
// CPU stats
percents, err := cpu.Percent(0, false)
util, err := s.sampleCPUUtilization()
if err != nil {
logger.Warning("get cpu percent failed:", err)
} else {
status.Cpu = percents[0]
status.Cpu = util
}
status.CpuCores, err = cpu.Counts(false)
@ -153,13 +227,30 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
status.LogicalPro = runtime.NumCPU()
cpuInfos, err := cpu.Info()
if err != nil {
logger.Warning("get cpu info failed:", err)
} else if len(cpuInfos) > 0 {
status.CpuSpeedMhz = cpuInfos[0].Mhz
} else {
logger.Warning("could not find cpu info")
if status.CpuSpeedMhz = s.cachedCpuSpeedMhz; s.cachedCpuSpeedMhz == 0 && time.Since(s.lastCpuInfoAttempt) > 5*time.Minute {
s.lastCpuInfoAttempt = time.Now()
done := make(chan struct{})
go func() {
defer close(done)
cpuInfos, err := cpu.Info()
if err != nil {
logger.Warning("get cpu info failed:", err)
return
}
if len(cpuInfos) > 0 {
s.cachedCpuSpeedMhz = cpuInfos[0].Mhz
status.CpuSpeedMhz = s.cachedCpuSpeedMhz
} else {
logger.Warning("could not find cpu info")
}
}()
select {
case <-done:
case <-time.After(1500 * time.Millisecond):
logger.Warning("cpu info query timed out; will retry later")
}
} else if s.cachedCpuSpeedMhz != 0 {
status.CpuSpeedMhz = s.cachedCpuSpeedMhz
}
// Uptime
@ -307,6 +398,103 @@ 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"

View file

@ -548,6 +548,57 @@ 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)
@ -1327,6 +1378,27 @@ 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)
}
}
@ -1927,6 +1999,11 @@ 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(
@ -2073,7 +2150,10 @@ 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.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)
@ -2459,6 +2539,74 @@ 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 {

View file

@ -289,18 +289,13 @@ func (s *Server) startTask() {
// check client ips from log file every day
s.cron.AddJob("@daily", job.NewClearLogsJob())
// 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"))
}
// 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