1
0

7 Коммитууд 76afff2a6f ... bc274d1e1f

Эзэн SHA1 Мессеж Огноо
  mhsanaei bc274d1e1f Reality: placeholder for min, max 1 өдөр өмнө
  mhsanaei dc21f41932 bug fix: del Depleted 1 өдөр өмнө
  mhsanaei f137b1af76 bug fix: enable 2 өдөр өмнө
  mhsanaei c4871ef8fe sub page: improved 2 өдөр өмнө
  mhsanaei ecfffa882a CPU History, CPU Utilization 2 өдөр өмнө
  mhsanaei 3af5026abe tgbot: subscription, qrcode, link - for admin 2 өдөр өмнө
  mhsanaei 1de7accd7c vnext removed 2 өдөр өмнө

+ 1 - 1
go.mod

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

+ 24 - 5
sub/sub.go

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

+ 16 - 43
sub/subJsonService.go

@@ -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"`
-}
-
-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"`
+	Settings       map[string]any       `json:"settings,omitempty"`
 }
 
-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"`

+ 1 - 9
sub/subService.go

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

+ 71 - 0
util/sys/sys_darwin.go

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

+ 100 - 0
util/sys/sys_linux.go

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

+ 81 - 0
util/sys/sys_windows.go

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

+ 36 - 32
web/assets/js/model/outbound.js

@@ -219,7 +219,7 @@ class KcpStreamSettings extends CommonClass {
 
 class WsStreamSettings extends CommonClass {
     constructor(
-        path = '/', 
+        path = '/',
         host = '',
         heartbeatPeriod = 0,
 
@@ -647,10 +647,6 @@ class Outbound extends CommonClass {
         ].includes(this.protocol);
     }
 
-    hasVnext() {
-        return [Protocols.VMess, Protocols.VLESS].includes(this.protocol);
-    }
-
     hasServers() {
         return [Protocols.Trojan, Protocols.Shadowsocks, Protocols.Socks, Protocols.HTTP].includes(this.protocol);
     }
@@ -690,13 +686,22 @@ class Outbound extends CommonClass {
             if (this.stream?.sockopt)
                 stream = { sockopt: this.stream.sockopt.toJson() };
         }
+        // For VMess/VLESS, emit settings as a flat object
+        let settingsOut = this.settings instanceof CommonClass ? this.settings.toJson() : this.settings;
+        // Remove undefined/null keys
+        if (settingsOut && typeof settingsOut === 'object') {
+            Object.keys(settingsOut).forEach(k => {
+                if (settingsOut[k] === undefined || settingsOut[k] === null) delete settingsOut[k];
+            });
+        }
         return {
-            tag: this.tag == '' ? undefined : this.tag,
             protocol: this.protocol,
-            settings: this.settings instanceof CommonClass ? this.settings.toJson() : this.settings,
-            streamSettings: stream,
-            sendThrough: this.sendThrough != "" ? this.sendThrough : undefined,
-            mux: this.mux?.enabled ? this.mux : undefined,
+            settings: settingsOut,
+            // Only include tag, streamSettings, sendThrough, mux if present and not empty
+            ...(this.tag ? { tag: this.tag } : {}),
+            ...(stream ? { streamSettings: stream } : {}),
+            ...(this.sendThrough ? { sendThrough: this.sendThrough } : {}),
+            ...(this.mux?.enabled ? { mux: this.mux } : {}),
         };
     }
 
@@ -908,7 +913,7 @@ Outbound.FreedomSettings = class extends CommonClass {
     toJson() {
         return {
             domainStrategy: ObjectUtil.isEmpty(this.domainStrategy) ? undefined : this.domainStrategy,
-            redirect: ObjectUtil.isEmpty(this.redirect) ? undefined: this.redirect,
+            redirect: ObjectUtil.isEmpty(this.redirect) ? undefined : this.redirect,
             fragment: Object.keys(this.fragment).length === 0 ? undefined : this.fragment,
             noises: this.noises.length === 0 ? undefined : Outbound.FreedomSettings.Noise.toJsonArray(this.noises),
         };
@@ -1026,22 +1031,21 @@ Outbound.VmessSettings = class extends CommonClass {
     }
 
     static fromJson(json = {}) {
-        if (ObjectUtil.isArrEmpty(json.vnext)) return new Outbound.VmessSettings();
+        if (ObjectUtil.isEmpty(json.address) || ObjectUtil.isEmpty(json.port)) return new Outbound.VmessSettings();
         return new Outbound.VmessSettings(
-            json.vnext[0].address,
-            json.vnext[0].port,
-            json.vnext[0].users[0].id,
-            json.vnext[0].users[0].security,
+            json.address,
+            json.port,
+            json.id,
+            json.security,
         );
     }
 
     toJson() {
         return {
-            vnext: [{
-                address: this.address,
-                port: this.port,
-                users: [{ id: this.id, security: this.security }],
-            }],
+            address: this.address,
+            port: this.port,
+            id: this.id,
+            security: this.security,
         };
     }
 };
@@ -1056,23 +1060,23 @@ Outbound.VLESSSettings = class extends CommonClass {
     }
 
     static fromJson(json = {}) {
-        if (ObjectUtil.isArrEmpty(json.vnext)) return new Outbound.VLESSSettings();
+        if (ObjectUtil.isEmpty(json.address) || ObjectUtil.isEmpty(json.port)) return new Outbound.VLESSSettings();
         return new Outbound.VLESSSettings(
-            json.vnext[0].address,
-            json.vnext[0].port,
-            json.vnext[0].users[0].id,
-            json.vnext[0].users[0].flow,
-            json.vnext[0].users[0].encryption,
+            json.address,
+            json.port,
+            json.id,
+            json.flow,
+            json.encryption
         );
     }
 
     toJson() {
         return {
-            vnext: [{
-                address: this.address,
-                port: this.port,
-                users: [{ id: this.id, flow: this.flow, encryption: this.encryption }],
-            }],
+            address: this.address,
+            port: this.port,
+            id: this.id,
+            flow: this.flow,
+            encryption: this.encryption,
         };
     }
 };

+ 28 - 4
web/controller/server.go

@@ -4,6 +4,7 @@ import (
 	"fmt"
 	"net/http"
 	"regexp"
+	"strconv"
 	"time"
 
 	"x-ui/web/global"
@@ -39,6 +40,7 @@ func NewServerController(g *gin.RouterGroup) *ServerController {
 func (a *ServerController) initRouter(g *gin.RouterGroup) {
 
 	g.GET("/status", a.status)
+	g.GET("/cpuHistory", a.getCpuHistory)
 	g.GET("/getXrayVersion", a.getXrayVersion)
 	g.GET("/getConfigJson", a.getConfigJson)
 	g.GET("/getDb", a.getDb)
@@ -61,16 +63,18 @@ 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()
 	})
 }
@@ -81,6 +85,26 @@ func (a *ServerController) status(c *gin.Context) {
 	jsonObj(c, a.lastStatus, nil)
 }
 
+// getCpuHistory returns recent CPU utilization points.
+// Query param q=minutes (int). Bounds: 1..360 (6 hours). Defaults to 60.
+func (a *ServerController) getCpuHistory(c *gin.Context) {
+	minsStr := c.Query("q")
+	mins := 60
+	if minsStr != "" {
+		if v, err := strconv.Atoi(minsStr); err == nil {
+			mins = v
+		}
+	}
+	if mins < 1 {
+		mins = 1
+	}
+	if mins > 360 {
+		mins = 360
+	}
+	res := a.serverService.GetCpuHistory(mins)
+	jsonObj(c, res, nil)
+}
+
 func (a *ServerController) getXrayVersion(c *gin.Context) {
 	now := time.Now()
 	if now.Sub(a.lastGetVersionsTime) <= time.Minute {

+ 4 - 4
web/html/component/aClientTable.html

@@ -37,7 +37,7 @@
     <template slot="content" >
       {{ i18n "lastOnline" }}: [[ formatLastOnline(client.email) ]]
     </template>
-    <template v-if="client.enable && isClientOnline(client.email)">
+    <template v-if="client.enable && isClientOnline(client.email) && !isClientDepleted">
       <a-tag color="green">{{ i18n "online" }}</a-tag>
     </template>
     <template v-else>
@@ -49,9 +49,9 @@
   <a-space direction="horizontal" :size="2">
     <a-tooltip>
       <template slot="title">
-        <template v-if="!isClientEnabled(record, client.email)">{{ i18n "depleted" }}</template>
-        <template v-else-if="!client.enable">{{ i18n "disabled" }}</template>
-        <template v-else-if="client.enable && isClientOnline(client.email)">{{ i18n "online" }}</template>
+        <template v-if="isClientDepleted">{{ i18n "depleted" }}</template>
+        <template v-if="!isClientDepleted && !client.enable">{{ i18n "disabled" }}</template>
+        <template v-if="!isClientDepleted && client.enable && isClientOnline(client.email)">{{ i18n "online" }}</template>
       </template>
       <a-badge :class="isClientOnline(client.email)? 'online-animation' : ''" :color="client.enable ? statsExpColor(record, client.email) : themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc'"></a-badge>
     </a-tooltip>

+ 1 - 1
web/html/form/outbound.html

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

+ 2 - 2
web/html/form/reality_settings.html

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

+ 1138 - 1064
web/html/inbounds.html

@@ -9,15 +9,13 @@
       <a-spin :spinning="loadingStates.spinning" :delay="500" tip='{{ i18n "loading"}}'>
         <transition name="list" appear>
           <a-alert type="error" v-if="showAlert && loadingStates.fetched" :style="{ marginBottom: '10px' }"
-            message='{{ i18n "secAlertTitle" }}'
-            color="red"
-            description='{{ i18n "secAlertSsl" }}'
-            show-icon closable>
+            message='{{ i18n "secAlertTitle" }}' color="red" description='{{ i18n "secAlertSsl" }}' show-icon closable>
           </a-alert>
         </transition>
         <transition name="list" appear>
           <a-row v-if="!loadingStates.fetched">
-            <a-card :style="{ textAlign: 'center', padding: '30px 0', marginTop: '10px', background: 'transparent', border: 'none' }">
+            <a-card
+              :style="{ textAlign: 'center', padding: '30px 0', marginTop: '10px', background: 'transparent', border: 'none' }">
               <a-spin tip='{{ i18n "loading" }}'></a-spin>
             </a-card>
           </a-row>
@@ -26,40 +24,47 @@
               <a-card size="small" :style="{ padding: '16px' }" hoverable>
                 <a-row>
                   <a-col :sm="12" :md="5">
-                    <a-custom-statistic title='{{ i18n "pages.inbounds.totalDownUp" }}' :value="`${SizeFormatter.sizeFormat(total.up)} / ${SizeFormatter.sizeFormat(total.down)}`">
+                    <a-custom-statistic title='{{ i18n "pages.inbounds.totalDownUp" }}'
+                      :value="`${SizeFormatter.sizeFormat(total.up)} / ${SizeFormatter.sizeFormat(total.down)}`">
                       <template #prefix>
                         <a-icon type="swap"></a-icon>
                       </template>
                     </a-custom-statistic>
                   </a-col>
                   <a-col :sm="12" :md="5">
-                    <a-custom-statistic title='{{ i18n "pages.inbounds.totalUsage" }}' :value="SizeFormatter.sizeFormat(total.up + total.down)" :style="{ marginTop: isMobile ? '10px' : 0 }">
+                    <a-custom-statistic title='{{ i18n "pages.inbounds.totalUsage" }}'
+                      :value="SizeFormatter.sizeFormat(total.up + total.down)"
+                      :style="{ marginTop: isMobile ? '10px' : 0 }">
                       <template #prefix>
                         <a-icon type="pie-chart"></a-icon>
                       </template>
                     </a-custom-statistic>
                   </a-col>
                   <a-col :sm="12" :md="5">
-                    <a-custom-statistic title='{{ i18n "pages.inbounds.allTimeTrafficUsage" }}' :value="SizeFormatter.sizeFormat(total.allTime)" :style="{ marginTop: isMobile ? '10px' : 0 }">
+                    <a-custom-statistic title='{{ i18n "pages.inbounds.allTimeTrafficUsage" }}'
+                      :value="SizeFormatter.sizeFormat(total.allTime)" :style="{ marginTop: isMobile ? '10px' : 0 }">
                       <template #prefix>
                         <a-icon type="history"></a-icon>
                       </template>
                     </a-custom-statistic>
                   </a-col>
                   <a-col :sm="12" :md="5">
-                    <a-custom-statistic title='{{ i18n "pages.inbounds.inboundCount" }}' :value="dbInbounds.length" :style="{ marginTop: isMobile ? '10px' : 0 }">
+                    <a-custom-statistic title='{{ i18n "pages.inbounds.inboundCount" }}' :value="dbInbounds.length"
+                      :style="{ marginTop: isMobile ? '10px' : 0 }">
                       <template #prefix>
                         <a-icon type="bars"></a-icon>
                       </template>
                     </a-custom-statistic>
                   </a-col>
                   <a-col :sm="12" :md="4">
-                    <a-custom-statistic title='{{ i18n "clients" }}' value=" " :style="{ marginTop: isMobile ? '10px' : 0 }">
+                    <a-custom-statistic title='{{ i18n "clients" }}' value=" "
+                      :style="{ marginTop: isMobile ? '10px' : 0 }">
                       <template #prefix>
                         <a-space direction="horizontal">
                           <a-icon type="team"></a-icon>
                           <div>
-                            <a-back-top :target="() => document.getElementById('content-layout')" visibility-height="200"></a-back-top>
+                            <a-back-top :target="() => document.getElementById('content-layout')"
+                              visibility-height="200"></a-back-top>
                             <a-tag color="green">[[ total.clients ]]</a-tag>
                             <a-popover title='{{ i18n "disabled" }}' :overlay-class-name="themeSwitcher.currentTheme">
                               <template slot="content">
@@ -73,7 +78,8 @@
                               </template>
                               <a-tag color="red" v-if="total.depleted.length">[[ total.depleted.length ]]</a-tag>
                             </a-popover>
-                            <a-popover title='{{ i18n "depletingSoon" }}' :overlay-class-name="themeSwitcher.currentTheme">
+                            <a-popover title='{{ i18n "depletingSoon" }}'
+                              :overlay-class-name="themeSwitcher.currentTheme">
                               <template slot="content">
                                 <div v-for="clientEmail in total.expiring"><span>[[ clientEmail ]]</span></div>
                               </template>
@@ -136,7 +142,7 @@
                 <template #extra>
                   <a-button-group>
                     <a-button icon="sync" @click="manualRefresh" :loading="refreshing"></a-button>
-                    <a-popover placement="bottomRight" trigger="click" :overlay-class-name="themeSwitcher.currentTheme"> 
+                    <a-popover placement="bottomRight" trigger="click" :overlay-class-name="themeSwitcher.currentTheme">
                       <template #title>
                         <div class="ant-custom-popover-title">
                           <a-switch v-model="isRefreshEnabled" @change="toggleRefresh" size="small"></a-switch>
@@ -146,11 +152,8 @@
                       <template #content>
                         <a-space direction="vertical">
                           <span>{{ i18n "pages.inbounds.autoRefreshInterval" }}</span>
-                          <a-select v-model="refreshInterval"
-                              :disabled="!isRefreshEnabled"
-                              :style="{ width: '100%' }"
-                              @change="changeRefreshInterval"
-                              :dropdown-class-name="themeSwitcher.currentTheme">
+                          <a-select v-model="refreshInterval" :disabled="!isRefreshEnabled" :style="{ width: '100%' }"
+                            @change="changeRefreshInterval" :dropdown-class-name="themeSwitcher.currentTheme">
                             <a-select-option v-for="key in [5,10,30,60]" :value="key*1000">[[ key ]]s</a-select-option>
                           </a-select>
                         </a-space>
@@ -162,13 +165,15 @@
                 <a-space direction="vertical">
                   <div :style="isMobile ? {} : { display: 'flex', alignItems: 'center', justifyContent: 'flex-start' }">
                     <a-switch v-model="enableFilter"
-                        :style="isMobile ? { marginBottom: '.5rem', display: 'flex' } : { marginRight: '.5rem' }"
-                        @change="toggleFilter">
+                      :style="isMobile ? { marginBottom: '.5rem', display: 'flex' } : { marginRight: '.5rem' }"
+                      @change="toggleFilter">
                       <a-icon slot="checkedChildren" type="search"></a-icon>
                       <a-icon slot="unCheckedChildren" type="filter"></a-icon>
                     </a-switch>
-                    <a-input v-if="!enableFilter" v-model.lazy="searchKey" placeholder='{{ i18n "search" }}' autofocus :style="{ maxWidth: '300px' }" :size="isMobile ? 'small' : ''"></a-input>
-                    <a-radio-group v-if="enableFilter" v-model="filterBy" @change="filterInbounds" button-style="solid" :size="isMobile ? 'small' : ''">
+                    <a-input v-if="!enableFilter" v-model.lazy="searchKey" placeholder='{{ i18n "search" }}' autofocus
+                      :style="{ maxWidth: '300px' }" :size="isMobile ? 'small' : ''"></a-input>
+                    <a-radio-group v-if="enableFilter" v-model="filterBy" @change="filterInbounds" button-style="solid"
+                      :size="isMobile ? 'small' : ''">
                       <a-radio-button value="">{{ i18n "none" }}</a-radio-button>
                       <a-radio-button value="deactive">{{ i18n "disabled" }}</a-radio-button>
                       <a-radio-button value="depleted">{{ i18n "depleted" }}</a-radio-button>
@@ -177,25 +182,24 @@
                     </a-radio-group>
                   </div>
                   <a-table :columns="isMobile ? mobileColumns : columns" :row-key="dbInbound => dbInbound.id"
-                      :data-source="searchedInbounds"
-                      :scroll="isMobile ? {} : { x: 1000 }"
-                      :pagination=pagination(searchedInbounds)
-                      :expand-icon-as-cell="false"
-                      :expand-row-by-click="false"
-                      :expand-icon-column-index="0"
-                      :indent-size="0"
-                      :row-class-name="dbInbound => (dbInbound.isMultiUser() ? '' : 'hideExpandIcon')"
-                      :style="{ marginTop: '10px' }"
-                      :locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}`, emptyText: `{{ i18n "noData" }}` }'>
+                    :data-source="searchedInbounds" :scroll="isMobile ? {} : { x: 1000 }"
+                    :pagination=pagination(searchedInbounds) :expand-icon-as-cell="false" :expand-row-by-click="false"
+                    :expand-icon-column-index="0" :indent-size="0"
+                    :row-class-name="dbInbound => (dbInbound.isMultiUser() ? '' : 'hideExpandIcon')"
+                    :style="{ marginTop: '10px' }"
+                    :locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}`, emptyText: `{{ i18n "noData" }}` }'>
                     <template slot="action" slot-scope="text, dbInbound">
                       <a-dropdown :trigger="['click']">
-                        <a-icon @click="e => e.preventDefault()" type="more" :style="{ fontSize: '20px', textDecoration: 'solid' }"></a-icon>
-                        <a-menu slot="overlay" @click="a => clickAction(a, dbInbound)" :theme="themeSwitcher.currentTheme">
+                        <a-icon @click="e => e.preventDefault()" type="more"
+                          :style="{ fontSize: '20px', textDecoration: 'solid' }"></a-icon>
+                        <a-menu slot="overlay" @click="a => clickAction(a, dbInbound)"
+                          :theme="themeSwitcher.currentTheme">
                           <a-menu-item key="edit">
                             <a-icon type="edit"></a-icon>
                             {{ i18n "edit" }}
                           </a-menu-item>
-                          <a-menu-item key="qrcode" v-if="(dbInbound.isSS && !dbInbound.toInbound().isSSMultiUser) || dbInbound.isWireguard">
+                          <a-menu-item key="qrcode"
+                            v-if="(dbInbound.isSS && !dbInbound.toInbound().isSSMultiUser) || dbInbound.isWireguard">
                             <a-icon type="qrcode"></a-icon>
                             {{ i18n "qrCode" }}
                           </a-menu-item>
@@ -247,7 +251,8 @@
                             </span>
                           </a-menu-item>
                           <a-menu-item v-if="isMobile">
-                            <a-switch size="small" v-model="dbInbound.enable" @change="switchEnable(dbInbound.id,dbInbound.enable)"></a-switch>
+                            <a-switch size="small" v-model="dbInbound.enable"
+                              @change="switchEnable(dbInbound.id,dbInbound.enable)"></a-switch>
                             {{ i18n "pages.inbounds.enable" }}
                           </a-menu-item>
                         </a-menu>
@@ -257,8 +262,10 @@
                       <a-tag :style="{ margin: '0' }" color="purple">[[ dbInbound.protocol ]]</a-tag>
                       <template v-if="dbInbound.isVMess || dbInbound.isVLess || dbInbound.isTrojan || dbInbound.isSS">
                         <a-tag :style="{ margin: '0' }" color="green">[[ dbInbound.toInbound().stream.network ]]</a-tag>
-                        <a-tag :style="{ margin: '0' }" v-if="dbInbound.toInbound().stream.isTls" color="blue">TLS</a-tag>
-                        <a-tag :style="{ margin: '0' }" v-if="dbInbound.toInbound().stream.isReality" color="blue">Reality</a-tag>
+                        <a-tag :style="{ margin: '0' }" v-if="dbInbound.toInbound().stream.isTls"
+                          color="blue">TLS</a-tag>
+                        <a-tag :style="{ margin: '0' }" v-if="dbInbound.toInbound().stream.isReality"
+                          color="blue">Reality</a-tag>
                       </template>
                     </template>
                     <template slot="clients" slot-scope="text, dbInbound">
@@ -266,59 +273,75 @@
                         <a-tag :style="{ margin: '0' }" color="green">[[ clientCount[dbInbound.id].clients ]]</a-tag>
                         <a-popover title='{{ i18n "disabled" }}' :overlay-class-name="themeSwitcher.currentTheme">
                           <template slot="content">
-                            <div v-for="clientEmail in clientCount[dbInbound.id].deactive" :key="clientEmail" class="client-popup-item">
+                            <div v-for="clientEmail in clientCount[dbInbound.id].deactive" :key="clientEmail"
+                              class="client-popup-item">
                               <span>[[ clientEmail ]]</span>
                               <a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
                                 <template #title>
                                   [[ clientCount[dbInbound.id].comments.get(clientEmail) ]]
                                 </template>
-                                <a-icon type="message" v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
+                                <a-icon type="message"
+                                  v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
                               </a-tooltip>
                             </div>
                           </template>
-                          <a-tag :style="{ margin: '0', padding: '0 2px' }" v-if="clientCount[dbInbound.id].deactive.length">[[ clientCount[dbInbound.id].deactive.length ]]</a-tag>
+                          <a-tag :style="{ margin: '0', padding: '0 2px' }"
+                            v-if="clientCount[dbInbound.id].deactive.length">[[
+                            clientCount[dbInbound.id].deactive.length ]]</a-tag>
                         </a-popover>
                         <a-popover title='{{ i18n "depleted" }}' :overlay-class-name="themeSwitcher.currentTheme">
                           <template slot="content">
-                            <div v-for="clientEmail in clientCount[dbInbound.id].depleted" :key="clientEmail" class="client-popup-item">
+                            <div v-for="clientEmail in clientCount[dbInbound.id].depleted" :key="clientEmail"
+                              class="client-popup-item">
                               <span>[[ clientEmail ]]</span>
                               <a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
                                 <template #title>
                                   [[ clientCount[dbInbound.id].comments.get(clientEmail) ]]
                                 </template>
-                                <a-icon type="message" v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
+                                <a-icon type="message"
+                                  v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
                               </a-tooltip>
                             </div>
                           </template>
-                          <a-tag :style="{ margin: '0', padding: '0 2px' }" color="red" v-if="clientCount[dbInbound.id].depleted.length">[[ clientCount[dbInbound.id].depleted.length ]]</a-tag>
+                          <a-tag :style="{ margin: '0', padding: '0 2px' }" color="red"
+                            v-if="clientCount[dbInbound.id].depleted.length">[[
+                            clientCount[dbInbound.id].depleted.length ]]</a-tag>
                         </a-popover>
                         <a-popover title='{{ i18n "depletingSoon" }}' :overlay-class-name="themeSwitcher.currentTheme">
                           <template slot="content">
-                            <div v-for="clientEmail in clientCount[dbInbound.id].expiring" :key="clientEmail" class="client-popup-item">
+                            <div v-for="clientEmail in clientCount[dbInbound.id].expiring" :key="clientEmail"
+                              class="client-popup-item">
                               <span>[[ clientEmail ]]</span>
                               <a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
                                 <template #title>
                                   [[ clientCount[dbInbound.id].comments.get(clientEmail) ]]
                                 </template>
-                                <a-icon type="message" v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
+                                <a-icon type="message"
+                                  v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
                               </a-tooltip>
                             </div>
                           </template>
-                          <a-tag :style="{ margin: '0', padding: '0 2px' }" color="orange" v-if="clientCount[dbInbound.id].expiring.length">[[ clientCount[dbInbound.id].expiring.length ]]</a-tag>
+                          <a-tag :style="{ margin: '0', padding: '0 2px' }" color="orange"
+                            v-if="clientCount[dbInbound.id].expiring.length">[[
+                            clientCount[dbInbound.id].expiring.length ]]</a-tag>
                         </a-popover>
                         <a-popover title='{{ i18n "online" }}' :overlay-class-name="themeSwitcher.currentTheme">
                           <template slot="content">
-                            <div v-for="clientEmail in clientCount[dbInbound.id].online" :key="clientEmail" class="client-popup-item">
+                            <div v-for="clientEmail in clientCount[dbInbound.id].online" :key="clientEmail"
+                              class="client-popup-item">
                               <span>[[ clientEmail ]]</span>
                               <a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
                                 <template #title>
                                   [[ clientCount[dbInbound.id].comments.get(clientEmail) ]]
                                 </template>
-                                <a-icon type="message" v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
+                                <a-icon type="message"
+                                  v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
                               </a-tooltip>
                             </div>
                           </template>
-                          <a-tag :style="{ margin: '0', padding: '0 2px' }" color="blue" v-if="clientCount[dbInbound.id].online.length">[[ clientCount[dbInbound.id].online.length ]]</a-tag>
+                          <a-tag :style="{ margin: '0', padding: '0 2px' }" color="blue"
+                            v-if="clientCount[dbInbound.id].online.length">[[ clientCount[dbInbound.id].online.length
+                            ]]</a-tag>
                         </a-popover>
                       </template>
                     </template>
@@ -336,14 +359,17 @@
                             </tr>
                           </table>
                         </template>
-                        <a-tag :color="ColorUtils.usageColor(dbInbound.up + dbInbound.down, app.trafficDiff, dbInbound.total)">
+                        <a-tag
+                          :color="ColorUtils.usageColor(dbInbound.up + dbInbound.down, app.trafficDiff, dbInbound.total)">
                           [[ SizeFormatter.sizeFormat(dbInbound.up + dbInbound.down) ]] /
                           <template v-if="dbInbound.total > 0">
-                              [[ SizeFormatter.sizeFormat(dbInbound.total) ]]
+                            [[ SizeFormatter.sizeFormat(dbInbound.total) ]]
                           </template>
                           <template v-else>
                             <svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
-                              <path d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" fill="currentColor"></path>
+                              <path
+                                d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z"
+                                fill="currentColor"></path>
                             </svg>
                           </template>
                         </a-tag>
@@ -353,7 +379,8 @@
                       <a-tag>[[ SizeFormatter.sizeFormat(dbInbound.allTime || 0) ]]</a-tag>
                     </template>
                     <template slot="enable" slot-scope="text, dbInbound">
-                      <a-switch v-model="dbInbound.enable" @change="switchEnable(dbInbound.id,dbInbound.enable)"></a-switch>
+                      <a-switch v-model="dbInbound.enable"
+                        @change="switchEnable(dbInbound.id,dbInbound.enable)"></a-switch>
                     </template>
                     <template slot="expiryTime" slot-scope="text, dbInbound">
                       <a-popover v-if="dbInbound.expiryTime > 0" :overlay-class-name="themeSwitcher.currentTheme">
@@ -363,28 +390,36 @@
                         <template v-else slot="content">
                           [[ DateUtil.convertToJalalian(moment(dbInbound.expiryTime)) ]]
                         </template>
-                        <a-tag :style="{ minWidth: '50px' }" :color="ColorUtils.usageColor(new Date().getTime(), app.expireDiff, dbInbound._expiryTime)">
+                        <a-tag :style="{ minWidth: '50px' }"
+                          :color="ColorUtils.usageColor(new Date().getTime(), app.expireDiff, dbInbound._expiryTime)">
                           [[ remainedDays(dbInbound._expiryTime) ]]
                         </a-tag>
                       </a-popover>
                       <a-tag v-else color="purple" class="infinite-tag">
                         <svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
-                          <path d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" fill="currentColor"></path>
+                          <path
+                            d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z"
+                            fill="currentColor"></path>
                         </svg>
                       </a-tag>
                     </template>
                     <template slot="info" slot-scope="text, dbInbound">
-                      <a-popover placement="bottomRight" :overlay-class-name="themeSwitcher.currentTheme" trigger="click">
+                      <a-popover placement="bottomRight" :overlay-class-name="themeSwitcher.currentTheme"
+                        trigger="click">
                         <template slot="content">
                           <table cellpadding="2">
                             <tr>
                               <td>{{ i18n "pages.inbounds.protocol" }}</td>
                               <td>
                                 <a-tag :style="{ margin: '0' }" color="purple">[[ dbInbound.protocol ]]</a-tag>
-                                <template v-if="dbInbound.isVMess || dbInbound.isVLess || dbInbound.isTrojan || dbInbound.isSS">
-                                  <a-tag :style="{ margin: '0' }" color="blue">[[ dbInbound.toInbound().stream.network ]]</a-tag>
-                                  <a-tag :style="{ margin: '0' }" v-if="dbInbound.toInbound().stream.isTls" color="green">tls</a-tag>
-                                  <a-tag :style="{ margin: '0' }" v-if="dbInbound.toInbound().stream.isReality" color="green">reality</a-tag>
+                                <template
+                                  v-if="dbInbound.isVMess || dbInbound.isVLess || dbInbound.isTrojan || dbInbound.isSS">
+                                  <a-tag :style="{ margin: '0' }" color="blue">[[ dbInbound.toInbound().stream.network
+                                    ]]</a-tag>
+                                  <a-tag :style="{ margin: '0' }" v-if="dbInbound.toInbound().stream.isTls"
+                                    color="green">tls</a-tag>
+                                  <a-tag :style="{ margin: '0' }" v-if="dbInbound.toInbound().stream.isReality"
+                                    color="green">reality</a-tag>
                                 </template>
                               </td>
                             </tr>
@@ -395,62 +430,82 @@
                             <tr v-if="clientCount[dbInbound.id]">
                               <td>{{ i18n "clients" }}</td>
                               <td>
-                                <a-tag :style="{ margin: '0' }" color="blue">[[ clientCount[dbInbound.id].clients ]]</a-tag>
-                                <a-popover title='{{ i18n "disabled" }}' :overlay-class-name="themeSwitcher.currentTheme">
+                                <a-tag :style="{ margin: '0' }" color="blue">[[ clientCount[dbInbound.id].clients
+                                  ]]</a-tag>
+                                <a-popover title='{{ i18n "disabled" }}'
+                                  :overlay-class-name="themeSwitcher.currentTheme">
                                   <template slot="content">
-                                    <div v-for="clientEmail in clientCount[dbInbound.id].deactive" :key="clientEmail" class="client-popup-item">
+                                    <div v-for="clientEmail in clientCount[dbInbound.id].deactive" :key="clientEmail"
+                                      class="client-popup-item">
                                       <span>[[ clientEmail ]]</span>
                                       <a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
                                         <template #title>
                                           [[ clientCount[dbInbound.id].comments.get(clientEmail) ]]
                                         </template>
-                                        <a-icon type="message" v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
+                                        <a-icon type="message"
+                                          v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
                                       </a-tooltip>
                                     </div>
                                   </template>
-                                  <a-tag :style="{ margin: '0', padding: '0 2px' }" v-if="clientCount[dbInbound.id].deactive.length">[[ clientCount[dbInbound.id].deactive.length ]]</a-tag>
+                                  <a-tag :style="{ margin: '0', padding: '0 2px' }"
+                                    v-if="clientCount[dbInbound.id].deactive.length">[[
+                                    clientCount[dbInbound.id].deactive.length ]]</a-tag>
                                 </a-popover>
-                                <a-popover title='{{ i18n "depleted" }}' :overlay-class-name="themeSwitcher.currentTheme">
+                                <a-popover title='{{ i18n "depleted" }}'
+                                  :overlay-class-name="themeSwitcher.currentTheme">
                                   <template slot="content">
-                                    <div v-for="clientEmail in clientCount[dbInbound.id].depleted" :key="clientEmail" class="client-popup-item">
+                                    <div v-for="clientEmail in clientCount[dbInbound.id].depleted" :key="clientEmail"
+                                      class="client-popup-item">
                                       <span>[[ clientEmail ]]</span>
                                       <a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
                                         <template #title>
                                           [[ clientCount[dbInbound.id].comments.get(clientEmail) ]]
                                         </template>
-                                        <a-icon type="message" v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
+                                        <a-icon type="message"
+                                          v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
                                       </a-tooltip>
                                     </div>
                                   </template>
-                                  <a-tag :style="{ margin: '0', padding: '0 2px' }" color="red" v-if="clientCount[dbInbound.id].depleted.length">[[ clientCount[dbInbound.id].depleted.length ]]</a-tag>
+                                  <a-tag :style="{ margin: '0', padding: '0 2px' }" color="red"
+                                    v-if="clientCount[dbInbound.id].depleted.length">[[
+                                    clientCount[dbInbound.id].depleted.length ]]</a-tag>
                                 </a-popover>
-                                <a-popover title='{{ i18n "depletingSoon" }}' :overlay-class-name="themeSwitcher.currentTheme">
+                                <a-popover title='{{ i18n "depletingSoon" }}'
+                                  :overlay-class-name="themeSwitcher.currentTheme">
                                   <template slot="content">
-                                    <div v-for="clientEmail in clientCount[dbInbound.id].expiring" :key="clientEmail" class="client-popup-item">
+                                    <div v-for="clientEmail in clientCount[dbInbound.id].expiring" :key="clientEmail"
+                                      class="client-popup-item">
                                       <span>[[ clientEmail ]]</span>
                                       <a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
                                         <template #title>
                                           [[ clientCount[dbInbound.id].comments.get(clientEmail) ]]
                                         </template>
-                                        <a-icon type="message" v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
+                                        <a-icon type="message"
+                                          v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
                                       </a-tooltip>
                                     </div>
                                   </template>
-                                  <a-tag :style="{ margin: '0', padding: '0 2px' }" color="orange" v-if="clientCount[dbInbound.id].expiring.length">[[ clientCount[dbInbound.id].expiring.length ]]</a-tag>
+                                  <a-tag :style="{ margin: '0', padding: '0 2px' }" color="orange"
+                                    v-if="clientCount[dbInbound.id].expiring.length">[[
+                                    clientCount[dbInbound.id].expiring.length ]]</a-tag>
                                 </a-popover>
                                 <a-popover title='{{ i18n "online" }}' :overlay-class-name="themeSwitcher.currentTheme">
                                   <template slot="content">
-                                    <div v-for="clientEmail in clientCount[dbInbound.id].online" :key="clientEmail" class="client-popup-item">
+                                    <div v-for="clientEmail in clientCount[dbInbound.id].online" :key="clientEmail"
+                                      class="client-popup-item">
                                       <span>[[ clientEmail ]]</span>
                                       <a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
                                         <template #title>
                                           [[ clientCount[dbInbound.id].comments.get(clientEmail) ]]
                                         </template>
-                                        <a-icon type="message" v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
+                                        <a-icon type="message"
+                                          v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
                                       </a-tooltip>
                                     </div>
                                   </template>
-                                  <a-tag :style="{ margin: '0', padding: '0 2px' }" color="green" v-if="clientCount[dbInbound.id].online.length">[[ clientCount[dbInbound.id].online.length ]]</a-tag>
+                                  <a-tag :style="{ margin: '0', padding: '0 2px' }" color="green"
+                                    v-if="clientCount[dbInbound.id].online.length">[[
+                                    clientCount[dbInbound.id].online.length ]]</a-tag>
                                 </a-popover>
                               </td>
                             </tr>
@@ -464,20 +519,25 @@
                                         <td>↑[[ SizeFormatter.sizeFormat(dbInbound.up) ]]</td>
                                         <td>↓[[ SizeFormatter.sizeFormat(dbInbound.down) ]]</td>
                                       </tr>
-                                      <tr v-if="dbInbound.total > 0 &&  dbInbound.up + dbInbound.down < dbInbound.total">
+                                      <tr
+                                        v-if="dbInbound.total > 0 &&  dbInbound.up + dbInbound.down < dbInbound.total">
                                         <td>{{ i18n "remained" }}</td>
-                                        <td>[[ SizeFormatter.sizeFormat(dbInbound.total - dbInbound.up - dbInbound.down) ]]</td>
+                                        <td>[[ SizeFormatter.sizeFormat(dbInbound.total - dbInbound.up - dbInbound.down)
+                                          ]]</td>
                                       </tr>
                                     </table>
                                   </template>
-                                  <a-tag :color="ColorUtils.usageColor(dbInbound.up + dbInbound.down, app.trafficDiff, dbInbound.total)">
-                                      [[ SizeFormatter.sizeFormat(dbInbound.up + dbInbound.down) ]] /
-                                      <template v-if="dbInbound.total > 0">
-                                          [[ SizeFormatter.sizeFormat(dbInbound.total) ]]
-                                      </template>
+                                  <a-tag
+                                    :color="ColorUtils.usageColor(dbInbound.up + dbInbound.down, app.trafficDiff, dbInbound.total)">
+                                    [[ SizeFormatter.sizeFormat(dbInbound.up + dbInbound.down) ]] /
+                                    <template v-if="dbInbound.total > 0">
+                                      [[ SizeFormatter.sizeFormat(dbInbound.total) ]]
+                                    </template>
                                     <template v-else>
                                       <svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
-                                        <path d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" fill="currentColor"></path>
+                                        <path
+                                          d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z"
+                                          fill="currentColor"></path>
                                       </svg>
                                     </template>
                                   </a-tag>
@@ -487,8 +547,8 @@
                             <tr>
                               <td>{{ i18n "pages.inbounds.expireDate" }}</td>
                               <td>
-                                <a-tag :style="{ minWidth: '50px', textAlign: 'center' }" v-if="dbInbound.expiryTime > 0"
-                                  :color="dbInbound.isExpiry? 'red': 'blue'">
+                                <a-tag :style="{ minWidth: '50px', textAlign: 'center' }"
+                                  v-if="dbInbound.expiryTime > 0" :color="dbInbound.isExpiry? 'red': 'blue'">
                                   <template v-if="app.datepicker === 'gregorian'">
                                     [[ DateUtil.formatMillis(dbInbound.expiryTime) ]]
                                   </template>
@@ -498,7 +558,9 @@
                                 </a-tag>
                                 <a-tag v-else :style="{ textAlign: 'center' }" color="purple" class="infinite-tag">
                                   <svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
-                                    <path d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" fill="currentColor"></path>
+                                    <path
+                                      d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z"
+                                      fill="currentColor"></path>
                                   </svg>
                                 </a-tag>
                               </td>
@@ -506,13 +568,15 @@
                             <tr>
                               <td>{{ i18n "pages.inbounds.periodicTrafficResetTitle" }}</td>
                               <td>
-                                <a-tag color="blue">[[ i18n("pages.inbounds.periodicTrafficReset." + dbInbound.trafficReset) ]]</a-tag>
+                                <a-tag color="blue">[[ i18n("pages.inbounds.periodicTrafficReset." +
+                                  dbInbound.trafficReset) ]]</a-tag>
                               </td>
                             </tr>
                           </table>
                         </template>
                         <a-badge>
-                          <a-icon v-if="!dbInbound.enable" slot="count" type="pause-circle" :style="{ color: themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc' }"></a-icon>
+                          <a-icon v-if="!dbInbound.enable" slot="count" type="pause-circle"
+                            :style="{ color: themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc' }"></a-icon>
                           <a-button shape="round" size="small" :style="{ fontSize: '14px', padding: '0 10px' }">
                             <a-icon type="info"></a-icon>
                           </a-button>
@@ -520,18 +584,15 @@
                       </a-popover>
                     </template>
                     <template slot="expandedRowRender" slot-scope="record">
-                      <a-table
-                        :row-key="client => client.id"
-                        :columns="isMobile ? innerMobileColumns : innerColumns"
-                        :data-source="getInboundClients(record)"
-                        :pagination=pagination(getInboundClients(record))
+                      <a-table :row-key="client => client.id" :columns="isMobile ? innerMobileColumns : innerColumns"
+                        :data-source="getInboundClients(record)" :pagination=pagination(getInboundClients(record))
                         :style="{ margin: `-10px ${isMobile ? '2px' : '22px'} -11px` }">
                         {{template "component/aClientTable"}}
                       </a-table>
                     </template>
                   </a-table>
                 </a-space>
-              </a-card> 
+              </a-card>
             </a-col>
           </a-row>
         </transition>
@@ -556,989 +617,1002 @@
 {{template "modals/clientsModal"}}
 {{template "modals/clientsBulkModal"}}
 <script>
-    const columns = [{
-        title: "ID",
-        align: 'right',
-        dataIndex: "id",
-        width: 30,
-        responsive: ["xs"],
-    }, {
-        title: '{{ i18n "pages.inbounds.operate" }}',
-        align: 'center',
-        width: 30,
-        scopedSlots: { customRender: 'action' },
-    }, {
-        title: '{{ i18n "pages.inbounds.enable" }}',
-        align: 'center',
-        width: 35,
-        scopedSlots: { customRender: 'enable' },
-    }, {
-        title: '{{ i18n "pages.inbounds.remark" }}',
-        align: 'center',
-        width: 60,
-        dataIndex: "remark",
-    }, {
-        title: '{{ i18n "pages.inbounds.port" }}',
-        align: 'center',
-        dataIndex: "port",
-        width: 40,
-    }, {
-        title: '{{ i18n "pages.inbounds.protocol" }}',
-        align: 'left',
-        width: 70,
-        scopedSlots: { customRender: 'protocol' },
-    }, {
-        title: '{{ i18n "clients" }}',
-        align: 'left',
-        width: 50,
-        scopedSlots: { customRender: 'clients' },
-    }, {
-        title: '{{ i18n "pages.inbounds.traffic" }}',
-        align: 'center',
-        width: 90,
-        scopedSlots: { customRender: 'traffic' },
-    }, {
-        title: '{{ i18n "pages.inbounds.allTimeTraffic" }}',
-        align: 'center',
-        width: 70,
-        scopedSlots: { customRender: 'allTimeInbound' },
-    }, {
-        title: '{{ i18n "pages.inbounds.expireDate" }}',
-        align: 'center',
-        width: 40,
-        scopedSlots: { customRender: 'expiryTime' },
-    }];
+  const columns = [{
+    title: "ID",
+    align: 'right',
+    dataIndex: "id",
+    width: 30,
+    responsive: ["xs"],
+  }, {
+    title: '{{ i18n "pages.inbounds.operate" }}',
+    align: 'center',
+    width: 30,
+    scopedSlots: { customRender: 'action' },
+  }, {
+    title: '{{ i18n "pages.inbounds.enable" }}',
+    align: 'center',
+    width: 35,
+    scopedSlots: { customRender: 'enable' },
+  }, {
+    title: '{{ i18n "pages.inbounds.remark" }}',
+    align: 'center',
+    width: 60,
+    dataIndex: "remark",
+  }, {
+    title: '{{ i18n "pages.inbounds.port" }}',
+    align: 'center',
+    dataIndex: "port",
+    width: 40,
+  }, {
+    title: '{{ i18n "pages.inbounds.protocol" }}',
+    align: 'left',
+    width: 70,
+    scopedSlots: { customRender: 'protocol' },
+  }, {
+    title: '{{ i18n "clients" }}',
+    align: 'left',
+    width: 50,
+    scopedSlots: { customRender: 'clients' },
+  }, {
+    title: '{{ i18n "pages.inbounds.traffic" }}',
+    align: 'center',
+    width: 90,
+    scopedSlots: { customRender: 'traffic' },
+  }, {
+    title: '{{ i18n "pages.inbounds.allTimeTraffic" }}',
+    align: 'center',
+    width: 70,
+    scopedSlots: { customRender: 'allTimeInbound' },
+  }, {
+    title: '{{ i18n "pages.inbounds.expireDate" }}',
+    align: 'center',
+    width: 40,
+    scopedSlots: { customRender: 'expiryTime' },
+  }];
 
-    const mobileColumns = [{
-        title: "ID",
-        align: 'right',
-        dataIndex: "id",
-        width: 10,
-        responsive: ["s"],
-    }, {
-        title: '{{ i18n "pages.inbounds.operate" }}',
-        align: 'center',
-        width: 25,
-        scopedSlots: { customRender: 'action' },
-    }, {
-        title: '{{ i18n "pages.inbounds.remark" }}',
-        align: 'left',
-        width: 70,
-        dataIndex: "remark",
-    }, {
-        title: '{{ i18n "pages.inbounds.info" }}',
-        align: 'center',
-        width: 10,
-        scopedSlots: { customRender: 'info' },
-    }];
+  const mobileColumns = [{
+    title: "ID",
+    align: 'right',
+    dataIndex: "id",
+    width: 10,
+    responsive: ["s"],
+  }, {
+    title: '{{ i18n "pages.inbounds.operate" }}',
+    align: 'center',
+    width: 25,
+    scopedSlots: { customRender: 'action' },
+  }, {
+    title: '{{ i18n "pages.inbounds.remark" }}',
+    align: 'left',
+    width: 70,
+    dataIndex: "remark",
+  }, {
+    title: '{{ i18n "pages.inbounds.info" }}',
+    align: 'center',
+    width: 10,
+    scopedSlots: { customRender: 'info' },
+  }];
 
-    const innerColumns = [
-        { title: '{{ i18n "pages.inbounds.operate" }}', width: 65, scopedSlots: { customRender: 'actions' } },
-        { title: '{{ i18n "pages.inbounds.enable" }}', width: 35, scopedSlots: { customRender: 'enable' } },
-        { title: '{{ i18n "online" }}', width: 32, scopedSlots: { customRender: 'online' } },
-        { title: '{{ i18n "pages.inbounds.client" }}', width: 80, scopedSlots: { customRender: 'client' } },
-        { title: '{{ i18n "pages.inbounds.traffic" }}', width: 80, align: 'center', scopedSlots: { customRender: 'traffic' } },
-        { title: '{{ i18n "pages.inbounds.allTimeTraffic" }}', width: 80, align: 'center', scopedSlots: { customRender: 'allTime' } },
-        { title: '{{ i18n "pages.inbounds.expireDate" }}', width: 80, align: 'center', scopedSlots: { customRender: 'expiryTime' } },
-    ];
+  const innerColumns = [
+    { title: '{{ i18n "pages.inbounds.operate" }}', width: 65, scopedSlots: { customRender: 'actions' } },
+    { title: '{{ i18n "pages.inbounds.enable" }}', width: 35, scopedSlots: { customRender: 'enable' } },
+    { title: '{{ i18n "online" }}', width: 32, scopedSlots: { customRender: 'online' } },
+    { title: '{{ i18n "pages.inbounds.client" }}', width: 80, scopedSlots: { customRender: 'client' } },
+    { title: '{{ i18n "pages.inbounds.traffic" }}', width: 80, align: 'center', scopedSlots: { customRender: 'traffic' } },
+    { title: '{{ i18n "pages.inbounds.allTimeTraffic" }}', width: 80, align: 'center', scopedSlots: { customRender: 'allTime' } },
+    { title: '{{ i18n "pages.inbounds.expireDate" }}', width: 80, align: 'center', scopedSlots: { customRender: 'expiryTime' } },
+  ];
 
-    const innerMobileColumns = [
-        { title: '{{ i18n "pages.inbounds.operate" }}', width: 10, align: 'center', scopedSlots: { customRender: 'actionMenu' } },
-        { title: '{{ i18n "pages.inbounds.client" }}', width: 90, align: 'left', scopedSlots: { customRender: 'client' } },
-        { title: '{{ i18n "pages.inbounds.info" }}', width: 10, align: 'center', scopedSlots: { customRender: 'info' } },
-    ];
+  const innerMobileColumns = [
+    { title: '{{ i18n "pages.inbounds.operate" }}', width: 10, align: 'center', scopedSlots: { customRender: 'actionMenu' } },
+    { title: '{{ i18n "pages.inbounds.client" }}', width: 90, align: 'left', scopedSlots: { customRender: 'client' } },
+    { title: '{{ i18n "pages.inbounds.info" }}', width: 10, align: 'center', scopedSlots: { customRender: 'info' } },
+  ];
 
-    const app = new Vue({
-        delimiters: ['[[', ']]'],
-        el: '#app',
-        mixins: [MediaQueryMixin],
-        data: {
-            themeSwitcher,
-            persianDatepicker,
-            loadingStates: {
-              fetched: false,
-              spinning: false
-            },
-            inbounds: [],
-            dbInbounds: [],
-            searchKey: '',
-            enableFilter: false,
-            filterBy: '',
-            searchedInbounds: [],
-            expireDiff: 0,
-            trafficDiff: 0,
-            defaultCert: '',
-            defaultKey: '',
-            clientCount: [],
-            onlineClients: [],
-            lastOnlineMap: {},
-            isRefreshEnabled: localStorage.getItem("isRefreshEnabled") === "true" ? true : false,
-            refreshing: false,
-            refreshInterval: Number(localStorage.getItem("refreshInterval")) || 5000,
-            subSettings: {
-                enable : false,
-                subTitle : '',
-                subURI : '',
-                subJsonURI : '',
-            },
-            remarkModel: '-ieo',
-            datepicker: 'gregorian',
-            tgBotEnable: false,
-            showAlert: false,
-            ipLimitEnable: false,
-            pageSize: 50,
-        },
-        methods: {
-            loading(spinning = true) {
-                this.loadingStates.spinning = spinning;
-            },
-            async getDBInbounds() {
-                this.refreshing = true;
-                const msg = await HttpUtil.get('/panel/api/inbounds/list');
-                if (!msg.success) {
-                    this.refreshing = false;
-                    return;
-                }
+  const app = new Vue({
+    delimiters: ['[[', ']]'],
+    el: '#app',
+    mixins: [MediaQueryMixin],
+    data: {
+      themeSwitcher,
+      persianDatepicker,
+      loadingStates: {
+        fetched: false,
+        spinning: false
+      },
+      inbounds: [],
+      dbInbounds: [],
+      searchKey: '',
+      enableFilter: false,
+      filterBy: '',
+      searchedInbounds: [],
+      expireDiff: 0,
+      trafficDiff: 0,
+      defaultCert: '',
+      defaultKey: '',
+      clientCount: [],
+      onlineClients: [],
+      lastOnlineMap: {},
+      isRefreshEnabled: localStorage.getItem("isRefreshEnabled") === "true" ? true : false,
+      refreshing: false,
+      refreshInterval: Number(localStorage.getItem("refreshInterval")) || 5000,
+      subSettings: {
+        enable: false,
+        subTitle: '',
+        subURI: '',
+        subJsonURI: '',
+      },
+      remarkModel: '-ieo',
+      datepicker: 'gregorian',
+      tgBotEnable: false,
+      showAlert: false,
+      ipLimitEnable: false,
+      pageSize: 50,
+    },
+    methods: {
+      loading(spinning = true) {
+        this.loadingStates.spinning = spinning;
+      },
+      async getDBInbounds() {
+        this.refreshing = true;
+        const msg = await HttpUtil.get('/panel/api/inbounds/list');
+        if (!msg.success) {
+          this.refreshing = false;
+          return;
+        }
 
-                await this.getLastOnlineMap();
-                await this.getOnlineUsers();
-                
-                this.setInbounds(msg.obj);
-                setTimeout(() => {
-                    this.refreshing = false;
-                }, 500);
-            },
-            async getOnlineUsers() {
-                const msg = await HttpUtil.post('/panel/api/inbounds/onlines');
-                if (!msg.success) {
-                    return;
-                }
-                this.onlineClients = msg.obj != null ? msg.obj : [];
-            },
-            async getLastOnlineMap() {
-                const msg = await HttpUtil.post('/panel/api/inbounds/lastOnline');
-                if (!msg.success || !msg.obj) return;
-                this.lastOnlineMap = msg.obj || {}
-            },
-            async getDefaultSettings() {
-                const msg = await HttpUtil.post('/panel/setting/defaultSettings');
-                if (!msg.success) {
-                    return;
-                }
-                with(msg.obj){
-                    this.expireDiff = expireDiff * 86400000;
-                    this.trafficDiff = trafficDiff * 1073741824;
-                    this.defaultCert = defaultCert;
-                    this.defaultKey = defaultKey;
-                    this.tgBotEnable = tgBotEnable;
-                    this.subSettings = {
-                        enable : subEnable,
-                        subTitle : subTitle,
-                        subURI: subURI,
-                        subJsonURI: subJsonURI
-                    };
-                    this.pageSize = pageSize;
-                    this.remarkModel = remarkModel;
-                    this.datepicker = datepicker;
-                    this.ipLimitEnable = ipLimitEnable;
-                }
-            },
-            setInbounds(dbInbounds) {
-                this.inbounds.splice(0);
-                this.dbInbounds.splice(0);
-                this.clientCount.splice(0);
-                for (const inbound of dbInbounds) {
-                    const dbInbound = new DBInbound(inbound);
-                    to_inbound = dbInbound.toInbound()
-                    this.inbounds.push(to_inbound);
-                    this.dbInbounds.push(dbInbound);
-                    if ([Protocols.VMESS, Protocols.VLESS, Protocols.TROJAN, Protocols.SHADOWSOCKS].includes(inbound.protocol)) {
-                        if (dbInbound.isSS && (!to_inbound.isSSMultiUser)) {
-                            continue;
-                        }
-                        this.clientCount[inbound.id] = this.getClientCounts(inbound, to_inbound);
-                    }
-                }
-                if (!this.loadingStates.fetched) {
-                    this.loadingStates.fetched = true
-                }
-                if(this.enableFilter){
-                    this.filterInbounds();
-                } else {
-                    this.searchInbounds(this.searchKey);
-                }
-            },
-            getClientCounts(dbInbound, inbound) {
-                let clientCount = 0, active = [], deactive = [], depleted = [], expiring = [], online = [], comments = new Map();
-                clients = inbound.clients;
-                clientStats = dbInbound.clientStats
-                now = new Date().getTime()
-                if (clients) {
-                    clientCount = clients.length;
-                    if (dbInbound.enable) {
-                        clients.forEach(client => {
-                            if (client.comment) {
-                              comments.set(client.email, client.comment)
-                            }
-                            if (client.enable) {
-                                active.push(client.email);
-                                if (this.isClientOnline(client.email)) online.push(client.email);
-                            } else {
-                                deactive.push(client.email);
-                            }
-                        });
-                        clientStats.forEach(client => {
-                            if (!client.enable) {
-                                depleted.push(client.email);
-                            } else {
-                                if ((client.expiryTime > 0 && (client.expiryTime - now < this.expireDiff)) ||
-                                    (client.total > 0 && (client.total - (client.up + client.down) < this.trafficDiff))) expiring.push(client.email);
-                            }
-                        });
-                    } else {
-                        clients.forEach(client => {
-                            deactive.push(client.email);
-                        });
-                    }
-                }
-                return {
-                    clients: clientCount,
-                    active: active,
-                    deactive: deactive,
-                    depleted: depleted,
-                    expiring: expiring,
-                    online: online,
-                    comments: comments,
-                };
-            },
+        await this.getLastOnlineMap();
+        await this.getOnlineUsers();
 
-            searchInbounds(key) {
-                if (ObjectUtil.isEmpty(key)) {
-                    this.searchedInbounds = this.dbInbounds.slice();
-                } else {
-                    this.searchedInbounds.splice(0, this.searchedInbounds.length);
-                    this.dbInbounds.forEach(inbound => {
-                        if (ObjectUtil.deepSearch(inbound, key)) {
-                            const newInbound = new DBInbound(inbound);
-                            const inboundSettings = JSON.parse(inbound.settings);
-                            if (inboundSettings.hasOwnProperty('clients')) {
-                                const searchedSettings = { "clients": [] };
-                                inboundSettings.clients.forEach(client => {
-                                    if (ObjectUtil.deepSearch(client, key)) {
-                                        searchedSettings.clients.push(client);
-                                    }
-                                });
-                                newInbound.settings = Inbound.Settings.fromJson(inbound.protocol, searchedSettings);
-                            }
-                            this.searchedInbounds.push(newInbound);
-                        }
-                    });
-                }
-            },
-            filterInbounds() {
-                if (ObjectUtil.isEmpty(this.filterBy)) {
-                    this.searchedInbounds = this.dbInbounds.slice();
-                } else {
-                    this.searchedInbounds.splice(0, this.searchedInbounds.length);
-                    this.dbInbounds.forEach(inbound => {
-                        const newInbound = new DBInbound(inbound);
-                        const inboundSettings = JSON.parse(inbound.settings);
-                        if (this.clientCount[inbound.id] && this.clientCount[inbound.id].hasOwnProperty(this.filterBy)){
-                            const list = this.clientCount[inbound.id][this.filterBy];
-                            if (list.length > 0) {
-                                const filteredSettings = { "clients": [] };
-                                if (inboundSettings.clients) {
-                                    inboundSettings.clients.forEach(client => {
-                                        if (list.includes(client.email)) {
-                                            filteredSettings.clients.push(client);
-                                        }
-                                    });
-                                }
-                                newInbound.settings = Inbound.Settings.fromJson(inbound.protocol, filteredSettings);
-                                this.searchedInbounds.push(newInbound);
-                            }
-                        }
-                    });
-                }
-            },
-            toggleFilter(){
-                if(this.enableFilter) {
-                    this.searchKey = '';
-                } else {
-                    this.filterBy = '';
-                    this.searchedInbounds = this.dbInbounds.slice();
-                }
-            },
-            generalActions(action) {
-                switch (action.key) {
-                    case "import":
-                        this.importInbound();
-                        break;
-                    case "export":
-                        this.exportAllLinks();
-                        break;
-                    case "subs":
-                        this.exportAllSubs();
-                        break;
-                    case "resetInbounds":
-                        this.resetAllTraffic();
-                        break;
-                    case "resetClients":
-                        this.resetAllClientTraffics(-1);
-                        break;
-                    case "delDepletedClients":
-                        this.delDepletedClients(-1)
-                        break;
-                }
-            },
-            clickAction(action, dbInbound) {
-                switch (action.key) {
-                    case "qrcode":
-                        this.showQrcode(dbInbound.id);
-                        break;
-                    case "showInfo":
-                        this.showInfo(dbInbound.id);
-                        break;
-                    case "edit":
-                        this.openEditInbound(dbInbound.id);
-                        break;
-                    case "addClient":
-                        this.openAddClient(dbInbound.id)
-                        break;
-                    case "addBulkClient":
-                        this.openAddBulkClient(dbInbound.id)
-                        break;
-                    case "export":
-                        this.inboundLinks(dbInbound.id);
-                        break;
-                    case "subs":
-                        this.exportSubs(dbInbound.id);
-                        break;
-                    case "clipboard":
-                        this.copy(dbInbound.id);
-                        break;
-                    case "resetTraffic":
-                        this.resetTraffic(dbInbound.id);
-                        break;
-                    case "resetClients":
-                        this.resetAllClientTraffics(dbInbound.id);
-                        break;
-                    case "clone":
-                        this.openCloneInbound(dbInbound);
-                        break;
-                    case "delete":
-                        this.delInbound(dbInbound.id);
-                        break;
-                    case "delDepletedClients":
-                        this.delDepletedClients(dbInbound.id)
-                        break;
-                }
-            },
-            openCloneInbound(dbInbound) {
-                this.$confirm({
-                    title: '{{ i18n "pages.inbounds.cloneInbound"}} \"' + dbInbound.remark + '\"',
-                    content: '{{ i18n "pages.inbounds.cloneInboundContent"}}',
-                    okText: '{{ i18n "pages.inbounds.cloneInboundOk"}}',
-                    class: themeSwitcher.currentTheme,
-                    cancelText: '{{ i18n "cancel" }}',
-                    onOk: () => {
-                        const baseInbound = dbInbound.toInbound();
-                        dbInbound.up = 0;
-                        dbInbound.down = 0;
-                        this.cloneInbound(baseInbound, dbInbound);
-                    },
-                });
-            },
-            async cloneInbound(baseInbound, dbInbound) {
-                const data = {
-                    up: dbInbound.up,
-                    down: dbInbound.down,
-                    total: dbInbound.total,
-                    remark: dbInbound.remark + " - Cloned",
-                    enable: dbInbound.enable,
-                    expiryTime: dbInbound.expiryTime,
-                    trafficReset: dbInbound.trafficReset,
-                    lastTrafficResetTime: dbInbound.lastTrafficResetTime,
+        this.setInbounds(msg.obj);
+        setTimeout(() => {
+          this.refreshing = false;
+        }, 500);
+      },
+      async getOnlineUsers() {
+        const msg = await HttpUtil.post('/panel/api/inbounds/onlines');
+        if (!msg.success) {
+          return;
+        }
+        this.onlineClients = msg.obj != null ? msg.obj : [];
+      },
+      async getLastOnlineMap() {
+        const msg = await HttpUtil.post('/panel/api/inbounds/lastOnline');
+        if (!msg.success || !msg.obj) return;
+        this.lastOnlineMap = msg.obj || {}
+      },
+      async getDefaultSettings() {
+        const msg = await HttpUtil.post('/panel/setting/defaultSettings');
+        if (!msg.success) {
+          return;
+        }
+        with (msg.obj) {
+          this.expireDiff = expireDiff * 86400000;
+          this.trafficDiff = trafficDiff * 1073741824;
+          this.defaultCert = defaultCert;
+          this.defaultKey = defaultKey;
+          this.tgBotEnable = tgBotEnable;
+          this.subSettings = {
+            enable: subEnable,
+            subTitle: subTitle,
+            subURI: subURI,
+            subJsonURI: subJsonURI
+          };
+          this.pageSize = pageSize;
+          this.remarkModel = remarkModel;
+          this.datepicker = datepicker;
+          this.ipLimitEnable = ipLimitEnable;
+        }
+      },
+      setInbounds(dbInbounds) {
+        this.inbounds.splice(0);
+        this.dbInbounds.splice(0);
+        this.clientCount.splice(0);
+        for (const inbound of dbInbounds) {
+          const dbInbound = new DBInbound(inbound);
+          to_inbound = dbInbound.toInbound()
+          this.inbounds.push(to_inbound);
+          this.dbInbounds.push(dbInbound);
+          if ([Protocols.VMESS, Protocols.VLESS, Protocols.TROJAN, Protocols.SHADOWSOCKS].includes(inbound.protocol)) {
+            if (dbInbound.isSS && (!to_inbound.isSSMultiUser)) {
+              continue;
+            }
+            this.clientCount[inbound.id] = this.getClientCounts(inbound, to_inbound);
+          }
+        }
+        if (!this.loadingStates.fetched) {
+          this.loadingStates.fetched = true
+        }
+        if (this.enableFilter) {
+          this.filterInbounds();
+        } else {
+          this.searchInbounds(this.searchKey);
+        }
+      },
+      getClientCounts(dbInbound, inbound) {
+        let clientCount = 0, active = [], deactive = [], depleted = [], expiring = [], online = [], comments = new Map();
+        clients = inbound.clients;
+        clientStats = dbInbound.clientStats
+        now = new Date().getTime()
+        if (clients) {
+          clientCount = clients.length;
+          if (dbInbound.enable) {
+            clients.forEach(client => {
+              if (client.comment) {
+                comments.set(client.email, client.comment)
+              }
+              if (client.enable) {
+                active.push(client.email);
+                if (this.isClientOnline(client.email)) online.push(client.email);
+              } else {
+                deactive.push(client.email);
+              }
+            });
+            clientStats.forEach(stats => {
+              const exhausted = stats.total > 0 && (stats.up + stats.down) >= stats.total;
+              const expired = stats.expiryTime > 0 && stats.expiryTime <= now;
+              if (expired || exhausted) {
+                depleted.push(stats.email);
+              } else {
+                const expiringSoon = (stats.expiryTime > 0 && (stats.expiryTime - now < this.expireDiff)) ||
+                  (stats.total > 0 && (stats.total - (stats.up + stats.down) < this.trafficDiff));
+                if (expiringSoon) expiring.push(stats.email);
+              }
+            });
+          } else {
+            clients.forEach(client => {
+              deactive.push(client.email);
+            });
+          }
+        }
+        return {
+          clients: clientCount,
+          active: active,
+          deactive: deactive,
+          depleted: depleted,
+          expiring: expiring,
+          online: online,
+          comments: comments,
+        };
+      },
 
-                    listen: '',
-                    port: RandomUtil.randomInteger(10000, 60000),
-                    protocol: baseInbound.protocol,
-                    settings: Inbound.Settings.getSettings(baseInbound.protocol).toString(),
-                    streamSettings: baseInbound.stream.toString(),
-                    sniffing: baseInbound.sniffing.toString(),
-                };
-                await this.submit('/panel/api/inbounds/add', data, inModal);
-            },
-            openAddInbound() {
-                inModal.show({
-                    title: '{{ i18n "pages.inbounds.addInbound"}}',
-                    okText: '{{ i18n "create"}}',
-                    cancelText: '{{ i18n "close" }}',
-                    confirm: async (inbound, dbInbound) => {
-                        await this.addInbound(inbound, dbInbound, inModal);
-                    },
-                    isEdit: false
-                });
-            },
-            openEditInbound(dbInboundId) {
-                dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
-                const inbound = dbInbound.toInbound();
-                inModal.show({
-                    title: '{{ i18n "pages.inbounds.modifyInbound"}}',
-                    okText: '{{ i18n "update"}}',
-                    cancelText: '{{ i18n "close" }}',
-                    inbound: inbound,
-                    dbInbound: dbInbound,
-                    confirm: async (inbound, dbInbound) => {
-                        await this.updateInbound(inbound, dbInbound);
-                    },
-                    isEdit: true
+      searchInbounds(key) {
+        if (ObjectUtil.isEmpty(key)) {
+          this.searchedInbounds = this.dbInbounds.slice();
+        } else {
+          this.searchedInbounds.splice(0, this.searchedInbounds.length);
+          this.dbInbounds.forEach(inbound => {
+            if (ObjectUtil.deepSearch(inbound, key)) {
+              const newInbound = new DBInbound(inbound);
+              const inboundSettings = JSON.parse(inbound.settings);
+              if (inboundSettings.hasOwnProperty('clients')) {
+                const searchedSettings = { "clients": [] };
+                inboundSettings.clients.forEach(client => {
+                  if (ObjectUtil.deepSearch(client, key)) {
+                    searchedSettings.clients.push(client);
+                  }
                 });
-            },
-            async addInbound(inbound, dbInbound) {
-                const data = {
-                    up: dbInbound.up,
-                    down: dbInbound.down,
-                    total: dbInbound.total,
-                    remark: dbInbound.remark,
-                    enable: dbInbound.enable,
-                    expiryTime: dbInbound.expiryTime,
-                    trafficReset: dbInbound.trafficReset,
-                    lastTrafficResetTime: dbInbound.lastTrafficResetTime,
-
-                    listen: inbound.listen,
-                    port: inbound.port,
-                    protocol: inbound.protocol,
-                    settings: inbound.settings.toString(),
-                };
-                if (inbound.canEnableStream()){
-                  data.streamSettings = inbound.stream.toString();
-                } else if (inbound.stream?.sockopt) {
-                  data.streamSettings = JSON.stringify({ sockopt: inbound.stream.sockopt.toJson() }, null, 2);
+                newInbound.settings = Inbound.Settings.fromJson(inbound.protocol, searchedSettings);
+              }
+              this.searchedInbounds.push(newInbound);
+            }
+          });
+        }
+      },
+      filterInbounds() {
+        if (ObjectUtil.isEmpty(this.filterBy)) {
+          this.searchedInbounds = this.dbInbounds.slice();
+        } else {
+          this.searchedInbounds.splice(0, this.searchedInbounds.length);
+          this.dbInbounds.forEach(inbound => {
+            const newInbound = new DBInbound(inbound);
+            const inboundSettings = JSON.parse(inbound.settings);
+            if (this.clientCount[inbound.id] && this.clientCount[inbound.id].hasOwnProperty(this.filterBy)) {
+              const list = this.clientCount[inbound.id][this.filterBy];
+              if (list.length > 0) {
+                const filteredSettings = { "clients": [] };
+                if (inboundSettings.clients) {
+                  inboundSettings.clients.forEach(client => {
+                    if (list.includes(client.email)) {
+                      filteredSettings.clients.push(client);
+                    }
+                  });
                 }
-                data.sniffing = inbound.sniffing.toString();
+                newInbound.settings = Inbound.Settings.fromJson(inbound.protocol, filteredSettings);
+                this.searchedInbounds.push(newInbound);
+              }
+            }
+          });
+        }
+      },
+      toggleFilter() {
+        if (this.enableFilter) {
+          this.searchKey = '';
+        } else {
+          this.filterBy = '';
+          this.searchedInbounds = this.dbInbounds.slice();
+        }
+      },
+      generalActions(action) {
+        switch (action.key) {
+          case "import":
+            this.importInbound();
+            break;
+          case "export":
+            this.exportAllLinks();
+            break;
+          case "subs":
+            this.exportAllSubs();
+            break;
+          case "resetInbounds":
+            this.resetAllTraffic();
+            break;
+          case "resetClients":
+            this.resetAllClientTraffics(-1);
+            break;
+          case "delDepletedClients":
+            this.delDepletedClients(-1)
+            break;
+        }
+      },
+      clickAction(action, dbInbound) {
+        switch (action.key) {
+          case "qrcode":
+            this.showQrcode(dbInbound.id);
+            break;
+          case "showInfo":
+            this.showInfo(dbInbound.id);
+            break;
+          case "edit":
+            this.openEditInbound(dbInbound.id);
+            break;
+          case "addClient":
+            this.openAddClient(dbInbound.id)
+            break;
+          case "addBulkClient":
+            this.openAddBulkClient(dbInbound.id)
+            break;
+          case "export":
+            this.inboundLinks(dbInbound.id);
+            break;
+          case "subs":
+            this.exportSubs(dbInbound.id);
+            break;
+          case "clipboard":
+            this.copy(dbInbound.id);
+            break;
+          case "resetTraffic":
+            this.resetTraffic(dbInbound.id);
+            break;
+          case "resetClients":
+            this.resetAllClientTraffics(dbInbound.id);
+            break;
+          case "clone":
+            this.openCloneInbound(dbInbound);
+            break;
+          case "delete":
+            this.delInbound(dbInbound.id);
+            break;
+          case "delDepletedClients":
+            this.delDepletedClients(dbInbound.id)
+            break;
+        }
+      },
+      openCloneInbound(dbInbound) {
+        this.$confirm({
+          title: '{{ i18n "pages.inbounds.cloneInbound"}} \"' + dbInbound.remark + '\"',
+          content: '{{ i18n "pages.inbounds.cloneInboundContent"}}',
+          okText: '{{ i18n "pages.inbounds.cloneInboundOk"}}',
+          class: themeSwitcher.currentTheme,
+          cancelText: '{{ i18n "cancel" }}',
+          onOk: () => {
+            const baseInbound = dbInbound.toInbound();
+            dbInbound.up = 0;
+            dbInbound.down = 0;
+            this.cloneInbound(baseInbound, dbInbound);
+          },
+        });
+      },
+      async cloneInbound(baseInbound, dbInbound) {
+        const data = {
+          up: dbInbound.up,
+          down: dbInbound.down,
+          total: dbInbound.total,
+          remark: dbInbound.remark + " - Cloned",
+          enable: dbInbound.enable,
+          expiryTime: dbInbound.expiryTime,
+          trafficReset: dbInbound.trafficReset,
+          lastTrafficResetTime: dbInbound.lastTrafficResetTime,
 
-                await this.submit('/panel/api/inbounds/add', data, inModal);
-            },
-            async updateInbound(inbound, dbInbound) {
-                const data = {
-                    up: dbInbound.up,
-                    down: dbInbound.down,
-                    total: dbInbound.total,
-                    remark: dbInbound.remark,
-                    enable: dbInbound.enable,
-                    expiryTime: dbInbound.expiryTime,
-                    trafficReset: dbInbound.trafficReset,
-                    lastTrafficResetTime: dbInbound.lastTrafficResetTime,
+          listen: '',
+          port: RandomUtil.randomInteger(10000, 60000),
+          protocol: baseInbound.protocol,
+          settings: Inbound.Settings.getSettings(baseInbound.protocol).toString(),
+          streamSettings: baseInbound.stream.toString(),
+          sniffing: baseInbound.sniffing.toString(),
+        };
+        await this.submit('/panel/api/inbounds/add', data, inModal);
+      },
+      openAddInbound() {
+        inModal.show({
+          title: '{{ i18n "pages.inbounds.addInbound"}}',
+          okText: '{{ i18n "create"}}',
+          cancelText: '{{ i18n "close" }}',
+          confirm: async (inbound, dbInbound) => {
+            await this.addInbound(inbound, dbInbound, inModal);
+          },
+          isEdit: false
+        });
+      },
+      openEditInbound(dbInboundId) {
+        dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
+        const inbound = dbInbound.toInbound();
+        inModal.show({
+          title: '{{ i18n "pages.inbounds.modifyInbound"}}',
+          okText: '{{ i18n "update"}}',
+          cancelText: '{{ i18n "close" }}',
+          inbound: inbound,
+          dbInbound: dbInbound,
+          confirm: async (inbound, dbInbound) => {
+            await this.updateInbound(inbound, dbInbound);
+          },
+          isEdit: true
+        });
+      },
+      async addInbound(inbound, dbInbound) {
+        const data = {
+          up: dbInbound.up,
+          down: dbInbound.down,
+          total: dbInbound.total,
+          remark: dbInbound.remark,
+          enable: dbInbound.enable,
+          expiryTime: dbInbound.expiryTime,
+          trafficReset: dbInbound.trafficReset,
+          lastTrafficResetTime: dbInbound.lastTrafficResetTime,
 
-                    listen: inbound.listen,
-                    port: inbound.port,
-                    protocol: inbound.protocol,
-                    settings: inbound.settings.toString(),
-                };
-                if (inbound.canEnableStream()){
-                  data.streamSettings = inbound.stream.toString();
-                } else if (inbound.stream?.sockopt) {
-                  data.streamSettings = JSON.stringify({ sockopt: inbound.stream.sockopt.toJson() }, null, 2);
-                }
-                data.sniffing = inbound.sniffing.toString();
+          listen: inbound.listen,
+          port: inbound.port,
+          protocol: inbound.protocol,
+          settings: inbound.settings.toString(),
+        };
+        if (inbound.canEnableStream()) {
+          data.streamSettings = inbound.stream.toString();
+        } else if (inbound.stream?.sockopt) {
+          data.streamSettings = JSON.stringify({ sockopt: inbound.stream.sockopt.toJson() }, null, 2);
+        }
+        data.sniffing = inbound.sniffing.toString();
 
-                await this.submit(`/panel/api/inbounds/update/${dbInbound.id}`, data, inModal);
-            },
-            openAddClient(dbInboundId) {
-                dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
-                clientModal.show({
-                    title: '{{ i18n "pages.client.add"}}',
-                    okText: '{{ i18n "pages.client.submitAdd"}}',
-                    dbInbound: dbInbound,
-                    confirm: async (clients, dbInboundId) => {
-                        await this.addClient(clients, dbInboundId, clientModal);
-                    },
-                    isEdit: false
-                });
-            },
-            openAddBulkClient(dbInboundId) {
-                dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
-                clientsBulkModal.show({
-                    title: '{{ i18n "pages.client.bulk"}} ' + dbInbound.remark,
-                    okText: '{{ i18n "pages.client.bulk"}}',
-                    dbInbound: dbInbound,
-                    confirm: async (clients, dbInboundId) => {
-                        await this.addClient(clients, dbInboundId, clientsBulkModal);
-                    },
-                });
-            },
-            openEditClient(dbInboundId, client) {
-                dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
-                clients = this.getInboundClients(dbInbound);
-                index = this.findIndexOfClient(dbInbound.protocol, clients, client);
-                clientModal.show({
-                    title: '{{ i18n "pages.client.edit"}}',
-                    okText: '{{ i18n "pages.client.submitEdit"}}',
-                    dbInbound: dbInbound,
-                    index: index,
-                    confirm: async (client, dbInboundId, clientId) => {
-                        clientModal.loading();
-                        await this.updateClient(client, dbInboundId, clientId);
-                        clientModal.close();
-                    },
-                    isEdit: true
-                });
-            },
-            findIndexOfClient(protocol, clients, client) {
-                switch (protocol) {
-                    case Protocols.TROJAN:
-                    case Protocols.SHADOWSOCKS:
-                        return clients.findIndex(item => item.password === client.password && item.email === client.email);
-                    default: return clients.findIndex(item => item.id === client.id && item.email === client.email);
-                }
-            },
-            async addClient(clients, dbInboundId, modal) {
-                const data = {
-                    id: dbInboundId,
-                    settings: '{"clients": [' + clients.toString() + ']}',
-                };
-                await this.submit(`/panel/api/inbounds/addClient`, data, modal);
-            },
-            async updateClient(client, dbInboundId, clientId) {
-                const data = {
-                    id: dbInboundId,
-                    settings: '{"clients": [' + client.toString() + ']}',
-                };
-                await this.submit(`/panel/api/inbounds/updateClient/${clientId}`, data, clientModal);
-            },
-            resetTraffic(dbInboundId) {
-                dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
-                this.$confirm({
-                    title: '{{ i18n "pages.inbounds.resetTraffic"}}' + ' #' + dbInboundId,
-                    content: '{{ i18n "pages.inbounds.resetTrafficContent"}}',
-                    class: themeSwitcher.currentTheme,
-                    okText: '{{ i18n "reset"}}',
-                    cancelText: '{{ i18n "cancel"}}',
-                    onOk: () => {
-                        const inbound = dbInbound.toInbound();
-                        dbInbound.up = 0;
-                        dbInbound.down = 0;
-                        this.updateInbound(inbound, dbInbound);
-                    },
-                });
-            },
-            delInbound(dbInboundId) {
-                this.$confirm({
-                    title: '{{ i18n "pages.inbounds.deleteInbound"}}' + ' #' + dbInboundId,
-                    content: '{{ i18n "pages.inbounds.deleteInboundContent"}}',
-                    class: themeSwitcher.currentTheme,
-                    okText: '{{ i18n "delete"}}',
-                    cancelText: '{{ i18n "cancel"}}',
-                    onOk: () => this.submit('/panel/api/inbounds/del/' + dbInboundId),
-                });
-            },
-            delClient(dbInboundId, client,confirmation = true) {
-                dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
-                clientId = this.getClientId(dbInbound.protocol, client);
-                if (confirmation){
-                    this.$confirm({
-                        title: '{{ i18n "pages.inbounds.deleteClient"}}' + ' ' + client.email,
-                        content: '{{ i18n "pages.inbounds.deleteClientContent"}}',
-                        class: themeSwitcher.currentTheme,
-                        okText: '{{ i18n "delete"}}',
-                        cancelText: '{{ i18n "cancel"}}',
-                        onOk: () => this.submit(`/panel/api/inbounds/${dbInboundId}/delClient/${clientId}`),
-                    });
-                } else {
-                    this.submit(`/panel/api/inbounds/${dbInboundId}/delClient/${clientId}`);
-                }
-            },
-            getSubGroupClients(dbInbounds, currentClient) {
-                const response = {
-                  inbounds: [],
-                  clients: [],
-                  editIds: []
-                }
-                if (dbInbounds && dbInbounds.length > 0 && currentClient) {
-                    dbInbounds.forEach((dbInboundItem) => {
-                        const dbInbound = new DBInbound(dbInboundItem);
-                        if (dbInbound) {
-                            const inbound = dbInbound.toInbound();
-                            if (inbound) {
-                                const clients = inbound.clients;
-                                if (clients.length > 0) {
-                                    clients.forEach((client) => {
-                                        if (client['subId'] === currentClient['subId']) {
-                                            client['inboundId'] = dbInboundItem.id
-                                            client['clientId'] = this.getClientId(dbInbound.protocol, client)
-                                            response.inbounds.push(dbInboundItem.id)
-                                            response.clients.push(client)
-                                            response.editIds.push(client['clientId'])
-                                        }
-                                    })
-                                }
-                            }
-                        }
-                    })
-                }
-                return response;
-            },
-            getClientId(protocol, client) {
-                switch (protocol) {
-                    case Protocols.TROJAN: return client.password;
-                    case Protocols.SHADOWSOCKS: return client.email;
-                    default: return client.id;
-                }
-            },
-            checkFallback(dbInbound) {
-                newDbInbound = new DBInbound(dbInbound);
-                if (dbInbound.listen.startsWith("@")){
-                    rootInbound = this.inbounds.find((i) => 
-                        i.isTcp && 
-                        ['trojan','vless'].includes(i.protocol) &&
-                        i.settings.fallbacks.find(f => f.dest === dbInbound.listen)
-                    );
-                    if (rootInbound) {
-                        newDbInbound.listen = rootInbound.listen;
-                        newDbInbound.port = rootInbound.port;
-                        newInbound = newDbInbound.toInbound();
-                        newInbound.stream.security = rootInbound.stream.security;
-                        newInbound.stream.tls = rootInbound.stream.tls;
-                        newInbound.stream.externalProxy = rootInbound.stream.externalProxy;
-                        newDbInbound.streamSettings = newInbound.stream.toString();
-                    }
-                }
-                return newDbInbound;
-            },
-            showQrcode(dbInboundId, client) {
-                dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
-                newDbInbound = this.checkFallback(dbInbound);
-                qrModal.show('{{ i18n "qrCode"}}', newDbInbound, client);
-            },
-            showInfo(dbInboundId, client) {
-                dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
-                index=0;
-                if (dbInbound.isMultiUser()){
-                    inbound = dbInbound.toInbound();
-                    clients = inbound.clients;
-                    index = this.findIndexOfClient(dbInbound.protocol, clients, client);
-                }
-                newDbInbound = this.checkFallback(dbInbound);
-                infoModal.show(newDbInbound, index);
-            },
-            switchEnable(dbInboundId,state) {
-              dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
-              dbInbound.enable = state;
-              this.submit(`/panel/api/inbounds/update/${dbInboundId}`, dbInbound);
-            },
-            async switchEnableClient(dbInboundId, client) {
-                this.loading()
-                dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
-                inbound = dbInbound.toInbound();
-                clients = inbound.clients;
-                index = this.findIndexOfClient(dbInbound.protocol, clients, client);
-                clients[index].enable = !clients[index].enable;
-                clientId = this.getClientId(dbInbound.protocol, clients[index]);
-                await this.updateClient(clients[index], dbInboundId, clientId);
-                this.loading(false);
-            },
-            async submit(url, data, modal) {
-                const msg = await HttpUtil.postWithModal(url, data, modal);
-                if (msg.success) {
-                    await this.getDBInbounds();
-                }
-            },
-            getInboundClients(dbInbound) {
-                return dbInbound.toInbound().clients;
-            },
-            resetClientTraffic(client, dbInboundId, confirmation = true) {
-                if (confirmation){
-                    this.$confirm({
-                        title: '{{ i18n "pages.inbounds.resetTraffic"}}' + ' ' + client.email,
-                        content: '{{ i18n "pages.inbounds.resetTrafficContent"}}',
-                        class: themeSwitcher.currentTheme,
-                        okText: '{{ i18n "reset"}}',
-                        cancelText: '{{ i18n "cancel"}}',
-                        onOk: () => this.submit('/panel/api/inbounds/' + dbInboundId + '/resetClientTraffic/' + client.email),
-                    })
-                } else {
-                    this.submit('/panel/api/inbounds/' + dbInboundId + '/resetClientTraffic/' + client.email);
-                }
-            },
-            resetAllTraffic() {
-                this.$confirm({
-                    title: '{{ i18n "pages.inbounds.resetAllTrafficTitle"}}',
-                    content: '{{ i18n "pages.inbounds.resetAllTrafficContent"}}',
-                    class: themeSwitcher.currentTheme,
-                    okText: '{{ i18n "reset"}}',
-                    cancelText: '{{ i18n "cancel"}}',
-                    onOk: () => this.submit('/panel/api/inbounds/resetAllTraffics'),
-                });
-            },
-            resetAllClientTraffics(dbInboundId) {
-                this.$confirm({
-                    title: dbInboundId > 0 ? '{{ i18n "pages.inbounds.resetInboundClientTrafficTitle"}}' : '{{ i18n "pages.inbounds.resetAllClientTrafficTitle"}}',
-                    content: dbInboundId > 0 ? '{{ i18n "pages.inbounds.resetInboundClientTrafficContent"}}' : '{{ i18n "pages.inbounds.resetAllClientTrafficContent"}}',
-                    class: themeSwitcher.currentTheme,
-                    okText: '{{ i18n "reset"}}',
-                    cancelText: '{{ i18n "cancel"}}',
-                    onOk: () => this.submit('/panel/api/inbounds/resetAllClientTraffics/' + dbInboundId),
-                })
-            },
-            delDepletedClients(dbInboundId) {
-                this.$confirm({
-                    title: '{{ i18n "pages.inbounds.delDepletedClientsTitle"}}',
-                    content: '{{ i18n "pages.inbounds.delDepletedClientsContent"}}',
-                    class: themeSwitcher.currentTheme,
-                    okText: '{{ i18n "delete"}}',
-                    cancelText: '{{ i18n "cancel"}}',
-                    onOk: () => this.submit('/panel/api/inbounds/delDepletedClients/' + dbInboundId),
-                })
-            },
-            isExpiry(dbInbound, index) {
-                return dbInbound.toInbound().isExpiry(index);
-            },
-            getUpStats(dbInbound, email) {
-                if (email.length == 0) return 0;
-                clientStats = dbInbound.clientStats.find(stats => stats.email === email);
-                return clientStats ? clientStats.up : 0;
-            },
-            getDownStats(dbInbound, email) {
-                if (email.length == 0) return 0;
-                clientStats = dbInbound.clientStats.find(stats => stats.email === email);
-                return clientStats ? clientStats.down : 0;
-            },
-            getSumStats(dbInbound, email) {
-                if (email.length == 0) return 0;
-                clientStats = dbInbound.clientStats.find(stats => stats.email === email);
-                return clientStats ? clientStats.up + clientStats.down : 0;
-            },
-            getAllTimeClient(dbInbound, email) {
-                if (email.length == 0) return 0;
-                clientStats = dbInbound.clientStats.find(stats => stats.email === email);
-                if (!clientStats) return 0;
-                return clientStats.allTime || (clientStats.up + clientStats.down);
-            },
-            getRemStats(dbInbound, email) {
-                if (email.length == 0) return 0;
-                clientStats = dbInbound.clientStats.find(stats => stats.email === email);
-                if (!clientStats) return 0;
-                remained = clientStats.total - (clientStats.up + clientStats.down);
-                return remained>0 ? remained : 0;
-            },
-            clientStatsColor(dbInbound, email) {
-                if (email.length == 0) return ColorUtils.clientUsageColor();
-                clientStats = dbInbound.clientStats.find(stats => stats.email === email);
-                return ColorUtils.clientUsageColor(clientStats, app.trafficDiff)
-            },
-            statsProgress(dbInbound, email) {
-                if (email.length == 0) return 100;
-                clientStats = dbInbound.clientStats.find(stats => stats.email === email);
-                if (!clientStats) return 0;
-                if (clientStats.total == 0) return 100;
-                return 100*(clientStats.down + clientStats.up)/clientStats.total;
-            },
-            expireProgress(expTime, reset) {
-                now = new Date().getTime();
-                remainedSeconds = expTime < 0 ? -expTime/1000 : (expTime-now)/1000;
-                resetSeconds = reset * 86400;
-                if (remainedSeconds >= resetSeconds) return 0;
-                return 100*(1-(remainedSeconds/resetSeconds));
-            },
-            remainedDays(expTime){
-                if (expTime == 0) return null;
-                if (expTime < 0) return TimeFormatter.formatSecond(expTime/-1000);
-                now = new Date().getTime();
-                if (expTime < now) return '{{ i18n "depleted" }}';
-                return TimeFormatter.formatSecond((expTime-now)/1000);
-            },
-            statsExpColor(dbInbound, email){
-                if (email.length == 0) return '#7a316f';
-                clientStats = dbInbound.clientStats.find(stats => stats.email === email);
-                if (!clientStats) return '#7a316f';
-                statsColor = ColorUtils.usageColor(clientStats.down + clientStats.up, this.trafficDiff, clientStats.total);
-                expColor = ColorUtils.usageColor(new Date().getTime(), this.expireDiff, clientStats.expiryTime);
-                switch (true) {
-                    case statsColor == "red" || expColor == "red":
-                        return "#cf3c3c"; // Red
-                    case statsColor == "orange" || expColor == "orange":
-                        return "#f37b24"; // Orange
-                    case statsColor == "green" || expColor == "green":
-                        return "#008771"; // Green
-                    default:
-                        return "#7a316f"; // purple
-                }
-            },
-            isClientEnabled(dbInbound, email) {
-                clientStats = dbInbound.clientStats ? dbInbound.clientStats.find(stats => stats.email === email) : null;
-                return clientStats ? clientStats['enable'] : true;
-            },
-            isClientOnline(email) {
-                return this.onlineClients.includes(email);
-            },
-            getLastOnline(email) {
-                return this.lastOnlineMap[email] || null
-            },
-            formatLastOnline(email) {
-                const ts = this.getLastOnline(email)
-                if (!ts) return '-'
-                if (this.datepicker === 'gregorian') {
-                    return DateUtil.formatMillis(ts)
-                }
-                return DateUtil.convertToJalalian(moment(ts))
-            },
-            isRemovable(dbInboundId) {
-                return this.getInboundClients(this.dbInbounds.find(row => row.id === dbInboundId)).length > 1;
-            },
-            inboundLinks(dbInboundId) {
-                dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
-                newDbInbound = this.checkFallback(dbInbound);
-                txtModal.show('{{ i18n "pages.inbounds.export"}}', newDbInbound.genInboundLinks(this.remarkModel), newDbInbound.remark);
-            },
-            exportSubs(dbInboundId) {
-                const dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
-                const clients = this.getInboundClients(dbInbound);
-                let subLinks = []
-                if (clients != null){
-                    clients.forEach(c => {
-                        if (c.subId && c.subId.length>0){
-                            subLinks.push(this.subSettings.subURI + c.subId)
-                        }
-                    })
-                }
-                txtModal.show(
-                    '{{ i18n "pages.inbounds.export"}} - {{ i18n "pages.settings.subSettings" }}',
-                    [...new Set(subLinks)].join('\n'),
-                    dbInbound.remark + "-Subs");
-            },
-            importInbound() {
-                promptModal.open({
-                    title: '{{ i18n "pages.inbounds.importInbound" }}',
-                    type: 'textarea',
-                    value: '',
-                    okText: '{{ i18n "pages.inbounds.import" }}',
-                    confirm: async (dbInboundText) => {
-                        await this.submit('/panel/api/inbounds/import', {data: dbInboundText}, promptModal);
-                    },
-                });
-            },
-            exportAllSubs() {
-                let subLinks = []
-                for (const dbInbound of this.dbInbounds) {
-                    const clients = this.getInboundClients(dbInbound);
-                    if (clients != null){
-                        clients.forEach(c => {
-                            if (c.subId && c.subId.length>0){
-                                subLinks.push(this.subSettings.subURI + c.subId)
-                            }
-                        })
-                    }
-                }
-                txtModal.show(
-                    '{{ i18n "pages.inbounds.export"}} - {{ i18n "pages.settings.subSettings" }}',
-                    [...new Set(subLinks)].join('\r\n'),
-                    'All-Inbounds-Subs');
-            },
-            exportAllLinks() {
-                let copyText = [];
-                for (const dbInbound of this.dbInbounds) {
-                    copyText.push(dbInbound.genInboundLinks(this.remarkModel));
-                }
-                txtModal.show('{{ i18n "pages.inbounds.export"}}', copyText.join('\r\n'), 'All-Inbounds');
-            },
-            copy(dbInboundId) {
-              dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
-              txtModal.show('{{ i18n "pages.inbounds.inboundData" }}', JSON.stringify(dbInbound, null, 2));
-            },
-            async startDataRefreshLoop() {
-                while (this.isRefreshEnabled) {
-                    try {
-                        await this.getDBInbounds();
-                    } catch (e) {
-                        console.error(e);
-                    }
-                    await PromiseUtil.sleep(this.refreshInterval);
-                }
-            },
-            toggleRefresh() {
-                localStorage.setItem("isRefreshEnabled", this.isRefreshEnabled);
-                if (this.isRefreshEnabled) {
-                    this.startDataRefreshLoop();
-                }
-            },
-            changeRefreshInterval() {
-                localStorage.setItem("refreshInterval", this.refreshInterval);
-            },
-            async manualRefresh() {
-                if (!this.refreshing) {
-                    this.loadingStates.spinning = true;
-                    await this.getDBInbounds();
-                    this.loadingStates.spinning = false;
-                }
-            },
-            pagination(obj){
-                if (this.pageSize > 0 && obj.length>this.pageSize) {
-                    // Set page options based on object size
-                    sizeOptions = [];
-                    for (i=this.pageSize;i<=obj.length;i=i+this.pageSize) {
-                        sizeOptions.push(i.toString());
-                    }
-                    // Add option to see all in one page
-                    sizeOptions.push(i.toString());
+        await this.submit('/panel/api/inbounds/add', data, inModal);
+      },
+      async updateInbound(inbound, dbInbound) {
+        const data = {
+          up: dbInbound.up,
+          down: dbInbound.down,
+          total: dbInbound.total,
+          remark: dbInbound.remark,
+          enable: dbInbound.enable,
+          expiryTime: dbInbound.expiryTime,
+          trafficReset: dbInbound.trafficReset,
+          lastTrafficResetTime: dbInbound.lastTrafficResetTime,
+
+          listen: inbound.listen,
+          port: inbound.port,
+          protocol: inbound.protocol,
+          settings: inbound.settings.toString(),
+        };
+        if (inbound.canEnableStream()) {
+          data.streamSettings = inbound.stream.toString();
+        } else if (inbound.stream?.sockopt) {
+          data.streamSettings = JSON.stringify({ sockopt: inbound.stream.sockopt.toJson() }, null, 2);
+        }
+        data.sniffing = inbound.sniffing.toString();
 
-                    p = {
-                        showSizeChanger: true,
-                        size: 'small',
-                        position: 'bottom',
-                        pageSize: this.pageSize,
-                        pageSizeOptions: sizeOptions
-                    };
-                    return p;
+        await this.submit(`/panel/api/inbounds/update/${dbInbound.id}`, data, inModal);
+      },
+      openAddClient(dbInboundId) {
+        dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
+        clientModal.show({
+          title: '{{ i18n "pages.client.add"}}',
+          okText: '{{ i18n "pages.client.submitAdd"}}',
+          dbInbound: dbInbound,
+          confirm: async (clients, dbInboundId) => {
+            await this.addClient(clients, dbInboundId, clientModal);
+          },
+          isEdit: false
+        });
+      },
+      openAddBulkClient(dbInboundId) {
+        dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
+        clientsBulkModal.show({
+          title: '{{ i18n "pages.client.bulk"}} ' + dbInbound.remark,
+          okText: '{{ i18n "pages.client.bulk"}}',
+          dbInbound: dbInbound,
+          confirm: async (clients, dbInboundId) => {
+            await this.addClient(clients, dbInboundId, clientsBulkModal);
+          },
+        });
+      },
+      openEditClient(dbInboundId, client) {
+        dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
+        clients = this.getInboundClients(dbInbound);
+        index = this.findIndexOfClient(dbInbound.protocol, clients, client);
+        clientModal.show({
+          title: '{{ i18n "pages.client.edit"}}',
+          okText: '{{ i18n "pages.client.submitEdit"}}',
+          dbInbound: dbInbound,
+          index: index,
+          confirm: async (client, dbInboundId, clientId) => {
+            clientModal.loading();
+            await this.updateClient(client, dbInboundId, clientId);
+            clientModal.close();
+          },
+          isEdit: true
+        });
+      },
+      findIndexOfClient(protocol, clients, client) {
+        switch (protocol) {
+          case Protocols.TROJAN:
+          case Protocols.SHADOWSOCKS:
+            return clients.findIndex(item => item.password === client.password && item.email === client.email);
+          default: return clients.findIndex(item => item.id === client.id && item.email === client.email);
+        }
+      },
+      async addClient(clients, dbInboundId, modal) {
+        const data = {
+          id: dbInboundId,
+          settings: '{"clients": [' + clients.toString() + ']}',
+        };
+        await this.submit(`/panel/api/inbounds/addClient`, data, modal);
+      },
+      async updateClient(client, dbInboundId, clientId) {
+        const data = {
+          id: dbInboundId,
+          settings: '{"clients": [' + client.toString() + ']}',
+        };
+        await this.submit(`/panel/api/inbounds/updateClient/${clientId}`, data, clientModal);
+      },
+      resetTraffic(dbInboundId) {
+        dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
+        this.$confirm({
+          title: '{{ i18n "pages.inbounds.resetTraffic"}}' + ' #' + dbInboundId,
+          content: '{{ i18n "pages.inbounds.resetTrafficContent"}}',
+          class: themeSwitcher.currentTheme,
+          okText: '{{ i18n "reset"}}',
+          cancelText: '{{ i18n "cancel"}}',
+          onOk: () => {
+            const inbound = dbInbound.toInbound();
+            dbInbound.up = 0;
+            dbInbound.down = 0;
+            this.updateInbound(inbound, dbInbound);
+          },
+        });
+      },
+      delInbound(dbInboundId) {
+        this.$confirm({
+          title: '{{ i18n "pages.inbounds.deleteInbound"}}' + ' #' + dbInboundId,
+          content: '{{ i18n "pages.inbounds.deleteInboundContent"}}',
+          class: themeSwitcher.currentTheme,
+          okText: '{{ i18n "delete"}}',
+          cancelText: '{{ i18n "cancel"}}',
+          onOk: () => this.submit('/panel/api/inbounds/del/' + dbInboundId),
+        });
+      },
+      delClient(dbInboundId, client, confirmation = true) {
+        dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
+        clientId = this.getClientId(dbInbound.protocol, client);
+        if (confirmation) {
+          this.$confirm({
+            title: '{{ i18n "pages.inbounds.deleteClient"}}' + ' ' + client.email,
+            content: '{{ i18n "pages.inbounds.deleteClientContent"}}',
+            class: themeSwitcher.currentTheme,
+            okText: '{{ i18n "delete"}}',
+            cancelText: '{{ i18n "cancel"}}',
+            onOk: () => this.submit(`/panel/api/inbounds/${dbInboundId}/delClient/${clientId}`),
+          });
+        } else {
+          this.submit(`/panel/api/inbounds/${dbInboundId}/delClient/${clientId}`);
+        }
+      },
+      getSubGroupClients(dbInbounds, currentClient) {
+        const response = {
+          inbounds: [],
+          clients: [],
+          editIds: []
+        }
+        if (dbInbounds && dbInbounds.length > 0 && currentClient) {
+          dbInbounds.forEach((dbInboundItem) => {
+            const dbInbound = new DBInbound(dbInboundItem);
+            if (dbInbound) {
+              const inbound = dbInbound.toInbound();
+              if (inbound) {
+                const clients = inbound.clients;
+                if (clients.length > 0) {
+                  clients.forEach((client) => {
+                    if (client['subId'] === currentClient['subId']) {
+                      client['inboundId'] = dbInboundItem.id
+                      client['clientId'] = this.getClientId(dbInbound.protocol, client)
+                      response.inbounds.push(dbInboundItem.id)
+                      response.clients.push(client)
+                      response.editIds.push(client['clientId'])
+                    }
+                  })
                 }
-                return false
-            }
-        },
-        watch: {
-            searchKey: Utils.debounce(function (newVal) {
-                this.searchInbounds(newVal);
-            }, 500)
-        },
-        mounted() {
-            if (window.location.protocol !== "https:") {
-                this.showAlert = true;
+              }
             }
-            this.loading();
-            this.getDefaultSettings();
-            if (this.isRefreshEnabled) {
-                this.startDataRefreshLoop();
+          })
+        }
+        return response;
+      },
+      getClientId(protocol, client) {
+        switch (protocol) {
+          case Protocols.TROJAN: return client.password;
+          case Protocols.SHADOWSOCKS: return client.email;
+          default: return client.id;
+        }
+      },
+      checkFallback(dbInbound) {
+        newDbInbound = new DBInbound(dbInbound);
+        if (dbInbound.listen.startsWith("@")) {
+          rootInbound = this.inbounds.find((i) =>
+            i.isTcp &&
+            ['trojan', 'vless'].includes(i.protocol) &&
+            i.settings.fallbacks.find(f => f.dest === dbInbound.listen)
+          );
+          if (rootInbound) {
+            newDbInbound.listen = rootInbound.listen;
+            newDbInbound.port = rootInbound.port;
+            newInbound = newDbInbound.toInbound();
+            newInbound.stream.security = rootInbound.stream.security;
+            newInbound.stream.tls = rootInbound.stream.tls;
+            newInbound.stream.externalProxy = rootInbound.stream.externalProxy;
+            newDbInbound.streamSettings = newInbound.stream.toString();
+          }
+        }
+        return newDbInbound;
+      },
+      showQrcode(dbInboundId, client) {
+        dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
+        newDbInbound = this.checkFallback(dbInbound);
+        qrModal.show('{{ i18n "qrCode"}}', newDbInbound, client);
+      },
+      showInfo(dbInboundId, client) {
+        dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
+        index = 0;
+        if (dbInbound.isMultiUser()) {
+          inbound = dbInbound.toInbound();
+          clients = inbound.clients;
+          index = this.findIndexOfClient(dbInbound.protocol, clients, client);
+        }
+        newDbInbound = this.checkFallback(dbInbound);
+        infoModal.show(newDbInbound, index);
+      },
+      switchEnable(dbInboundId, state) {
+        dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
+        dbInbound.enable = state;
+        this.submit(`/panel/api/inbounds/update/${dbInboundId}`, dbInbound);
+      },
+      async switchEnableClient(dbInboundId, client) {
+        this.loading()
+        dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
+        inbound = dbInbound.toInbound();
+        clients = inbound.clients;
+        index = this.findIndexOfClient(dbInbound.protocol, clients, client);
+        clients[index].enable = !clients[index].enable;
+        clientId = this.getClientId(dbInbound.protocol, clients[index]);
+        await this.updateClient(clients[index], dbInboundId, clientId);
+        this.loading(false);
+      },
+      async submit(url, data, modal) {
+        const msg = await HttpUtil.postWithModal(url, data, modal);
+        if (msg.success) {
+          await this.getDBInbounds();
+        }
+      },
+      getInboundClients(dbInbound) {
+        return dbInbound.toInbound().clients;
+      },
+      resetClientTraffic(client, dbInboundId, confirmation = true) {
+        if (confirmation) {
+          this.$confirm({
+            title: '{{ i18n "pages.inbounds.resetTraffic"}}' + ' ' + client.email,
+            content: '{{ i18n "pages.inbounds.resetTrafficContent"}}',
+            class: themeSwitcher.currentTheme,
+            okText: '{{ i18n "reset"}}',
+            cancelText: '{{ i18n "cancel"}}',
+            onOk: () => this.submit('/panel/api/inbounds/' + dbInboundId + '/resetClientTraffic/' + client.email),
+          })
+        } else {
+          this.submit('/panel/api/inbounds/' + dbInboundId + '/resetClientTraffic/' + client.email);
+        }
+      },
+      resetAllTraffic() {
+        this.$confirm({
+          title: '{{ i18n "pages.inbounds.resetAllTrafficTitle"}}',
+          content: '{{ i18n "pages.inbounds.resetAllTrafficContent"}}',
+          class: themeSwitcher.currentTheme,
+          okText: '{{ i18n "reset"}}',
+          cancelText: '{{ i18n "cancel"}}',
+          onOk: () => this.submit('/panel/api/inbounds/resetAllTraffics'),
+        });
+      },
+      resetAllClientTraffics(dbInboundId) {
+        this.$confirm({
+          title: dbInboundId > 0 ? '{{ i18n "pages.inbounds.resetInboundClientTrafficTitle"}}' : '{{ i18n "pages.inbounds.resetAllClientTrafficTitle"}}',
+          content: dbInboundId > 0 ? '{{ i18n "pages.inbounds.resetInboundClientTrafficContent"}}' : '{{ i18n "pages.inbounds.resetAllClientTrafficContent"}}',
+          class: themeSwitcher.currentTheme,
+          okText: '{{ i18n "reset"}}',
+          cancelText: '{{ i18n "cancel"}}',
+          onOk: () => this.submit('/panel/api/inbounds/resetAllClientTraffics/' + dbInboundId),
+        })
+      },
+      delDepletedClients(dbInboundId) {
+        this.$confirm({
+          title: '{{ i18n "pages.inbounds.delDepletedClientsTitle"}}',
+          content: '{{ i18n "pages.inbounds.delDepletedClientsContent"}}',
+          class: themeSwitcher.currentTheme,
+          okText: '{{ i18n "delete"}}',
+          cancelText: '{{ i18n "cancel"}}',
+          onOk: () => this.submit('/panel/api/inbounds/delDepletedClients/' + dbInboundId),
+        })
+      },
+      isExpiry(dbInbound, index) {
+        return dbInbound.toInbound().isExpiry(index);
+      },
+      getUpStats(dbInbound, email) {
+        if (email.length == 0) return 0;
+        clientStats = dbInbound.clientStats.find(stats => stats.email === email);
+        return clientStats ? clientStats.up : 0;
+      },
+      getDownStats(dbInbound, email) {
+        if (email.length == 0) return 0;
+        clientStats = dbInbound.clientStats.find(stats => stats.email === email);
+        return clientStats ? clientStats.down : 0;
+      },
+      getSumStats(dbInbound, email) {
+        if (email.length == 0) return 0;
+        clientStats = dbInbound.clientStats.find(stats => stats.email === email);
+        return clientStats ? clientStats.up + clientStats.down : 0;
+      },
+      getAllTimeClient(dbInbound, email) {
+        if (email.length == 0) return 0;
+        clientStats = dbInbound.clientStats.find(stats => stats.email === email);
+        if (!clientStats) return 0;
+        return clientStats.allTime || (clientStats.up + clientStats.down);
+      },
+      getRemStats(dbInbound, email) {
+        if (email.length == 0) return 0;
+        clientStats = dbInbound.clientStats.find(stats => stats.email === email);
+        if (!clientStats) return 0;
+        remained = clientStats.total - (clientStats.up + clientStats.down);
+        return remained > 0 ? remained : 0;
+      },
+      clientStatsColor(dbInbound, email) {
+        if (email.length == 0) return ColorUtils.clientUsageColor();
+        clientStats = dbInbound.clientStats.find(stats => stats.email === email);
+        return ColorUtils.clientUsageColor(clientStats, app.trafficDiff)
+      },
+      statsProgress(dbInbound, email) {
+        if (email.length == 0) return 100;
+        clientStats = dbInbound.clientStats.find(stats => stats.email === email);
+        if (!clientStats) return 0;
+        if (clientStats.total == 0) return 100;
+        return 100 * (clientStats.down + clientStats.up) / clientStats.total;
+      },
+      expireProgress(expTime, reset) {
+        now = new Date().getTime();
+        remainedSeconds = expTime < 0 ? -expTime / 1000 : (expTime - now) / 1000;
+        resetSeconds = reset * 86400;
+        if (remainedSeconds >= resetSeconds) return 0;
+        return 100 * (1 - (remainedSeconds / resetSeconds));
+      },
+      remainedDays(expTime) {
+        if (expTime == 0) return null;
+        if (expTime < 0) return TimeFormatter.formatSecond(expTime / -1000);
+        now = new Date().getTime();
+        if (expTime < now) return '{{ i18n "depleted" }}';
+        return TimeFormatter.formatSecond((expTime - now) / 1000);
+      },
+      statsExpColor(dbInbound, email) {
+        if (email.length == 0) return '#7a316f';
+        clientStats = dbInbound.clientStats.find(stats => stats.email === email);
+        if (!clientStats) return '#7a316f';
+        statsColor = ColorUtils.usageColor(clientStats.down + clientStats.up, this.trafficDiff, clientStats.total);
+        expColor = ColorUtils.usageColor(new Date().getTime(), this.expireDiff, clientStats.expiryTime);
+        switch (true) {
+          case statsColor == "red" || expColor == "red":
+            return "#cf3c3c"; // Red
+          case statsColor == "orange" || expColor == "orange":
+            return "#f37b24"; // Orange
+          case statsColor == "green" || expColor == "green":
+            return "#008771"; // Green
+          default:
+            return "#7a316f"; // purple
+        }
+      },
+      isClientEnabled(dbInbound, email) {
+        clientStats = dbInbound.clientStats ? dbInbound.clientStats.find(stats => stats.email === email) : null;
+        return clientStats ? clientStats['enable'] : true;
+      },
+      // Returns true when client's traffic is exhausted or expiry time is passed
+      isClientDepleted(dbInbound, email) {
+        if (!email || !dbInbound || !dbInbound.clientStats) return false;
+        const stats = dbInbound.clientStats.find(s => s.email === email);
+        if (!stats) return false;
+        const now = new Date().getTime();
+        const exhausted = stats.total > 0 && (stats.up + stats.down) >= stats.total;
+        const expired = stats.expiryTime > 0 && now >= stats.expiryTime;
+        return exhausted || expired;
+      },
+      isClientOnline(email) {
+        return this.onlineClients.includes(email);
+      },
+      getLastOnline(email) {
+        return this.lastOnlineMap[email] || null
+      },
+      formatLastOnline(email) {
+        const ts = this.getLastOnline(email)
+        if (!ts) return '-'
+        if (this.datepicker === 'gregorian') {
+          return DateUtil.formatMillis(ts)
+        }
+        return DateUtil.convertToJalalian(moment(ts))
+      },
+      isRemovable(dbInboundId) {
+        return this.getInboundClients(this.dbInbounds.find(row => row.id === dbInboundId)).length > 1;
+      },
+      inboundLinks(dbInboundId) {
+        dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
+        newDbInbound = this.checkFallback(dbInbound);
+        txtModal.show('{{ i18n "pages.inbounds.export"}}', newDbInbound.genInboundLinks(this.remarkModel), newDbInbound.remark);
+      },
+      exportSubs(dbInboundId) {
+        const dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
+        const clients = this.getInboundClients(dbInbound);
+        let subLinks = []
+        if (clients != null) {
+          clients.forEach(c => {
+            if (c.subId && c.subId.length > 0) {
+              subLinks.push(this.subSettings.subURI + c.subId)
             }
-            else {
-                this.getDBInbounds();
-            }
-            this.loading(false);
-        },
-        computed: {
-            total() {
-                let down = 0, up = 0, allTime = 0;
-                let clients = 0, deactive = [], depleted = [], expiring = [];
-                this.dbInbounds.forEach(dbInbound => {
-                    down += dbInbound.down;
-                    up += dbInbound.up;
-                    allTime += (dbInbound.allTime || (dbInbound.up + dbInbound.down));
-                    if (this.clientCount[dbInbound.id]) {
-                        clients += this.clientCount[dbInbound.id].clients;
-                        deactive = deactive.concat(this.clientCount[dbInbound.id].deactive);
-                        depleted = depleted.concat(this.clientCount[dbInbound.id].depleted);
-                        expiring = expiring.concat(this.clientCount[dbInbound.id].expiring);
-                    }
-                });
-                return {
-                    down: down,
-                    up: up,
-                    allTime: allTime,
-                    clients: clients,
-                    deactive: deactive,
-                    depleted: depleted,
-                    expiring: expiring,
-                };
-            }
-        },
-    });
+          })
+        }
+        txtModal.show(
+          '{{ i18n "pages.inbounds.export"}} - {{ i18n "pages.settings.subSettings" }}',
+          [...new Set(subLinks)].join('\n'),
+          dbInbound.remark + "-Subs");
+      },
+      importInbound() {
+        promptModal.open({
+          title: '{{ i18n "pages.inbounds.importInbound" }}',
+          type: 'textarea',
+          value: '',
+          okText: '{{ i18n "pages.inbounds.import" }}',
+          confirm: async (dbInboundText) => {
+            await this.submit('/panel/api/inbounds/import', { data: dbInboundText }, promptModal);
+          },
+        });
+      },
+      exportAllSubs() {
+        let subLinks = []
+        for (const dbInbound of this.dbInbounds) {
+          const clients = this.getInboundClients(dbInbound);
+          if (clients != null) {
+            clients.forEach(c => {
+              if (c.subId && c.subId.length > 0) {
+                subLinks.push(this.subSettings.subURI + c.subId)
+              }
+            })
+          }
+        }
+        txtModal.show(
+          '{{ i18n "pages.inbounds.export"}} - {{ i18n "pages.settings.subSettings" }}',
+          [...new Set(subLinks)].join('\r\n'),
+          'All-Inbounds-Subs');
+      },
+      exportAllLinks() {
+        let copyText = [];
+        for (const dbInbound of this.dbInbounds) {
+          copyText.push(dbInbound.genInboundLinks(this.remarkModel));
+        }
+        txtModal.show('{{ i18n "pages.inbounds.export"}}', copyText.join('\r\n'), 'All-Inbounds');
+      },
+      copy(dbInboundId) {
+        dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
+        txtModal.show('{{ i18n "pages.inbounds.inboundData" }}', JSON.stringify(dbInbound, null, 2));
+      },
+      async startDataRefreshLoop() {
+        while (this.isRefreshEnabled) {
+          try {
+            await this.getDBInbounds();
+          } catch (e) {
+            console.error(e);
+          }
+          await PromiseUtil.sleep(this.refreshInterval);
+        }
+      },
+      toggleRefresh() {
+        localStorage.setItem("isRefreshEnabled", this.isRefreshEnabled);
+        if (this.isRefreshEnabled) {
+          this.startDataRefreshLoop();
+        }
+      },
+      changeRefreshInterval() {
+        localStorage.setItem("refreshInterval", this.refreshInterval);
+      },
+      async manualRefresh() {
+        if (!this.refreshing) {
+          this.loadingStates.spinning = true;
+          await this.getDBInbounds();
+          this.loadingStates.spinning = false;
+        }
+      },
+      pagination(obj) {
+        if (this.pageSize > 0 && obj.length > this.pageSize) {
+          // Set page options based on object size
+          sizeOptions = [];
+          for (i = this.pageSize; i <= obj.length; i = i + this.pageSize) {
+            sizeOptions.push(i.toString());
+          }
+          // Add option to see all in one page
+          sizeOptions.push(i.toString());
+
+          p = {
+            showSizeChanger: true,
+            size: 'small',
+            position: 'bottom',
+            pageSize: this.pageSize,
+            pageSizeOptions: sizeOptions
+          };
+          return p;
+        }
+        return false
+      }
+    },
+    watch: {
+      searchKey: Utils.debounce(function (newVal) {
+        this.searchInbounds(newVal);
+      }, 500)
+    },
+    mounted() {
+      if (window.location.protocol !== "https:") {
+        this.showAlert = true;
+      }
+      this.loading();
+      this.getDefaultSettings();
+      if (this.isRefreshEnabled) {
+        this.startDataRefreshLoop();
+      }
+      else {
+        this.getDBInbounds();
+      }
+      this.loading(false);
+    },
+    computed: {
+      total() {
+        let down = 0, up = 0, allTime = 0;
+        let clients = 0, deactive = [], depleted = [], expiring = [];
+        this.dbInbounds.forEach(dbInbound => {
+          down += dbInbound.down;
+          up += dbInbound.up;
+          allTime += (dbInbound.allTime || (dbInbound.up + dbInbound.down));
+          if (this.clientCount[dbInbound.id]) {
+            clients += this.clientCount[dbInbound.id].clients;
+            deactive = deactive.concat(this.clientCount[dbInbound.id].deactive);
+            depleted = depleted.concat(this.clientCount[dbInbound.id].depleted);
+            expiring = expiring.concat(this.clientCount[dbInbound.id].expiring);
+          }
+        });
+        return {
+          down: down,
+          up: up,
+          allTime: allTime,
+          clients: clients,
+          deactive: deactive,
+          depleted: depleted,
+          expiring: expiring,
+        };
+      }
+    },
+  });
 </script>
 {{ template "page/body_end" .}}

+ 262 - 0
web/html/index.html

@@ -41,6 +41,11 @@
                                 <div><b>{{ i18n "pages.index.frequency" }}:</b> [[ CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz) ]]</div>
                               </template>
                             </a-tooltip>
+                            <a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
+                              <a-button size="small" type="default" class="ml-8" @click="openCpuHistory()">
+                                <a-icon type="history" />
+                              </a-button>
+                            </a-tooltip>
                           </div>
                         </a-col>
                         <a-col :span="12" class="text-center">
@@ -423,6 +428,36 @@
       </a-list-item>
     </a-list>
   </a-modal>
+  <!-- CPU History Modal -->
+  <a-modal id="cpu-history-modal"
+           v-model="cpuHistoryModal.visible"
+           :closable="true" @cancel="() => cpuHistoryModal.visible = false"
+           :class="themeSwitcher.currentTheme"
+           width="900px" footer="">
+    <template slot="title">
+      CPU History
+      <a-select size="small" v-model="cpuHistoryModal.minutes" class="ml-10" style="width: 120px" @change="loadCpuHistory">
+        <a-select-option :value="15">15 min</a-select-option>
+        <a-select-option :value="60">1 hour</a-select-option>
+        <a-select-option :value="180">3 hours</a-select-option>
+        <a-select-option :value="360">6 hours</a-select-option>
+      </a-select>
+    </template>
+    <div style="padding: 8px 0;">
+      <sparkline :data="cpuHistoryLong"
+                 :labels="cpuHistoryLabels"
+                 :vb-width="840"
+                 :height="220"
+                 :stroke="status.cpu.color"
+                 :stroke-width="2.2"
+                 :show-grid="true"
+                 :show-axes="true"
+                 :tick-count-x="5"
+                 :fill-opacity="0.18"
+                 :marker-radius="3.2"
+                 :show-tooltip="true" />
+    </div>
+  </a-modal>
 </a-layout>
 {{template "page/body_scripts" .}}
 {{template "component/aSidebar" .}}
@@ -430,6 +465,190 @@
 {{template "component/aCustomStatistic" .}}
 {{template "modals/textModal"}}
 <script>
+  // Tiny Sparkline component using an inline SVG polyline
+  Vue.component('sparkline', {
+    props: {
+      data: { type: Array, required: true },
+      // viewBox width for drawing space; SVG width will be 100% of container
+      vbWidth: { type: Number, default: 320 },
+      height: { type: Number, default: 80 },
+      stroke: { type: String, default: '#008771' },
+      strokeWidth: { type: Number, default: 2 },
+      maxPoints: { type: Number, default: 120 },
+      showGrid: { type: Boolean, default: true },
+      gridColor: { type: String, default: 'rgba(255,255,255,0.08)' },
+      fillOpacity: { type: Number, default: 0.15 },
+      showMarker: { type: Boolean, default: true },
+      markerRadius: { type: Number, default: 2.8 },
+      // New opts for axes/labels/tooltip
+      labels: { type: Array, default: () => [] }, // same length as data for x labels (e.g., timestamps)
+      showAxes: { type: Boolean, default: false },
+      yTickStep: { type: Number, default: 25 }, // percent ticks
+      tickCountX: { type: Number, default: 4 },
+      paddingLeft: { type: Number, default: 32 },
+      paddingRight: { type: Number, default: 6 },
+      paddingTop: { type: Number, default: 6 },
+      paddingBottom: { type: Number, default: 20 },
+      showTooltip: { type: Boolean, default: false },
+    },
+    data() {
+      return {
+        hoverIdx: -1,
+      }
+    },
+    computed: {
+      viewBoxAttr() {
+        return '0 0 ' + this.vbWidth + ' ' + this.height
+      },
+      drawWidth() {
+        return Math.max(1, this.vbWidth - this.paddingLeft - this.paddingRight)
+      },
+      drawHeight() {
+        return Math.max(1, this.height - this.paddingTop - this.paddingBottom)
+      },
+      nPoints() {
+        return Math.min(this.data.length, this.maxPoints)
+      },
+      dataSlice() {
+        const n = this.nPoints
+        if (n === 0) return []
+        return this.data.slice(this.data.length - n)
+      },
+      labelsSlice() {
+        const n = this.nPoints
+        if (!this.labels || this.labels.length === 0 || n === 0) return []
+        const start = Math.max(0, this.labels.length - n)
+        return this.labels.slice(start)
+      },
+      pointsArr() {
+        const n = this.nPoints
+        if (n === 0) return []
+        const slice = this.dataSlice
+        const max = 100
+        const w = this.drawWidth
+        const h = this.drawHeight
+        const dx = n > 1 ? w / (n - 1) : 0
+        return slice.map((v, i) => {
+          const x = Math.round(this.paddingLeft + i * dx)
+          const y = Math.round(this.paddingTop + (h - (Math.max(0, Math.min(100, v)) / max) * h))
+          return [x, y]
+        })
+      },
+      points() {
+        return this.pointsArr.map(p => `${p[0]},${p[1]}`).join(' ')
+      },
+      areaPath() {
+        if (this.pointsArr.length === 0) return ''
+        const first = this.pointsArr[0]
+        const last = this.pointsArr[this.pointsArr.length - 1]
+        const line = this.points
+        // Close to bottom to create an area fill
+        return `M ${first[0]},${this.paddingTop + this.drawHeight} L ${line.replace(/ /g,' L ')} L ${last[0]},${this.paddingTop + this.drawHeight} Z`
+      },
+      gridLines() {
+        if (!this.showGrid) return []
+        const h = this.drawHeight
+        const w = this.drawWidth
+        // draw at 25%, 50%, 75%
+        return [0.25, 0.5, 0.75]
+          .map(r => Math.round(this.paddingTop + h * r))
+          .map(y => ({ x1: this.paddingLeft, y1: y, x2: this.paddingLeft + w, y2: y }))
+      },
+      lastPoint() {
+        if (this.pointsArr.length === 0) return null
+        return this.pointsArr[this.pointsArr.length - 1]
+      },
+      yTicks() {
+        if (!this.showAxes) return []
+        const step = Math.max(1, this.yTickStep)
+        const ticks = []
+        for (let p = 0; p <= 100; p += step) {
+          const y = Math.round(this.paddingTop + (this.drawHeight - (p / 100) * this.drawHeight))
+          ticks.push({ y, label: `${p}%` })
+        }
+        return ticks
+      },
+      xTicks() {
+        if (!this.showAxes) return []
+        const labels = this.labelsSlice
+        const n = this.nPoints
+        const m = Math.max(2, this.tickCountX)
+        const ticks = []
+        if (n === 0) return ticks
+        const w = this.drawWidth
+        const dx = n > 1 ? w / (n - 1) : 0
+        const positions = []
+        for (let i = 0; i < m; i++) {
+          const idx = Math.round((i * (n - 1)) / (m - 1))
+          positions.push(idx)
+        }
+        positions.forEach(idx => {
+          const label = labels[idx] != null ? String(labels[idx]) : String(idx)
+          const x = Math.round(this.paddingLeft + idx * dx)
+          ticks.push({ x, label })
+        })
+        return ticks
+      },
+    },
+    methods: {
+      onMouseMove(evt) {
+        if (!this.showTooltip || this.pointsArr.length === 0) return
+        const rect = evt.currentTarget.getBoundingClientRect()
+        const px = evt.clientX - rect.left
+        // translate to viewBox space
+        const x = (px / rect.width) * this.vbWidth
+        const n = this.nPoints
+        const dx = n > 1 ? this.drawWidth / (n - 1) : 0
+        const idx = Math.max(0, Math.min(n - 1, Math.round((x - this.paddingLeft) / (dx || 1))))
+        this.hoverIdx = idx
+      },
+      onMouseLeave() {
+        this.hoverIdx = -1
+      },
+      fmtHoverText() {
+        const labels = this.labelsSlice
+        const idx = this.hoverIdx
+        if (idx < 0 || idx >= this.dataSlice.length) return ''
+        const val = Math.max(0, Math.min(100, Number(this.dataSlice[idx] || 0)))
+        const lab = labels[idx] != null ? labels[idx] : ''
+        return `${val}%${lab ? ' • ' + lab : ''}`
+      },
+    },
+    template: `
+      <svg width="100%" :height="height" :viewBox="viewBoxAttr" preserveAspectRatio="none" style="display:block"
+           @mousemove="onMouseMove" @mouseleave="onMouseLeave">
+        <defs>
+          <linearGradient id="spkGrad" x1="0" y1="0" x2="0" y2="1">
+            <stop offset="0%" :stop-color="stroke" :stop-opacity="fillOpacity"/>
+            <stop offset="100%" :stop-color="stroke" stop-opacity="0"/>
+          </linearGradient>
+        </defs>
+        <g v-if="showGrid">
+          <line v-for="(g,i) in gridLines" :key="i" :x1="g.x1" :y1="g.y1" :x2="g.x2" :y2="g.y2" :stroke="gridColor" stroke-width="1"/>
+        </g>
+        <g v-if="showAxes">
+          <!-- Y ticks/labels -->
+          <g v-for="(t,i) in yTicks" :key="'y'+i">
+            <text :x="Math.max(0, paddingLeft - 4)" :y="t.y + 4" text-anchor="end" font-size="10" fill="rgba(200,200,200,0.8)" v-text="t.label"></text>
+          </g>
+          <!-- X ticks/labels -->
+          <g v-for="(t,i) in xTicks" :key="'x'+i">
+            <text :x="t.x" :y="paddingTop + drawHeight + 14" text-anchor="middle" font-size="10" fill="rgba(200,200,200,0.8)" v-text="t.label"></text>
+          </g>
+        </g>
+        <path v-if="areaPath" :d="areaPath" fill="url(#spkGrad)" stroke="none" />
+        <polyline :points="points" fill="none" :stroke="stroke" :stroke-width="strokeWidth" stroke-linecap="round" stroke-linejoin="round"/>
+        <circle v-if="showMarker && lastPoint" :cx="lastPoint[0]" :cy="lastPoint[1]" :r="markerRadius" :fill="stroke" />
+        <!-- Hover marker/tooltip -->
+        <g v-if="showTooltip && hoverIdx >= 0">
+          <line :x1="pointsArr[hoverIdx][0]" :x2="pointsArr[hoverIdx][0]" :y1="paddingTop" :y2="paddingTop + drawHeight" stroke="rgba(255,255,255,0.25)" stroke-width="1" />
+          <circle :cx="pointsArr[hoverIdx][0]" :cy="pointsArr[hoverIdx][1]" r="3.5" :fill="stroke" />
+          <text :x="pointsArr[hoverIdx][0]" :y="paddingTop + 12" text-anchor="middle" font-size="11" fill="#fff" style="paint-order: stroke; stroke: rgba(0,0,0,0.35); stroke-width: 3;" v-text="fmtHoverText()"></text>
+        </g>
+      </svg>
+    `,
+  })
+
     class CurTotal {
 
         constructor(current, total) {
@@ -659,6 +878,10 @@ ${dateTime}
               spinning: false
             },
             status: new Status(),
+            cpuHistory: [], // keep last N cpu utilization points (0..100)
+            cpuHistoryLong: [], // long-range history for modal (values)
+            cpuHistoryLabels: [], // formatted timestamps matching long history
+            cpuHistoryModal: { visible: false, minutes: 60 },
             versionModal,
             logModal,
             xraylogModal,
@@ -689,7 +912,46 @@ ${dateTime}
             },
             setStatus(data) {
                 this.status = new Status(data);
+                // Push CPU percent into history (clamped 0..100)
+                const v = Math.max(0, Math.min(100, Number(data?.cpu ?? 0)))
+                this.cpuHistory.push(v)
+                const maxPoints = this.isMobile ? 60 : 120
+                if (this.cpuHistory.length > maxPoints) {
+                  this.cpuHistory.splice(0, this.cpuHistory.length - maxPoints)
+                }
             },
+      openCpuHistory() {
+        this.cpuHistoryModal.visible = true
+        this.loadCpuHistory()
+      },
+      async loadCpuHistory() {
+        const mins = this.cpuHistoryModal.minutes || 60
+        try {
+          const msg = await HttpUtil.get(`/panel/api/server/cpuHistory?q=${mins}`)
+          if (msg.success && Array.isArray(msg.obj)) {
+            // msg.obj is array of {t, cpu}
+            const arr = msg.obj.map(p => Math.max(0, Math.min(100, Number(p.cpu || 0))))
+            const labels = msg.obj.map(p => {
+              const t = p.t
+              let d
+              if (typeof t === 'number') {
+                // Heuristic: if seconds, convert to ms
+                d = new Date(t < 1e12 ? t * 1000 : t)
+              } else {
+                d = new Date(t)
+              }
+              if (isNaN(d.getTime())) return ''
+              const hh = String(d.getHours()).padStart(2, '0')
+              const mm = String(d.getMinutes()).padStart(2, '0')
+              return `${hh}:${mm}`
+            })
+            this.cpuHistoryLong = arr
+            this.cpuHistoryLabels = labels
+          }
+        } catch (e) {
+          console.error('Failed to load CPU history', e)
+        }
+      },
             async openSelectV2rayVersion() {
                 this.loading(true);
                 const msg = await HttpUtil.get('/panel/api/server/getXrayVersion');

+ 11 - 3
web/html/modals/inbound_info_modal.html

@@ -180,9 +180,9 @@
         <tr>
           <td>{{ i18n "status" }}</td>
           <td>
-            <a-tag v-if="isEnable" color="green">{{ i18n "enabled" }}</a-tag>
-            <a-tag v-else>{{ i18n "disabled" }}</a-tag>
-            <a-tag v-if="!isActive" color="red">{{ i18n "depleted" }}</a-tag>
+            <a-tag v-if="isEnable && isActive && !isDepleted" color="green">{{ i18n "enabled" }}</a-tag>
+            <a-tag v-if="!isEnable && !isDepleted">{{ i18n "disabled" }}</a-tag>
+            <a-tag v-if="isDepleted" color="red">{{ i18n "depleted" }}</a-tag>
           </td>
         </tr>
         <tr v-if="infoModal.clientStats">
@@ -587,6 +587,14 @@
         }
         return infoModal.dbInbound.isEnable;
       },
+      get isDepleted() {
+        const stats = this.infoModal.clientStats;
+        if (!stats) return false;
+        const now = new Date().getTime();
+        const expired = stats.expiryTime > 0 && now >= stats.expiryTime;
+        const exhausted = stats.total > 0 && (stats.up + stats.down) >= stats.total;
+        return expired || exhausted;
+      },
     },
     methods: {
       copy(content) {

+ 3 - 1
web/html/xray.html

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

+ 23 - 11
web/service/inbound.go

@@ -1272,7 +1272,7 @@ func (s *InboundService) AddClientStat(tx *gorm.DB, inboundId int, client *model
 	clientTraffic.Email = client.Email
 	clientTraffic.Total = client.TotalGB
 	clientTraffic.ExpiryTime = client.ExpiryTime
-	clientTraffic.Enable = true
+	clientTraffic.Enable = client.Enable
 	clientTraffic.Up = 0
 	clientTraffic.Down = 0
 	clientTraffic.Reset = client.Reset
@@ -1285,7 +1285,7 @@ func (s *InboundService) UpdateClientStat(tx *gorm.DB, email string, client *mod
 	result := tx.Model(xray.ClientTraffic{}).
 		Where("email = ?", email).
 		Updates(map[string]any{
-			"enable":      true,
+			"enable":      client.Enable,
 			"email":       client.Email,
 			"total":       client.TotalGB,
 			"expiry_time": client.ExpiryTime,
@@ -1837,8 +1837,14 @@ func (s *InboundService) DelDepletedClients(id int) (err error) {
 		whereText += "= ?"
 	}
 
+	// Only consider truly depleted clients: expired OR traffic exhausted
+	now := time.Now().Unix() * 1000
 	depletedClients := []xray.ClientTraffic{}
-	err = db.Model(xray.ClientTraffic{}).Where(whereText+" and enable = ?", id, false).Select("inbound_id, GROUP_CONCAT(email) as email").Group("inbound_id").Find(&depletedClients).Error
+	err = db.Model(xray.ClientTraffic{}).
+		Where(whereText+" and ((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?))", id, now).
+		Select("inbound_id, GROUP_CONCAT(email) as email").
+		Group("inbound_id").
+		Find(&depletedClients).Error
 	if err != nil {
 		return err
 	}
@@ -1889,7 +1895,8 @@ func (s *InboundService) DelDepletedClients(id int) (err error) {
 		}
 	}
 
-	err = tx.Where(whereText+" and enable = ?", id, false).Delete(xray.ClientTraffic{}).Error
+	// Delete stats only for truly depleted clients
+	err = tx.Where(whereText+" and ((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?))", id, now).Delete(xray.ClientTraffic{}).Error
 	if err != nil {
 		return err
 	}
@@ -1937,18 +1944,17 @@ func (s *InboundService) GetClientTrafficTgBot(tgId int64) ([]*xray.ClientTraffi
 }
 
 func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.ClientTraffic, err error) {
-	db := database.GetDB()
-	var traffics []*xray.ClientTraffic
-
-	err = db.Model(xray.ClientTraffic{}).Where("email = ?", email).Find(&traffics).Error
+	// Prefer retrieving along with client to reflect actual enabled state from inbound settings
+	t, client, err := s.GetClientByEmail(email)
 	if err != nil {
 		logger.Warningf("Error retrieving ClientTraffic with email %s: %v", email, err)
 		return nil, err
 	}
-	if len(traffics) > 0 {
-		return traffics[0], nil
+	if t != nil && client != nil {
+		// Ensure enable mirrors the client's current enable flag in settings
+		t.Enable = client.Enable
+		return t, nil
 	}
-
 	return nil, nil
 }
 
@@ -1983,6 +1989,12 @@ func (s *InboundService) GetClientTrafficByID(id string) ([]xray.ClientTraffic,
 		logger.Debug(err)
 		return nil, err
 	}
+	// Reconcile enable flag with client settings per email to avoid stale DB value
+	for i := range traffics {
+		if ct, client, e := s.GetClientByEmail(traffics[i].Email); e == nil && ct != nil && client != nil {
+			traffics[i].Enable = client.Enable
+		}
+	}
 	return traffics, err
 }
 

+ 148 - 2
web/service/server.go

@@ -16,6 +16,7 @@ import (
 	"runtime"
 	"strconv"
 	"strings"
+	"sync"
 	"time"
 
 	"x-ui/config"
@@ -98,6 +99,20 @@ type ServerService struct {
 	cachedIPv4     string
 	cachedIPv6     string
 	noIPv6         bool
+	// CPU utilization smoothing state
+	mu               sync.Mutex
+	lastCPUTimes     cpu.TimesStat
+	hasLastCPUSample bool
+	emaCPU           float64
+	// CPU history buffer (in-memory, protected by mu)
+	cpuHistory  []CPUSample
+	cpuCapacity int
+}
+
+// CPUSample represents a single CPU utilization sample with timestamp
+type CPUSample struct {
+	T   int64   `json:"t"`   // unix seconds
+	Cpu float64 `json:"cpu"` // percent 0..100
 }
 
 func getPublicIP(url string) string {
@@ -139,11 +154,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)
@@ -307,6 +322,137 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
 	return status
 }
 
+// AppendCpuSample appends a CPU sample into the in-memory history with capacity trimming.
+func (s *ServerService) AppendCpuSample(t time.Time, v float64) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.cpuCapacity == 0 {
+		s.cpuCapacity = 10800 // ~6 hours at 2s per sample
+	}
+	p := CPUSample{T: t.Unix(), Cpu: v}
+	s.cpuHistory = append(s.cpuHistory, p)
+	if len(s.cpuHistory) > s.cpuCapacity {
+		drop := len(s.cpuHistory) - s.cpuCapacity
+		s.cpuHistory = s.cpuHistory[drop:]
+	}
+}
+
+// GetCpuHistory returns samples from the last 'mins' minutes (bounded 1..360).
+func (s *ServerService) GetCpuHistory(mins int) []CPUSample {
+	if mins < 1 {
+		mins = 1
+	}
+	if mins > 360 {
+		mins = 360
+	}
+	cutoff := time.Now().Add(-time.Duration(mins) * time.Minute).Unix()
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if len(s.cpuHistory) == 0 {
+		return nil
+	}
+	// find first index >= cutoff (linear scan from end is fine for these sizes)
+	i := len(s.cpuHistory) - 1
+	for ; i >= 0; i-- {
+		if s.cpuHistory[i].T < cutoff {
+			i++
+			break
+		}
+	}
+	if i < 0 {
+		i = 0
+	}
+	// copy to avoid exposing internal slice
+	out := make([]CPUSample, len(s.cpuHistory)-i)
+	copy(out, s.cpuHistory[i:])
+	return out
+}
+
+// sampleCPUUtilization returns a smoothed total CPU utilization percentage across all logical processors.
+// It computes utilization from CPU time deltas (non-blocking) and applies an exponential moving average
+// to reduce spikes similar to Task Manager's smoothing.
+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"

+ 149 - 1
web/service/tgbot.go

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

+ 7 - 12
web/web.go

@@ -289,18 +289,13 @@ func (s *Server) startTask() {
 	// check client ips from log file every day
 	s.cron.AddJob("@daily", job.NewClearLogsJob())
 
-	// Periodic traffic resets
-	logger.Info("Scheduling periodic traffic reset jobs")
-	{
-		// Inbound traffic reset jobs
-		// Run once a day, midnight
-		s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("daily"))
-		// Run once a week, midnight between Sat/Sun
-		s.cron.AddJob("@weekly", job.NewPeriodicTrafficResetJob("weekly"))
-		// Run once a month, midnight, first of month
-		s.cron.AddJob("@monthly", job.NewPeriodicTrafficResetJob("monthly"))
-
-	}
+	// Inbound traffic reset jobs
+	// Run once a day, midnight
+	s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("daily"))
+	// Run once a week, midnight between Sat/Sun
+	s.cron.AddJob("@weekly", job.NewPeriodicTrafficResetJob("weekly"))
+	// Run once a month, midnight, first of month
+	s.cron.AddJob("@monthly", job.NewPeriodicTrafficResetJob("monthly"))
 
 	// Make a traffic condition every day, 8:30
 	var entry cron.EntryID