Merge branch 'main' into fix-shadowrocket-subscription-link

This commit is contained in:
Sanaei 2025-09-18 12:09:05 +02:00 committed by GitHub
commit f6644182e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 3074 additions and 1734 deletions

View file

@ -1 +1 @@
2.8.0 2.8.1

View file

@ -27,16 +27,18 @@ type User struct {
} }
type Inbound struct { type Inbound struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
UserId int `json:"-"` UserId int `json:"-"`
Up int64 `json:"up" form:"up"` Up int64 `json:"up" form:"up"`
Down int64 `json:"down" form:"down"` Down int64 `json:"down" form:"down"`
Total int64 `json:"total" form:"total"` Total int64 `json:"total" form:"total"`
AllTime int64 `json:"allTime" form:"allTime" gorm:"default:0"` AllTime int64 `json:"allTime" form:"allTime" gorm:"default:0"`
Remark string `json:"remark" form:"remark"` Remark string `json:"remark" form:"remark"`
Enable bool `json:"enable" form:"enable"` Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1"`
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` ExpiryTime int64 `json:"expiryTime" form:"expiryTime"`
ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"` TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2"`
LastTrafficResetTime int64 `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"`
ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"`
// config part // config part
Listen string `json:"listen" form:"listen"` Listen string `json:"listen" form:"listen"`

2
go.mod
View file

@ -21,6 +21,7 @@ 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
@ -89,7 +90,6 @@ 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

View file

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

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 { 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
outbound.Settings = OutboundSettings{ // Emit flattened settings inside Settings to match new Xray format
Vnext: vnextData, 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, "", " ") result, _ := json.MarshalIndent(outbound, "", " ")
return result return result
@ -356,8 +347,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 = OutboundSettings{ outbound.Settings = map[string]any{
Servers: serverData, "servers": serverData,
} }
result, _ := json.MarshalIndent(outbound, "", " ") result, _ := json.MarshalIndent(outbound, "", " ")
@ -369,28 +360,10 @@ 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"`
ProxySettings map[string]any `json:"proxySettings,omitempty"` Settings map[string]any `json:"settings,omitempty"`
Settings OutboundSettings `json:"settings,omitempty"`
} }
type OutboundSettings struct { // Legacy vnext-related structs removed for flattened schema
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"`

View file

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

View file

@ -4,7 +4,12 @@
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) {
@ -22,3 +27,69 @@ 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
}

View file

@ -4,10 +4,14 @@
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) {
@ -79,3 +83,99 @@ 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
}

View file

@ -5,6 +5,9 @@ package sys
import ( import (
"errors" "errors"
"sync"
"syscall"
"unsafe"
"github.com/shirou/gopsutil/v4/net" "github.com/shirou/gopsutil/v4/net"
) )
@ -28,3 +31,81 @@ 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
}

File diff suppressed because one or more lines are too long

View file

@ -10,6 +10,8 @@ class DBInbound {
this.remark = ""; this.remark = "";
this.enable = true; this.enable = true;
this.expiryTime = 0; this.expiryTime = 0;
this.trafficReset = "never";
this.lastTrafficResetTime = 0;
this.listen = ""; this.listen = "";
this.port = 0; this.port = 0;

View file

@ -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,10 +647,6 @@ 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);
} }
@ -690,13 +686,22 @@ 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: this.settings instanceof CommonClass ? this.settings.toJson() : this.settings, settings: settingsOut,
streamSettings: stream, // Only include tag, streamSettings, sendThrough, mux if present and not empty
sendThrough: this.sendThrough != "" ? this.sendThrough : undefined, ...(this.tag ? { tag: this.tag } : {}),
mux: this.mux?.enabled ? this.mux : undefined, ...(stream ? { streamSettings: stream } : {}),
...(this.sendThrough ? { sendThrough: this.sendThrough } : {}),
...(this.mux?.enabled ? { mux: this.mux } : {}),
}; };
} }
@ -908,7 +913,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),
}; };
@ -1026,22 +1031,21 @@ Outbound.VmessSettings = class extends CommonClass {
} }
static fromJson(json = {}) { 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( return new Outbound.VmessSettings(
json.vnext[0].address, json.address,
json.vnext[0].port, json.port,
json.vnext[0].users[0].id, json.id,
json.vnext[0].users[0].security, json.security,
); );
} }
toJson() { toJson() {
return { return {
vnext: [{ address: this.address,
address: this.address, port: this.port,
port: this.port, id: this.id,
users: [{ id: this.id, security: this.security }], security: this.security,
}],
}; };
} }
}; };
@ -1056,23 +1060,23 @@ Outbound.VLESSSettings = class extends CommonClass {
} }
static fromJson(json = {}) { 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( return new Outbound.VLESSSettings(
json.vnext[0].address, json.address,
json.vnext[0].port, json.port,
json.vnext[0].users[0].id, json.id,
json.vnext[0].users[0].flow, json.flow,
json.vnext[0].users[0].encryption, json.encryption
); );
} }
toJson() { toJson() {
return { return {
vnext: [{ address: this.address,
address: this.address, port: this.port,
port: this.port, id: this.id,
users: [{ id: this.id, flow: this.flow, encryption: this.encryption }], flow: this.flow,
}], encryption: this.encryption,
}; };
} }
}; };

View file

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"regexp" "regexp"
"strconv"
"time" "time"
"x-ui/web/global" "x-ui/web/global"
@ -20,17 +21,14 @@ 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 time.Time lastGetVersionsTime int64 // unix seconds
} }
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
@ -39,6 +37,7 @@ 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)
@ -61,29 +60,50 @@ 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() {
now := time.Now() // Always refresh to keep CPU history collected continuously.
if now.Sub(a.lastGetStatusTime) > time.Minute*3 { // Sampling is lightweight and capped to ~6 hours in memory.
return
}
a.refreshStatus() a.refreshStatus()
}) })
} }
func (a *ServerController) status(c *gin.Context) { func (a *ServerController) status(c *gin.Context) { jsonObj(c, a.lastStatus, nil) }
a.lastGetStatusTime = time.Now()
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) { func (a *ServerController) getXrayVersion(c *gin.Context) {
now := time.Now() now := time.Now().Unix()
if now.Sub(a.lastGetVersionsTime) <= time.Minute { if now-a.lastGetVersionsTime <= 60 { // 1 minute cache
jsonObj(c, a.lastVersions, nil) jsonObj(c, a.lastVersions, nil)
return return
} }
@ -95,7 +115,7 @@ func (a *ServerController) getXrayVersion(c *gin.Context) {
} }
a.lastVersions = versions a.lastVersions = versions
a.lastGetVersionsTime = time.Now() a.lastGetVersionsTime = now
jsonObj(c, versions, nil) jsonObj(c, versions, nil)
} }
@ -113,7 +133,6 @@ 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)
@ -229,9 +248,7 @@ 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()
defer func() { // lastGetStatusTime removed; no longer needed
a.lastGetStatusTime = time.Now()
}()
// Import it // Import it
err = a.serverService.ImportDB(file) err = a.serverService.ImportDB(file)
if err != nil { if err != nil {

View file

@ -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("/update", a.updateSetting)
g.GET("/getXrayResult", a.getXrayResult)
g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
g.POST("/warp/:action", a.warp) g.POST("/warp/:action", a.warp)
g.GET("/getOutboundsTraffic", a.getOutboundsTraffic) g.POST("/update", a.updateSetting)
g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic) g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
} }

View file

@ -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="!isClientEnabled(record, client.email)">{{ i18n "depleted" }}</template> <template v-if="isClientDepleted">{{ i18n "depleted" }}</template>
<template v-else-if="!client.enable">{{ i18n "disabled" }}</template> <template v-if="!isClientDepleted && !client.enable">{{ i18n "disabled" }}</template>
<template v-else-if="client.enable && isClientOnline(client.email)">{{ i18n "online" }}</template> <template v-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>

View file

@ -44,6 +44,30 @@
<a-input-number v-model.number="dbInbound.totalGB" :min="0"></a-input-number> <a-input-number v-model.number="dbInbound.totalGB" :min="0"></a-input-number>
</a-form-item> </a-form-item>
<a-form-item>
<template slot="label">
<a-tooltip>
<template slot="title">
<span>{{ i18n "pages.inbounds.periodicTrafficResetDesc" }}</span>
<br v-if="dbInbound.lastTrafficResetTime && dbInbound.lastTrafficResetTime > 0">
<span v-if="dbInbound.lastTrafficResetTime && dbInbound.lastTrafficResetTime > 0">
<strong>{{ i18n "pages.inbounds.lastReset" }}:</strong>
<span v-if="datepicker == 'gregorian'">[[ moment(dbInbound.lastTrafficResetTime).format('YYYY-MM-DD HH:mm:ss') ]]</span>
<span v-else>[[ DateUtil.convertToJalalian(moment(dbInbound.lastTrafficResetTime)) ]]</span>
</span>
</template>
{{ i18n "pages.inbounds.periodicTrafficResetTitle" }}
<a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
<a-select v-model="dbInbound.trafficReset" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value="never">{{ i18n "pages.inbounds.periodicTrafficReset.never" }}</a-select-option>
<a-select-option value="daily">{{ i18n "pages.inbounds.periodicTrafficReset.daily" }}</a-select-option>
<a-select-option value="weekly">{{ i18n "pages.inbounds.periodicTrafficReset.weekly" }}</a-select-option>
<a-select-option value="monthly">{{ i18n "pages.inbounds.periodicTrafficReset.monthly" }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item> <a-form-item>
<template slot="label"> <template slot="label">
<a-tooltip> <a-tooltip>

View file

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

View file

@ -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"></a-input> <a-input v-model.trim="inbound.stream.reality.minClientVer" placeholder='25.9.11'></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"></a-input> <a-input v-model.trim="inbound.stream.reality.maxClientVer" placeholder='25.9.11'></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

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,7 @@
{{ template "page/body_start" .}} {{ template "page/body_start" .}}
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' login-app'"> <a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' login-app'">
<transition name="list" appear> <transition name="list" appear>
<a-layout-content class="under min-h-100vh"> <a-layout-content class="under min-h-0">
<div class="waves-header"> <div class="waves-header">
<div class="waves-inner-header"></div> <div class="waves-inner-header"></div>
<svg class="waves" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" <svg class="waves" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
@ -20,7 +20,7 @@
</g> </g>
</svg> </svg>
</div> </div>
<a-row type="flex" justify="center" align="middle" class="h-100 overflow-hidden-auto"> <a-row type="flex" justify="center" align="middle" class="h-100 overflow-y-auto overflow-x-hidden">
<a-col :xs="22" :sm="12" :md="10" :lg="8" :xl="6" :xxl="5" id="login" class="my-3rem"> <a-col :xs="22" :sm="12" :md="10" :lg="8" :xl="6" :xxl="5" id="login" class="my-3rem">
<template v-if="!loadingStates.fetched"> <template v-if="!loadingStates.fetched">
<div class="text-center"> <div class="text-center">
@ -184,5 +184,80 @@
newWord.classList.add('is-visible'); newWord.classList.add('is-visible');
} }
}); });
const pm_input_selector = 'input.ant-input, textarea.ant-input';
const pm_strip_props = [
'background',
'background-color',
'background-image',
'color'
];
const pm_observed_forms = new WeakSet();
function pm_strip_inline(el) {
if (!el || el.nodeType !== 1 || !el.matches?.(pm_input_selector)) return;
let did_change = false;
for (const prop of pm_strip_props) {
if (el.style.getPropertyValue(prop)) {
el.style.removeProperty(prop);
did_change = true;
}
}
if (did_change && el.style.length === 0) {
el.removeAttribute('style');
}
}
function pm_attach_observer(form) {
if (pm_observed_forms.has(form)) return;
pm_observed_forms.add(form);
form.querySelectorAll(pm_input_selector).forEach(pm_strip_inline);
const pm_mo = new MutationObserver(mutations => {
for (const m of mutations) {
if (m.type === 'attributes') {
pm_strip_inline(m.target);
} else if (m.type === 'childList') {
for (const n of m.addedNodes) {
if (n.nodeType !== 1) continue;
if (n.matches?.(pm_input_selector)) pm_strip_inline(n);
n.querySelectorAll?.(pm_input_selector).forEach(pm_strip_inline);
}
}
}
});
pm_mo.observe(form, {
attributes: true,
attributeFilter: ['style'],
childList: true,
subtree: true
});
}
function pm_init() {
document.querySelectorAll('form.ant-form').forEach(pm_attach_observer);
const pm_host = document.getElementById('login') || document.body;
const pm_wait_for_forms = new MutationObserver(mutations => {
for (const m of mutations) {
for (const n of m.addedNodes) {
if (n.nodeType !== 1) continue;
if (n.matches?.('form.ant-form')) pm_attach_observer(n);
n.querySelectorAll?.('form.ant-form').forEach(pm_attach_observer);
}
}
});
pm_wait_for_forms.observe(pm_host, { childList: true, subtree: true });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', pm_init, { once: true });
} else {
pm_init();
}
</script> </script>
{{ template "page/body_end" .}} {{ template "page/body_end" .}}

View file

@ -102,14 +102,15 @@
<a-tag :color="inbound.stream.security == 'none' ? 'red' : 'green'">[[ inbound.stream.security ]]</a-tag> <a-tag :color="inbound.stream.security == 'none' ? 'red' : 'green'">[[ inbound.stream.security ]]</a-tag>
<br /> <br />
<td>Authentication</td> <td>Authentication</td>
<a-tag :color="inbound.settings.selectedAuth ? 'green' : 'red'">[[ inbound.settings.selectedAuth ? inbound.settings.selectedAuth : '' ]]</a-tag> <a-tag v-if="inbound.settings.selectedAuth" color="green">[[ inbound.settings.selectedAuth ? inbound.settings.selectedAuth : '' ]]</a-tag>
<a-tag v-else color="red">{{ i18n "none" }}</a-tag>
<br /> <br />
{{ i18n "encryption" }} {{ i18n "encryption" }}
<a-tag :color="inbound.settings.encryption ? 'green' : 'red'">[[ inbound.settings.encryption ? inbound.settings.encryption : '' ]]</a-tag> <a-tag :color="inbound.settings.encryption ? 'green' : 'red'">[[ inbound.settings.encryption ? inbound.settings.encryption : '' ]]</a-tag>
<br /> <br />
<template v-if="inbound.stream.security != 'none'"> <template v-if="inbound.stream.security != 'none'">
{{ i18n "domainName" }} {{ i18n "domainName" }}
<a-tag v-if="inbound.serverName" :color="inbound.serverName ? 'green' : 'orange'">[[ inbound.serverName ? inbound.serverName : '' ]]</a-tag> <a-tag v-if="inbound.serverName" color="green">[[ inbound.serverName ? inbound.serverName : '' ]]</a-tag>
<a-tag v-else color="orange">{{ i18n "none" }}</a-tag> <a-tag v-else color="orange">{{ i18n "none" }}</a-tag>
</template> </template>
</template> </template>
@ -179,9 +180,9 @@
<tr> <tr>
<td>{{ i18n "status" }}</td> <td>{{ i18n "status" }}</td>
<td> <td>
<a-tag v-if="isEnable" color="green">{{ i18n "enabled" }}</a-tag> <a-tag v-if="isEnable && isActive && !isDepleted" color="green">{{ i18n "enabled" }}</a-tag>
<a-tag v-else>{{ i18n "disabled" }}</a-tag> <a-tag v-if="!isEnable && !isDepleted">{{ i18n "disabled" }}</a-tag>
<a-tag v-if="!isActive" color="red">{{ i18n "depleted" }}</a-tag> <a-tag v-if="isDepleted" color="red">{{ i18n "depleted" }}</a-tag>
</td> </td>
</tr> </tr>
<tr v-if="infoModal.clientStats"> <tr v-if="infoModal.clientStats">
@ -586,6 +587,14 @@
} }
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) {

View file

@ -9,7 +9,7 @@
{{ template "page/head_end" .}} {{ template "page/head_end" .}}
{{ template "page/body_start" .}} {{ template "page/body_start" .}}
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme"> <a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' subscription-page'">
<a-layout-content class="p-2"> <a-layout-content class="p-2">
<a-row type="flex" justify="center" class="mt-2"> <a-row type="flex" justify="center" class="mt-2">
<a-col :xs="24" :sm="22" :md="18" :lg="14" :xl="12"> <a-col :xs="24" :sm="22" :md="18" :lg="14" :xl="12">
@ -200,7 +200,7 @@
style="text-align:center;"> style="text-align:center;">
<!-- Android dropdown --> <!-- Android dropdown -->
<a-dropdown :trigger="['click']"> <a-dropdown :trigger="['click']">
<a-button :block="isMobile" <a-button icon="android" :block="isMobile"
:style="{ marginTop: isMobile ? '6px' : 0 }" :style="{ marginTop: isMobile ? '6px' : 0 }"
size="large" type="primary"> size="large" type="primary">
Android <a-icon type="down" /> Android <a-icon type="down" />
@ -225,7 +225,7 @@
style="text-align:center;"> style="text-align:center;">
<!-- iOS dropdown --> <!-- iOS dropdown -->
<a-dropdown :trigger="['click']"> <a-dropdown :trigger="['click']">
<a-button :block="isMobile" <a-button icon="apple" :block="isMobile"
:style="{ marginTop: isMobile ? '6px' : 0 }" :style="{ marginTop: isMobile ? '6px' : 0 }"
size="large" type="primary"> size="large" type="primary">
iOS <a-icon type="down" /> iOS <a-icon type="down" />

View file

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

View file

@ -0,0 +1,48 @@
package job
import (
"x-ui/logger"
"x-ui/web/service"
)
type Period string
type PeriodicTrafficResetJob struct {
inboundService service.InboundService
period Period
}
func NewPeriodicTrafficResetJob(period Period) *PeriodicTrafficResetJob {
return &PeriodicTrafficResetJob{
period: period,
}
}
func (j *PeriodicTrafficResetJob) Run() {
inbounds, err := j.inboundService.GetInboundsByTrafficReset(string(j.period))
if err != nil {
logger.Warning("Failed to get inbounds for traffic reset:", err)
return
}
if len(inbounds) == 0 {
return
}
logger.Infof("Running periodic traffic reset job for period: %s (%d matching inbounds)", j.period, len(inbounds))
resetCount := 0
for _, inbound := range inbounds {
if err := j.inboundService.ResetAllClientTraffics(inbound.Id); err != nil {
logger.Warning("Failed to reset traffic for inbound", inbound.Id, ":", err)
continue
}
resetCount++
logger.Infof("Reset traffic for inbound %d (%s)", inbound.Id, inbound.Remark)
}
if resetCount > 0 {
logger.Infof("Periodic traffic reset completed: %d inbounds reset", resetCount)
}
}

View file

@ -41,6 +41,16 @@ func (s *InboundService) GetAllInbounds() ([]*model.Inbound, error) {
return inbounds, nil return inbounds, nil
} }
func (s *InboundService) GetInboundsByTrafficReset(period string) ([]*model.Inbound, error) {
db := database.GetDB()
var inbounds []*model.Inbound
err := db.Model(model.Inbound{}).Where("traffic_reset = ?", period).Find(&inbounds).Error
if err != nil && err != gorm.ErrRecordNotFound {
return nil, err
}
return inbounds, nil
}
func (s *InboundService) checkPortExist(listen string, port int, ignoreId int) (bool, error) { func (s *InboundService) checkPortExist(listen string, port int, ignoreId int) (bool, error) {
db := database.GetDB() db := database.GetDB()
if listen == "" || listen == "0.0.0.0" || listen == "::" || listen == "::0" { if listen == "" || listen == "0.0.0.0" || listen == "::" || listen == "::0" {
@ -409,6 +419,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
oldInbound.Remark = inbound.Remark oldInbound.Remark = inbound.Remark
oldInbound.Enable = inbound.Enable oldInbound.Enable = inbound.Enable
oldInbound.ExpiryTime = inbound.ExpiryTime oldInbound.ExpiryTime = inbound.ExpiryTime
oldInbound.TrafficReset = inbound.TrafficReset
oldInbound.Listen = inbound.Listen oldInbound.Listen = inbound.Listen
oldInbound.Port = inbound.Port oldInbound.Port = inbound.Port
oldInbound.Protocol = inbound.Protocol oldInbound.Protocol = inbound.Protocol
@ -698,6 +709,7 @@ func (s *InboundService) DelInboundClient(inboundId int, clientId string) (bool,
} }
func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId string) (bool, error) { func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId string) (bool, error) {
// TODO: check if TrafficReset field is updating
clients, err := s.GetClients(data) clients, err := s.GetClients(data)
if err != nil { if err != nil {
return false, err return false, err
@ -1260,7 +1272,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 = true clientTraffic.Enable = client.Enable
clientTraffic.Up = 0 clientTraffic.Up = 0
clientTraffic.Down = 0 clientTraffic.Down = 0
clientTraffic.Reset = client.Reset clientTraffic.Reset = client.Reset
@ -1273,7 +1285,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": true, "enable": client.Enable,
"email": client.Email, "email": client.Email,
"total": client.TotalGB, "total": client.TotalGB,
"expiry_time": client.ExpiryTime, "expiry_time": client.ExpiryTime,
@ -1684,6 +1696,7 @@ func (s *InboundService) ResetClientTrafficLimitByEmail(clientEmail string, tota
func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error { func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error {
db := database.GetDB() db := database.GetDB()
// Reset traffic stats in ClientTraffic table
result := db.Model(xray.ClientTraffic{}). result := db.Model(xray.ClientTraffic{}).
Where("email = ?", clientEmail). Where("email = ?", clientEmail).
Updates(map[string]any{"enable": true, "up": 0, "down": 0}) Updates(map[string]any{"enable": true, "up": 0, "down": 0})
@ -1692,6 +1705,7 @@ func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error {
if err != nil { if err != nil {
return err return err
} }
return nil return nil
} }
@ -1759,20 +1773,39 @@ func (s *InboundService) ResetClientTraffic(id int, clientEmail string) (bool, e
func (s *InboundService) ResetAllClientTraffics(id int) error { func (s *InboundService) ResetAllClientTraffics(id int) error {
db := database.GetDB() db := database.GetDB()
now := time.Now().Unix() * 1000
whereText := "inbound_id " return db.Transaction(func(tx *gorm.DB) error {
if id == -1 { whereText := "inbound_id "
whereText += " > ?" if id == -1 {
} else { whereText += " > ?"
whereText += " = ?" } else {
} whereText += " = ?"
}
result := db.Model(xray.ClientTraffic{}). // Reset client traffics
Where(whereText, id). result := tx.Model(xray.ClientTraffic{}).
Updates(map[string]any{"enable": true, "up": 0, "down": 0}) Where(whereText, id).
Updates(map[string]any{"enable": true, "up": 0, "down": 0})
err := result.Error if result.Error != nil {
return err return result.Error
}
// Update lastTrafficResetTime for the inbound(s)
inboundWhereText := "id "
if id == -1 {
inboundWhereText += " > ?"
} else {
inboundWhereText += " = ?"
}
result = tx.Model(model.Inbound{}).
Where(inboundWhereText, id).
Update("last_traffic_reset_time", now)
return result.Error
})
} }
func (s *InboundService) ResetAllTraffics() error { func (s *InboundService) ResetAllTraffics() error {
@ -1804,8 +1837,14 @@ 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{}).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 { if err != nil {
return err return err
} }
@ -1856,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 { if err != nil {
return err return err
} }
@ -1904,18 +1944,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) {
db := database.GetDB() // Prefer retrieving along with client to reflect actual enabled state from inbound settings
var traffics []*xray.ClientTraffic t, client, err := s.GetClientByEmail(email)
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 len(traffics) > 0 { if t != nil && client != nil {
return traffics[0], nil t.Enable = client.Enable
t.SubId = client.SubID
t.UUID = client.ID
return t, nil
} }
return nil, nil return nil, nil
} }
@ -1950,6 +1990,14 @@ 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
traffics[i].SubId = client.SubID
traffics[i].UUID = client.ID
}
}
return traffics, err return traffics, err
} }

View file

@ -16,6 +16,7 @@ import (
"runtime" "runtime"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"x-ui/config" "x-ui/config"
@ -93,11 +94,94 @@ 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
}
type LogEntry struct {
DateTime time.Time
FromAddress string
ToAddress string
Inbound string
Outbound string
Email string
Event int
} }
func getPublicIP(url string) string { func getPublicIP(url string) string {
@ -139,11 +223,11 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
} }
// CPU stats // CPU stats
percents, err := cpu.Percent(0, false) util, err := s.sampleCPUUtilization()
if err != nil { if err != nil {
logger.Warning("get cpu percent failed:", err) logger.Warning("get cpu percent failed:", err)
} else { } else {
status.Cpu = percents[0] status.Cpu = util
} }
status.CpuCores, err = cpu.Counts(false) status.CpuCores, err = cpu.Counts(false)
@ -153,13 +237,30 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
status.LogicalPro = runtime.NumCPU() status.LogicalPro = runtime.NumCPU()
cpuInfos, err := cpu.Info() if status.CpuSpeedMhz = s.cachedCpuSpeedMhz; s.cachedCpuSpeedMhz == 0 && time.Since(s.lastCpuInfoAttempt) > 5*time.Minute {
if err != nil { s.lastCpuInfoAttempt = time.Now()
logger.Warning("get cpu info failed:", err) done := make(chan struct{})
} else if len(cpuInfos) > 0 { go func() {
status.CpuSpeedMhz = cpuInfos[0].Mhz defer close(done)
} else { cpuInfos, err := cpu.Info()
logger.Warning("could not find 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 // Uptime
@ -307,6 +408,103 @@ 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"
@ -516,19 +714,25 @@ func (s *ServerService) GetXrayLogs(
showBlocked string, showBlocked string,
showProxy string, showProxy string,
freedoms []string, freedoms []string,
blackholes []string) []string { blackholes []string) []LogEntry {
const (
Direct = iota
Blocked
Proxied
)
countInt, _ := strconv.Atoi(count) countInt, _ := strconv.Atoi(count)
var lines []string var entries []LogEntry
pathToAccessLog, err := xray.GetAccessLogPath() pathToAccessLog, err := xray.GetAccessLogPath()
if err != nil { if err != nil {
return lines return nil
} }
file, err := os.Open(pathToAccessLog) file, err := os.Open(pathToAccessLog)
if err != nil { if err != nil {
return lines return nil
} }
defer file.Close() defer file.Close()
@ -547,37 +751,62 @@ func (s *ServerService) GetXrayLogs(
continue continue
} }
//adding suffixes to further distinguish entries by outbound var entry LogEntry
if hasSuffix(line, freedoms) { parts := strings.Fields(line)
for i, part := range parts {
if i == 0 {
dateTime, err := time.Parse("2006/01/02 15:04:05.999999", parts[0]+" "+parts[1])
if err != nil {
continue
}
entry.DateTime = dateTime
}
if part == "from" {
entry.FromAddress = parts[i+1]
} else if part == "accepted" {
entry.ToAddress = parts[i+1]
} else if strings.HasPrefix(part, "[") {
entry.Inbound = part[1:]
} else if strings.HasSuffix(part, "]") {
entry.Outbound = part[:len(part)-1]
} else if part == "email:" {
entry.Email = parts[i+1]
}
}
if logEntryContains(line, freedoms) {
if showDirect == "false" { if showDirect == "false" {
continue continue
} }
line = line + " f" entry.Event = Direct
} else if hasSuffix(line, blackholes) { } else if logEntryContains(line, blackholes) {
if showBlocked == "false" { if showBlocked == "false" {
continue continue
} }
line = line + " b" entry.Event = Blocked
} else { } else {
if showProxy == "false" { if showProxy == "false" {
continue continue
} }
line = line + " p" entry.Event = Proxied
} }
lines = append(lines, line) entries = append(entries, entry)
} }
if len(lines) > countInt { if len(entries) > countInt {
lines = lines[len(lines)-countInt:] entries = entries[len(entries)-countInt:]
} }
return lines return entries
} }
func hasSuffix(line string, suffixes []string) bool { func logEntryContains(line string, suffixes []string) bool {
for _, sfx := range suffixes { for _, sfx := range suffixes {
if strings.HasSuffix(line, sfx+"]") { if strings.Contains(line, sfx+"]") {
return true return true
} }
} }

View file

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

View file

@ -244,6 +244,9 @@
"exportInbound" = "تصدير الإدخال" "exportInbound" = "تصدير الإدخال"
"import" = "استيراد" "import" = "استيراد"
"importInbound" = "استيراد إدخال" "importInbound" = "استيراد إدخال"
"periodicTrafficResetTitle" = "إعادة تعيين حركة المرور"
"periodicTrafficResetDesc" = "إعادة تعيين عداد حركة المرور تلقائيًا في فترات محددة"
"lastReset" = "آخر إعادة تعيين"
[pages.client] [pages.client]
"add" = "أضف عميل" "add" = "أضف عميل"
@ -263,6 +266,12 @@
"renew" = "تجديد تلقائي" "renew" = "تجديد تلقائي"
"renewDesc" = "تجديد تلقائي بعد انتهاء الصلاحية. (0 = تعطيل)(الوحدة: يوم)" "renewDesc" = "تجديد تلقائي بعد انتهاء الصلاحية. (0 = تعطيل)(الوحدة: يوم)"
[pages.inbounds.periodicTrafficReset]
"never" = "أبداً"
"daily" = "يومياً"
"weekly" = "أسبوعياً"
"monthly" = "شهرياً"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "تم الحصول عليه" "obtain" = "تم الحصول عليه"
"updateSuccess" = "تم التحديث بنجاح" "updateSuccess" = "تم التحديث بنجاح"

View file

@ -244,6 +244,9 @@
"exportInbound" = "Export Inbound" "exportInbound" = "Export Inbound"
"import" = "Import" "import" = "Import"
"importInbound" = "Import an Inbound" "importInbound" = "Import an Inbound"
"periodicTrafficResetTitle" = "Traffic Reset"
"periodicTrafficResetDesc" = "Automatically reset traffic counter at specified intervals"
"lastReset" = "Last Reset"
[pages.client] [pages.client]
"add" = "Add Client" "add" = "Add Client"
@ -263,6 +266,12 @@
"renew" = "Auto Renew" "renew" = "Auto Renew"
"renewDesc" = "Auto-renewal after expiration. (0 = disable)(unit: day)" "renewDesc" = "Auto-renewal after expiration. (0 = disable)(unit: day)"
[pages.inbounds.periodicTrafficReset]
"never" = "Never"
"daily" = "Daily"
"weekly" = "Weekly"
"monthly" = "Monthly"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "Obtain" "obtain" = "Obtain"
"updateSuccess" = "The update was successful." "updateSuccess" = "The update was successful."

View file

@ -244,6 +244,9 @@
"exportInbound" = "Exportación entrante" "exportInbound" = "Exportación entrante"
"import" = "Importar" "import" = "Importar"
"importInbound" = "Importar un entrante" "importInbound" = "Importar un entrante"
"periodicTrafficResetTitle" = "Reset de Tráfico"
"periodicTrafficResetDesc" = "Reiniciar automáticamente el contador de tráfico en intervalos especificados"
"lastReset" = "Último reinicio"
[pages.client] [pages.client]
"add" = "Agregar Cliente" "add" = "Agregar Cliente"
@ -263,6 +266,12 @@
"renew" = "Renovación automática" "renew" = "Renovación automática"
"renewDesc" = "Renovación automática después de la expiración. (0 = desactivar) (unidad: día)" "renewDesc" = "Renovación automática después de la expiración. (0 = desactivar) (unidad: día)"
[pages.inbounds.periodicTrafficReset]
"never" = "Nunca"
"daily" = "Diariamente"
"weekly" = "Semanalmente"
"monthly" = "Mensualmente"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "Recibir" "obtain" = "Recibir"
"updateSuccess" = "La actualización fue exitosa" "updateSuccess" = "La actualización fue exitosa"

View file

@ -244,6 +244,9 @@
"exportInbound" = "استخراج ورودی" "exportInbound" = "استخراج ورودی"
"import" = "افزودن" "import" = "افزودن"
"importInbound" = "افزودن یک ورودی" "importInbound" = "افزودن یک ورودی"
"periodicTrafficResetTitle" = "بازنشانی ترافیک"
"periodicTrafficResetDesc" = "بازنشانی خودکار شمارنده ترافیک در فواصل زمانی مشخص"
"lastReset" = "آخرین بازنشانی"
[pages.client] [pages.client]
"add" = "کاربر جدید" "add" = "کاربر جدید"
@ -261,7 +264,13 @@
"expireDays" = "مدت زمان" "expireDays" = "مدت زمان"
"days" = "(روز)" "days" = "(روز)"
"renew" = "تمدید خودکار" "renew" = "تمدید خودکار"
"renewDesc" = "(تمدید خودکار پس‌از ‌انقضا. (0 = غیرفعال)(واحد: روز" "renewDesc" = "تمدید خودکار پس‌از ‌انقضا. (0 = غیرفعال)(واحد: روز)"
[pages.inbounds.periodicTrafficReset]
"never" = "هرگز"
"daily" = "روزانه"
"weekly" = "هفتگی"
"monthly" = "ماهانه"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "فراهم‌سازی" "obtain" = "فراهم‌سازی"

View file

@ -244,6 +244,9 @@
"exportInbound" = "Ekspor Masuk" "exportInbound" = "Ekspor Masuk"
"import" = "Impor" "import" = "Impor"
"importInbound" = "Impor Masuk" "importInbound" = "Impor Masuk"
"periodicTrafficResetTitle" = "Reset Trafik Berkala"
"periodicTrafficResetDesc" = "Reset otomatis penghitung trafik pada interval tertentu"
"lastReset" = "Reset Terakhir"
[pages.client] [pages.client]
"add" = "Tambah Klien" "add" = "Tambah Klien"
@ -263,6 +266,12 @@
"renew" = "Perpanjang Otomatis" "renew" = "Perpanjang Otomatis"
"renewDesc" = "Perpanjangan otomatis setelah kedaluwarsa. (0 = nonaktif)(unit: hari)" "renewDesc" = "Perpanjangan otomatis setelah kedaluwarsa. (0 = nonaktif)(unit: hari)"
[pages.inbounds.periodicTrafficReset]
"never" = "Tidak Pernah"
"daily" = "Harian"
"weekly" = "Mingguan"
"monthly" = "Bulanan"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "Dapatkan" "obtain" = "Dapatkan"
"updateSuccess" = "Pembaruan berhasil" "updateSuccess" = "Pembaruan berhasil"

View file

@ -244,6 +244,9 @@
"exportInbound" = "インバウンドルールをエクスポート" "exportInbound" = "インバウンドルールをエクスポート"
"import" = "インポート" "import" = "インポート"
"importInbound" = "インバウンドルールをインポート" "importInbound" = "インバウンドルールをインポート"
"periodicTrafficResetTitle" = "トラフィックリセット"
"periodicTrafficResetDesc" = "指定された間隔でトラフィックカウンタを自動的にリセット"
"lastReset" = "最後のリセット"
[pages.client] [pages.client]
"add" = "クライアント追加" "add" = "クライアント追加"
@ -263,6 +266,12 @@
"renew" = "自動更新" "renew" = "自動更新"
"renewDesc" = "期限が切れた後に自動更新。0 = 無効)(単位:日)" "renewDesc" = "期限が切れた後に自動更新。0 = 無効)(単位:日)"
[pages.inbounds.periodicTrafficReset]
"never" = "なし"
"daily" = "毎日"
"weekly" = "毎週"
"monthly" = "毎月"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "取得" "obtain" = "取得"
"updateSuccess" = "更新が成功しました" "updateSuccess" = "更新が成功しました"

View file

@ -244,6 +244,9 @@
"exportInbound" = "Exportar Inbound" "exportInbound" = "Exportar Inbound"
"import" = "Importar" "import" = "Importar"
"importInbound" = "Importar um Inbound" "importInbound" = "Importar um Inbound"
"periodicTrafficResetTitle" = "Reset de Tráfego"
"periodicTrafficResetDesc" = "Reinicia automaticamente o contador de tráfego em intervalos especificados"
"lastReset" = "Último Reset"
[pages.client] [pages.client]
"add" = "Adicionar Cliente" "add" = "Adicionar Cliente"
@ -263,6 +266,12 @@
"renew" = "Renovação Automática" "renew" = "Renovação Automática"
"renewDesc" = "Renovação automática após expiração. (0 = desativado)(unidade: dia)" "renewDesc" = "Renovação automática após expiração. (0 = desativado)(unidade: dia)"
[pages.inbounds.periodicTrafficReset]
"never" = "Nunca"
"daily" = "Diariamente"
"weekly" = "Semanalmente"
"monthly" = "Mensalmente"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "Obter" "obtain" = "Obter"
"updateSuccess" = "A atualização foi bem-sucedida" "updateSuccess" = "A atualização foi bem-sucedida"

View file

@ -244,6 +244,9 @@
"exportInbound" = "Экспорт инбаундов" "exportInbound" = "Экспорт инбаундов"
"import" = "Импортировать" "import" = "Импортировать"
"importInbound" = "Импорт инбаундов" "importInbound" = "Импорт инбаундов"
"periodicTrafficResetTitle" = "Сброс трафика"
"periodicTrafficResetDesc" = "Автоматический сброс счетчика трафика через указанные интервалы"
"lastReset" = "Последний сброс"
[pages.client] [pages.client]
"add" = "Создать клиента" "add" = "Создать клиента"
@ -263,6 +266,12 @@
"renew" = "Автопродление" "renew" = "Автопродление"
"renewDesc" = "Автопродление после истечения срока действия. (0 = отключить)(единица: день)" "renewDesc" = "Автопродление после истечения срока действия. (0 = отключить)(единица: день)"
[pages.inbounds.periodicTrafficReset]
"never" = "Никогда"
"daily" = "Ежедневно"
"weekly" = "Еженедельно"
"monthly" = "Ежемесячно"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "Получить" "obtain" = "Получить"
"updateSuccess" = "Обновление прошло успешно" "updateSuccess" = "Обновление прошло успешно"

View file

@ -244,6 +244,9 @@
"exportInbound" = "Geleni Dışa Aktar" "exportInbound" = "Geleni Dışa Aktar"
"import" = "İçe Aktar" "import" = "İçe Aktar"
"importInbound" = "Bir Gelen İçe Aktar" "importInbound" = "Bir Gelen İçe Aktar"
"periodicTrafficResetTitle" = "Trafik Sıfırlama"
"periodicTrafficResetDesc" = "Belirtilen aralıklarla trafik sayacını otomatik olarak sıfırla"
"lastReset" = "Son Sıfırlama"
[pages.client] [pages.client]
"add" = "Müşteri Ekle" "add" = "Müşteri Ekle"
@ -263,6 +266,12 @@
"renew" = "Otomatik Yenile" "renew" = "Otomatik Yenile"
"renewDesc" = "Süresi dolduktan sonra otomatik yenileme. (0 = devre dışı)(birim: gün)" "renewDesc" = "Süresi dolduktan sonra otomatik yenileme. (0 = devre dışı)(birim: gün)"
[pages.inbounds.periodicTrafficReset]
"never" = "Asla"
"daily" = "Günlük"
"weekly" = "Haftalık"
"monthly" = "Aylık"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "Elde Et" "obtain" = "Elde Et"
"updateSuccess" = "Güncelleme başarılı oldu" "updateSuccess" = "Güncelleme başarılı oldu"

View file

@ -244,6 +244,9 @@
"exportInbound" = "Експортувати вхідні" "exportInbound" = "Експортувати вхідні"
"import" = "Імпорт" "import" = "Імпорт"
"importInbound" = "Імпортувати вхідний" "importInbound" = "Імпортувати вхідний"
"periodicTrafficResetTitle" = "Скидання трафіку"
"periodicTrafficResetDesc" = "Автоматично скидати лічильник трафіку через певні проміжки часу"
"lastReset" = "Останнє скидання"
[pages.client] [pages.client]
"add" = "Додати клієнта" "add" = "Додати клієнта"
@ -263,6 +266,12 @@
"renew" = "Автоматичне оновлення" "renew" = "Автоматичне оновлення"
"renewDesc" = "Автоматичне поновлення після закінчення терміну дії. (0 = вимкнено)(одиниця: день)" "renewDesc" = "Автоматичне поновлення після закінчення терміну дії. (0 = вимкнено)(одиниця: день)"
[pages.inbounds.periodicTrafficReset]
"never" = "Ніколи"
"daily" = "Щодня"
"weekly" = "Щотижня"
"monthly" = "Щомісяця"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "Отримати" "obtain" = "Отримати"
"updateSuccess" = "Оновлення пройшло успішно" "updateSuccess" = "Оновлення пройшло успішно"

View file

@ -244,6 +244,9 @@
"exportInbound" = "Xuất nhập khẩu" "exportInbound" = "Xuất nhập khẩu"
"import" = "Nhập" "import" = "Nhập"
"importInbound" = "Nhập inbound" "importInbound" = "Nhập inbound"
"periodicTrafficResetTitle" = "Đặt lại lưu lượng"
"periodicTrafficResetDesc" = "Tự động đặt lại bộ đếm lưu lượng theo khoảng thời gian xác định"
"lastReset" = "Đặt lại lần cuối"
[pages.client] [pages.client]
"add" = "Thêm người dùng" "add" = "Thêm người dùng"
@ -263,6 +266,12 @@
"renew" = "Tự động gia hạn" "renew" = "Tự động gia hạn"
"renewDesc" = "Tự động gia hạn sau khi hết hạn. (0 = tắt)(đơn vị: ngày)" "renewDesc" = "Tự động gia hạn sau khi hết hạn. (0 = tắt)(đơn vị: ngày)"
[pages.inbounds.periodicTrafficReset]
"never" = "Không bao giờ"
"daily" = "Hàng ngày"
"weekly" = "Hàng tuần"
"monthly" = "Hàng tháng"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "Nhận" "obtain" = "Nhận"
"updateSuccess" = "Cập nhật thành công" "updateSuccess" = "Cập nhật thành công"

View file

@ -244,6 +244,9 @@
"exportInbound" = "导出入站规则" "exportInbound" = "导出入站规则"
"import"="导入" "import"="导入"
"importInbound" = "导入入站规则" "importInbound" = "导入入站规则"
"periodicTrafficResetTitle" = "流量重置"
"periodicTrafficResetDesc" = "按指定间隔自动重置流量计数器"
"lastReset" = "上次重置"
[pages.client] [pages.client]
"add" = "添加客户端" "add" = "添加客户端"
@ -263,6 +266,12 @@
"renew" = "自动续订" "renew" = "自动续订"
"renewDesc" = "到期后自动续订。(0 = 禁用)(单位: 天)" "renewDesc" = "到期后自动续订。(0 = 禁用)(单位: 天)"
[pages.inbounds.periodicTrafficReset]
"never" = "从不"
"daily" = "每日"
"weekly" = "每周"
"monthly" = "每月"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "获取" "obtain" = "获取"
"updateSuccess" = "更新成功" "updateSuccess" = "更新成功"

View file

@ -244,6 +244,9 @@
"exportInbound" = "匯出入站規則" "exportInbound" = "匯出入站規則"
"import"="匯入" "import"="匯入"
"importInbound" = "匯入入站規則" "importInbound" = "匯入入站規則"
"periodicTrafficResetTitle" = "流量重置"
"periodicTrafficResetDesc" = "按指定間隔自動重置流量計數器"
"lastReset" = "上次重置"
[pages.client] [pages.client]
"add" = "新增客戶端" "add" = "新增客戶端"
@ -263,6 +266,12 @@
"renew" = "自動續訂" "renew" = "自動續訂"
"renewDesc" = "到期後自動續訂。(0 = 禁用)(單位: 天)" "renewDesc" = "到期後自動續訂。(0 = 禁用)(單位: 天)"
[pages.inbounds.periodicTrafficReset]
"never" = "從不"
"daily" = "每日"
"weekly" = "每週"
"monthly" = "每月"
[pages.inbounds.toasts] [pages.inbounds.toasts]
"obtain" = "獲取" "obtain" = "獲取"
"updateSuccess" = "更新成功" "updateSuccess" = "更新成功"

View file

@ -289,6 +289,14 @@ 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
// 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 // Make a traffic condition every day, 8:30
var entry cron.EntryID var entry cron.EntryID
isTgbotenabled, err := s.settingService.GetTgbotEnabled() isTgbotenabled, err := s.settingService.GetTgbotEnabled()

View file

@ -5,6 +5,8 @@ type ClientTraffic struct {
InboundId int `json:"inboundId" form:"inboundId"` InboundId int `json:"inboundId" form:"inboundId"`
Enable bool `json:"enable" form:"enable"` Enable bool `json:"enable" form:"enable"`
Email string `json:"email" form:"email" gorm:"unique"` Email string `json:"email" form:"email" gorm:"unique"`
UUID string `json:"uuid" form:"uuid" gorm:"unique;type:char(36)"`
SubId string `json:"subId" form:"subId" gorm:"-"`
Up int64 `json:"up" form:"up"` Up int64 `json:"up" form:"up"`
Down int64 `json:"down" form:"down"` Down int64 `json:"down" form:"down"`
AllTime int64 `json:"allTime" form:"allTime"` AllTime int64 `json:"allTime" form:"allTime"`