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 {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
UserId int `json:"-"`
Up int64 `json:"up" form:"up"`
Down int64 `json:"down" form:"down"`
Total int64 `json:"total" form:"total"`
AllTime int64 `json:"allTime" form:"allTime" gorm:"default:0"`
Remark string `json:"remark" form:"remark"`
Enable bool `json:"enable" form:"enable"`
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"`
ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"`
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
UserId int `json:"-"`
Up int64 `json:"up" form:"up"`
Down int64 `json:"down" form:"down"`
Total int64 `json:"total" form:"total"`
AllTime int64 `json:"allTime" form:"allTime" gorm:"default:0"`
Remark string `json:"remark" form:"remark"`
Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1"`
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"`
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
Listen string `json:"listen" form:"listen"`

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
}

File diff suppressed because one or more lines are too long

View file

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

View file

@ -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

@ -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="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

@ -44,6 +44,30 @@
<a-input-number v-model.number="dbInbound.totalGB" :min="0"></a-input-number>
</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>
<template slot="label">
<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

@ -4,7 +4,7 @@
{{ template "page/body_start" .}}
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' login-app'">
<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-inner-header"></div>
<svg class="waves" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
@ -20,7 +20,7 @@
</g>
</svg>
</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">
<template v-if="!loadingStates.fetched">
<div class="text-center">
@ -184,5 +184,80 @@
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>
{{ template "page/body_end" .}}

View file

@ -102,14 +102,15 @@
<a-tag :color="inbound.stream.security == 'none' ? 'red' : 'green'">[[ inbound.stream.security ]]</a-tag>
<br />
<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 />
{{ i18n "encryption" }}
<a-tag :color="inbound.settings.encryption ? 'green' : 'red'">[[ inbound.settings.encryption ? inbound.settings.encryption : '' ]]</a-tag>
<br />
<template v-if="inbound.stream.security != 'none'">
{{ 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>
</template>
</template>
@ -179,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">
@ -586,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

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

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

@ -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
}
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) {
db := database.GetDB()
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.Enable = inbound.Enable
oldInbound.ExpiryTime = inbound.ExpiryTime
oldInbound.TrafficReset = inbound.TrafficReset
oldInbound.Listen = inbound.Listen
oldInbound.Port = inbound.Port
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) {
// TODO: check if TrafficReset field is updating
clients, err := s.GetClients(data)
if err != nil {
return false, err
@ -1260,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
@ -1273,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,
@ -1684,6 +1696,7 @@ func (s *InboundService) ResetClientTrafficLimitByEmail(clientEmail string, tota
func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error {
db := database.GetDB()
// Reset traffic stats in ClientTraffic table
result := db.Model(xray.ClientTraffic{}).
Where("email = ?", clientEmail).
Updates(map[string]any{"enable": true, "up": 0, "down": 0})
@ -1692,6 +1705,7 @@ func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error {
if err != nil {
return err
}
return nil
}
@ -1759,20 +1773,39 @@ func (s *InboundService) ResetClientTraffic(id int, clientEmail string) (bool, e
func (s *InboundService) ResetAllClientTraffics(id int) error {
db := database.GetDB()
now := time.Now().Unix() * 1000
whereText := "inbound_id "
if id == -1 {
whereText += " > ?"
} else {
whereText += " = ?"
}
return db.Transaction(func(tx *gorm.DB) error {
whereText := "inbound_id "
if id == -1 {
whereText += " > ?"
} else {
whereText += " = ?"
}
result := db.Model(xray.ClientTraffic{}).
Where(whereText, id).
Updates(map[string]any{"enable": true, "up": 0, "down": 0})
// Reset client traffics
result := tx.Model(xray.ClientTraffic{}).
Where(whereText, id).
Updates(map[string]any{"enable": true, "up": 0, "down": 0})
err := result.Error
return err
if result.Error != nil {
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 {
@ -1804,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
}
@ -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 {
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) {
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 {
t.Enable = client.Enable
t.SubId = client.SubID
t.UUID = client.ID
return t, nil
}
return nil, nil
}
@ -1950,6 +1990,14 @@ 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
traffics[i].SubId = client.SubID
traffics[i].UUID = client.ID
}
}
return traffics, err
}

View file

@ -16,6 +16,7 @@ import (
"runtime"
"strconv"
"strings"
"sync"
"time"
"x-ui/config"
@ -93,11 +94,94 @@ 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
}
type LogEntry struct {
DateTime time.Time
FromAddress string
ToAddress string
Inbound string
Outbound string
Email string
Event int
}
func getPublicIP(url string) string {
@ -139,11 +223,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 +237,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 +408,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"
@ -516,19 +714,25 @@ func (s *ServerService) GetXrayLogs(
showBlocked string,
showProxy string,
freedoms []string,
blackholes []string) []string {
blackholes []string) []LogEntry {
const (
Direct = iota
Blocked
Proxied
)
countInt, _ := strconv.Atoi(count)
var lines []string
var entries []LogEntry
pathToAccessLog, err := xray.GetAccessLogPath()
if err != nil {
return lines
return nil
}
file, err := os.Open(pathToAccessLog)
if err != nil {
return lines
return nil
}
defer file.Close()
@ -547,37 +751,62 @@ func (s *ServerService) GetXrayLogs(
continue
}
//adding suffixes to further distinguish entries by outbound
if hasSuffix(line, freedoms) {
var entry LogEntry
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" {
continue
}
line = line + " f"
} else if hasSuffix(line, blackholes) {
entry.Event = Direct
} else if logEntryContains(line, blackholes) {
if showBlocked == "false" {
continue
}
line = line + " b"
entry.Event = Blocked
} else {
if showProxy == "false" {
continue
}
line = line + " p"
entry.Event = Proxied
}
lines = append(lines, line)
entries = append(entries, entry)
}
if len(lines) > countInt {
lines = lines[len(lines)-countInt:]
if len(entries) > 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 {
if strings.HasSuffix(line, sfx+"]") {
if strings.Contains(line, sfx+"]") {
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 {
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

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

View file

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

View file

@ -244,6 +244,9 @@
"exportInbound" = "Exportación entrante"
"import" = "Importar"
"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]
"add" = "Agregar Cliente"
@ -263,6 +266,12 @@
"renew" = "Renovación automática"
"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]
"obtain" = "Recibir"
"updateSuccess" = "La actualización fue exitosa"

View file

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

View file

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

View file

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

View file

@ -244,6 +244,9 @@
"exportInbound" = "Exportar Inbound"
"import" = "Importar"
"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]
"add" = "Adicionar Cliente"
@ -263,6 +266,12 @@
"renew" = "Renovação Automática"
"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]
"obtain" = "Obter"
"updateSuccess" = "A atualização foi bem-sucedida"

View file

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

View file

@ -244,6 +244,9 @@
"exportInbound" = "Geleni Dışa Aktar"
"import" = "İç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]
"add" = "Müşteri Ekle"
@ -263,6 +266,12 @@
"renew" = "Otomatik Yenile"
"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]
"obtain" = "Elde Et"
"updateSuccess" = "Güncelleme başarılı oldu"

View file

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

View file

@ -244,6 +244,9 @@
"exportInbound" = "Xuất nhập khẩu"
"import" = "Nhập"
"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]
"add" = "Thêm người dùng"
@ -263,6 +266,12 @@
"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)"
[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]
"obtain" = "Nhận"
"updateSuccess" = "Cập nhật thành công"

View file

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

View file

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

View file

@ -289,6 +289,14 @@ func (s *Server) startTask() {
// check client ips from log file every day
s.cron.AddJob("@daily", job.NewClearLogsJob())
// Inbound traffic reset jobs
// Run once a day, midnight
s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("daily"))
// Run once a week, midnight between Sat/Sun
s.cron.AddJob("@weekly", job.NewPeriodicTrafficResetJob("weekly"))
// Run once a month, midnight, first of month
s.cron.AddJob("@monthly", job.NewPeriodicTrafficResetJob("monthly"))
// Make a traffic condition every day, 8:30
var entry cron.EntryID
isTgbotenabled, err := s.settingService.GetTgbotEnabled()

View file

@ -5,6 +5,8 @@ type ClientTraffic struct {
InboundId int `json:"inboundId" form:"inboundId"`
Enable bool `json:"enable" form:"enable"`
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"`
Down int64 `json:"down" form:"down"`
AllTime int64 `json:"allTime" form:"allTime"`