Browse Source

Merge pull request #26 from MHSanaei/dev

alireza
MHSanaei 2 years ago
parent
commit
f25a7a571e
51 changed files with 2210 additions and 861 deletions
  1. 1 1
      config/version
  2. 2 1
      database/model/model.go
  3. 5 5
      main.go
  4. 1 0
      util/sys/sys_darwin.go
  5. 1 0
      util/sys/sys_linux.go
  6. 0 0
      web/assets/[email protected]/antd.min.css
  7. 63 7
      web/assets/css/custom.css
  8. 1 1
      web/assets/js/langs.js
  9. 9 7
      web/assets/js/model/models.js
  10. 17 36
      web/assets/js/model/xray.js
  11. 1 1
      web/assets/js/util/utils.js
  12. 78 2
      web/controller/inbound.go
  13. 3 3
      web/controller/index.go
  14. 2 1
      web/controller/setting.go
  15. 6 3
      web/entity/entity.go
  16. 1 1
      web/html/common/prompt_modal.html
  17. 18 3
      web/html/common/qrcode_modal.html
  18. 2 3
      web/html/common/text_modal.html
  19. 1 1
      web/html/login.html
  20. 160 0
      web/html/xui/client_bulk_modal.html
  21. 133 0
      web/html/xui/client_modal.html
  22. 7 8
      web/html/xui/common_sider.html
  23. 110 0
      web/html/xui/form/client.html
  24. 2 1
      web/html/xui/form/inbound.html
  25. 4 1
      web/html/xui/form/protocol/dokodemo.html
  26. 2 2
      web/html/xui/form/protocol/shadowsocks.html
  27. 1 1
      web/html/xui/form/protocol/socks.html
  28. 42 61
      web/html/xui/form/protocol/trojan.html
  29. 36 63
      web/html/xui/form/protocol/vless.html
  30. 39 65
      web/html/xui/form/protocol/vmess.html
  31. 2 2
      web/html/xui/form/stream/stream_quic.html
  32. 2 2
      web/html/xui/form/stream/stream_settings.html
  33. 10 10
      web/html/xui/form/tls_settings.html
  34. 44 0
      web/html/xui/inbound_client_table.html
  35. 87 32
      web/html/xui/inbound_info_modal.html
  36. 4 76
      web/html/xui/inbound_modal.html
  37. 162 79
      web/html/xui/inbounds.html
  38. 12 6
      web/html/xui/index.html
  39. 118 2
      web/html/xui/setting.html
  40. 30 0
      web/job/check_cpu_usage.go
  41. 3 222
      web/job/stats_notify_job.go
  42. 1 2
      web/job/xray_traffic_job.go
  43. 203 52
      web/service/inbound.go
  44. 2 2
      web/service/server.go
  45. 46 10
      web/service/setting.go
  46. 546 0
      web/service/tgbot.go
  47. 1 1
      web/service/xray.go
  48. 80 50
      web/translation/translate.en_US.toml
  49. 48 18
      web/translation/translate.fa_IR.toml
  50. 42 12
      web/translation/translate.zh_Hans.toml
  51. 19 5
      web/web.go

+ 1 - 1
config/version

@@ -1 +1 @@
-1.1.0
+

+ 2 - 1
database/model/model.go

@@ -73,10 +73,11 @@ type Setting struct {
 
 type Client struct {
 	ID         string `json:"id"`
+	Password   string `json:"password"`
+	Flow       string `json:"flow"`
 	AlterIds   uint16 `json:"alterId"`
 	Email      string `json:"email"`
 	LimitIP    int    `json:"limitIp"`
-	Security   string `json:"security"`
 	TotalGB    int64  `json:"totalGB" form:"totalGB"`
 	ExpiryTime int64  `json:"expiryTime" form:"expiryTime"`
 }

+ 5 - 5
main.go

@@ -136,7 +136,7 @@ func updateTgbotEnableSts(status bool) {
 	return
 }
 
-func updateTgbotSetting(tgBotToken string, tgBotChatid int, tgBotRuntime string) {
+func updateTgbotSetting(tgBotToken string, tgBotChatid string, tgBotRuntime string) {
 	err := database.InitDB(config.GetDBPath())
 	if err != nil {
 		fmt.Println(err)
@@ -165,7 +165,7 @@ func updateTgbotSetting(tgBotToken string, tgBotChatid int, tgBotRuntime string)
 		}
 	}
 
-	if tgBotChatid != 0 {
+	if tgBotChatid != "" {
 		err := settingService.SetTgBotChatId(tgBotChatid)
 		if err != nil {
 			fmt.Println(err)
@@ -224,7 +224,7 @@ func main() {
 	var username string
 	var password string
 	var tgbottoken string
-	var tgbotchatid int
+	var tgbotchatid string
 	var enabletgbot bool
 	var tgbotRuntime string
 	var reset bool
@@ -236,7 +236,7 @@ func main() {
 	settingCmd.StringVar(&password, "password", "", "set login password")
 	settingCmd.StringVar(&tgbottoken, "tgbottoken", "", "set telegrame bot token")
 	settingCmd.StringVar(&tgbotRuntime, "tgbotRuntime", "", "set telegrame bot cron time")
-	settingCmd.IntVar(&tgbotchatid, "tgbotchatid", 0, "set telegrame bot chat id")
+	settingCmd.StringVar(&tgbotchatid, "tgbotchatid", "", "set telegrame bot chat id")
 	settingCmd.BoolVar(&enabletgbot, "enabletgbot", false, "enable telegram bot notify")
 
 	oldUsage := flag.Usage
@@ -287,7 +287,7 @@ func main() {
 		if show {
 			showSetting(show)
 		}
-		if (tgbottoken != "") || (tgbotchatid != 0) || (tgbotRuntime != "") {
+		if (tgbottoken != "") || (tgbotchatid != "") || (tgbotRuntime != "") {
 			updateTgbotSetting(tgbottoken, tgbotchatid, tgbotRuntime)
 		}
 	default:

+ 1 - 0
util/sys/sys_darwin.go

@@ -1,3 +1,4 @@
+//go:build darwin
 // +build darwin
 
 package sys

+ 1 - 0
util/sys/sys_linux.go

@@ -1,3 +1,4 @@
+//go:build linux
 // +build linux
 
 package sys

File diff suppressed because it is too large
+ 0 - 0
web/assets/[email protected]/antd.min.css


+ 63 - 7
web/assets/css/custom.css

@@ -151,6 +151,11 @@
     background-color: rgb(255, 127, 127);
 }
 
+.ant-table-tbody>tr>td,
+.ant-table-thead>tr>th{
+    padding:16px;
+}
+
 .ant-card-dark {
     color: hsla(0,0%,100%,.65);
     background-color: #1a212a;
@@ -163,7 +168,7 @@
 
 .ant-card-dark .ant-table-thead th {
     color: hsla(0,0%,100%,.65);
-    background-color: #1b202b;
+    background-color: #161b22;
 }
 
 .ant-card-dark .ant-table-tbody tr td,
@@ -171,7 +176,10 @@
     color: hsla(0,0%,100%,.65);
 }
 
-.ant-card-dark .ant-collapse-content {
+.ant-card-dark .ant-collapse-content,
+.ant-card-dark .ant-calendar,
+.ant-card-dark .ant-table-placeholder {
+    color: hsla(0,0%,100%,.65);
     background-color: #1a212a;
 }
 
@@ -180,11 +188,23 @@
 .ant-card-dark .ant-form-item-label>label,
 .ant-card-dark .ant-form-item,
 .ant-card-dark .ant-divider-inner-text,
-.ant-card-dark .ant-collapse>.ant-collapse-item>.ant-collapse-header {
+.ant-card-dark .ant-modal-confirm-content,
+.ant-card-dark .ant-modal-confirm-title,
+.ant-card-dark .ant-progress-text,
+.ant-card-dark .ant-modal-close,
+.ant-card-dark i,
+.ant-card-dark .ant-select-dropdown-menu-item,
+.ant-card-dark .ant-calendar-month-select,
+.ant-card-dark .ant-calendar-year-select,
+.ant-card-dark .ant-calendar-date,
+.ant-card-dark .ant-collapse>.ant-collapse-item>.ant-collapse-header,
+.ant-card-dark .ant-empty-normal {
     color: hsla(0,0%,100%,.65);
 }
 
-.ant-card-dark .ant-table-tbody>tr:hover:not(.ant-table-expanded-row):not(.ant-table-row-selected)>td {
+.ant-card-dark .ant-table-tbody>tr:hover:not(.ant-table-expanded-row):not(.ant-table-row-selected)>td,
+.ant-card-dark .ant-select-dropdown-menu-item:hover:not(.ant-select-dropdown-menu-item-disabled),
+.ant-card-dark .ant-calendar-date:hover {
     background-color: #004488;
 }
 
@@ -195,19 +215,55 @@
 
 .ant-card-dark .ant-input,
 .ant-card-dark .ant-input-number,
+.ant-card-dark .ant-calendar-input,
+.ant-card-dark .ant-select-dropdown-menu-item-selected,
 .ant-card-dark .ant-select-selection {
-    background-color: #023366;
     color: hsla(0,0%,100%,.65);
+    background-color: #023366;
 }
 
 .ant-card-dark .ant-collapse-item {
-    background-color: #1b202b;
     color: hsla(0,0%,100%,.65);
+    background-color: #161b22;
 }
 
 .ant-card-dark .ant-modal-content,
 .ant-card-dark .ant-modal-body,
-.ant-card-dark .ant-modal-header {
+.ant-card-dark .ant-modal-header,
+.ant-card-dark .ant-calendar-selected-day .ant-calendar-date {
     color: hsla(0,0%,100%,.65);
     background-color: #242c3a; 
+}
+
+.client-table-header {
+    background-color: #f0f2f5;
+}
+
+.client-table-odd-row {
+    background-color: #fafafa;
+}
+
+.ant-card-dark .client-table-header {
+    background-color:  #023366;
+    color: hsla(0,0%,100%,.65);
+}
+
+.ant-card-dark .client-table-odd-row {
+    color: hsla(0,0%,100%,.65);
+    background-color: #242c3a;
+}
+
+.ant-card-dark .ant-calendar-last-month-cell .ant-calendar-date,
+.ant-card-dark .ant-calendar-next-month-btn-day .ant-calendar-date {
+    color: hsla(0,0%,100%,.30);
+}
+
+.ant-drawer-dark {
+    color: hsla(0,0%,100%,.65);
+}
+
+.ant-drawer-dark .ant-drawer-wrapper-body,
+.ant-drawer-dark .drawer-handle {
+    background-color: #1a212a;
+    border: 1px solid hsla(0,0%,100%,.30);
 }

+ 1 - 1
web/assets/js/langs.js

@@ -4,7 +4,7 @@ supportLangs = [
        value : "en-US",
        icon : "🇺🇸"
     },
-	{
+    {
         name : "Farsi",
         value : "fa_IR",
         icon : "🇮🇷"

+ 9 - 7
web/assets/js/model/models.js

@@ -36,7 +36,8 @@ class DBInbound {
         this.remark = "";
         this.enable = true;
         this.expiryTime = 0;
-        this.iplimit = 0;
+        this.limitIp = 0;
+
         this.listen = "";
         this.port = 0;
         this.protocol = "";
@@ -109,10 +110,6 @@ class DBInbound {
     get isExpiry() {
         return this.expiryTime < new Date().getTime();
     }
-	get isDBInboundEmpty() {
-        const inbound = this.toInbound();
-        return inbound.isInboundEmpty();
-    }
 
     toInbound() {
         let settings = {};
@@ -159,6 +156,7 @@ class DBInbound {
         const inbound = this.toInbound();
         return inbound.genLink(this.address, this.remark, clientIndex);
     }
+    
 	get genInboundLinks() {
         const inbound = this.toInbound();
         return inbound.genInboundLinks(this.address, this.remark);
@@ -175,8 +173,12 @@ class AllSetting {
         this.webBasePath = "/";
         this.tgBotEnable = false;
         this.tgBotToken = "";
-        this.tgBotChatId = 0;
-        this.tgRunTime = "";
+        this.tgBotChatId = "";
+        this.tgRunTime = "@daily";
+        this.tgBotBackup = false;
+        this.tgExpireDiff = "";
+        this.tgTrafficDiff = "";
+        this.tgCpu = "";
         this.xrayTemplateConfig = "";
 
         this.timeLocation = "Asia/Tehran";

+ 17 - 36
web/assets/js/model/xray.js

@@ -794,18 +794,6 @@ class Inbound extends XrayCommonClass {
         return this.network === "http";
     }
 
-    isInboundEmpty() {
-        if (this.protocol == Protocols.VMESS && this.settings.vmesses.length == 0) {
-            return true;
-        } else if (this.protocol == Protocols.VLESS && this.settings.vlesses.length == 0) {
-            return true;
-        } else if (this.protocol == Protocols.TROJAN && this.settings.trojans.length == 0) {
-            return true;
-        } else {
-            return false;
-        }
-    }
-
     // VMess & VLess
     get uuid() {
         switch (this.protocol) {
@@ -1170,23 +1158,19 @@ class Inbound extends XrayCommonClass {
                 else{
                    params.set("sni", address);
                 }
-                if (type === "tcp") {
+               if (type === "tcp" && this.settings.vlesses[clientIndex].flow.length > 0) {
                     params.set("flow", this.settings.vlesses[clientIndex].flow);
                 }
 			}
         }
 		
-		if (this.stream.security === 'xtls') {
-            if (!ObjectUtil.isEmpty(this.stream.tls.server)) {
-                address = this.stream.tls.server;
-                if (this.stream.tls.settings[0]['serverName'] !== ''){
-                    params.set("sni", this.stream.tls.settings[0]['serverName']);
-                }
-                else{
-                   params.set("sni", address);
-                }
-                if (type === "tcp") {
-                    params.set("flow", this.settings.vlesses[clientIndex].flow);
+		 if (this.xtls) {
+            if (this.stream.security === 'xtls') {
+                if (!ObjectUtil.isEmpty(this.stream.tls.server)) {
+                    address = this.stream.tls.server;
+                    if (type === "tcp") {
+                        params.set("flow", this.settings.vlesses[clientIndex].flow);
+                    }
                 }
 			}
         }
@@ -1281,13 +1265,7 @@ class Inbound extends XrayCommonClass {
 		if (this.stream.security === 'xtls') {
             if (!ObjectUtil.isEmpty(this.stream.tls.server)) {
                 address = this.stream.tls.server;
-                if (this.stream.tls.settings[0]['serverName'] !== ''){
-                    params.set("sni", this.stream.tls.settings[0]['serverName']);
-                }
-                else{
-                   params.set("sni", address);
-                }
-                if (type === "tcp") {
+                 if (type === "tcp" && this.settings.trojans[clientIndex].flow.length > 0) {
                     params.set("flow", this.settings.trojans[clientIndex].flow);
                 }
 			}
@@ -1306,18 +1284,18 @@ class Inbound extends XrayCommonClass {
         switch (this.protocol) {
             case Protocols.VMESS:
                 if (this.settings.vmesses[clientIndex].email != ""){
-                    remark += '-' + this.settings.vmesses[clientIndex].email
+                    remark = this.settings.vmesses[clientIndex].email
                 }
                 return this.genVmessLink(address, remark, clientIndex);
             case Protocols.VLESS:
                 if (this.settings.vlesses[clientIndex].email != ""){
-                    remark += '-' + this.settings.vlesses[clientIndex].email
+                    remark = this.settings.vlesses[clientIndex].email
                 }
                 return this.genVLESSLink(address, remark, clientIndex);
             case Protocols.SHADOWSOCKS: return this.genSSLink(address, remark);
             case Protocols.TROJAN:
                 if (this.settings.trojans[clientIndex].email != ""){
-                    remark += '-' + this.settings.trojans[clientIndex].email
+                    remark = this.settings.trojans[clientIndex].email
                 }
                 return this.genTrojanLink(address, remark, clientIndex);
             default: return '';
@@ -1652,7 +1630,7 @@ Inbound.TrojanSettings = class extends Inbound.Settings {
     }
 };
 Inbound.TrojanSettings.Trojan = class extends XrayCommonClass {
-    constructor(password=RandomUtil.randomSeq(10), flow ='', email=RandomUtil.randomText(),limitIp=0, totalGB=0, expiryTime='') {
+    constructor(password=RandomUtil.randomSeq(10), flow='', email=RandomUtil.randomText(),limitIp=0, totalGB=0, expiryTime='') {
         super();
         this.password = password;
         this.flow = flow;
@@ -1779,11 +1757,12 @@ Inbound.ShadowsocksSettings = class extends Inbound.Settings {
 };
 
 Inbound.DokodemoSettings = class extends Inbound.Settings {
-    constructor(protocol, address, port, network='tcp,udp') {
+    constructor(protocol, address, port, network='tcp,udp', followRedirect=false) {
         super(protocol);
         this.address = address;
         this.port = port;
         this.network = network;
+		this.followRedirect = followRedirect;
     }
 
     static fromJson(json={}) {
@@ -1792,6 +1771,7 @@ Inbound.DokodemoSettings = class extends Inbound.Settings {
             json.address,
             json.port,
             json.network,
+			json.followRedirect,
         );
     }
 
@@ -1800,6 +1780,7 @@ Inbound.DokodemoSettings = class extends Inbound.Settings {
             address: this.address,
             port: this.port,
             network: this.network,
+			followRedirect: this.followRedirect,
         };
     }
 };

+ 1 - 1
web/assets/js/util/utils.js

@@ -136,7 +136,7 @@ class RandomUtil {
             return (c === 'x' ? r : (r & 0x7 | 0x8)).toString(16);
         });
     }
-    
+
     static randomText() {
         var chars = 'abcdefghijklmnopqrstuvwxyz1234567890';
         var string = '';

+ 78 - 2
web/controller/inbound.go

@@ -33,7 +33,10 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
 	g.POST("/update/:id", a.updateInbound)
 	g.POST("/clientIps/:email", a.getClientIps)
 	g.POST("/clearClientIps/:email", a.clearClientIps)
-	g.POST("/resetClientTraffic/:email", a.resetClientTraffic)
+	g.POST("/addClient/", a.addInboundClient)
+	g.POST("/delClient/:email", a.delInboundClient)
+	g.POST("/updateClient/:index", a.updateInboundClient)
+	g.POST("/:id/resetClientTraffic/:email", a.resetClientTraffic)
 
 }
 
@@ -124,6 +127,7 @@ func (a *InboundController) updateInbound(c *gin.Context) {
 		a.xrayService.SetToNeedRestart()
 	}
 }
+
 func (a *InboundController) getClientIps(c *gin.Context) {
 	email := c.Param("email")
 
@@ -144,13 +148,85 @@ func (a *InboundController) clearClientIps(c *gin.Context) {
 	}
 	jsonMsg(c, "Log Cleared", nil)
 }
+func (a *InboundController) addInboundClient(c *gin.Context) {
+	inbound := &model.Inbound{}
+	err := c.ShouldBind(inbound)
+	if err != nil {
+		jsonMsg(c, I18n(c, "pages.inbounds.revise"), err)
+		return
+	}
+
+	err = a.inboundService.AddInboundClient(inbound)
+	if err != nil {
+		jsonMsg(c, "something worng!", err)
+		return
+	}
+	jsonMsg(c, "Client added", nil)
+	if err == nil {
+		a.xrayService.SetToNeedRestart()
+	}
+}
+
+func (a *InboundController) delInboundClient(c *gin.Context) {
+	email := c.Param("email")
+	inbound := &model.Inbound{}
+	err := c.ShouldBind(inbound)
+	if err != nil {
+		jsonMsg(c, I18n(c, "pages.inbounds.revise"), err)
+		return
+	}
+
+	err = a.inboundService.DelInboundClient(inbound, email)
+	if err != nil {
+		jsonMsg(c, "something worng!", err)
+		return
+	}
+	jsonMsg(c, "Client deleted", nil)
+	if err == nil {
+		a.xrayService.SetToNeedRestart()
+	}
+}
+
+func (a *InboundController) updateInboundClient(c *gin.Context) {
+	index, err := strconv.Atoi(c.Param("index"))
+	if err != nil {
+		jsonMsg(c, I18n(c, "pages.inbounds.revise"), err)
+		return
+	}
+
+	inbound := &model.Inbound{}
+	err = c.ShouldBind(inbound)
+	if err != nil {
+		jsonMsg(c, I18n(c, "pages.inbounds.revise"), err)
+		return
+	}
+
+	err = a.inboundService.UpdateInboundClient(inbound, index)
+	if err != nil {
+		jsonMsg(c, "something worng!", err)
+		return
+	}
+	jsonMsg(c, "Client updated", nil)
+	if err == nil {
+		a.xrayService.SetToNeedRestart()
+	}
+}
+
 func (a *InboundController) resetClientTraffic(c *gin.Context) {
+	id, err := strconv.Atoi(c.Param("id"))
+	if err != nil {
+		jsonMsg(c, I18n(c, "pages.inbounds.revise"), err)
+		return
+	}
 	email := c.Param("email")
 
-	err := a.inboundService.ResetClientTraffic(email)
+	err = a.inboundService.ResetClientTraffic(id, email)
 	if err != nil {
 		jsonMsg(c, "something worng!", err)
 		return
 	}
 	jsonMsg(c, "traffic reseted", nil)
+	if err == nil {
+		a.xrayService.SetToNeedRestart()
+	}
 }

+ 3 - 3
web/controller/index.go

@@ -4,7 +4,6 @@ import (
 	"net/http"
 	"time"
 	"x-ui/logger"
-	"x-ui/web/job"
 	"x-ui/web/service"
 	"x-ui/web/session"
 
@@ -20,6 +19,7 @@ type IndexController struct {
 	BaseController
 
 	userService service.UserService
+	tgbot       service.Tgbot
 }
 
 func NewIndexController(g *gin.RouterGroup) *IndexController {
@@ -60,13 +60,13 @@ func (a *IndexController) login(c *gin.Context) {
 	user := a.userService.CheckUser(form.Username, form.Password)
 	timeStr := time.Now().Format("2006-01-02 15:04:05")
 	if user == nil {
-		job.NewStatsNotifyJob().UserLoginNotify(form.Username, getRemoteIp(c), timeStr, 0)
+		a.tgbot.UserLoginNotify(form.Username, getRemoteIp(c), timeStr, 0)
 		logger.Infof("wrong username or password: \"%s\" \"%s\"", form.Username, form.Password)
 		pureJsonMsg(c, false, I18n(c, "pages.login.toasts.wrongUsernameOrPassword"))
 		return
 	} else {
 		logger.Infof("%s login success,Ip Address:%s\n", form.Username, getRemoteIp(c))
-		job.NewStatsNotifyJob().UserLoginNotify(form.Username, getRemoteIp(c), timeStr, 1)
+		a.tgbot.UserLoginNotify(form.Username, getRemoteIp(c), timeStr, 1)
 	}
 
 	err = session.SetLoginUser(c, user)

+ 2 - 1
web/controller/setting.go

@@ -2,11 +2,12 @@ package controller
 
 import (
 	"errors"
-	"github.com/gin-gonic/gin"
 	"time"
 	"x-ui/web/entity"
 	"x-ui/web/service"
 	"x-ui/web/session"
+
+	"github.com/gin-gonic/gin"
 )
 
 type updateUserForm struct {

+ 6 - 3
web/entity/entity.go

@@ -34,11 +34,14 @@ type AllSetting struct {
 	WebBasePath        string `json:"webBasePath" form:"webBasePath"`
 	TgBotEnable        bool   `json:"tgBotEnable" form:"tgBotEnable"`
 	TgBotToken         string `json:"tgBotToken" form:"tgBotToken"`
-	TgBotChatId        int    `json:"tgBotChatId" form:"tgBotChatId"`
+	TgBotChatId        string `json:"tgBotChatId" form:"tgBotChatId"`
 	TgRunTime          string `json:"tgRunTime" form:"tgRunTime"`
+	TgBotBackup        bool   `json:"tgBotBackup" form:"tgBotBackup"`
+	TgExpireDiff       int    `json:"tgExpireDiff" form:"tgExpireDiff"`
+	TgTrafficDiff      int    `json:"tgTrafficDiff" form:"tgTrafficDiff"`
+	TgCpu              int    `json:"tgCpu" form:"tgCpu"`
 	XrayTemplateConfig string `json:"xrayTemplateConfig" form:"xrayTemplateConfig"`
-
-	TimeLocation string `json:"timeLocation" form:"timeLocation"`
+	TimeLocation       string `json:"timeLocation" form:"timeLocation"`
 }
 
 func (s *AllSetting) CheckValid() error {

+ 1 - 1
web/html/common/prompt_modal.html

@@ -1,7 +1,7 @@
 {{define "promptModal"}}
 <a-modal id="prompt-modal" v-model="promptModal.visible" :title="promptModal.title"
          :closable="true" @ok="promptModal.ok" :mask-closable="false"
-		 :class="siderDrawer.isDarkTheme ? darkClass : ''"
+         :class="siderDrawer.isDarkTheme ? darkClass : ''"
          :ok-text="promptModal.okText" cancel-text='{{ i18n "cancel" }}'>
     <a-input id="prompt-modal-input" :type="promptModal.type"
              v-model="promptModal.value"

+ 18 - 3
web/html/common/qrcode_modal.html

@@ -1,9 +1,10 @@
 {{define "qrcodeModal"}}
 <a-modal id="qrcode-modal" v-model="qrModal.visible" :title="qrModal.title"
          :closable="true" width="300px" :ok-text="qrModal.okText"
-		 :class="siderDrawer.isDarkTheme ? darkClass : ''"
+         :class="siderDrawer.isDarkTheme ? darkClass : ''"
          cancel-text='{{ i18n "close" }}' :ok-button-props="{attrs:{id:'qr-modal-ok-btn'}}">
-		<canvas id="qrCode" style="width: 100%; height: 100%;"></canvas>
+         <a-tag color="green" style="margin-bottom: 10px;display: block;text-align: center;" >{{ i18n "pages.inbounds.clickOnQRcode" }}</a-tag>
+    <canvas @click="copyToClipboard()" id="qrCode" style="width: 100%; height: 100%;"></canvas>
 </a-modal>
 
 <script>
@@ -35,7 +36,10 @@
                     this.clipboard = new ClipboardJS('#qr-modal-ok-btn', {
                         text: () => this.copyText,
                     });
-                    this.clipboard.on('success', () => app.$message.success('{{ i18n "copied" }}'));
+                    this.clipboard.on('success', () => {
+                        app.$message.success('{{ i18n "copied" }}')
+                        this.clipboard.destroy();
+                    });
                 }
                 if (this.qrcode === null) {
                     this.qrcode = new QRious({
@@ -58,6 +62,17 @@
         data: {
             qrModal: qrModal,
         },
+        methods: {
+            copyToClipboard() {
+                this.qrModal.clipboard = new ClipboardJS('#qrCode', {
+                    text: () => this.qrModal.copyText,
+                });
+                this.qrModal.clipboard.on('success', () => {
+                    app.$message.success('{{ i18n "copied" }}')
+                    this.qrModal.clipboard.destroy();
+                });
+            }
+        },
     });
 
 </script>

+ 2 - 3
web/html/common/text_modal.html

@@ -1,7 +1,7 @@
 {{define "textModal"}}
 <a-modal id="text-modal" v-model="txtModal.visible" :title="txtModal.title"
          :closable="true" ok-text='{{ i18n "copy" }}' cancel-text='{{ i18n "close" }}'
-		 :class="siderDrawer.isDarkTheme ? darkClass : ''"
+         :class="siderDrawer.isDarkTheme ? darkClass : ''"
          :ok-button-props="{attrs:{id:'txt-modal-ok-btn'}}">
     <a-button v-if="!ObjectUtil.isEmpty(txtModal.fileName)" type="primary" style="margin-bottom: 10px;"
               :href="'data:application/text;charset=utf-8,' + encodeURIComponent(txtModal.content)" :download="txtModal.fileName">
@@ -32,7 +32,6 @@
                     });
                     this.clipboard.on('success', () => app.$message.success('{{ i18n "copied" }}'));
                 }
-               
             });
         },
         close: function () {
@@ -41,7 +40,7 @@
     };
 
     const textModalApp = new Vue({
-    	delimiters: ['[[', ']]'],
+        delimiters: ['[[', ']]'],
         el: '#text-modal',
         data: {
             txtModal: txtModal,

+ 1 - 1
web/html/login.html

@@ -39,7 +39,7 @@
         <a-layout-content>
             <a-row type="flex" justify="center">
                 <a-col :xs="22" :sm="20" :md="16" :lg="12" :xl="8">
-                    <h1>3x-ui {{ i18n "pages.login.title" }}</h1>
+                    <h1>{{ i18n "pages.login.title" }}</h1>
                 </a-col>
             </a-row>
             <a-row type="flex" justify="center">

+ 160 - 0
web/html/xui/client_bulk_modal.html

@@ -0,0 +1,160 @@
+{{define "clientsBulkModal"}}
+<a-modal id="client-bulk-modal" v-model="clientsBulkModal.visible" :title="clientsBulkModal.title" @ok="clientsBulkModal.ok"
+         :confirm-loading="clientsBulkModal.confirmLoading" :closable="true" :mask-closable="false"
+         :class="siderDrawer.isDarkTheme ? darkClass : ''"
+         :ok-text="clientsBulkModal.okText" cancel-text='{{ i18n "close" }}'>
+    <a-form layout="inline">
+        <a-form-item label='{{ i18n "pages.client.method" }}'>
+            <a-select v-model="clientsBulkModal.emailMethod" buttonStyle="solid" style="width: 350px" :dropdown-class-name="siderDrawer.isDarkTheme ? 'ant-card-dark' : ''">
+                <a-select-option :value="0">Random</a-select-option>
+                <a-select-option :value="1">Random_Prefix</a-select-option>
+                <a-select-option :value="2">Random_Prefix+Num</a-select-option>
+                <a-select-option :value="3">Random_Prefix+Num+Postfix</a-select-option>
+                <a-select-option :value="4">Random_Prefix+Num@Telegram Username</a-select-option>
+            </a-select>
+        </a-form-item><br />
+        <a-form-item v-if="clientsBulkModal.emailMethod>1">
+            <span slot="label">{{ i18n "pages.client.first" }}</span>
+            <a-input-number v-model="clientsBulkModal.firstNum" :min="1"></a-input-number>
+        </a-form-item>
+        <a-form-item v-if="clientsBulkModal.emailMethod>1">
+            <span slot="label">{{ i18n "pages.client.last" }}</span>
+            <a-input-number v-model="clientsBulkModal.lastNum" :min="clientsBulkModal.firstNum"></a-input-number>
+        </a-form-item>
+        <a-form-item v-if="clientsBulkModal.emailMethod>0">
+            <span slot="label">{{ i18n "pages.client.prefix" }}</span>
+            <a-input v-model="clientsBulkModal.emailPrefix" style="width: 120px"></a-input>
+        </a-form-item>
+        <a-form-item v-if="clientsBulkModal.emailMethod>2">
+            <span slot="label" v-if="clientsBulkModal.emailMethod == 4">tg_uname</span>
+            <span slot="label" v-else>{{ i18n "pages.client.postfix" }}</span>
+            <a-input v-model="clientsBulkModal.emailPostfix" style="width: 120px"></a-input>
+        </a-form-item>
+
+        <a-form-item v-if="clientsBulkModal.emailMethod < 2">
+            <span slot="label">{{ i18n "pages.client.clientCount" }}</span>
+            <a-input-number v-model="clientsBulkModal.quantity" :min="1" :max="100"></a-input-number>
+        </a-form-item>
+        <a-form-item>
+            <span slot="label">
+                <span >{{ i18n "pages.inbounds.totalFlow" }}</span>(GB)
+                <a-tooltip>
+                    <template slot="title">
+                        0 <span>{{ i18n "pages.inbounds.meansNoLimit" }}</span>
+                    </template>
+                    <a-icon type="question-circle" theme="filled"></a-icon>
+                </a-tooltip>
+            </span>
+        <a-input-number v-model="clientsBulkModal.totalGB" :min="0"></a-input-number>
+        </a-form-item>
+        <a-form-item>
+            <span slot="label">
+                <span >{{ i18n "pages.inbounds.expireDate" }}</span>
+                <a-tooltip>
+                    <template slot="title">
+                        <span>{{ i18n "pages.inbounds.leaveBlankToNeverExpire" }}</span>
+                    </template>
+                    <a-icon type="question-circle" theme="filled"></a-icon>
+                </a-tooltip>
+            </span>
+            <a-date-picker :show-time="{ format: 'HH:mm' }" format="YYYY-MM-DD HH:mm"
+                           :dropdown-class-name="siderDrawer.isDarkTheme ? 'ant-card-dark' : ''"
+                           v-model="clientsBulkModal.expiryTime" style="width: 300px;"></a-date-picker>
+        </a-form-item>
+    </a-form>
+</a-modal>
+<script>
+
+    const clientsBulkModal = {
+        visible: false,
+        confirmLoading: false,
+        title: '',
+        okText: '',
+        confirm: null,
+        dbInbound: new DBInbound(),
+        inbound: new Inbound(),
+        clients: [],
+        quantity: 1,
+        totalGB: 0,
+        expiryTime: '',
+        emailMethod: 0,
+        firstNum: 1,
+        lastNum: 1,
+        emailPrefix: "",
+        emailPostfix: "",
+        ok() {
+            method=clientsBulkModal.emailMethod;
+            if(method>1){
+                start=clientsBulkModal.firstNum;
+                end=clientsBulkModal.lastNum + 1;
+            } else {
+                start=0;
+                end=clientsBulkModal.quantity;
+            }
+            prefix = (method>0 && clientsBulkModal.emailPrefix.length>0) ? "_" + clientsBulkModal.emailPrefix : "";
+            useNum=(method>1);
+            postfix = (method>2 && clientsBulkModal.emailPostfix.length>0) ? (method == 4 ? "@" : "") + clientsBulkModal.emailPostfix : "";
+            for (let i = start; i < end; i++) {
+                newClient = clientsBulkModal.newClient(clientsBulkModal.dbInbound.protocol);
+                newClient.email += useNum ? prefix + i.toString() + postfix : prefix + postfix;
+                newClient._totalGB = clientsBulkModal.totalGB;
+                newClient._expiryTime = clientsBulkModal.expiryTime;
+                clientsBulkModal.clients.push(newClient);
+            }
+            ObjectUtil.execute(clientsBulkModal.confirm, clientsBulkModal.inbound, clientsBulkModal.dbInbound);
+        },
+        show({ title='', okText='{{ i18n "sure" }}', dbInbound=null, confirm=(inbound, dbInbound)=>{} }) {
+            this.visible = true;
+            this.title = title;
+            this.okText = okText;
+            this.confirm = confirm;
+            this.quantity = 1;
+            this.totalGB = 0;
+            this.expiryTime = '';
+            this.emailMethod= 0;
+            this.firstNum= 1;
+            this.lastNum= 1;
+            this.emailPrefix= "";
+            this.emailPostfix= "";
+
+            this.dbInbound = new DBInbound(dbInbound);
+            this.inbound = dbInbound.toInbound();
+            this.clients = this.getClients(this.inbound.protocol, this.inbound.settings);
+        },
+        getClients(protocol, clientSettings) {
+            switch(protocol){
+                case Protocols.VMESS: return clientSettings.vmesses;
+                case Protocols.VLESS: return clientSettings.vlesses;
+                case Protocols.TROJAN: return clientSettings.trojans;
+                default: return null;
+            }
+        },
+        newClient(protocol) {
+            switch (protocol) {
+                case Protocols.VMESS: return new Inbound.VmessSettings.Vmess();
+                case Protocols.VLESS: return new Inbound.VLESSSettings.VLESS();
+                case Protocols.TROJAN: return new Inbound.TrojanSettings.Trojan();
+                default: return null;
+            }
+        },
+        close() {
+            clientsBulkModal.visible = false;
+            clientsBulkModal.loading(false);
+        },
+        loading(loading) {
+            clientsBulkModal.confirmLoading = loading;
+        },
+    };
+
+    const clientsBulkModalApp = new Vue({
+        delimiters: ['[[', ']]'],
+        el: '#client-bulk-modal',
+        data: {
+            clientsBulkModal,
+            get inbound() {
+                return this.clientsBulkModal.inbound;
+            },
+        },
+    });
+</script>
+{{end}}

+ 133 - 0
web/html/xui/client_modal.html

@@ -0,0 +1,133 @@
+{{define "clientsModal"}}
+<a-modal id="client-modal" v-model="clientModal.visible" :title="clientModal.title" @ok="clientModal.ok"
+         :confirm-loading="clientModal.confirmLoading" :closable="true" :mask-closable="false"
+		 :class="siderDrawer.isDarkTheme ? darkClass : ''"
+         :ok-text="clientModal.okText" cancel-text='{{ i18n "close" }}'>
+    {{template "form/client"}}
+</a-modal>
+<script>
+
+    const clientModal = {
+        visible: false,
+        confirmLoading: false,
+        title: '',
+        okText: '',
+        dbInbound: new DBInbound(),
+        inbound: new Inbound(),
+        clients: [],
+        clientStats: [],
+        index: null,
+        clientIps: null,
+        isExpired: false,
+        ok() {
+            ObjectUtil.execute(clientModal.confirm, clientModal.inbound, clientModal.dbInbound, clientModal.index);
+        },
+        show({ title='', okText='{{ i18n "sure" }}', index=null, dbInbound=null, confirm=(index, dbInbound)=>{}, isEdit=false  }) {
+            this.visible = true;
+            this.title = title;
+            this.okText = okText;
+            this.isEdit = isEdit;
+            this.dbInbound = new DBInbound(dbInbound);
+            this.inbound = dbInbound.toInbound();
+            this.clients = this.getClients(this.inbound.protocol, this.inbound.settings);
+            this.index = index === null ? this.clients.length : index;
+            this.isExpired = isEdit ? this.inbound.isExpiry(this.index) : false;
+            if (!isEdit){
+                this.addClient(this.inbound.protocol, this.clients);
+            }
+            this.clientStats = this.dbInbound.clientStats.find(row => row.email === this.clients[this.index].email);
+            this.confirm = confirm;
+        },
+        getClients(protocol, clientSettings) {
+            switch(protocol){
+                case Protocols.VMESS: return clientSettings.vmesses;
+                case Protocols.VLESS: return clientSettings.vlesses;
+                case Protocols.TROJAN: return clientSettings.trojans;
+                default: return null;
+            }
+        },
+        addClient(protocol, clients) {
+            switch (protocol) {
+                case Protocols.VMESS: return clients.push(new Inbound.VmessSettings.Vmess());
+                case Protocols.VLESS: return clients.push(new Inbound.VLESSSettings.VLESS());
+                case Protocols.TROJAN: return clients.push(new Inbound.TrojanSettings.Trojan());
+                default: return null;
+            }
+        },
+        close() {
+            clientModal.visible = false;
+            clientModal.loading(false);
+        },
+        loading(loading) {
+            clientModal.confirmLoading = loading;
+        },
+    };
+
+    const clientModalApp = new Vue({
+        delimiters: ['[[', ']]'],
+        el: '#client-modal',
+        data: {
+            clientModal,
+            get inbound() {
+                return this.clientModal.inbound;
+            },
+            get client() {
+                return this.clientModal.clients[this.clientModal.index];
+            },
+            get clientStats() {
+                return this.clientModal.clientStats;
+            },
+            get isEdit() {
+                return this.clientModal.isEdit;
+            },
+            get isTrafficExhausted() {
+                if(!clientStats) return false
+                if(clientStats.total == 0) return false
+                if(clientStats.up + clientStats.down < clientStats.total) return false
+                return true
+            },
+            get isExpiry() {
+                return this.clientModal.isExpired
+            },
+            get statsColor() {
+                if(!clientStats) return 'blue'
+                if(clientStats.total === 0) return 'blue'
+                else if(clientStats.total > 0 && (clientStats.down+clientStats.up) < clientStats.total) return 'cyan'
+                else return 'red'
+            }
+        },
+        methods: {
+            getNewEmail(client) {
+                var chars = 'abcdefghijklmnopqrstuvwxyz1234567890';
+                var string = '';
+                var len = 6 + Math.floor(Math.random() * 5);
+                for(var ii=0; ii<len; ii++){
+                    string += chars[Math.floor(Math.random() * chars.length)];
+                }
+                client.email = string;
+            },
+            async getDBClientIps(email,event) {
+                const msg = await HttpUtil.post('/xui/inbound/clientIps/'+ email);
+                if (!msg.success) {
+                    return;
+                }
+                try {
+                    ips = JSON.parse(msg.obj)
+                    ips = ips.join(",")
+                    event.target.value = ips
+                } catch (error) {
+                    // text
+                    event.target.value = msg.obj
+                }
+            },
+            async clearDBClientIps(email,event) {
+                const msg = await HttpUtil.post('/xui/inbound/clearClientIps/'+ email);
+                if (!msg.success) {
+                    return;
+                }
+                event.target.value = ""
+            },
+        },
+    });
+</script>
+{{end}}

+ 7 - 8
web/html/xui/common_sider.html

@@ -13,14 +13,14 @@
 </a-menu-item>
 <!--<a-menu-item key="{{ .base_path }}xui/clients">-->
 <!--    <a-icon type="laptop"></a-icon>-->
-<!--    <span>client</span>-->
+<!--    <span>Client</span>-->
 <!--</a-menu-item>-->
 <a-sub-menu>
     <template slot="title">
         <a-icon type="link"></a-icon>
         <span>{{ i18n "menu.link"}}</span>
     </template>
-    <a-menu-item key="https://github.com/mhsanaei/3x-ui/">
+     <a-menu-item key="https://github.com/mhsanaei/3x-ui/">
         <a-icon type="github"></a-icon>
         <span>Github</span>
     </a-menu-item>
@@ -55,11 +55,12 @@
 <a-drawer id="sider-drawer" placement="left" :closable="false"
           @close="siderDrawer.close()"
           :visible="siderDrawer.visible"
+          :wrap-class-name="siderDrawer.isDarkTheme ? 'ant-drawer-dark' : ''"
           :wrap-style="{ padding: 0 }">
     <div class="drawer-handle" @click="siderDrawer.change()" slot="handle">
         <a-icon :type="siderDrawer.visible ? 'close' : 'menu-fold'"></a-icon>
     </div>
-    <a-menu mode="inline" selected-keys="">
+    <a-menu :theme="siderDrawer.theme" mode="inline" selected-keys="">
         <a-menu-item mode="inline">
             <a-icon type="bg-colors"></a-icon>
             <a-switch :default-checked="siderDrawer.isDarkTheme"
@@ -68,19 +69,17 @@
             @change="siderDrawer.changeTheme()"></a-switch>
         </a-menu-item>
     </a-menu>
-    <a-menu mode="inline" :selected-keys="['{{ .request_uri }}']"
+    <a-menu :theme="siderDrawer.theme" mode="inline" :selected-keys="['{{ .request_uri }}']"
         @click="({key}) => key.startsWith('http') ? window.open(key) : location.href = key">
         {{template "menuItems" .}}
     </a-menu>
 </a-drawer>
 <script>
-
-
     const darkClass = "ant-card-dark";
     const bgDarkStyle = "background-color: #242c3a";
     const siderDrawer = {
         visible: false,
-		collapsed: false,
+        collapsed: false,
         isDarkTheme: localStorage.getItem("dark-mode") === 'true' ? true : false,
         show() {
             this.visible = true;
@@ -90,7 +89,7 @@
         },
         change() {
             this.visible = !this.visible;
-		 },
+        },
         toggleCollapsed() {
             this.collapsed = !this.collapsed;
         },

+ 110 - 0
web/html/xui/form/client.html

@@ -0,0 +1,110 @@
+{{define "form/client"}}
+<a-form layout="inline" v-if="client">
+    <template v-if="isEdit">
+    <a-tag v-if="isExpiry || isTrafficExhausted" color="red" style="margin-bottom: 10px;display: block;text-align: center;">Account is (Expired|Traffic Ended) And Disabled</a-tag>
+    </template>
+    <a-form-item>
+        <span slot="label">
+            Email
+            <a-tooltip>
+                <template slot="title">
+                    The Email Must Be Completely Unique
+                </template>
+                <a-icon type="sync" @click="getNewEmail(client)"></a-icon>
+            </a-tooltip>
+        </span>
+        <a-input v-model.trim="client.email" style="width: 150px;" ></a-input>
+    </a-form-item>
+    <a-form-item label="Password" v-if="inbound.protocol === Protocols.TROJAN">
+        <a-input v-model.trim="client.password" style="width: 150px;" ></a-input>
+    </a-form-item>
+    <a-form-item label="ID" v-if="inbound.protocol === Protocols.VMESS || inbound.protocol === Protocols.VLESS">
+        <a-input v-model.trim="client.id" style="width: 300px;"></a-input>
+    </a-form-item>
+    <a-form-item label='{{ i18n "additional" }} ID' v-if="inbound.protocol === Protocols.VMESS">
+        <a-input type="number" v-model.number="client.alterId" style="width: 70px;"></a-input>
+    </a-form-item>
+	<a-form-item>
+		<span slot="label">
+			IP Count Limit
+			<a-tooltip>
+				<template slot="title">
+				Disable inbound if more than entered count (0 for disable limit ip)
+				</template>
+				<a-icon type="question-circle" theme="filled"></a-icon>
+			</a-tooltip>
+		</span>
+		<a-input type="number" v-model.number="client.limitIp" min="0" style="width: 70px;" ></a-input>
+	</a-form-item>
+	<a-form-item v-if="client.email && client.limitIp > 0 && isEdit">
+		<span slot="label">
+			IP Log
+			<a-tooltip>
+				<template slot="title">
+				IPs history Log (before enabling inbound after it has been disabled by IP limit, you should clear the log)
+				</template>
+				<a-icon type="question-circle" theme="filled"></a-icon>
+			</a-tooltip>
+			<a-tooltip>
+				<template slot="title">
+				Clear The Log
+				</template>
+				<span style="color: #FF4D4F">
+				<a-icon type="delete" @click="clearDBClientIps(client.email,$event)"></a-icon>
+				</span>
+			</a-tooltip>
+		</span>
+		<a-form layout="block">
+			<a-textarea readonly @click="getDBClientIps(client.email,$event)" placeholder="Click To Get IPs"  :auto-size="{ minRows: 2, maxRows: 10 }">
+			</a-textarea>
+		</a-form>
+	</a-form-item>
+    <a-form-item v-if="inbound.XTLS" label="Flow">
+        <a-select v-model="client.flow" style="width: 150px">
+            <a-select-option value="">{{ i18n "none" }}</a-select-option>
+            <a-select-option v-for="key in XTLS_FLOW_CONTROL" :value="key">[[ key ]]</a-select-option>
+        </a-select>
+    </a-form-item>
+    <a-form-item v-else-if="inbound.canEnableTlsFlow()" label="Flow" layout="inline">
+        <a-select v-model="client.flow" style="width: 150px">
+            <a-select-option value="" selected>{{ i18n "none" }}</a-select-option>
+            <a-select-option v-for="key in TLS_FLOW_CONTROL" :value="key">[[ key ]]</a-select-option>
+        </a-select>
+    </a-form-item>
+    <a-form-item>
+        <span slot="label">
+            <span >{{ i18n "pages.inbounds.totalFlow" }}</span>(GB)
+            <a-tooltip>
+                <template slot="title">
+                    0 <span>{{ i18n "pages.inbounds.meansNoLimit" }}</span>
+                </template>
+                <a-icon type="question-circle" theme="filled"></a-icon>
+            </a-tooltip>
+        </span>
+        <a-input-number v-model="client._totalGB":min="0" style="width: 70px;"></a-input-number>
+        <template v-if="isEdit && clientStats">
+            	{{ i18n "usage" }}: 
+            <a-tag :color="statsColor">
+                [[ sizeFormat(clientStats.up) ]] / 
+                [[ sizeFormat(clientStats.down) ]]
+                ([[ sizeFormat(clientStats.up + clientStats.down) ]])
+            </a-tag>
+        </template>
+    </a-form-item>
+    <a-form-item>
+        <span slot="label">
+            <span >{{ i18n "pages.inbounds.expireDate" }}</span>
+            <a-tooltip>
+                <template slot="title">
+                    <span>{{ i18n "pages.inbounds.leaveBlankToNeverExpire" }}</span>
+                </template>
+                <a-icon type="question-circle" theme="filled"></a-icon>
+            </a-tooltip>
+        </span>
+        <a-date-picker :show-time="{ format: 'HH:mm' }" format="YYYY-MM-DD HH:mm"
+						:dropdown-class-name="siderDrawer.isDarkTheme ? 'ant-card-dark' : ''"	
+                        v-model="client._expiryTime" style="width: 170px;"></a-date-picker>
+        <a-tag color="red" v-if="isExpiry">Expired</a-tag>
+    </a-form-item>
+</a-form>
+{{end}}

+ 2 - 1
web/html/xui/form/inbound.html

@@ -8,7 +8,7 @@
         <a-switch v-model="dbInbound.enable"></a-switch>
     </a-form-item>
     <a-form-item label='{{ i18n "protocol" }}'>
-        <a-select v-model="inbound.protocol" style="width: 160px;">
+        <a-select v-model="inbound.protocol" style="width: 160px;" :disabled="isEdit" :dropdown-class-name="siderDrawer.isDarkTheme ? 'ant-card-dark' : ''">
             <a-select-option v-for="p in Protocols" :key="p" :value="p">[[ p ]]</a-select-option>
         </a-select>
     </a-form-item>
@@ -50,6 +50,7 @@
             </a-tooltip>
         </span>
         <a-date-picker :show-time="{ format: 'HH:mm' }" format="YYYY-MM-DD HH:mm"
+                       :dropdown-class-name="siderDrawer.isDarkTheme ? 'ant-card-dark' : ''"
                        v-model="dbInbound._expiryTime" style="width: 300px;"></a-date-picker>
     </a-form-item>
 </a-form>

+ 4 - 1
web/html/xui/form/protocol/dokodemo.html

@@ -7,11 +7,14 @@
         <a-input type="number" v-model.number="inbound.settings.port"></a-input>
     </a-form-item>
     <a-form-item label='{{ i18n "pages.inbounds.network"}}'>
-        <a-select v-model="inbound.settings.network" style="width: 100px;">
+        <a-select v-model="inbound.settings.network" style="width: 100px;" :dropdown-class-name="siderDrawer.isDarkTheme ? 'ant-card-dark' : ''">
             <a-select-option value="tcp,udp">tcp+udp</a-select-option>
             <a-select-option value="tcp">tcp</a-select-option>
             <a-select-option value="udp">udp</a-select-option>
         </a-select>
     </a-form-item>
+    <a-form-item label="FollowRedirect">
+        <a-switch v-model="inbound.settings.followRedirect"></a-switch>
+    </a-form-item>
 </a-form>
 {{end}}

+ 2 - 2
web/html/xui/form/protocol/shadowsocks.html

@@ -1,7 +1,7 @@
 {{define "form/shadowsocks"}}
 <a-form layout="inline">
     <a-form-item label='{{ i18n "encryption" }}'>
-        <a-select v-model="inbound.settings.method" style="width: 165px;">
+        <a-select v-model="inbound.settings.method" style="width: 165px;" :dropdown-class-name="siderDrawer.isDarkTheme ? 'ant-card-dark' : ''">
             <a-select-option v-for="method in SSMethods" :value="method">[[ method ]]</a-select-option>
         </a-select>
     </a-form-item>
@@ -9,7 +9,7 @@
         <a-input v-model.trim="inbound.settings.password"></a-input>
     </a-form-item>
     <a-form-item label='{{ i18n "pages.inbounds.network" }}'>
-        <a-select v-model="inbound.settings.network" style="width: 100px;">
+        <a-select v-model="inbound.settings.network" style="width: 100px;" :dropdown-class-name="siderDrawer.isDarkTheme ? 'ant-card-dark' : ''">
             <a-select-option value="tcp,udp">tcp+udp</a-select-option>
             <a-select-option value="tcp">tcp</a-select-option>
             <a-select-option value="udp">udp</a-select-option>

+ 1 - 1
web/html/xui/form/protocol/socks.html

@@ -1,6 +1,6 @@
 {{define "form/socks"}}
 <a-form layout="inline">
-<!--    <a-form-item label="密码认证">-->
+<!--    <a-form-item label="Password authentication">-->
     <a-form-item label='{{ i18n "password" }}'>
         <a-switch :checked="inbound.settings.auth === 'password'"
                   @change="checked => inbound.settings.auth = checked ? 'password' : 'noauth'"></a-switch>

+ 42 - 61
web/html/xui/form/protocol/trojan.html

@@ -1,11 +1,7 @@
 {{define "form/trojan"}}
 <a-form layout="inline">
-<label style="color: green;">{{ i18n "clients"}}</label>
-<a-collapse activeKey="0"  v-for="(trojan, index) in inbound.settings.trojans"
-:key="`trojan-${index}`">
-
-    <a-collapse-panel :class="getHeaderStyle(trojan.email)" :header="getHeaderText(trojan.email)">
-        <a-tag v-if="isExpiry(index) || ((getUpStats(trojan.email) + getDownStats(trojan.email)) > trojan.totalGB && trojan.totalGB != 0)" color="red" style="margin-bottom: 10px;display: block;text-align: center;">Account is (Expired|Traffic Ended) And Disabled</a-tag>
+<a-collapse activeKey="0" v-for="(client, index) in inbound.settings.trojans.slice(0,1)" v-if="!isEdit">  
+    <a-collapse-panel header="{{ i18n "pages.inbounds.client" }}">
         <a-form layout="inline">
             <a-form-item>
                 <span slot="label">
@@ -14,18 +10,16 @@
                         <template slot="title">
                             The Email Must Be Completely Unique
                         </template>
-                        <!--Renew Svg Icon-->
-                        <svg 
-                            @click="getNewEmail(trojan)"
-                            xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="anticon anticon-question-circle" viewBox="0 0 16 16"> <path d="M11.534 7h3.932a.25.25 0 0 1 .192.41l-1.966 2.36a.25.25 0 0 1-.384 0l-1.966-2.36a.25.25 0 0 1 .192-.41zm-11 2h3.932a.25.25 0 0 0 .192-.41L2.692 6.23a.25.25 0 0 0-.384 0L.342 8.59A.25.25 0 0 0 .534 9z"/> <path fill-rule="evenodd" d="M8 3c-1.552 0-2.94.707-3.857 1.818a.5.5 0 1 1-.771-.636A6.002 6.002 0 0 1 13.917 7H12.9A5.002 5.002 0 0 0 8 3zM3.1 9a5.002 5.002 0 0 0 8.757 2.182.5.5 0 1 1 .771.636A6.002 6.002 0 0 1 2.083 9H3.1z"/> </svg>
+                        <a-icon @click="getNewEmail(client)" type="sync"> </a-icon>
                     </a-tooltip>
                 </span>
-                <a-input v-model.trim="trojan.email" style="width: 150px;"></a-input>
+                <a-input v-model.trim="client.email" style="width: 150px;" ></a-input>
             </a-form-item>
-			<a-form-item label="Password" >
-				<a-input v-model.trim="trojan.password" style="width: 150px;"></a-input>
-			</a-form-item>
-			<a-form-item>
+        </a-form>
+        <a-form-item label="Password">
+            <a-input v-model.trim="client.password" style="width: 150px;"></a-input>
+        </a-form-item>
+		<a-form-item>
                 <span slot="label">
                     IP Count Limit
                     <a-tooltip>
@@ -35,9 +29,9 @@
                         <a-icon type="question-circle" theme="filled"></a-icon>
                     </a-tooltip>
                 </span>
-                <a-input type="number" v-model.number="trojan.limitIp" min="0" style="width: 70px;"></a-input>
-            </a-form-item>
-            <a-form-item v-if="trojan.email && trojan.limitIp > 0 && isEdit">
+                <a-input type="number" v-model.number="client.limitIp" min="0"  style="width: 70px;" ></a-input>
+		</a-form-item>
+		<a-form-item v-if="client.email && client.limitIp > 0 && isEdit">
                 <span slot="label">
                     IP log
                     <a-tooltip>
@@ -51,21 +45,25 @@
                             clear the log
                         </template>
                         <span style="color: #FF4D4F">
-                            <a-icon type="delete" @click="clearDBClientIps(trojan.email,$event)"></a-icon>
+                            <a-icon type="delete" @click="clearDBClientIps(client.email,$event)"></a-icon>
                         </span>
                     </a-tooltip>
                 </span>
                 <a-form layout="block">
-                    <a-textarea readonly @click="getDBClientIps(trojan.email,$event)" placeholder="Click To Get IPs"  :auto-size="{ minRows: 2, maxRows: 10 }">
+                    <a-textarea readonly @click="getDBClientIps(client.email,$event)" placeholder="Click To Get IPs"  :auto-size="{ minRows: 2, maxRows: 10 }">
                     </a-textarea>
                 </a-form>
-            </a-form-item>
-        </a-form>
-        <a-form-item v-if="inbound.XTLS" label="Flow">
-            <a-select v-model="trojan.flow" style="width: 150px">
+		</a-form-item>
+        <a-form-item v-if="inbound.xtls" label="Flow">
+            <a-select v-model="client.flow" style="width: 150px">
                 <a-select-option value="">{{ i18n "none" }}</a-select-option>
                 <a-select-option v-for="key in XTLS_FLOW_CONTROL" :value="key">[[ key ]]</a-select-option>
             </a-select>
+        </a-form-item>
+		<a-form-item v-if="inbound.tls" label="uTLS" layout="inline">
+			<a-select v-model="inbound.settings.trojans[index].fingerprint" label="uTLS" style="width: 150px">
+				<a-select-option v-for="key in UTLS_FINGERPRINT" :value="key">[[ key ]]</a-select-option>
+			</a-select>
         </a-form-item>
         <a-form-item>
             <span slot="label">
@@ -77,7 +75,7 @@
                     <a-icon type="question-circle" theme="filled"></a-icon>
                 </a-tooltip>
             </span>
-            <a-input-number v-model="trojan._totalGB" :min="0"></a-input-number>
+            <a-input-number v-model="client._totalGB" :min="0"></a-input-number>
         </a-form-item>
         <a-form-item>
             <span slot="label">
@@ -90,39 +88,22 @@
                 </a-tooltip>
             </span>
             <a-date-picker :show-time="{ format: 'HH:mm' }" format="YYYY-MM-DD HH:mm"
-                           v-model="trojan._expiryTime" style="width: 170px;"></a-date-picker>
+                            v-model="client._expiryTime" style="width: 170px;"></a-date-picker>
         </a-form-item>
-        <a-form layout="inline">
-            <a-tooltip v-if="trojan._totalGB > 0">
-                <template slot="title">
-                    {{ i18n "pages.inbounds.resetTraffic" }}
-                </template>
-                <span style="color: #FF4D4F">
-                    <a-icon type="delete" @click="resetClientTraffic(trojan,$event)"></a-icon>
-                </span>
-            </a-tooltip>
-            <a-tag color="blue">[[ sizeFormat(getUpStats(trojan.email)) ]] / [[ sizeFormat(getDownStats(trojan.email)) ]]</a-tag>
-            <a-tag v-if="trojan._totalGB > 0" color="red">used : [[ sizeFormat(getUpStats(trojan.email) + getDownStats(trojan.email)) ]]</a-tag>
-            <a-tag v-show="inbound.settings.trojans.length > 1" @click="removeClient(index, inbound.settings.trojans)">
-                <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22" width="22" height="22" class="mt-2 cursor-pointer">
-                    <path fill="none" d="M0 0h24v24H0z" />
-                    <path fill="#EC4899"
-                    d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-2a8 8 0 1 0 0-16 8 8 0 0 0 0 16zm0-9.414l2.828-2.829 1.415 1.415L13.414 12l2.829 2.828-1.415 1.415L12 13.414l-2.828 2.829-1.415-1.415L10.586 12 7.757 9.172l1.415-1.415L12 10.586z"
-                    />
-                </svg>
-            </a-tag>
-        </a-form>
     </a-collapse-panel>
 </a-collapse>
-<a-tag @click="addClient(inbound.protocol, inbound.settings.trojans)">
-    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" class="ml-2 cursor-pointer">
-        <path fill="none" d="M0 0h24v24H0z" />
-        <path fill="green"
-        d="M11 11V7h2v4h4v2h-4v4h-2v-4H7v-2h4zm1 11C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-2a8 8 0 1 0 0-16 8 8 0 0 0 0 16z"
-        />
-    </svg>
-</a-tag>
-
+<a-collapse v-else>
+    <a-collapse-panel :header="'{{ i18n "pages.client.clientCount"}} : ' + inbound.settings.trojans.length">
+        <table width="100%">
+            <tr class="client-table-header">
+                <th v-for="col in Object.keys(inbound.settings.trojans[0]).slice(0, 3)">[[ col ]]</th>
+            </tr>
+            <tr v-for="(client, index) in inbound.settings.trojans" :class="index % 2 == 1 ? 'client-table-odd-row' : ''">
+                <td v-for="col in Object.values(client).slice(0, 3)">[[ col ]]</td>
+            </tr>
+        </table>
+    </a-collapse-panel>
+</a-collapse>
 <template v-if="inbound.isTcp && inbound.tls">
     <a-form layout="inline">
         <a-form-item label="Fallbacks">
@@ -135,26 +116,26 @@
         </a-form-item>
     </a-form>
 
- <!-- trojan fallbacks -->
+    <!-- trojan fallbacks -->
     <a-form v-for="(fallback, index) in inbound.settings.fallbacks" layout="inline">
         <a-divider>
             fallback[[ index + 1 ]]
             <a-icon type="delete" @click="() => inbound.settings.delTrojanFallback(index)"
                     style="color: rgb(255, 77, 79);cursor: pointer;"/>
         </a-divider>
-        <a-form-item label="name">
+        <a-form-item label="Name">
             <a-input v-model="fallback.name"></a-input>
         </a-form-item>
-        <a-form-item label="alpn">
+        <a-form-item label="Alpn">
             <a-input v-model="fallback.alpn"></a-input>
         </a-form-item>
-        <a-form-item label="path">
+        <a-form-item label="Path">
             <a-input v-model="fallback.path"></a-input>
         </a-form-item>
-        <a-form-item label="dest">
+        <a-form-item label="Dest">
             <a-input v-model="fallback.dest"></a-input>
         </a-form-item>
-        <a-form-item label="xver">
+        <a-form-item label="xVer">
             <a-input type="number" v-model.number="fallback.xver"></a-input>
         </a-form-item>
         <a-divider v-if="inbound.settings.fallbacks.length - 1 === index"/>

+ 36 - 63
web/html/xui/form/protocol/vless.html

@@ -1,12 +1,7 @@
 {{define "form/vless"}}
 <a-form layout="inline">
-<label style="color: green;">{{ i18n "clients"}}</label>
-<a-collapse activeKey="0"  v-for="(vless, index) in inbound.settings.vlesses"
-:key="`vless-${index}`">
-
-    <a-collapse-panel :class="getHeaderStyle(vless.email)" :header="getHeaderText(vless.email)">
-        <a-tag v-if="isExpiry(index) || ((getUpStats(vless.email) + getDownStats(vless.email)) > vless.totalGB && vless.totalGB != 0)" color="red" style="margin-bottom: 10px;display: block;text-align: center;">Account is (Expired|Traffic Ended) And Disabled</a-tag>
-
+<a-collapse activeKey="0" v-for="(client, index) in inbound.settings.vlesses.slice(0,1)" v-if="!isEdit">    
+    <a-collapse-panel header="{{ i18n "pages.inbounds.client" }}">
         <a-form layout="inline">
             <a-form-item>
                 <span slot="label">
@@ -15,18 +10,16 @@
                         <template slot="title">
                             The Email Must Be Completely Unique
                         </template>
-                        <!--Renew Svg Icon-->
-                        <svg 
-                            @click="getNewEmail(vless)"
-                            xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="anticon anticon-question-circle" viewBox="0 0 16 16"> <path d="M11.534 7h3.932a.25.25 0 0 1 .192.41l-1.966 2.36a.25.25 0 0 1-.384 0l-1.966-2.36a.25.25 0 0 1 .192-.41zm-11 2h3.932a.25.25 0 0 0 .192-.41L2.692 6.23a.25.25 0 0 0-.384 0L.342 8.59A.25.25 0 0 0 .534 9z"/> <path fill-rule="evenodd" d="M8 3c-1.552 0-2.94.707-3.857 1.818a.5.5 0 1 1-.771-.636A6.002 6.002 0 0 1 13.917 7H12.9A5.002 5.002 0 0 0 8 3zM3.1 9a5.002 5.002 0 0 0 8.757 2.182.5.5 0 1 1 .771.636A6.002 6.002 0 0 1 2.083 9H3.1z"/> </svg>
+                        <a-icon type="sync" @click="getNewEmail(client)"></a-icon>
                     </a-tooltip>
                 </span>
-                <a-input v-model.trim="vless.email" style="width: 150px;"></a-input>
+                <a-input v-model.trim="client.email" style="width: 150px;" ></a-input>
             </a-form-item>
-			<a-form-item label="ID">
-				<a-input v-model.trim="vless.id" style="width: 300px;" ></a-input>
-			</a-form-item>
-			<a-form-item>
+        </a-form>
+        <a-form-item label="ID">
+            <a-input v-model.trim="client.id"  style="width: 300px;" ></a-input>
+        </a-form-item>
+		<a-form-item>
                 <span slot="label">
                     IP Count Limit
                     <a-tooltip>
@@ -36,9 +29,9 @@
                         <a-icon type="question-circle" theme="filled"></a-icon>
                     </a-tooltip>
                 </span>
-                <a-input type="number" v-model.number="vless.limitIp" min="0" style="width: 70px;"></a-input>
-            </a-form-item>
-            <a-form-item v-if="vless.email && vless.limitIp > 0 && isEdit">
+                <a-input type="number" v-model.number="client.limitIp" min="0"  style="width: 70px;" ></a-input>
+		</a-form-item>
+		<a-form-item v-if="client.email && client.limitIp > 0 && isEdit">
                 <span slot="label">
                     IP log
                     <a-tooltip>
@@ -52,17 +45,15 @@
                             clear the log
                         </template>
                         <span style="color: #FF4D4F">
-                            <a-icon type="delete" @click="clearDBClientIps(vless.email,$event)"></a-icon>
+                            <a-icon type="delete" @click="clearDBClientIps(client.email,$event)"></a-icon>
                         </span>
                     </a-tooltip>
                 </span>
                 <a-form layout="block">
-
-                    <a-textarea readonly @click="getDBClientIps(vless.email,$event)" placeholder="Click To Get IPs"  :auto-size="{ minRows: 2, maxRows: 10 }">
+                    <a-textarea readonly @click="getDBClientIps(client.email,$event)" placeholder="Click To Get IPs"  :auto-size="{ minRows: 2, maxRows: 10 }">
                     </a-textarea>
                 </a-form>
-            </a-form-item>
-        </a-form>
+		</a-form-item>
 		<a-form-item v-if="inbound.XTLS" label="Flow">
             <a-select v-model="inbound.settings.vlesses[index].flow" style="width: 150px">
                 <a-select-option value="" selected>{{ i18n "none" }}</a-select-option>
@@ -85,7 +76,7 @@
                     <a-icon type="question-circle" theme="filled"></a-icon>
                 </a-tooltip>
             </span>
-            <a-input-number v-model="vless._totalGB" :min="0"></a-input-number>
+            <a-input-number v-model="client._totalGB" :min="0"></a-input-number>
         </a-form-item>
         <a-form-item>
             <span slot="label">
@@ -98,40 +89,22 @@
                 </a-tooltip>
             </span>
             <a-date-picker :show-time="{ format: 'HH:mm' }" format="YYYY-MM-DD HH:mm"
-                           v-model="vless._expiryTime" style="width: 300px;"></a-date-picker>
+                           v-model="client._expiryTime" style="width: 170px;"></a-date-picker>
         </a-form-item>
-        <a-form layout="inline">
-            <a-tooltip v-if="vless._totalGB > 0">
-                <template slot="title">
-                    {{ i18n "pages.inbounds.resetTraffic" }}
-                </template>
-                <span style="color: #FF4D4F">
-                    <a-icon type="delete" @click="resetClientTraffic(vless,$event)"></a-icon>
-                </span>
-            </a-tooltip>
-            <a-tag color="blue">[[ sizeFormat(getUpStats(vless.email)) ]] / [[ sizeFormat(getDownStats(vless.email)) ]]</a-tag>
-            <a-tag v-if="vless._totalGB > 0" color="red">used : [[ sizeFormat(getUpStats(vless.email) + getDownStats(vless.email)) ]]</a-tag>
-
-            <a-tag v-show="inbound.settings.vlesses.length > 1" @click="removeClient(index, inbound.settings.vlesses)">
-                <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22" width="22" height="22" class="mt-2 cursor-pointer">
-                    <path fill="none" d="M0 0h24v24H0z" />
-                    <path fill="#EC4899"
-                    d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-2a8 8 0 1 0 0-16 8 8 0 0 0 0 16zm0-9.414l2.828-2.829 1.415 1.415L13.414 12l2.829 2.828-1.415 1.415L12 13.414l-2.828 2.829-1.415-1.415L10.586 12 7.757 9.172l1.415-1.415L12 10.586z"
-                    />
-                </svg>
-            </a-tag>
-        </a-form>
     </a-collapse-panel>
 </a-collapse>
-<a-tag @click="addClient(inbound.protocol, inbound.settings.vlesses)">
-    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" class="ml-2 cursor-pointer">
-        <path fill="none" d="M0 0h24v24H0z" />
-        <path fill="green"
-        d="M11 11V7h2v4h4v2h-4v4h-2v-4H7v-2h4zm1 11C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-2a8 8 0 1 0 0-16 8 8 0 0 0 0 16z"
-        />
-    </svg>
-</a-tag>
-
+<a-collapse v-else>
+    <a-collapse-panel :header="'{{ i18n "pages.client.clientCount"}} : ' + inbound.settings.vlesses.length">
+        <table width="100%">
+            <tr class="client-table-header">
+                <th v-for="col in Object.keys(inbound.settings.vlesses[0]).slice(0, 3)">[[ col ]]</th>
+            </tr>
+            <tr v-for="(client, index) in inbound.settings.vlesses" :class="index % 2 == 1 ? 'client-table-odd-row' : ''">
+                <td v-for="col in Object.values(client).slice(0, 3)">[[ col ]]</td>
+            </tr>
+        </table>
+    </a-collapse-panel>
+</a-collapse>
 <template v-if="inbound.isTcp && inbound.tls">
     <a-form layout="inline">
         <a-form-item label="Fallbacks">
@@ -144,29 +117,29 @@
         </a-form-item>
     </a-form>
 
-<!-- vless fallbacks -->
+    <!-- vless fallbacks -->
     <a-form v-for="(fallback, index) in inbound.settings.fallbacks" layout="inline">
         <a-divider>
             fallback[[ index + 1 ]]
             <a-icon type="delete" @click="() => inbound.settings.delFallback(index)"
                     style="color: rgb(255, 77, 79);cursor: pointer;"/>
         </a-divider>
-        <a-form-item label="name">
+        <a-form-item label="Name">
             <a-input v-model="fallback.name"></a-input>
         </a-form-item>
-        <a-form-item label="alpn">
+        <a-form-item label="Alpn">
             <a-input v-model="fallback.alpn"></a-input>
         </a-form-item>
-        <a-form-item label="path">
+        <a-form-item label="Path">
             <a-input v-model="fallback.path"></a-input>
         </a-form-item>
-        <a-form-item label="dest">
+        <a-form-item label="Dest">
             <a-input v-model="fallback.dest"></a-input>
         </a-form-item>
-        <a-form-item label="xver">
+        <a-form-item label="xVer">
             <a-input type="number" v-model.number="fallback.xver"></a-input>
         </a-form-item>
         <a-divider v-if="inbound.settings.fallbacks.length - 1 === index"/>
     </a-form>
 </template>
-{{end}}
+{{end}}

+ 39 - 65
web/html/xui/form/protocol/vmess.html

@@ -1,11 +1,7 @@
 {{define "form/vmess"}}
 <a-form layout="inline">
-<label style="color: green;">{{ i18n "clients"}}</label>
-<a-collapse activeKey="0"  v-for="(vmess, index) in inbound.settings.vmesses"
-:key="`vmess-${index}`">
-    <a-collapse-panel :class="getHeaderStyle(vmess.email)" :header="getHeaderText(vmess.email)">
-        <a-tag v-if="isExpiry(index) || ((getUpStats(vmess.email) + getDownStats(vmess.email)) > vmess.totalGB && vmess.totalGB != 0)" color="red" style="margin-bottom: 10px;display: block;text-align: center;">Account is (Expired|Traffic Ended) And Disabled</a-tag>
-
+<a-collapse activeKey="0" v-for="(client, index) in inbound.settings.vmesses.slice(0,1)" v-if="!isEdit">    
+    <a-collapse-panel header="{{ i18n "pages.inbounds.client" }}">
         <a-form layout="inline">
             <a-form-item>
                 <span slot="label">
@@ -14,35 +10,33 @@
                         <template slot="title">
                             The Email Must Be Completely Unique
                         </template>
-                        <!--Renew Svg Icon-->
-                        <svg 
-                            @click="getNewEmail(vmess)"
-                            xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="anticon anticon-question-circle" viewBox="0 0 16 16"> <path d="M11.534 7h3.932a.25.25 0 0 1 .192.41l-1.966 2.36a.25.25 0 0 1-.384 0l-1.966-2.36a.25.25 0 0 1 .192-.41zm-11 2h3.932a.25.25 0 0 0 .192-.41L2.692 6.23a.25.25 0 0 0-.384 0L.342 8.59A.25.25 0 0 0 .534 9z"/> <path fill-rule="evenodd" d="M8 3c-1.552 0-2.94.707-3.857 1.818a.5.5 0 1 1-.771-.636A6.002 6.002 0 0 1 13.917 7H12.9A5.002 5.002 0 0 0 8 3zM3.1 9a5.002 5.002 0 0 0 8.757 2.182.5.5 0 1 1 .771.636A6.002 6.002 0 0 1 2.083 9H3.1z"/> </svg>
+                        <a-icon type="sync" @click="getNewEmail(client)"></a-icon>
                     </a-tooltip>
                 </span>
-                <a-input v-model.trim="vmess.email" style="width: 150px;"></a-input>
+                <a-input v-model.trim="client.email" style="width: 150px;"></a-input>
             </a-form-item>
-			<a-form-item label="ID">
-				<a-input v-model.trim="vmess.id" style="width: 300px;" ></a-input>
-			</a-form-item>
-			<a-form-item label='{{ i18n "additional" }} ID'>
-				<a-input type="number" v-model.number="vmess.alterId"></a-input>
-			</a-form-item>
-			<a-form-item>
+        </a-form>
+        <a-form-item label="ID">
+            <a-input v-model.trim="client.id" style="width: 300px;"></a-input>
+        </a-form-item>
+        <a-form-item label='{{ i18n "additional" }} ID'>
+            <a-input type="number" v-model.number="client.alterId" style="width: 70px;"></a-input>
+        </a-form-item>
+		<a-form-item>
                 <span slot="label">
                     IP Count Limit
                     <a-tooltip>
                         <template slot="title">
-                            disable inbound if more than entered count (0 for disable limit ip)
+                            Disable inbound if more than entered count (0 for disable limit ip)
                         </template>
                         <a-icon type="question-circle" theme="filled"></a-icon>
                     </a-tooltip>
                 </span>
-              <a-input type="number" v-model.number="vmess.limitIp" min="0" style="width: 70px;" ></a-input>
-            </a-form-item>
-            <a-form-item v-if="vmess.email && vmess.limitIp > 0 && isEdit">
+                <a-input type="number" v-model.number="client.limitIp" min="0" style="width: 70px;"></a-input>
+		</a-form-item>
+		<a-form-item v-if="client.email && client.limitIp > 0 && isEdit">
                 <span slot="label">
-                    IP Log 
+                    IP Log
                     <a-tooltip>
                         <template slot="title">
                             IPs history Log (before enabling inbound after it has been disabled by IP limit, you should clear the log)
@@ -51,17 +45,18 @@
                     </a-tooltip>
                     <a-tooltip>
                         <template slot="title">
-                            clear the log
+                            Clear The Log
                         </template>
                         <span style="color: #FF4D4F">
-                            <a-icon type="delete" @click="clearDBClientIps(vmess.email,$event)"></a-icon>
+                            <a-icon type="delete" @click="clearDBClientIps(client.email,$event)"></a-icon>
                         </span>
                     </a-tooltip>
                 </span>
-                <a-textarea readonly @click="getDBClientIps(vmess.email,$event)" placeholder="Click To Get IPs"  :auto-size="{ minRows: 2, maxRows: 10 }">
-                </a-textarea>
-            </a-form-item>
-        </a-form>
+                <a-form layout="block">
+                    <a-textarea readonly @click="getDBClientIps(client.email,$event)" placeholder="Click To Get IPs"  :auto-size="{ minRows: 2, maxRows: 10 }">
+                    </a-textarea>
+                </a-form>
+		</a-form-item>
         <a-form-item>
             <span slot="label">
                 <span >{{ i18n "pages.inbounds.totalFlow" }}</span>(GB)
@@ -72,7 +67,7 @@
                     <a-icon type="question-circle" theme="filled"></a-icon>
                 </a-tooltip>
             </span>
-            <a-input-number v-model="vmess._totalGB" :min="0"></a-input-number>
+            <a-input-number v-model="client._totalGB" :min="0"></a-input-number>
         </a-form-item>
         <a-form-item>
             <span slot="label">
@@ -85,47 +80,26 @@
                 </a-tooltip>
             </span>
             <a-date-picker :show-time="{ format: 'HH:mm' }" format="YYYY-MM-DD HH:mm"
-                           v-model="vmess._expiryTime" style="width: 300px;"></a-date-picker>
+                           v-model="client._expiryTime" style="width: 170px;"></a-date-picker>
         </a-form-item>
-        <a-form layout="inline">
-            <a-tooltip v-if="vmess._totalGB > 0">
-                <template slot="title">
-                    {{ i18n "pages.inbounds.resetTraffic" }}
-                </template>
-                <span style="color: #FF4D4F">
-                    <a-icon type="delete" @click="resetClientTraffic(vmess,$event)"></a-icon>
-                </span>
-            </a-tooltip>
-            <a-tag color="blue">[[ sizeFormat(getUpStats(vmess.email)) ]] / [[ sizeFormat(getDownStats(vmess.email)) ]]</a-tag>
-            <a-tag v-if="vmess._totalGB > 0" color="red">used : [[ sizeFormat(getUpStats(vmess.email) + getDownStats(vmess.email)) ]]</a-tag>
-                <a-tag v-show="inbound.settings.vmesses.length > 1" @click="removeClient(index, inbound.settings.vmesses)">
-                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22" width="22" height="22" class="mt-2 cursor-pointer">
-                        <path fill="none" d="M0 0h24v24H0z" />
-                        <path fill="#EC4899"
-                        d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-2a8 8 0 1 0 0-16 8 8 0 0 0 0 16zm0-9.414l2.828-2.829 1.415 1.415L13.414 12l2.829 2.828-1.415 1.415L12 13.414l-2.828 2.829-1.415-1.415L10.586 12 7.757 9.172l1.415-1.415L12 10.586z"
-                        />
-                    </svg>
-            </a-tag>
-        </a-form>
-
-
     </a-collapse-panel>
-
 </a-collapse>
-
-<a-tag @click="addClient(inbound.protocol, inbound.settings.vmesses)">
-    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" class="ml-2 cursor-pointer">
-        <path fill="none" d="M0 0h24v24H0z" />
-        <path fill="green"
-        d="M11 11V7h2v4h4v2h-4v4h-2v-4H7v-2h4zm1 11C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-2a8 8 0 1 0 0-16 8 8 0 0 0 0 16z"
-        />
-    </svg>
-</a-tag>
-
+<a-collapse v-else>
+    <a-collapse-panel :header="'{{ i18n "pages.client.clientCount"}} : ' + inbound.settings.vmesses.length">
+        <table width="100%">
+                <tr class="client-table-header">
+                <th v-for="col in Object.keys(inbound.settings.vmesses[0]).slice(0, 3)">[[ col ]]</th>
+            </tr>
+            <tr v-for="(client, index) in inbound.settings.vmesses" :class="index % 2 == 1 ? 'client-table-odd-row' : ''">
+                <td v-for="col in Object.values(client).slice(0, 3)">[[ col ]]</td>
+            </tr>
+        </table>
+    </a-collapse-panel>
+</a-collapse>
 <a-form layout="inline">
     <a-form-item label='{{ i18n "pages.inbounds.disableInsecureEncryption" }}'>
         <a-switch v-model.number="inbound.settings.disableInsecure"></a-switch>
     </a-form-item>
 </a-form>
 
-{{end}}
+{{end}}

+ 2 - 2
web/html/xui/form/stream/stream_quic.html

@@ -1,7 +1,7 @@
 {{define "form/streamQUIC"}}
 <a-form layout="inline">
     <a-form-item label='{{ i18n "pages.inbounds.stream.quic.encryption" }}'>
-        <a-select v-model="inbound.stream.quic.security" style="width: 165px;">
+        <a-select v-model="inbound.stream.quic.security" style="width: 165px;" :dropdown-class-name="siderDrawer.isDarkTheme ? 'ant-card-dark' : ''">
             <a-select-option value="none">none</a-select-option>
             <a-select-option value="aes-128-gcm">aes-128-gcm</a-select-option>
             <a-select-option value="chacha20-poly1305">chacha20-poly1305</a-select-option>
@@ -11,7 +11,7 @@
         <a-input v-model.trim="inbound.stream.quic.key"></a-input>
     </a-form-item>
     <a-form-item label='{{ i18n "camouflage" }}'>
-        <a-select v-model="inbound.stream.quic.type" style="width: 280px;">
+        <a-select v-model="inbound.stream.quic.type" style="width: 280px;" :dropdown-class-name="siderDrawer.isDarkTheme ? 'ant-card-dark' : ''">
             <a-select-option value="none">none(not camouflage)</a-select-option>
             <a-select-option value="srtp">srtp(camouflage video call)</a-select-option>
             <a-select-option value="utp">utp(camouflage BT download)</a-select-option>

+ 2 - 2
web/html/xui/form/stream/stream_settings.html

@@ -2,13 +2,13 @@
 <!-- select stream network -->
 <a-form layout="inline">
     <a-form-item label='{{ i18n "transmission" }}'>
-        <a-select v-model="inbound.stream.network" @change="streamNetworkChange">
+        <a-select v-model="inbound.stream.network" @change="streamNetworkChange" :dropdown-class-name="siderDrawer.isDarkTheme ? 'ant-card-dark' : ''">
             <a-select-option value="tcp">TCP</a-select-option>
             <a-select-option value="kcp">KCP</a-select-option>
             <a-select-option value="ws">WS</a-select-option>
             <a-select-option value="http">HTTP</a-select-option>
             <a-select-option value="quic">QUIC</a-select-option>
-            <a-select-option value="grpc">gRPC</a-select-option>
+            <a-select-option value="grpc">GRPC</a-select-option>
         </a-select>
     </a-form-item>
 </a-form>

+ 10 - 10
web/html/xui/form/tls_settings.html

@@ -11,7 +11,7 @@
 </a-form>
 
 <!-- tls settings -->
-<a-form v-if="inbound.tls || inbound.XTLS"layout="inline">
+<a-form v-if="inbound.tls || inbound.XTLS" layout="inline">
     <a-form-item label="SNI" placeholder="Server Name Indication" v-if="inbound.tls">
         <a-input v-model.trim="inbound.stream.tls.settings[0].serverName"></a-input>
     </a-form-item>
@@ -22,25 +22,25 @@
         </a-select>
     </a-form-item>
     <a-form-item label="MinVersion">
-        <a-select v-model="inbound.stream.tls.minVersion" style="width: 60px">
+        <a-select v-model="inbound.stream.tls.minVersion" style="width: 60px" :dropdown-class-name="siderDrawer.isDarkTheme ? 'ant-card-dark' : ''">
             <a-select-option v-for="key in TLS_VERSION_OPTION" :value="key">[[ key ]]</a-select-option>
         </a-select>
     </a-form-item>
     <a-form-item label="MaxVersion">
-        <a-select v-model="inbound.stream.tls.maxVersion" style="width: 60px">
+        <a-select v-model="inbound.stream.tls.maxVersion" style="width: 60px" :dropdown-class-name="siderDrawer.isDarkTheme ? 'ant-card-dark' : ''">
             <a-select-option v-for="key in TLS_VERSION_OPTION" :value="key">[[ key ]]</a-select-option>
         </a-select>
     </a-form-item>
-        <a-form-item label="uTLS" v-if="inbound.tls" >
-            <a-select v-model="inbound.stream.tls.settings[0].fingerprint" style="width: 135px">
-                <a-select-option value=''>None</a-select-option>
-                <a-select-option v-for="key in UTLS_FINGERPRINT" :value="key">[[ key ]]</a-select-option>
-            </a-select>
-        </a-form-item>
+    <a-form-item label="uTLS" v-if="inbound.tls" >
+        <a-select v-model="inbound.stream.tls.settings[0].fingerprint" style="width: 135px">
+            <a-select-option value=''>None</a-select-option>
+            <a-select-option v-for="key in UTLS_FINGERPRINT" :value="key">[[ key ]]</a-select-option>
+        </a-select>
+    </a-form-item>
     <a-form-item label='{{ i18n "domainName" }}'>
         <a-input v-model.trim="inbound.stream.tls.server"></a-input>
     </a-form-item>
-    <a-form-item label="Alpn" placeholder="http/1.1,h2" v-if="inbound.tls">
+    <a-form-item label="Alpn" v-if="inbound.tls">
         <a-select v-model="inbound.stream.tls.alpn[0]" style="width:200px">
             <a-select-option value=''>auto</a-select-option>
             <a-select-option v-for="key in ALPN_OPTION" :value="key">[[ key ]]</a-select-option>

+ 44 - 0
web/html/xui/inbound_client_table.html

@@ -0,0 +1,44 @@
+{{define "client_table"}}
+<template slot="actions" slot-scope="text, client, index">
+    <a-tooltip>
+        <template slot="title">{{ i18n "qrCode" }}</template>
+        <a-icon style="font-size: 24px;" type="qrcode" v-if="record.hasLink()" @click="showQrcode(record,index);"></a-icon>
+    </a-tooltip>
+    <a-tooltip>
+        <template slot="title">{{ i18n "pages.client.edit" }}</template>
+        <a-icon style="font-size: 24px;" type="edit" @click="openEditClient(record.id,client);"></a-icon>
+    </a-tooltip>
+    <a-tooltip>
+        <template slot="title">{{ i18n "info" }}</template>
+        <a-icon style="font-size: 24px;" type="info-circle" @click="showInfo(record,index);"></a-icon>
+    </a-tooltip>
+    <a-tooltip>
+        <template slot="title">{{ i18n "pages.inbounds.resetTraffic" }}</template>
+        <a-icon style="font-size: 24px;" type="retweet" @click="resetClientTraffic(client,record.id)" v-if="client.email.length > 0"></a-icon>
+    </a-tooltip>
+    <a-tooltip>
+        <template slot="title"><span style="color: #FF4D4F"> {{ i18n "delete"}}</span></template>
+        <a-icon style="font-size: 24px;" type="delete" v-if="isRemovable(record.id)" @click="delClient(record.id,client)"></a-icon>
+    </a-tooltip>
+</template>
+<template slot="client" slot-scope="text, client">
+    [[ client.email ]]
+    <a-tag v-if="!isClientEnabled(record, client.email)" color="red">{{ i18n "disabled" }}</a-tag>
+</template>                                    
+<template slot="traffic" slot-scope="text, client">
+    <a-tag color="blue">[[ sizeFormat(getUpStats(record, client.email)) ]] / [[ sizeFormat(getDownStats(record, client.email)) ]]</a-tag>
+    <template v-if="client._totalGB > 0">
+        <a-tag v-if="isTrafficExhausted(record, client.email)" color="red">[[client._totalGB]]GB</a-tag>
+        <a-tag v-else color="cyan">[[client._totalGB]]GB</a-tag>
+    </template>
+    <a-tag v-else color="green">{{ i18n "indefinite" }}</a-tag>
+</template>                                    
+<template slot="expiryTime" slot-scope="text, client, index">
+    <template v-if="client._expiryTime > 0">
+        <a-tag :color="isExpiry(record, index)? 'red' : 'blue'">
+            [[ DateUtil.formatMillis(client._expiryTime) ]]
+        </a-tag>
+    </template>
+    <a-tag v-else color="green">{{ i18n "indefinite" }}</a-tag>
+</template>
+{{end}}

+ 87 - 32
web/html/xui/inbound_info_modal.html

@@ -3,7 +3,7 @@
     v-model="infoModal.visible" title='{{ i18n "pages.inbounds.details"}}'
     :closable="true"
     :mask-closable="true"
-	:class="siderDrawer.isDarkTheme ? darkClass : ''"
+    :class="siderDrawer.isDarkTheme ? darkClass : ''"
     :footer="null"
     width="600px"
     >
@@ -44,7 +44,7 @@
             </template>
             </table>
         </td></tr>
-            <tr colspan="2">
+            <tr colspan="2" v-if="dbInbound.hasLink()">
                 <td v-if="inbound.tls">
                     tls: <a-tag color="green">{{ i18n "enabled" }}</a-tag><br />
                     tls {{ i18n "domainName" }}: <a-tag :color="inbound.serverName ? 'green' : 'orange'">[[ inbound.serverName ? inbound.serverName : '' ]]</a-tag>
@@ -57,20 +57,20 @@
             </td>
         </tr>
     </table>
+    <template v-if="infoModal.clientSettings">
     <a-divider>{{ i18n "pages.inbounds.client" }}</a-divider>
     <table style="margin-bottom: 10px; width: 100%;">
-        <tr><th>[[ Object.keys(infoModal.clientSettings)[0] ]]</th><th>[[ Object.keys(infoModal.clientSettings)[1] ]]</th><th>[[ Object.keys(infoModal.clientSettings)[2] ]]</th></tr>
         <tr>
-            <td><a-tag color="green">[[ Object.values(infoModal.clientSettings)[0] ]]</a-tag></td>
-            <td><a-tag color="green">[[ Object.values(infoModal.clientSettings)[1] ]]</a-tag></td>
-            <td><a-tag color="green">[[ Object.values(infoModal.clientSettings)[2] ]]</a-tag></td>
+            <th v-for="col in Object.keys(infoModal.clientSettings).slice(0, 3)">[[ col ]]</th>
         </tr>
+        <tr>
+            <td v-for="col in Object.values(infoModal.clientSettings).slice(0, 3)"><a-tag color="green">[[ col ]]</a-tag></td>
     </table>
     <table style="margin-bottom: 10px; width: 100%;">
             <tr><th>{{ i18n "usage" }}</th><th>{{ i18n "pages.inbounds.totalFlow" }}</th><th>{{ i18n "pages.inbounds.expireDate" }}</th><th>{{ i18n "enable" }}</th></tr>    
         <tr>
             <td>
-                <a-tag :color="statsColor(infoModal.clientStats)">
+                <a-tag v-if="infoModal.clientStats" :color="statsColor(infoModal.clientStats)">
                     [[ sizeFormat(infoModal.clientStats['up']) ]] / 
                     [[ sizeFormat(infoModal.clientStats['down']) ]]
                     ([[ sizeFormat(infoModal.clientStats['up'] + infoModal.clientStats['down']) ]])
@@ -89,11 +89,72 @@
                 <a-tag v-else color="green">{{ i18n "indefinite" }}</a-tag>
             </td>
             <td>
-                <a-tag v-if="infoModal.clientStats.enable" color="blue">{{ i18n "enabled" }}</a-tag>
+                <a-tag v-if="isEnable" color="blue">{{ i18n "enabled" }}</a-tag>
                 <a-tag v-else color="red">{{ i18n "disabled" }}</a-tag>
             </td>
         </tr>
     </table>
+    </template>
+    <template v-else>
+        <a-divider></a-divider>
+        <table v-if="inbound.protocol == Protocols.SHADOWSOCKS" style="margin-bottom: 10px; width: 100%;">
+            <tr>
+                <th>{{ i18n "encryption" }}</th>
+                <th>{{ i18n "password" }}</th>
+                <th>{{ i18n "pages.inbounds.network" }}</th>
+            </tr><tr>
+                <td><a-tag color="green">[[ inbound.settings.method ]]</a-tag></td>
+                <td><a-tag color="blue">[[ inbound.settings.password ]]</a-tag></td>
+                <td><a-tag color="green">[[ inbound.settings.network ]]</a-tag></td>
+            </tr>
+        </table>
+        <table v-if="inbound.protocol == Protocols.DOKODEMO" style="margin-bottom: 10px; width: 100%;">
+            <tr>
+                <th>{{ i18n "pages.inbounds.targetAddress" }}</th>
+                <th>{{ i18n "pages.inbounds.destinationPort" }}</th>
+                <th>{{ i18n "pages.inbounds.network" }}</th>
+                <th>FollowRedirect</th>
+            </tr><tr>
+                <td><a-tag color="green">[[ inbound.settings.address ]]</a-tag></td>
+                <td><a-tag color="blue">[[ inbound.settings.port ]]</a-tag></td>
+                <td><a-tag color="green">[[ inbound.settings.network ]]</a-tag></td>
+                <td><a-tag color="blue">[[ inbound.settings.followRedirect ]]</a-tag></td>
+            </tr>
+        </table>
+        </table>
+        <table v-if="inbound.protocol == Protocols.SOCKS" style="margin-bottom: 10px; width: 100%;">
+            <tr>
+                <th>{{ i18n "password" }} Auth</th>
+                <th>{{ i18n "pages.inbounds.enable" }} udp</th>
+                <th>IP</th>
+            </tr><tr>
+                <td><a-tag color="green">[[ inbound.settings.auth ]]</a-tag></td>
+                <td><a-tag color="blue">[[ inbound.settings.udp]]</a-tag></td>
+                <td><a-tag color="green">[[ inbound.settings.ip ]]</a-tag></td>
+            </tr><tr v-if="inbound.settings.auth == 'password'">
+                <td> </td>
+                <td>{{ i18n "username" }}</td>
+                <td>{{ i18n "password" }}</td>
+            </tr><tr v-for="account,index in inbound.settings.accounts">
+                <td><a-tag color="green">[[ index ]]</a-tag></td>
+                <td><a-tag color="blue">[[ account.user ]]</a-tag></td>
+                <td><a-tag color="green">[[ account.pass ]]</a-tag></td>
+            </tr>
+        </table>
+        </table>
+        <table v-if="inbound.protocol == Protocols.HTTP" style="margin-bottom: 10px; width: 100%;">
+            <tr>
+                <th> </th>
+                <th>{{ i18n "username" }}</th>
+                <th>{{ i18n "password" }}</th>
+            </tr><tr v-for="account,index in inbound.settings.accounts">
+                <td><a-tag color="green">[[ index ]]</a-tag></td>
+                <td><a-tag color="blue">[[ account.user ]]</a-tag></td>
+                <td><a-tag color="green">[[ account.pass ]]</a-tag></td>
+            </tr>
+        </table>
+        </table>
+    </template>
     <div v-if="dbInbound.hasLink()">
         <a-divider>URL</a-divider>
         <p>[[ infoModal.link ]]</p>
@@ -106,39 +167,31 @@
         visible: false,
         inbound: new Inbound(),
         dbInbound: new DBInbound(),
+        settings: null,
         clientSettings: new Inbound.Settings(),
         clientStats: [],
         upStats: 0,
         downStats: 0,
         clipboard: null,
         link: null,
-        index: 0,
+        index: null,
         isExpired: false,
-        show(dbInbound, index=0) {
+        show(dbInbound, index) {
             this.index = index;
             this.inbound = dbInbound.toInbound();
             this.dbInbound = new DBInbound(dbInbound);
             this.link = dbInbound.genLink(index);
-            this.clientSettings = Object.values(JSON.parse(this.inbound.settings).clients)[index];
-            this.clientStats = dbInbound.clientStats;
+            this.settings = JSON.parse(this.inbound.settings);
+            this.clientSettings = this.settings.clients ? Object.values(this.settings.clients)[index] : null;
             this.isExpired = this.inbound.isExpiry(index);
-            if(dbInbound.clientStats.length > 0)
-            {
-                for (const key in dbInbound.clientStats) {
-                    if (Object.hasOwnProperty.call(dbInbound.clientStats, key)) {
-                        if(dbInbound.clientStats[key]['email'] == this.clientSettings.email)
-                            this.clientStats = dbInbound.clientStats[key];
-
-                    }
-                }
-            }
+            this.clientStats = this.settings.clients ? this.dbInbound.clientStats.find(row => row.email === this.clientSettings.email) : [];
             this.visible = true;
             infoModalApp.$nextTick(() => {
                 if (this.clipboard === null) {
                     this.clipboard = new ClipboardJS('#copy-url-link', {
                         text: () => this.link,
                     });
-                    this.clipboard.on('success', () => app.$message.success('{{ i18n "copySuccess" }}'));
+                    this.clipboard.on('success', () => app.$message.success('{{ i18n "copied" }}'));
                 }
             });
         },
@@ -146,6 +199,7 @@
             infoModal.visible = false;
         },
     };
+
     const infoModalApp = new Vue({
         delimiters: ['[[', ']]'],
         el: '#inbound-info-modal',
@@ -156,32 +210,33 @@
             },
             get inbound() {
                 return this.infoModal.inbound;
+            },
+            get isEnable() {
+                if(infoModal.clientStats){
+                    return infoModal.clientStats.enable;
+                }
+                return infoModal.dbInbound.isEnable;
             }
         },
         methods: {
-            setQrCode(elmentId,index) {
-                content = infoModal.inbound.genLink(infoModal.dbInbound.address,infoModal.dbInbound.remark,index)
-                new QRious({
-                        element: document.querySelector('#'+elmentId),
-                        size: 260,
-                        value: content,
-                    });
-            },
             copyTextToClipboard(elmentId,content) {
                 this.infoModal.clipboard = new ClipboardJS('#' + elmentId, {
                         text: () => content,
                     });
                 this.infoModal.clipboard.on('success', () => { 
-                    app.$message.success('{{ i18n "copySuccess" }}')
+                    app.$message.success('{{ i18n "copied" }}')
                     this.infoModal.clipboard.destroy();
                 });
             },
             statsColor(stats) {
+                if(!stats) return 'blue'
                 if(stats['total'] === 0) return 'blue'
                 else if(stats['total'] > 0 && (stats['down']+stats['up']) < stats['total']) return 'cyan'
                 else return 'red'
             }
         },
+        
     });
+
 </script>
 {{end}}

+ 4 - 76
web/html/xui/inbound_modal.html

@@ -1,7 +1,7 @@
 {{define "inboundModal"}}
 <a-modal id="inbound-modal" v-model="inModal.visible" :title="inModal.title" @ok="inModal.ok"
          :confirm-loading="inModal.confirmLoading" :closable="true" :mask-closable="false"
-		 :class="siderDrawer.isDarkTheme ? darkClass : ''"
+         :class="siderDrawer.isDarkTheme ? darkClass : ''"
          :ok-text="inModal.okText" cancel-text='{{ i18n "close" }}'>
     {{template "form/inbound"}}
 </a-modal>
@@ -89,96 +89,24 @@
             removeClient(index, clients) {
                 clients.splice(index, 1);
             },
-            async getDBClientIps(email, event) {
-              const msg = await HttpUtil.post('/xui/inbound/clientIps/' + email);
-              if (!msg.success) {
-                return;
-              }
-              try {
-                let ips = JSON.parse(msg.obj);
-                ips = ips.join(",");
-                event.target.value = ips;
-              } catch (error) {
-                event.target.value = msg.obj;
-              }
-            },
-            async clearDBClientIps(email,event) {
-                const msg = await HttpUtil.post('/xui/inbound/clearClientIps/'+ email);
-                if (!msg.success) {
-                    return;
-                }
-                event.target.value = ""
-            },
-            async resetClientTraffic(client, event) {
-              const msg = await HttpUtil.post(`/xui/inbound/resetClientTraffic/${client.email}`);
-              if (!msg.success) {
-                return;
-              }
-              const clientStats = this.inbound.clientStats;
-              if (clientStats.length > 0) {
-				for (let i = 0; i < clientStats.length; i++) {
-                if (clientStats[i].email === client.email) {
-                clientStats[i].up = 0;
-                clientStats[i].down = 0;
-                break; // Stop looping once we've found the matching client.
-                            
-                        }
-                    }
-                }
-            },
             isExpiry(index) {
                 return this.inbound.isExpiry(index)
             },
-            getUpStats(email) {
-                clientStats = this.inbound.clientStats
-                if(clientStats.length > 0)
-                {
-                    for (const key in clientStats) {
-                        if (Object.hasOwnProperty.call(clientStats, key)) {
-                            if(clientStats[key]['email'] == email)
-                                return clientStats[key]['up']
-
-                        }
-                    }
-                }
-            },
-            getDownStats(email) {
-                clientStats = this.inbound.clientStats
-                if(clientStats.length > 0)
-                {
-                    for (const key in clientStats) {
-                        if (Object.hasOwnProperty.call(clientStats, key)) {
-                            if(clientStats[key]['email'] == email)
-                                return clientStats[key]['down']
-
-                        }
-                    }
-                }
-            },
             isClientEnable(email) {
                 clientStats = this.dbInbound.clientStats ? this.dbInbound.clientStats.find(stats => stats.email === email) : null
                 return clientStats ? clientStats['enable'] : true
             },
-            getHeaderText(email) {
-                if(email == "")
-                    return "Add Client"
-                
-                return email + (this.isClientEnable(email) == true ? ' Active' : ' Deactive')
-            },
-            getHeaderStyle(email) {
-                return (this.isClientEnable(email) == true ? '' : 'deactive-client')
-            },
             getNewEmail(client) {
                 var chars = 'abcdefghijklmnopqrstuvwxyz1234567890';
                 var string = '';
-                var len = 7 + Math.floor(Math.random() * 5)
+                var len = 6 + Math.floor(Math.random() * 5);
                 for(var ii=0; ii<len; ii++){
                     string += chars[Math.floor(Math.random() * chars.length)];
                 }
-                client.email = string
+                client.email = string;
             }
         },
     });
 
 </script>
-{{end}}
+{{end}}

+ 162 - 79
web/html/xui/inbounds.html

@@ -31,15 +31,15 @@
                     <a-card hoverable style="margin-bottom: 20px;" :class="siderDrawer.isDarkTheme ? darkClass : ''">
                         <a-row>
                             <a-col :xs="24" :sm="24" :lg="12">
-                                {{ i18n "pages.inbounds.totalDownUp" }}
+                                {{ i18n "pages.inbounds.totalDownUp" }}:
                                 <a-tag color="green">[[ sizeFormat(total.up) ]] / [[ sizeFormat(total.down) ]]</a-tag>
                             </a-col>
                             <a-col :xs="24" :sm="24" :lg="12">
-                                {{ i18n "pages.inbounds.totalUsage" }}
+                                {{ i18n "pages.inbounds.totalUsage" }}:
                                 <a-tag color="green">[[ sizeFormat(total.up + total.down) ]]</a-tag>
                             </a-col>
                             <a-col :xs="24" :sm="24" :lg="12">
-                                {{ i18n "pages.inbounds.inboundCount" }}
+                                {{ i18n "pages.inbounds.inboundCount" }}:
                                 <a-tag color="green">[[ dbInbounds.length ]]</a-tag>
                             </a-col>
                             <a-col :xs="24" :sm="24" :lg="12">
@@ -54,10 +54,10 @@
                 <transition name="list" appear>
                     <a-card hoverable :class="siderDrawer.isDarkTheme ? darkClass : ''">
                         <div slot="title">
-                             <a-button type="primary" @click="openAddInbound">Add Inbound</a-button>
-							 <a-button type="primary" @click="exportAllLinks" class="copy-btn">Export Links</a-button>
+                            <a-button type="primary" icon="plus" @click="openAddInbound">{{ i18n "pages.inbounds.addInbound" }}</a-button>
+                            <a-button type="primary" icon="export" @click="exportAllLinks">{{ i18n "pages.inbounds.export" }}</a-button>
                         </div>
-						<a-input v-model.lazy="searchKey" placeholder="{{ i18n "search" }}" autofocus style="max-width: 300px"></a-input>
+                        <a-input v-model.lazy="searchKey" placeholder="{{ i18n "search" }}" autofocus style="max-width: 300px"></a-input>
                         <a-table :columns="columns" :row-key="dbInbound => dbInbound.id"
                                  :data-source="searchedInbounds"
                                  :loading="spinning" :scroll="{ x: 1300 }"
@@ -67,8 +67,8 @@
                             <template slot="action" slot-scope="text, dbInbound">
                                 <a-icon type="edit" style="font-size: 25px" @click="openEditInbound(dbInbound.id);"></a-icon>
                                 <a-dropdown :trigger="['click']">
-                                    <a @click="e => e.preventDefault()">{{ i18n "pages.inbounds.operate" }}</a>
-                                    <a-menu slot="overlay" @click="a => clickAction(a, dbInbound)">
+                                     <a @click="e => e.preventDefault()">{{ i18n "pages.inbounds.operate" }}</a>
+                                    <a-menu slot="overlay" @click="a => clickAction(a, dbInbound)" :theme="siderDrawer.theme" style="border: 1px solid rgba(255, 255, 255, 0.65);">
                                         <a-menu-item v-if="dbInbound.isSS" key="qrcode">
                                             <a-icon type="qrcode"></a-icon>
                                             {{ i18n "qrCode" }}
@@ -77,12 +77,26 @@
                                             <a-icon type="edit"></a-icon>
                                             {{ i18n "edit" }}
                                         </a-menu-item>
-                                         <template v-if="dbInbound.isTrojan || dbInbound.isVLess || dbInbound.isVMess">
+                                        <template v-if="dbInbound.isTrojan || dbInbound.isVLess || dbInbound.isVMess">
+                                            <a-menu-item key="addClient">
+                                                <a-icon type="user"></a-icon>
+                                                {{ i18n "pages.client.add"}}
+                                            </a-menu-item>
+                                            <a-menu-item key="addBulkClient">
+                                                <a-icon type="team"></a-icon>
+                                                {{ i18n "pages.client.bulk"}}
+                                            </a-menu-item>
                                             <a-menu-item key="export">
                                                 <a-icon type="export"></a-icon>
                                                 {{ i18n "pages.inbounds.export"}}
                                             </a-menu-item>
                                         </template>
+                                        <template v-else>
+                                            <a-menu-item key="showInfo">
+                                                <a-icon type="info-circle"></a-icon>
+                                                {{ i18n "info"}}
+                                            </a-menu-item>
+                                        </template>
                                         <a-menu-item key="resetTraffic">
                                             <a-icon type="retweet"></a-icon> {{ i18n "pages.inbounds.resetTraffic" }}
                                         </a-menu-item>
@@ -114,7 +128,7 @@
                                 <template v-else>{{ i18n "none" }}</template>
                             </template>
                             <template slot="enable" slot-scope="text, dbInbound">
-                                <a-switch v-model="dbInbound.enable" @change="switchEnable(dbInbound)"></a-switch>
+                                <a-switch v-model="dbInbound.enable" @change="switchEnable(dbInbound.id)"></a-switch>
                             </template>
                             <template slot="expiryTime" slot-scope="text, dbInbound">
                                 <template v-if="dbInbound.expiryTime > 0">
@@ -135,7 +149,7 @@
                                 :data-source="getInboundClients(record)"
                                 :pagination="false"
                                 >
-                                    {{template "client_row"}}
+                                    {{template "client_table"}}
                                 </a-table>
                                 <a-table
                                 v-else-if="record.protocol === Protocols.TROJAN"
@@ -144,16 +158,7 @@
                                 :data-source="getInboundClients(record)"
                                 :pagination="false"
                                 >
-                                    {{template "client_row"}}
-                                </a-table>
-                                <a-table
-                                v-else
-                                :row-key="client => client.id"
-                                :columns="innerOneColumns"
-                                :data-source="record"
-                                :pagination="false"
-                                >
-                                    {{template "client_row"}}
+                                    {{template "client_table"}}
                                 </a-table>
                             </template>
                         </a-table>
@@ -177,7 +182,7 @@
         width: 40,
         scopedSlots: { customRender: 'enable' },
     }, {
-        title: "Id",
+        title: "ID",
         align: 'center',
         dataIndex: "id",
         width: 30,
@@ -201,7 +206,7 @@
         align: 'center',
         width: 150,
         scopedSlots: { customRender: 'traffic' },
-    },{
+    }, {
         title: '{{ i18n "pages.inbounds.transportConfig" }}',
         align: 'center',
         width: 60,
@@ -214,26 +219,20 @@
     }];
 
     const innerColumns = [
-        { title: '', width: 70, scopedSlots: { customRender: 'actions' } },
+        { title: '{{ i18n "pages.inbounds.operate" }}', width: 70, scopedSlots: { customRender: 'actions' } },
         { title: '{{ i18n "pages.inbounds.client" }}', width: 60, scopedSlots: { customRender: 'client' } },
         { title: '{{ i18n "pages.inbounds.traffic" }}↑|↓', width: 100, scopedSlots: { customRender: 'traffic' } },
         { title: '{{ i18n "pages.inbounds.expireDate" }}', width: 70, scopedSlots: { customRender: 'expiryTime' } },
         { title: 'UID', width: 150, dataIndex: "id" },
-		
     ];
-
     const innerTrojanColumns = [
-        { title: '', width: 70, scopedSlots: { customRender: 'actions' } },
+        { title: '{{ i18n "pages.inbounds.operate" }}', width: 70, scopedSlots: { customRender: 'actions' } },
         { title: '{{ i18n "pages.inbounds.client" }}', width: 60, scopedSlots: { customRender: 'client' } },
         { title: '{{ i18n "pages.inbounds.traffic" }}↑|↓', width: 100, scopedSlots: { customRender: 'traffic' } },
         { title: '{{ i18n "pages.inbounds.expireDate" }}', width: 70, scopedSlots: { customRender: 'expiryTime' } },
         { title: 'Password', width: 100, dataIndex: "password" },
     ];
 
-    const innerOneColumns = [
-        { title: '', width: 70, scopedSlots: { customRender: 'actions' } },
-    ];
-
     const app = new Vue({
         delimiters: ['[[', ']]'],
         el: '#app',
@@ -257,6 +256,7 @@
                     return;
                 }
                 this.setInbounds(msg.obj);
+                this.searchKey = '';
             },
             setInbounds(dbInbounds) {
                 this.inbounds.splice(0);
@@ -297,17 +297,26 @@
                     case "qrcode":
                         this.showQrcode(dbInbound);
                         break;
-                    case "export":
-                        this.inboundLinks(dbInbound.id);
+                    case "showInfo":
+                        this.showInfo(dbInbound);
                         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 "resetTraffic":
-                        this.resetTraffic(dbInbound);
+                        this.resetTraffic(dbInbound.id);
                         break;
                     case "delete":
-                        this.delInbound(dbInbound);
+                        this.delInbound(dbInbound.id);
                         break;
                 }
             },
@@ -324,8 +333,8 @@
                     isEdit: false
                 });
             },
-            openEditInbound(dbInbound_id) {
-                dbInbound = this.dbInbounds.find(row => row.id === dbInbound_id);
+            openEditInbound(dbInboundId) {
+                dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
                 const inbound = dbInbound.toInbound();
                 inModal.show({
                     title: '{{ i18n "pages.inbounds.modifyInbound"}}',
@@ -354,9 +363,10 @@
                     port: inbound.port,
                     protocol: inbound.protocol,
                     settings: inbound.settings.toString(),
-                    streamSettings: inbound.stream.toString(),
-                    sniffing: inbound.canSniffing() ? inbound.sniffing.toString() : '{}',
                 };
+                if (inbound.canEnableStream()) data.streamSettings = inbound.stream.toString();
+                if (inbound.canSniffing()) data.sniffing = inbound.sniffing.toString();
+
                 await this.submit('/xui/inbound/add', data, inModal);
             },
             async updateInbound(inbound, dbInbound) {
@@ -372,15 +382,80 @@
                     port: inbound.port,
                     protocol: inbound.protocol,
                     settings: inbound.settings.toString(),
-                    streamSettings: inbound.stream.toString(),
-                    sniffing: inbound.canSniffing() ? inbound.sniffing.toString() : '{}',
                 };
+                if (inbound.canEnableStream()) data.streamSettings = inbound.stream.toString();
+                if (inbound.canSniffing()) data.sniffing = inbound.sniffing.toString();
+
                 await this.submit(`/xui/inbound/update/${dbInbound.id}`, data, inModal);
             },
-            resetTraffic(dbInbound) {
+            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 (inbound, dbInbound, index) => {
+                        clientModal.loading();
+                        await this.addClient(inbound, dbInbound);
+                        clientModal.close();
+                    },
+                    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 (inbound, dbInbound) => {
+                        clientsBulkModal.loading();
+                        await this.addClient(inbound, dbInbound);
+                        clientsBulkModal.close();
+                    },
+                });
+            },
+            openEditClient(dbInboundId, client) {
+                dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
+                clients = this.getInboundClients(dbInbound);
+                index = this.findIndexOfClient(clients, client);
+                clientModal.show({
+                    title: '{{ i18n "pages.client.edit"}}',
+                    okText: '{{ i18n "pages.client.submitEdit"}}',
+                    dbInbound: dbInbound,
+                    index: index,
+                    confirm: async (inbound, dbInbound, index) => {
+                        clientModal.loading();
+                        await this.updateClient(inbound, dbInbound, index);
+                        clientModal.close();
+                    },
+                    isEdit: true
+                });
+            },
+            findIndexOfClient(clients,client) {
+                firstKey = Object.keys(client)[0];
+                return clients.findIndex(c => c[firstKey] === client[firstKey]);
+            },
+            async addClient(inbound, dbInbound) {
+                const data = {
+                    id: dbInbound.id,
+                    settings: inbound.settings.toString(),
+                };
+                await this.submit('/xui/inbound/addClient', data);
+            },
+            async updateClient(inbound, dbInbound, index) {
+                const data = {
+                    id: dbInbound.id,
+                    settings: inbound.settings.toString(),
+                };
+                await this.submit(`/xui/inbound/updateClient/${index}`, data);
+            },
+            resetTraffic(dbInboundId) {
+                dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
                 this.$confirm({
                     title: '{{ i18n "pages.inbounds.resetTraffic"}}',
                     content: '{{ i18n "pages.inbounds.resetTrafficContent"}}',
+                    class: siderDrawer.isDarkTheme ? darkClass : '',
                     okText: '{{ i18n "reset"}}',
                     cancelText: '{{ i18n "cancel"}}',
                     onOk: () => {
@@ -391,16 +466,37 @@
                     },
                 });
             },
-            delInbound(dbInbound) {
+            delInbound(dbInboundId) {
                 this.$confirm({
                     title: '{{ i18n "pages.inbounds.deleteInbound"}}',
                     content: '{{ i18n "pages.inbounds.deleteInboundContent"}}',
+                    class: siderDrawer.isDarkTheme ? darkClass : '',
                     okText: '{{ i18n "delete"}}',
                     cancelText: '{{ i18n "cancel"}}',
-                    onOk: () => this.submit('/xui/inbound/del/' + dbInbound.id),
+                    onOk: () => this.submit('/xui/inbound/del/' + dbInboundId),
                 });
             },
-			getClients(protocol, clientSettings) {
+            delClient(dbInboundId,client) {
+                dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
+                newDbInbound = new DBInbound(dbInbound);
+                inbound = newDbInbound.toInbound();
+                clients = this.getClients(dbInbound.protocol, inbound.settings);
+                index = this.findIndexOfClient(clients, client);
+                clients.splice(index, 1);
+                const data = {
+                    id: dbInboundId,
+                    settings: inbound.settings.toString(),
+                };
+                this.$confirm({
+                    title: '{{ i18n "pages.inbounds.deleteInbound"}}',
+                    content: '{{ i18n "pages.inbounds.deleteInboundContent"}}',
+                    class: siderDrawer.isDarkTheme ? darkClass : '',
+                    okText: '{{ i18n "delete"}}',
+                    cancelText: '{{ i18n "cancel"}}',
+                    onOk: () => this.submit('/xui/inbound/delClient/' + client.email, data),
+                });
+            },
+            getClients(protocol, clientSettings) {
                 switch(protocol){
                     case Protocols.VMESS: return clientSettings.vmesses;
                     case Protocols.VLESS: return clientSettings.vlesses;
@@ -408,18 +504,19 @@
                     default: return null;
                 }
             },
-             showQrcode(dbInbound, clientIndex) {
+            showQrcode(dbInbound, clientIndex) {
                 const link = dbInbound.genLink(clientIndex);
                 qrModal.show('{{ i18n "qrCode"}}', link, dbInbound);
             },
             showInfo(dbInbound, index) {
                 infoModal.show(dbInbound, index);
             },
-            switchEnable(dbInbound) {
-                this.submit(`/xui/inbound/update/${dbInbound.id}`, dbInbound);
+            switchEnable(dbInboundId) {
+                dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
+                this.submit(`/xui/inbound/update/${dbInboundId}`, dbInbound);
             },
-            async submit(url, data, modal) {
-                const msg = await HttpUtil.postWithModal(url, data, modal);
+            async submit(url, data) {
+                const msg = await HttpUtil.postWithModal(url, data);
                 if (msg.success) {
                     await this.getDBInbounds();
                 }
@@ -433,34 +530,15 @@
                     return dbInbound.toInbound().settings.trojans
                 }
             },
-            resetClientTraffic(client,inbound,event) {
+            resetClientTraffic(client,dbInboundId) {
                 this.$confirm({
                     title: '{{ i18n "pages.inbounds.resetTraffic"}}',
                     content: '{{ i18n "pages.inbounds.resetTrafficContent"}}',
+                    class: siderDrawer.isDarkTheme ? darkClass : '',
                     okText: '{{ i18n "reset"}}',
                     cancelText: '{{ i18n "cancel"}}',
-                    onOk: () => {
-                        this.resetClTraffic(client,inbound,event);
-                    },
-                });
-            },
-            async resetClTraffic(client,inbound,event) {
-                const msg = await HttpUtil.post('/xui/inbound/resetClientTraffic/'+ client.email);
-                if (!msg.success) {
-                    return;
-                }
-                clientStats = inbound.clientStats
-                if(clientStats.length > 0)
-                {
-                    for (const key in clientStats) {
-                        if (Object.hasOwnProperty.call(clientStats, key)) {
-                            if(clientStats[key]['email'] == client.email){ 
-                                clientStats[key]['up'] = 0
-                                clientStats[key]['down'] = 0
-                            }
-                        }
-                    }
-                }
+                    onOk: () => this.submit('/xui/inbound/' + dbInboundId + '/resetClientTraffic/'+ client.email),
+                })
             },
             isExpiry(dbInbound, index) {
                 return dbInbound.toInbound().isExpiry(index)
@@ -480,6 +558,13 @@
                 clientStats = dbInbound.clientStats.find(stats => stats.email === email)
                 return clientStats ? clientStats.down + clientStats.up > clientStats.total : false
             },
+            isClientEnabled(dbInbound, email) {
+                clientStats = dbInbound.clientStats ? dbInbound.clientStats.find(stats => stats.email === email) : null
+                return clientStats ? clientStats['enable'] : true
+            },
+            isRemovable(dbInbound_id){
+                return this.getInboundClients(this.dbInbounds.find(row => row.id === dbInbound_id)).length > 1
+            },
             inboundLinks(dbInboundId) {
                 dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
                 txtModal.show('{{ i18n "pages.inbounds.export"}}',dbInbound.genInboundLinks,dbInbound.remark);
@@ -491,10 +576,6 @@
                 }
                 txtModal.show('{{ i18n "pages.inbounds.export"}}',copyText,'All-Inbounds');
             },
-            isClientEnabled(dbInbound, email) {
-                clientStats = dbInbound.clientStats ? dbInbound.clientStats.find(stats => stats.email === email) : null
-                return clientStats ? clientStats['enable'] : true
-            },
         },
         watch: {
             searchKey: debounce(function (newVal) {
@@ -507,7 +588,7 @@
         computed: {
             total() {
                 let down = 0, up = 0;
-				let clients = 0, active = 0, deactive = 0;
+                let clients = 0, active = 0, deactive = 0;
                 this.dbInbounds.forEach(dbInbound => {
                     down += dbInbound.down;
                     up += dbInbound.up;
@@ -545,5 +626,7 @@
 {{template "qrcodeModal"}}
 {{template "textModal"}}
 {{template "inboundInfoModal"}}
+{{template "clientsModal"}}
+{{template "clientsBulkModal"}}
 </body>
-</html>
+</html>

+ 12 - 6
web/html/xui/index.html

@@ -11,6 +11,10 @@
     .ant-col-sm-24 {
         margin-top: 10px;
     }
+
+    .ant-card-dark h2 {
+        color: hsla(0,0%,100%,.65);
+    }
 </style>
 <body>
 <a-layout id="app" v-cloak>
@@ -27,14 +31,14 @@
                                     <a-col :span="12" style="text-align: center">
                                         <a-progress type="dashboard" status="normal"
                                                     :stroke-color="status.cpu.color"
-													:class="siderDrawer.isDarkTheme ? darkClass : ''"
+                                                    :class="siderDrawer.isDarkTheme ? darkClass : ''"
                                                     :percent="status.cpu.percent"></a-progress>
                                         <div>CPU</div>
                                     </a-col>
                                     <a-col :span="12" style="text-align: center">
                                         <a-progress type="dashboard" status="normal"
                                                     :stroke-color="status.mem.color"
-													:class="siderDrawer.isDarkTheme ? darkClass : ''"
+                                                    :class="siderDrawer.isDarkTheme ? darkClass : ''"
                                                     :percent="status.mem.percent"></a-progress>
                                         <div>
                                             {{ i18n "pages.index.memory"}}: [[ sizeFormat(status.mem.current) ]] / [[ sizeFormat(status.mem.total) ]]
@@ -47,7 +51,7 @@
                                     <a-col :span="12" style="text-align: center">
                                         <a-progress type="dashboard" status="normal"
                                                     :stroke-color="status.swap.color"
-													:class="siderDrawer.isDarkTheme ? darkClass : ''"
+                                                    :class="siderDrawer.isDarkTheme ? darkClass : ''"
                                                     :percent="status.swap.percent"></a-progress>
                                         <div>
                                             Swap: [[ sizeFormat(status.swap.current) ]] / [[ sizeFormat(status.swap.total) ]]
@@ -56,7 +60,7 @@
                                     <a-col :span="12" style="text-align: center">
                                         <a-progress type="dashboard" status="normal"
                                                     :stroke-color="status.disk.color"
-													:class="siderDrawer.isDarkTheme ? darkClass : ''"
+                                                    :class="siderDrawer.isDarkTheme ? darkClass : ''"
                                                     :percent="status.disk.percent"></a-progress>
                                         <div>
                                             {{ i18n "pages.index.hard"}}: [[ sizeFormat(status.disk.current) ]] / [[ sizeFormat(status.disk.total) ]]
@@ -172,6 +176,7 @@
     </a-layout>
     <a-modal id="version-modal" v-model="versionModal.visible" title='{{ i18n "pages.index.xraySwitch" }}'
              :closable="true" @ok="() => versionModal.visible = false"
+             :class="siderDrawer.isDarkTheme ? darkClass : ''"
              ok-text='{{ i18n "confirm" }}' cancel-text='{{ i18n "cancel"}}'>
         <h2>{{ i18n "pages.index.xraySwitchClick"}}</h2>
         <h2>{{ i18n "pages.index.xraySwitchClickDesk"}}</h2>
@@ -313,6 +318,7 @@
                     title: '{{ i18n "pages.index.xraySwitchVersionDialog"}}',
                     content: '{{ i18n "pages.index.xraySwitchVersionDialogDesc"}}' + ` ${version}?`,
                     okText: '{{ i18n "confirm"}}',
+                    class: siderDrawer.isDarkTheme ? darkClass : '',
                     cancelText: '{{ i18n "cancel"}}',
                     onOk: async () => {
                         versionModal.hide();
@@ -322,7 +328,7 @@
                     },
                 });
             },
-			//here add stop xray function
+	        //here add stop xray function
             async stopXrayService() {
                 this.loading(true);
                 const msg = await HttpUtil.post('server/stopXrayService');
@@ -331,7 +337,7 @@
                     return;
                 }
             },
-        //here add restart xray function
+            //here add restart xray function
             async restartXrayService() {
                 this.loading(true);
                 const msg = await HttpUtil.post('server/restartXrayService');

+ 118 - 2
web/html/xui/setting.html

@@ -20,7 +20,7 @@
         display: block;
     }
 
-	:not(.ant-card-dark)>.ant-tabs-top-bar {
+    :not(.ant-card-dark)>.ant-tabs-top-bar {
         background: white;
     }
 </style>
@@ -56,6 +56,7 @@
                                                         ref="selectLang"
                                                         v-model="lang"
                                                         @change="setLang(lang)"
+                                                        :dropdown-class-name="siderDrawer.isDarkTheme ? 'ant-card-dark' : ''"
                                                         style="width: 100%"
                                                 >
                                                     <a-select-option :value="l.value" :label="l.value" v-for="l in supportLangs">
@@ -87,13 +88,28 @@
                                              style="max-width: 300px"></a-input>
                                 </a-form-item>
                                 <a-form-item>
-<!--                                    <a-button type="primary" @click="updateUser">update</a-button>-->
+<!--                                    <a-button type="primary" @click="updateUser">Revise</a-button>-->
                                     <a-button type="primary" @click="updateUser">{{ i18n "confirm" }}</a-button>
                                 </a-form-item>
                             </a-form>
                         </a-tab-pane>
                         <a-tab-pane key="3" tab='{{ i18n "pages.setting.xrayConfiguration"}}'>
                             <a-list item-layout="horizontal" :style="siderDrawer.isDarkTheme ? 'color: hsla(0,0%,100%,.65);': 'background: white;'">
+                                <setting-list-item type="switch" title='{{ i18n "pages.setting.xrayConfigTorrent"}}' desc='{{ i18n "pages.setting.xrayConfigTorrentDesc"}}'  v-model="torrentSettings"></setting-list-item>
+                                <setting-list-item type="switch" title='{{ i18n "pages.setting.xrayConfigPrivateIp"}}' desc='{{ i18n "pages.setting.xrayConfigPrivateIpDesc"}}'  v-model="privateIpSettings"></setting-list-item>
+                                <a-divider>{{ i18n "pages.setting.advancedTemplate"}}</a-divider>
+                                <a-collapse>
+                                    <a-collapse-panel header="{{ i18n "pages.setting.xrayConfigInbounds"}}">
+                                        <setting-list-item type="textarea" title='{{ i18n "pages.setting.xrayConfigInbounds"}}' desc='{{ i18n "pages.setting.xrayConfigInboundsDesc"}}' v-model ="inboundSettings"></setting-list-item>
+                                    </a-collapse-panel>
+                                    <a-collapse-panel header="{{ i18n "pages.setting.xrayConfigOutbounds"}}">
+                                        <setting-list-item type="textarea" title='{{ i18n "pages.setting.xrayConfigOutbounds"}}' desc='{{ i18n "pages.setting.xrayConfigOutboundsDesc"}}' v-model ="outboundSettings"></setting-list-item>
+                                    </a-collapse-panel>
+                                    <a-collapse-panel header="{{ i18n "pages.setting.xrayConfigRoutings"}}">
+                                        <setting-list-item type="textarea" title='{{ i18n "pages.setting.xrayConfigRoutings"}}' desc='{{ i18n "pages.setting.xrayConfigRoutingsDesc"}}' v-model ="routingRuleSettings"></setting-list-item>
+                                    </a-collapse-panel>
+                                </a-collapse>
+                                <a-divider>{{ i18n "pages.setting.completeTemplate"}}</a-divider>
                                 <setting-list-item type="textarea" title='{{ i18n "pages.setting.xrayConfigTemplate"}}' desc='{{ i18n "pages.setting.xrayConfigTemplateDesc"}}' v-model="allSetting.xrayTemplateConfig"></setting-list-item>
                             </a-list>
                         </a-tab-pane>
@@ -103,6 +119,10 @@
                                 <setting-list-item type="text" title='{{ i18n "pages.setting.telegramToken"}}' desc='{{ i18n "pages.setting.telegramTokenDesc"}}'  v-model="allSetting.tgBotToken"></setting-list-item>
                                 <setting-list-item type="number" title='{{ i18n "pages.setting.telegramChatId"}}' desc='{{ i18n "pages.setting.telegramChatIdDesc"}}'  v-model.number="allSetting.tgBotChatId"></setting-list-item>
                                 <setting-list-item type="text" title='{{ i18n "pages.setting.telegramNotifyTime"}}' desc='{{ i18n "pages.setting.telegramNotifyTimeDesc"}}'  v-model="allSetting.tgRunTime"></setting-list-item>
+                                <setting-list-item type="switch" title='{{ i18n "pages.setting.tgNotifyBackup" }}' desc='{{ i18n "pages.setting.tgNotifyBackupDesc" }}'  v-model="allSetting.tgBotBackup"></setting-list-item>
+                                <setting-list-item type="number" title='{{ i18n "pages.setting.tgNotifyExpireTimeDiff" }}' desc='{{ i18n "pages.setting.tgNotifyExpireTimeDiffDesc" }}'  v-model="allSetting.tgExpireDiff" :min="0"></setting-list-item>
+                                <setting-list-item type="number" title='{{ i18n "pages.setting.tgNotifyTrafficDiff" }}' desc='{{ i18n "pages.setting.tgNotifyTrafficDiffDesc" }}'  v-model="allSetting.tgTrafficDiff" :min="0"></setting-list-item>
+                                <setting-list-item type="number" title='{{ i18n "pages.setting.tgNotifyCpu" }}' desc='{{ i18n "pages.setting.tgNotifyCpuDesc" }}'  v-model="allSetting.tgCpu" :min="0" :max="100"></setting-list-item>
                             </a-list>
                         </a-tab-pane>
                         <a-tab-pane key="5" tab='{{ i18n "pages.setting.otherSetting"}}'>
@@ -189,6 +209,102 @@
                 this.saveBtnDisable = this.oldAllSetting.equals(this.allSetting);
             }
         },
+        computed: {
+            templateSettings: {
+                get: function () { return this.allSetting.xrayTemplateConfig ? JSON.parse(this.allSetting.xrayTemplateConfig) : null ; },
+                set: function (newValue) { this.allSetting.xrayTemplateConfig = JSON.stringify(newValue, null, 2) },
+            },
+            inboundSettings: {
+                get: function () { return this.templateSettings ? JSON.stringify(this.templateSettings.inbounds, null, 2) : null; },
+                set: function (newValue) {
+                    newTemplateSettings = this.templateSettings;
+                    newTemplateSettings.inbounds = JSON.parse(newValue)
+                    this.templateSettings = newTemplateSettings
+                },
+            },
+            outboundSettings: {
+                get: function () { return this.templateSettings ? JSON.stringify(this.templateSettings.outbounds, null, 2) : null; },
+                set: function (newValue) {
+                    newTemplateSettings = this.templateSettings;
+                    newTemplateSettings.outbounds = JSON.parse(newValue)
+                    this.templateSettings = newTemplateSettings
+                },
+            },
+            routingRuleSettings: {
+                get: function () { return this.templateSettings ? JSON.stringify(this.templateSettings.routing.rules, null, 2) : null; },
+                set: function (newValue) {
+                    newTemplateSettings = this.templateSettings;
+                    newTemplateSettings.routing.rules = JSON.parse(newValue)
+                    this.templateSettings = newTemplateSettings
+                },
+            },
+            torrentSettings: {
+                get: function () {
+                    torrentFilter = false
+                    if(this.templateSettings != null){
+                        this.templateSettings.routing.rules.forEach(routingRule => {
+                            if(routingRule.hasOwnProperty("protocol")){
+                                if (routingRule.protocol[0] === "bittorrent" && routingRule.outboundTag == "blocked"){
+                                    torrentFilter = true
+                                }
+                            }
+                        });
+                    }
+                    return torrentFilter
+                },
+                set: function (newValue) {
+                    newTemplateSettings = JSON.parse(this.allSetting.xrayTemplateConfig);
+                    if (newValue){
+                        newTemplateSettings.routing.rules.push(JSON.parse("{\"outboundTag\": \"blocked\",\"protocol\": [\"bittorrent\"],\"type\": \"field\"}"))
+                    }
+                    else {
+                        newTemplateSettings.routing.rules = [];
+                        this.templateSettings.routing.rules.forEach(routingRule => {
+                            if (routingRule.hasOwnProperty('protocol')){
+                                if (routingRule.protocol[0] === "bittorrent" && routingRule.outboundTag == "blocked"){
+                                    return;
+                                }
+                            }
+                            newTemplateSettings.routing.rules.push(routingRule);
+                        });
+                    }
+                    this.templateSettings = newTemplateSettings
+                },
+            },
+            privateIpSettings: {
+                get: function () {
+                    localIpFilter = false
+                    if(this.templateSettings != null){
+                        this.templateSettings.routing.rules.forEach(routingRule => {
+                            if(routingRule.hasOwnProperty("ip")){
+                                if (routingRule.ip[0] === "geoip:private" && routingRule.outboundTag == "blocked"){
+                                    localIpFilter = true
+                                }
+                            }
+                        });
+                    }
+                    return localIpFilter
+                },
+                set: function (newValue) {
+                    newTemplateSettings = JSON.parse(this.allSetting.xrayTemplateConfig);
+                    if (newValue){
+                        newTemplateSettings.routing.rules.push(JSON.parse("{\"outboundTag\": \"blocked\",\"ip\": [\"geoip:private\"],\"type\": \"field\"}"))
+                    }
+                    else {
+                        newTemplateSettings.routing.rules = [];
+                        this.templateSettings.routing.rules.forEach(routingRule => {
+                            if (routingRule.hasOwnProperty('ip')){
+                                if (routingRule.ip[0] === "geoip:private" && routingRule.outboundTag == "blocked"){
+                                    return;
+                                }
+                            }
+                            newTemplateSettings.routing.rules.push(routingRule);
+                        });
+                    }
+                    this.templateSettings = newTemplateSettings
+                },
+            },
+        }
     });
 
 </script>

+ 30 - 0
web/job/check_cpu_usage.go

@@ -0,0 +1,30 @@
+package job
+
+import (
+	"fmt"
+	"time"
+	"x-ui/web/service"
+
+	"github.com/shirou/gopsutil/v3/cpu"
+)
+
+type CheckCpuJob struct {
+	tgbotService   service.Tgbot
+	settingService service.SettingService
+}
+
+func NewCheckCpuJob() *CheckCpuJob {
+	return new(CheckCpuJob)
+}
+
+// Here run is a interface method of Job interface
+func (j *CheckCpuJob) Run() {
+	threshold, _ := j.settingService.GetTgCpu()
+
+	// get latest status of server
+	percent, err := cpu.Percent(1*time.Second, false)
+	if err == nil && percent[0] > float64(threshold) {
+		msg := fmt.Sprintf("🔴 CPU usage %.2f%% is more than threshold %d%%", percent[0], threshold)
+		j.tgbotService.SendMsgToTgbotAdmins(msg)
+	}
+}

+ 3 - 222
web/job/stats_notify_job.go

@@ -1,15 +1,7 @@
 package job
 
 import (
-	"fmt"
-	"net"
-	"os"
-	"time"
-	"x-ui/logger"
-	"x-ui/util/common"
 	"x-ui/web/service"
-
-	tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
 )
 
 type LoginStatus byte
@@ -20,229 +12,18 @@ const (
 )
 
 type StatsNotifyJob struct {
-	enable         bool
-	xrayService    service.XrayService
-	inboundService service.InboundService
-	settingService service.SettingService
+	xrayService  service.XrayService
+	tgbotService service.Tgbot
 }
 
 func NewStatsNotifyJob() *StatsNotifyJob {
 	return new(StatsNotifyJob)
 }
 
-func (j *StatsNotifyJob) SendMsgToTgbot(msg string) {
-	//Telegram bot basic info
-	tgBottoken, err := j.settingService.GetTgBotToken()
-	if err != nil || tgBottoken == "" {
-		logger.Warning("sendMsgToTgbot failed,GetTgBotToken fail:", err)
-		return
-	}
-	tgBotid, err := j.settingService.GetTgBotChatId()
-	if err != nil {
-		logger.Warning("sendMsgToTgbot failed,GetTgBotChatId fail:", err)
-		return
-	}
-
-	bot, err := tgbotapi.NewBotAPI(tgBottoken)
-	if err != nil {
-		fmt.Println("get tgbot error:", err)
-		return
-	}
-	bot.Debug = true
-	fmt.Printf("Authorized on account %s", bot.Self.UserName)
-	info := tgbotapi.NewMessage(int64(tgBotid), msg)
-	//msg.ReplyToMessageID = int(tgBotid)
-	bot.Send(info)
-}
-
 // Here run is a interface method of Job interface
 func (j *StatsNotifyJob) Run() {
 	if !j.xrayService.IsXrayRunning() {
 		return
 	}
-	var info string
-	//get hostname
-	name, err := os.Hostname()
-	if err != nil {
-		fmt.Println("get hostname error:", err)
-		return
-	}
-	info = fmt.Sprintf("Hostname:%s\r\n", name)
-	//get ip address
-	var ip string
-	netInterfaces, err := net.Interfaces()
-	if err != nil {
-		fmt.Println("net.Interfaces failed, err:", err.Error())
-		return
-	}
-
-	for i := 0; i < len(netInterfaces); i++ {
-		if (netInterfaces[i].Flags & net.FlagUp) != 0 {
-			addrs, _ := netInterfaces[i].Addrs()
-
-			for _, address := range addrs {
-				if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
-					if ipnet.IP.To4() != nil {
-						ip = ipnet.IP.String()
-						break
-					} else {
-						ip = ipnet.IP.String()
-						break
-					}
-				}
-			}
-		}
-	}
-	info += fmt.Sprintf("IP:%s\r\n \r\n", ip)
-
-	// get traffic
-	inbouds, err := j.inboundService.GetAllInbounds()
-	if err != nil {
-		logger.Warning("StatsNotifyJob run failed:", err)
-		return
-	}
-	// NOTE:If there no any sessions here,need to notify here
-	// TODO:Sub-node push, automatic conversion format
-	for _, inbound := range inbouds {
-		info += fmt.Sprintf("Node name:%s\r\nPort:%d\r\nUpload↑:%s\r\nDownload↓:%s\r\nTotal:%s\r\n", inbound.Remark, inbound.Port, common.FormatTraffic(inbound.Up), common.FormatTraffic(inbound.Down), common.FormatTraffic((inbound.Up + inbound.Down)))
-		if inbound.ExpiryTime == 0 {
-			info += fmt.Sprintf("Expire date:unlimited\r\n \r\n")
-		} else {
-			info += fmt.Sprintf("Expire date:%s\r\n \r\n", time.Unix((inbound.ExpiryTime/1000), 0).Format("2006-01-02 15:04:05"))
-		}
-	}
-	j.SendMsgToTgbot(info)
-}
-
-func (j *StatsNotifyJob) UserLoginNotify(username string, ip string, time string, status LoginStatus) {
-	if username == "" || ip == "" || time == "" {
-		logger.Warning("UserLoginNotify failed,invalid info")
-		return
-	}
-	var msg string
-	// Get hostname
-	name, err := os.Hostname()
-	if err != nil {
-		fmt.Println("get hostname error:", err)
-		return
-	}
-	if status == LoginSuccess {
-		msg = fmt.Sprintf("Successfully logged-in to the panel\r\nHostname:%s\r\n", name)
-	} else if status == LoginFail {
-		msg = fmt.Sprintf("Login to the panel was unsuccessful\r\nHostname:%s\r\n", name)
-	}
-	msg += fmt.Sprintf("Time:%s\r\n", time)
-	msg += fmt.Sprintf("Username:%s\r\n", username)
-	msg += fmt.Sprintf("IP:%s\r\n", ip)
-	j.SendMsgToTgbot(msg)
-}
-
-var numericKeyboard = tgbotapi.NewInlineKeyboardMarkup(
-	tgbotapi.NewInlineKeyboardRow(
-		tgbotapi.NewInlineKeyboardButtonData("Get Usage", "get_usage"),
-	),
-)
-
-func (j *StatsNotifyJob) OnReceive() *StatsNotifyJob {
-	tgBottoken, err := j.settingService.GetTgBotToken()
-	if err != nil || tgBottoken == "" {
-		logger.Warning("sendMsgToTgbot failed,GetTgBotToken fail:", err)
-		return j
-	}
-	bot, err := tgbotapi.NewBotAPI(tgBottoken)
-	if err != nil {
-		fmt.Println("get tgbot error:", err)
-		return j
-	}
-	bot.Debug = false
-	u := tgbotapi.NewUpdate(0)
-	u.Timeout = 10
-
-	updates := bot.GetUpdatesChan(u)
-
-	for update := range updates {
-		if update.Message == nil {
-
-			if update.CallbackQuery != nil {
-				// Respond to the callback query, telling Telegram to show the user
-				// a message with the data received.
-				callback := tgbotapi.NewCallback(update.CallbackQuery.ID, update.CallbackQuery.Data)
-				if _, err := bot.Request(callback); err != nil {
-					logger.Warning(err)
-				}
-
-				// And finally, send a message containing the data received.
-				msg := tgbotapi.NewMessage(update.CallbackQuery.Message.Chat.ID, "")
-
-				switch update.CallbackQuery.Data {
-				case "get_usage":
-					msg.Text = "for get your usage send command like this : \n <code>/usage uuid | id</code> \n example : <code>/usage fc3239ed-8f3b-4151-ff51-b183d5182142</code>"
-					msg.ParseMode = "HTML"
-				}
-				if _, err := bot.Send(msg); err != nil {
-					logger.Warning(err)
-				}
-			}
-
-			continue
-		}
-
-		if !update.Message.IsCommand() { // ignore any non-command Messages
-			continue
-		}
-
-		// Create a new MessageConfig. We don't have text yet,
-		// so we leave it empty.
-		msg := tgbotapi.NewMessage(update.Message.Chat.ID, "")
-
-		// Extract the command from the Message.
-		switch update.Message.Command() {
-		case "help":
-			msg.Text = "What you need?"
-			msg.ReplyMarkup = numericKeyboard
-		case "start":
-			msg.Text = "Hi :) \n What you need?"
-			msg.ReplyMarkup = numericKeyboard
-
-		case "status":
-			msg.Text = "bot is ok."
-
-		case "usage":
-			msg.Text = j.getClientUsage(update.Message.CommandArguments())
-		default:
-			msg.Text = "I don't know that command, /help"
-			msg.ReplyMarkup = numericKeyboard
-
-		}
-
-		if _, err := bot.Send(msg); err != nil {
-			logger.Warning(err)
-		}
-	}
-	return j
-
-}
-func (j *StatsNotifyJob) getClientUsage(id string) string {
-	traffic, err := j.inboundService.GetClientTrafficById(id)
-	if err != nil {
-		logger.Warning(err)
-		return "something wrong!"
-	}
-	expiryTime := ""
-	if traffic.ExpiryTime == 0 {
-		expiryTime = fmt.Sprintf("unlimited")
-	} else {
-		expiryTime = fmt.Sprintf("%s", time.Unix((traffic.ExpiryTime/1000), 0).Format("2006-01-02 15:04:05"))
-	}
-	total := ""
-	if traffic.Total == 0 {
-		total = fmt.Sprintf("unlimited")
-	} else {
-		total = fmt.Sprintf("%s", common.FormatTraffic((traffic.Total)))
-	}
-	output := fmt.Sprintf("💡 Active: %t\r\n📧 Email: %s\r\n🔼 Download↑: %s\r\n🔽 Upload↓: %s\r\n🔄 Total: %s / %s\r\n📅 Expire in: %s\r\n",
-		traffic.Enable, traffic.Email, common.FormatTraffic(traffic.Up), common.FormatTraffic(traffic.Down), common.FormatTraffic((traffic.Up + traffic.Down)),
-		total, expiryTime)
-
-	return output
+	j.tgbotService.SendReport()
 }

+ 1 - 2
web/job/xray_traffic_job.go

@@ -28,11 +28,10 @@ func (j *XrayTrafficJob) Run() {
 	if err != nil {
 		logger.Warning("add traffic failed:", err)
 	}
-	
+
 	err = j.inboundService.AddClientTraffic(clientTraffics)
 	if err != nil {
 		logger.Warning("add client traffic failed:", err)
 	}
 
-
 }

+ 203 - 52
web/service/inbound.go

@@ -54,7 +54,7 @@ func (s *InboundService) getClients(inbound *model.Inbound) ([]model.Client, err
 	settings := map[string][]model.Client{}
 	json.Unmarshal([]byte(inbound.Settings), &settings)
 	if settings == nil {
-		return nil, fmt.Errorf("Setting is null")
+		return nil, fmt.Errorf("setting is null")
 	}
 
 	clients := settings["clients"]
@@ -125,11 +125,18 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, err
 		return inbound, common.NewError("Duplicate email:", existEmail)
 	}
 
+	clients, err := s.getClients(inbound)
+	if err != nil {
+		return inbound, err
+	}
+
 	db := database.GetDB()
 
 	err = db.Save(inbound).Error
 	if err == nil {
-		s.UpdateClientStat(inbound.Id, inbound.Settings)
+		for _, client := range clients {
+			s.AddClientStat(inbound.Id, &client)
+		}
 	}
 	return inbound, err
 }
@@ -168,6 +175,10 @@ func (s *InboundService) AddInbounds(inbounds []*model.Inbound) error {
 
 func (s *InboundService) DelInbound(id int) error {
 	db := database.GetDB()
+	err := db.Where("inbound_id = ?", id).Delete(xray.ClientTraffic{}).Error
+	if err != nil {
+		return err
+	}
 	return db.Delete(model.Inbound{}, id).Error
 }
 
@@ -216,11 +227,108 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 	oldInbound.Sniffing = inbound.Sniffing
 	oldInbound.Tag = fmt.Sprintf("inbound-%v", inbound.Port)
 
-	s.UpdateClientStat(inbound.Id, inbound.Settings)
 	db := database.GetDB()
 	return inbound, db.Save(oldInbound).Error
 }
 
+func (s *InboundService) AddInboundClient(inbound *model.Inbound) error {
+	existEmail, err := s.checkEmailExistForInbound(inbound)
+	if err != nil {
+		return err
+	}
+
+	if existEmail != "" {
+		return common.NewError("Duplicate email:", existEmail)
+	}
+
+	clients, err := s.getClients(inbound)
+	if err != nil {
+		return err
+	}
+
+	oldInbound, err := s.GetInbound(inbound.Id)
+	if err != nil {
+		return err
+	}
+
+	oldClients, err := s.getClients(oldInbound)
+	if err != nil {
+		return err
+	}
+
+	oldInbound.Settings = inbound.Settings
+
+	if len(clients[len(clients)-1].Email) > 0 {
+		s.AddClientStat(inbound.Id, &clients[len(clients)-1])
+	}
+	for i := len(oldClients); i < len(clients); i++ {
+		if len(clients[i].Email) > 0 {
+			s.AddClientStat(inbound.Id, &clients[i])
+		}
+	}
+	db := database.GetDB()
+	return db.Save(oldInbound).Error
+}
+
+func (s *InboundService) DelInboundClient(inbound *model.Inbound, email string) error {
+	db := database.GetDB()
+	err := s.DelClientStat(db, email)
+	if err != nil {
+		logger.Error("Delete stats Data Error")
+		return err
+	}
+
+	oldInbound, err := s.GetInbound(inbound.Id)
+	if err != nil {
+		logger.Error("Load Old Data Error")
+		return err
+	}
+
+	oldInbound.Settings = inbound.Settings
+
+	return db.Save(oldInbound).Error
+}
+
+func (s *InboundService) UpdateInboundClient(inbound *model.Inbound, index int) error {
+	existEmail, err := s.checkEmailExistForInbound(inbound)
+	if err != nil {
+		return err
+	}
+	if existEmail != "" {
+		return common.NewError("Duplicate email:", existEmail)
+	}
+
+	clients, err := s.getClients(inbound)
+	if err != nil {
+		return err
+	}
+
+	oldInbound, err := s.GetInbound(inbound.Id)
+	if err != nil {
+		return err
+	}
+
+	oldClients, err := s.getClients(oldInbound)
+	if err != nil {
+		return err
+	}
+
+	oldInbound.Settings = inbound.Settings
+
+	db := database.GetDB()
+
+	if len(clients[index].Email) > 0 {
+		if len(oldClients[index].Email) > 0 {
+			s.UpdateClientStat(oldClients[index].Email, &clients[index])
+		} else {
+			s.AddClientStat(inbound.Id, &clients[index])
+		}
+	} else {
+		s.DelClientStat(db, oldClients[index].Email)
+	}
+	return db.Save(oldInbound).Error
+}
+
 func (s *InboundService) AddTraffic(traffics []*xray.Traffic) (err error) {
 	if len(traffics) == 0 {
 		return nil
@@ -283,10 +391,12 @@ func (s *InboundService) AddClientTraffic(traffics []*xray.ClientTraffic) (err e
 			}
 			continue
 		}
+
 		err = txInbound.Where("id=?", client.InboundId).First(inbound).Error
 		if err != nil {
 			if err == gorm.ErrRecordNotFound {
 				logger.Warning(err, traffic.Email)
+
 			}
 			continue
 		}
@@ -300,7 +410,7 @@ func (s *InboundService) AddClientTraffic(traffics []*xray.ClientTraffic) (err e
 				traffic.Total = client.TotalGB
 			}
 		}
-		if tx.Where("inbound_id = ?", inbound.Id).Where("email = ?", traffic.Email).
+		if tx.Where("inbound_id = ? and email = ?", inbound.Id, traffic.Email).
 			UpdateColumns(map[string]interface{}{
 				"enable":      true,
 				"expiry_time": traffic.ExpiryTime,
@@ -339,82 +449,92 @@ func (s *InboundService) DisableInvalidClients() (int64, error) {
 	count := result.RowsAffected
 	return count, err
 }
-func (s *InboundService) UpdateClientStat(inboundId int, inboundSettings string) error {
+func (s *InboundService) AddClientStat(inboundId int, client *model.Client) error {
 	db := database.GetDB()
 
-	// get settings clients
-	settings := map[string][]model.Client{}
-	json.Unmarshal([]byte(inboundSettings), &settings)
-	clients := settings["clients"]
-	for _, client := range clients {
-		result := db.Model(xray.ClientTraffic{}).
-			Where("inbound_id = ? and email = ?", inboundId, client.Email).
-			Updates(map[string]interface{}{"enable": true, "total": client.TotalGB, "expiry_time": client.ExpiryTime})
-		if result.RowsAffected == 0 {
-			clientTraffic := xray.ClientTraffic{}
-			clientTraffic.InboundId = inboundId
-			clientTraffic.Email = client.Email
-			clientTraffic.Total = client.TotalGB
-			clientTraffic.ExpiryTime = client.ExpiryTime
-			clientTraffic.Enable = true
-			clientTraffic.Up = 0
-			clientTraffic.Down = 0
-			db.Create(&clientTraffic)
-		}
-		err := result.Error
-		if err != nil {
-			return err
-		}
-
+	clientTraffic := xray.ClientTraffic{}
+	clientTraffic.InboundId = inboundId
+	clientTraffic.Email = client.Email
+	clientTraffic.Total = client.TotalGB
+	clientTraffic.ExpiryTime = client.ExpiryTime
+	clientTraffic.Enable = true
+	clientTraffic.Up = 0
+	clientTraffic.Down = 0
+	result := db.Create(&clientTraffic)
+	err := result.Error
+	if err != nil {
+		return err
 	}
 	return nil
 }
-func (s *InboundService) DelClientStat(tx *gorm.DB, email string) error {
-	return tx.Where("email = ?", email).Delete(xray.ClientTraffic{}).Error
-}
-func (s *InboundService) GetInboundClientIps(clientEmail string) (string, error) {
+func (s *InboundService) UpdateClientStat(email string, client *model.Client) error {
 	db := database.GetDB()
-	InboundClientIps := &model.InboundClientIps{}
-	err := db.Model(model.InboundClientIps{}).Where("client_email = ?", clientEmail).First(InboundClientIps).Error
+
+	result := db.Model(xray.ClientTraffic{}).
+		Where("email = ?", email).
+		Updates(map[string]interface{}{
+			"enable":      true,
+			"email":       client.Email,
+			"total":       client.TotalGB,
+			"expiry_time": client.ExpiryTime})
+	err := result.Error
 	if err != nil {
-		return "", err
+		return err
 	}
-	return InboundClientIps.Ips, nil
+	return nil
 }
-func (s *InboundService) ClearClientIps(clientEmail string) (error) {
+func (s *InboundService) DelClientStat(tx *gorm.DB, email string) error {
+	return tx.Where("email = ?", email).Delete(xray.ClientTraffic{}).Error
+}
+
+func (s *InboundService) ResetClientTraffic(id int, clientEmail string) error {
 	db := database.GetDB()
 
-	result := db.Model(model.InboundClientIps{}).
-		Where("client_email = ?", clientEmail).
-		Update("ips", "")
-	err := result.Error
+	result := db.Model(xray.ClientTraffic{}).
+		Where("inbound_id = ? and email = ?", id, clientEmail).
+		Updates(map[string]interface{}{"enable": true, "up": 0, "down": 0})
 
+	err := result.Error
 
 	if err != nil {
 		return err
 	}
 	return nil
 }
-func (s *InboundService) ResetClientTraffic(clientEmail string) error {
+func (s *InboundService) GetClientTrafficTgBot(tguname string) (traffic []*xray.ClientTraffic, err error) {
 	db := database.GetDB()
+	var traffics []*xray.ClientTraffic
 
-	result := db.Model(xray.ClientTraffic{}).
-		Where("email = ?", clientEmail).
-		Updates(map[string]interface{}{"up": 0, "down": 0})
+	err = db.Model(xray.ClientTraffic{}).Where("email like ?", "%@"+tguname).Find(&traffics).Error
+	if err != nil {
+		if err == gorm.ErrRecordNotFound {
+			logger.Warning(err)
+			return nil, err
+		}
+	}
+	return traffics, err
+}
 
-	err := result.Error
+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 like ?", "%"+email+"%").Find(&traffics).Error
 	if err != nil {
-		return err
+		if err == gorm.ErrRecordNotFound {
+			logger.Warning(err)
+			return nil, err
+		}
 	}
-	return nil
+	return traffics, err
 }
-func (s *InboundService) GetClientTrafficById(uuid string) (traffic *xray.ClientTraffic, err error) {
+
+func (s *InboundService) SearchClientTraffic(query string) (traffic *xray.ClientTraffic, err error) {
 	db := database.GetDB()
 	inbound := &model.Inbound{}
 	traffic = &xray.ClientTraffic{}
 
-	err = db.Model(model.Inbound{}).Where("settings like ?", "%"+uuid+"%").First(inbound).Error
+	err = db.Model(model.Inbound{}).Where("settings like ?", "%\""+query+"\"%").First(inbound).Error
 	if err != nil {
 		if err == gorm.ErrRecordNotFound {
 			logger.Warning(err)
@@ -428,10 +548,18 @@ func (s *InboundService) GetClientTrafficById(uuid string) (traffic *xray.Client
 	json.Unmarshal([]byte(inbound.Settings), &settings)
 	clients := settings["clients"]
 	for _, client := range clients {
-		if uuid == client.ID {
+		if client.ID == query && client.Email != "" {
+			traffic.Email = client.Email
+			break
+		}
+		if client.Password == query && client.Email != "" {
 			traffic.Email = client.Email
+			break
 		}
 	}
+	if traffic.Email == "" {
+		return nil, err
+	}
 	err = db.Model(xray.ClientTraffic{}).Where("email = ?", traffic.Email).First(traffic).Error
 	if err != nil {
 		logger.Warning(err)
@@ -439,3 +567,26 @@ func (s *InboundService) GetClientTrafficById(uuid string) (traffic *xray.Client
 	}
 	return traffic, err
 }
+func (s *InboundService) GetInboundClientIps(clientEmail string) (string, error) {
+	db := database.GetDB()
+	InboundClientIps := &model.InboundClientIps{}
+	err := db.Model(model.InboundClientIps{}).Where("client_email = ?", clientEmail).First(InboundClientIps).Error
+	if err != nil {
+		return "", err
+	}
+	return InboundClientIps.Ips, nil
+}
+func (s *InboundService) ClearClientIps(clientEmail string) (error) {
+	db := database.GetDB()
+
+	result := db.Model(model.InboundClientIps{}).
+		Where("client_email = ?", clientEmail).
+		Update("ips", "")
+	err := result.Error
+
+
+	if err != nil {
+		return err
+	}
+	return nil
+}

+ 2 - 2
web/service/server.go

@@ -143,7 +143,7 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
 	} else {
 		logger.Warning("can not find io counters")
 	}
-	
+
 	status.TcpCount, err = sys.GetTCPCount()
 	if err != nil {
 		logger.Warning("get tcp connections failed:", err)
@@ -153,7 +153,7 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
 	if err != nil {
 		logger.Warning("get udp connections failed:", err)
 	}
-	
+
 	if s.xrayService.IsXrayRunning() {
 		status.Xray.State = Running
 		status.Xray.ErrorMsg = ""

+ 46 - 10
web/service/setting.go

@@ -31,8 +31,12 @@ var defaultValueMap = map[string]string{
 	"timeLocation":       "Asia/Tehran",
 	"tgBotEnable":        "false",
 	"tgBotToken":         "",
-	"tgBotChatId":        "0",
-	"tgRunTime":          "",
+	"tgBotChatId":        "",
+	"tgRunTime":          "@daily",
+	"tgBotBackup":        "false",
+	"tgExpireDiff":       "0",
+	"tgTrafficDiff":      "0",
+	"tgCpu":              "0",
 }
 
 type SettingService struct {
@@ -202,28 +206,60 @@ func (s *SettingService) SetTgBotToken(token string) error {
 	return s.setString("tgBotToken", token)
 }
 
-func (s *SettingService) GetTgBotChatId() (int, error) {
-	return s.getInt("tgBotChatId")
+func (s *SettingService) GetTgBotChatId() (string, error) {
+	return s.getString("tgBotChatId")
 }
 
-func (s *SettingService) SetTgBotChatId(chatId int) error {
-	return s.setInt("tgBotChatId", chatId)
+func (s *SettingService) SetTgBotChatId(chatIds string) error {
+	return s.setString("tgBotChatId", chatIds)
+}
+
+func (s *SettingService) GetTgbotenabled() (bool, error) {
+	return s.getBool("tgBotEnable")
 }
 
 func (s *SettingService) SetTgbotenabled(value bool) error {
 	return s.setBool("tgBotEnable", value)
 }
 
-func (s *SettingService) GetTgbotenabled() (bool, error) {
-	return s.getBool("tgBotEnable")
+func (s *SettingService) GetTgbotRuntime() (string, error) {
+	return s.getString("tgRunTime")
 }
 
 func (s *SettingService) SetTgbotRuntime(time string) error {
 	return s.setString("tgRunTime", time)
 }
 
-func (s *SettingService) GetTgbotRuntime() (string, error) {
-	return s.getString("tgRunTime")
+func (s *SettingService) GetTgBotBackup() (bool, error) {
+	return s.getBool("tgBotBackup")
+}
+
+func (s *SettingService) SetTgBotBackup(value bool) error {
+	return s.setBool("tgBotBackup", value)
+}
+
+func (s *SettingService) GetTgExpireDiff() (int, error) {
+	return s.getInt("tgExpireDiff")
+}
+
+func (s *SettingService) SetTgExpireDiff(value int) error {
+	return s.setInt("tgExpireDiff", value)
+}
+
+func (s *SettingService) GetTgTrafficDiff() (int, error) {
+	return s.getInt("tgTrafficDiff")
+}
+
+func (s *SettingService) SetTgTrafficDiff(value int) error {
+	return s.setInt("tgTrafficDiff", value)
+}
+
+func (s *SettingService) GetTgCpu() (int, error) {
+	return s.getInt("tgCpu")
+}
+
+func (s *SettingService) SetTgCpu(value int) error {
+	return s.setInt("tgCpu", value)
 }
 
 func (s *SettingService) GetPort() (int, error) {

+ 546 - 0
web/service/tgbot.go

@@ -0,0 +1,546 @@
+package service
+
+import (
+	"fmt"
+	"net"
+	"os"
+	"strconv"
+	"strings"
+	"time"
+	"x-ui/config"
+	"x-ui/database/model"
+	"x-ui/logger"
+	"x-ui/util/common"
+	"x-ui/xray"
+
+	tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
+)
+
+var bot *tgbotapi.BotAPI
+var adminIds []int64
+var isRunning bool
+
+type LoginStatus byte
+
+const (
+	LoginSuccess LoginStatus = 1
+	LoginFail    LoginStatus = 0
+)
+
+type Tgbot struct {
+	inboundService InboundService
+	settingService SettingService
+	serverService  ServerService
+	lastStatus     *Status
+}
+
+func (t *Tgbot) NewTgbot() *Tgbot {
+	return new(Tgbot)
+}
+
+func (t *Tgbot) Start() error {
+	tgBottoken, err := t.settingService.GetTgBotToken()
+	if err != nil || tgBottoken == "" {
+		logger.Warning("Get TgBotToken failed:", err)
+		return err
+	}
+
+	tgBotid, err := t.settingService.GetTgBotChatId()
+	if err != nil {
+		logger.Warning("Get GetTgBotChatId failed:", err)
+		return err
+	}
+
+	for _, adminId := range strings.Split(tgBotid, ",") {
+		id, err := strconv.Atoi(adminId)
+		if err != nil {
+			logger.Warning("Failed to get IDs from GetTgBotChatId:", err)
+			return err
+		}
+		adminIds = append(adminIds, int64(id))
+	}
+
+	bot, err = tgbotapi.NewBotAPI(tgBottoken)
+	if err != nil {
+		fmt.Println("Get tgbot's api error:", err)
+		return err
+	}
+	bot.Debug = false
+
+	// listen for TG bot income messages
+	if !isRunning {
+		logger.Info("Starting Telegram receiver ...")
+		go t.OnReceive()
+		isRunning = true
+	}
+
+	return nil
+}
+
+func (t *Tgbot) IsRunnging() bool {
+	return isRunning
+}
+
+func (t *Tgbot) Stop() {
+	bot.StopReceivingUpdates()
+	logger.Info("Stop Telegram receiver ...")
+	isRunning = false
+	adminIds = nil
+}
+
+func (t *Tgbot) OnReceive() {
+	u := tgbotapi.NewUpdate(0)
+	u.Timeout = 10
+
+	updates := bot.GetUpdatesChan(u)
+
+	for update := range updates {
+		tgId := update.FromChat().ID
+		chatId := update.FromChat().ChatConfig().ChatID
+		isAdmin := checkAdmin(tgId)
+		if update.Message == nil {
+			if update.CallbackQuery != nil {
+				t.asnwerCallback(update.CallbackQuery, isAdmin)
+			}
+		} else {
+			if update.Message.IsCommand() {
+				t.answerCommand(update.Message, chatId, isAdmin)
+			} else {
+				t.aswerChat(update.Message.Text, chatId, isAdmin)
+			}
+		}
+	}
+}
+
+func (t *Tgbot) answerCommand(message *tgbotapi.Message, chatId int64, isAdmin bool) {
+	msg := ""
+	// Extract the command from the Message.
+	switch message.Command() {
+	case "help":
+		msg = "This bot is providing you some specefic data from the server.\n\n Please choose:"
+	case "start":
+		msg = "Hello <i>" + message.From.FirstName + "</i> 👋"
+		if isAdmin {
+			hostname, _ := os.Hostname()
+			msg += "\nWelcome to <b>" + hostname + "</b> management bot"
+		}
+		msg += "\n\nI can do some magics for you, please choose:"
+	case "status":
+		msg = "bot is ok ✅"
+	case "usage":
+		if isAdmin {
+			t.searchClient(chatId, message.CommandArguments())
+		} else {
+			t.searchForClient(chatId, message.CommandArguments())
+		}
+	default:
+		msg = "❗ Unknown command"
+	}
+	t.SendAnswer(chatId, msg, isAdmin)
+}
+
+func (t *Tgbot) aswerChat(message string, chatId int64, isAdmin bool) {
+	t.SendAnswer(chatId, "❗ Unknown message", isAdmin)
+}
+
+func (t *Tgbot) asnwerCallback(callbackQuery *tgbotapi.CallbackQuery, isAdmin bool) {
+	// Respond to the callback query, telling Telegram to show the user
+	// a message with the data received.
+	callback := tgbotapi.NewCallback(callbackQuery.ID, callbackQuery.Data)
+	if _, err := bot.Request(callback); err != nil {
+		logger.Warning(err)
+	}
+
+	switch callbackQuery.Data {
+	case "get_usage":
+		t.SendMsgToTgbot(callbackQuery.From.ID, t.getServerUsage())
+	case "inbounds":
+		t.SendMsgToTgbot(callbackQuery.From.ID, t.getInboundUsages())
+	case "exhausted_soon":
+		t.SendMsgToTgbot(callbackQuery.From.ID, t.getExhausted())
+	case "get_backup":
+		t.sendBackup(callbackQuery.From.ID)
+	case "client_traffic":
+		t.getClientUsage(callbackQuery.From.ID, callbackQuery.From.UserName)
+	case "client_commands":
+		t.SendMsgToTgbot(callbackQuery.From.ID, "To search for statistics, just use folowing command:\r\n \r\n<code>/usage [UID|Passowrd]</code>\r\n \r\nUse UID for vmess and vless and Password for Trojan.")
+	case "commands":
+		t.SendMsgToTgbot(callbackQuery.From.ID, "To search for a client email, just use folowing command:\r\n \r\n<code>/usage email</code>")
+	}
+}
+
+func checkAdmin(tgId int64) bool {
+	for _, adminId := range adminIds {
+		if adminId == tgId {
+			return true
+		}
+	}
+	return false
+}
+
+func (t *Tgbot) SendAnswer(chatId int64, msg string, isAdmin bool) {
+	var numericKeyboard = tgbotapi.NewInlineKeyboardMarkup(
+		tgbotapi.NewInlineKeyboardRow(
+			tgbotapi.NewInlineKeyboardButtonData("Server Usage", "get_usage"),
+			tgbotapi.NewInlineKeyboardButtonData("Get DB Backup", "get_backup"),
+		),
+		tgbotapi.NewInlineKeyboardRow(
+			tgbotapi.NewInlineKeyboardButtonData("Get Inbounds", "inbounds"),
+			tgbotapi.NewInlineKeyboardButtonData("Exhausted soon", "exhausted_soon"),
+		),
+		tgbotapi.NewInlineKeyboardRow(
+			tgbotapi.NewInlineKeyboardButtonData("Commands", "commands"),
+		),
+	)
+	var numericKeyboardClient = tgbotapi.NewInlineKeyboardMarkup(
+		tgbotapi.NewInlineKeyboardRow(
+			tgbotapi.NewInlineKeyboardButtonData("Get Usage", "client_traffic"),
+			tgbotapi.NewInlineKeyboardButtonData("Commands", "client_commands"),
+		),
+	)
+	msgConfig := tgbotapi.NewMessage(chatId, msg)
+	msgConfig.ParseMode = "HTML"
+	if isAdmin {
+		msgConfig.ReplyMarkup = numericKeyboard
+	} else {
+		msgConfig.ReplyMarkup = numericKeyboardClient
+	}
+	_, err := bot.Send(msgConfig)
+	if err != nil {
+		logger.Warning("Error sending telegram message :", err)
+	}
+}
+
+func (t *Tgbot) SendMsgToTgbot(tgid int64, msg string) {
+	var allMessages []string
+	limit := 2000
+	// paging message if it is big
+	if len(msg) > limit {
+		messages := strings.Split(msg, "\r\n \r\n")
+		lastIndex := -1
+		for _, message := range messages {
+			if (len(allMessages) == 0) || (len(allMessages[lastIndex])+len(message) > limit) {
+				allMessages = append(allMessages, message)
+				lastIndex++
+			} else {
+				allMessages[lastIndex] += "\r\n \r\n" + message
+			}
+		}
+	} else {
+		allMessages = append(allMessages, msg)
+	}
+	for _, message := range allMessages {
+		info := tgbotapi.NewMessage(tgid, message)
+		info.ParseMode = "HTML"
+		_, err := bot.Send(info)
+		if err != nil {
+			logger.Warning("Error sending telegram message :", err)
+		}
+		time.Sleep(500 * time.Millisecond)
+	}
+}
+
+func (t *Tgbot) SendMsgToTgbotAdmins(msg string) {
+	for _, adminId := range adminIds {
+		t.SendMsgToTgbot(adminId, msg)
+	}
+}
+
+func (t *Tgbot) SendReport() {
+	runTime, err := t.settingService.GetTgbotRuntime()
+	if err == nil && len(runTime) > 0 {
+		t.SendMsgToTgbotAdmins("🕰 Scheduled reports: " + runTime + "\r\nDate-Time: " + time.Now().Format("2006-01-02 15:04:05"))
+	}
+	info := t.getServerUsage()
+	t.SendMsgToTgbotAdmins(info)
+	exhausted := t.getExhausted()
+	t.SendMsgToTgbotAdmins(exhausted)
+	backupEnable, err := t.settingService.GetTgBotBackup()
+	if err == nil && backupEnable {
+		for _, adminId := range adminIds {
+			t.sendBackup(int64(adminId))
+		}
+	}
+}
+
+func (t *Tgbot) getServerUsage() string {
+	var info string
+	//get hostname
+	name, err := os.Hostname()
+	if err != nil {
+		logger.Error("get hostname error:", err)
+		name = ""
+	}
+	info = fmt.Sprintf("💻 Hostname: %s\r\n", name)
+	//get ip address
+	var ip string
+	var ipv6 string
+	netInterfaces, err := net.Interfaces()
+	if err != nil {
+		logger.Error("net.Interfaces failed, err:", err.Error())
+		info += "🌐 IP: Unknown\r\n \r\n"
+	} else {
+		for i := 0; i < len(netInterfaces); i++ {
+			if (netInterfaces[i].Flags & net.FlagUp) != 0 {
+				addrs, _ := netInterfaces[i].Addrs()
+
+				for _, address := range addrs {
+					if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
+						if ipnet.IP.To4() != nil {
+							ip += ipnet.IP.String() + " "
+						} else if ipnet.IP.To16() != nil && !ipnet.IP.IsLinkLocalUnicast() {
+							ipv6 += ipnet.IP.String() + " "
+						}
+					}
+				}
+			}
+		}
+		info += fmt.Sprintf("🌐IP: %s\r\n🌐IPv6: %s\r\n", ip, ipv6)
+	}
+
+	// get latest status of server
+	t.lastStatus = t.serverService.GetStatus(t.lastStatus)
+	info += fmt.Sprintf("🔌Server Uptime: %d days\r\n", int(t.lastStatus.Uptime/86400))
+	info += fmt.Sprintf("📈Server Load: %.1f, %.1f, %.1f\r\n", t.lastStatus.Loads[0], t.lastStatus.Loads[1], t.lastStatus.Loads[2])
+	info += fmt.Sprintf("📋Server Memory: %s/%s\r\n", common.FormatTraffic(int64(t.lastStatus.Mem.Current)), common.FormatTraffic(int64(t.lastStatus.Mem.Total)))
+	info += fmt.Sprintf("🔹TcpCount: %d\r\n", t.lastStatus.TcpCount)
+	info += fmt.Sprintf("🔸UdpCount: %d\r\n", t.lastStatus.UdpCount)
+	info += fmt.Sprintf("🚦Traffic: %s (↑%s,↓%s)\r\n", common.FormatTraffic(int64(t.lastStatus.NetTraffic.Sent+t.lastStatus.NetTraffic.Recv)), common.FormatTraffic(int64(t.lastStatus.NetTraffic.Sent)), common.FormatTraffic(int64(t.lastStatus.NetTraffic.Recv)))
+	info += fmt.Sprintf("ℹXray status: %s", t.lastStatus.Xray.State)
+
+	return info
+}
+
+func (t *Tgbot) UserLoginNotify(username string, ip string, time string, status LoginStatus) {
+	if username == "" || ip == "" || time == "" {
+		logger.Warning("UserLoginNotify failed,invalid info")
+		return
+	}
+	var msg string
+	// Get hostname
+	name, err := os.Hostname()
+	if err != nil {
+		logger.Warning("get hostname error:", err)
+		return
+	}
+	if status == LoginSuccess {
+		msg = fmt.Sprintf("✅ Successfully logged-in to the panel\r\nHostname:%s\r\n", name)
+	} else if status == LoginFail {
+		msg = fmt.Sprintf("❗ Login to the panel was unsuccessful\r\nHostname:%s\r\n", name)
+	}
+	msg += fmt.Sprintf("⏰ Time:%s\r\n", time)
+	msg += fmt.Sprintf("🆔 Username:%s\r\n", username)
+	msg += fmt.Sprintf("🌐 IP:%s\r\n", ip)
+	t.SendMsgToTgbotAdmins(msg)
+}
+
+func (t *Tgbot) getInboundUsages() string {
+	info := ""
+	// get traffic
+	inbouds, err := t.inboundService.GetAllInbounds()
+	if err != nil {
+		logger.Warning("GetAllInbounds run failed:", err)
+		info += "❌ Failed to get inbounds"
+	} else {
+		// NOTE:If there no any sessions here,need to notify here
+		// TODO:Sub-node push, automatic conversion format
+		for _, inbound := range inbouds {
+			info += fmt.Sprintf("📍Inbound:%s\r\nPort:%d\r\n", inbound.Remark, inbound.Port)
+			info += fmt.Sprintf("Traffic: %s (↑%s,↓%s)\r\n", common.FormatTraffic((inbound.Up + inbound.Down)), common.FormatTraffic(inbound.Up), common.FormatTraffic(inbound.Down))
+			if inbound.ExpiryTime == 0 {
+				info += "Expire date: ♾ Unlimited\r\n \r\n"
+			} else {
+				info += fmt.Sprintf("Expire date:%s\r\n \r\n", time.Unix((inbound.ExpiryTime/1000), 0).Format("2006-01-02 15:04:05"))
+			}
+		}
+	}
+	return info
+}
+
+func (t *Tgbot) getClientUsage(chatId int64, tgUserName string) {
+	traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserName)
+	if err != nil {
+		logger.Warning(err)
+		msg := "❌ Something went wrong!"
+		t.SendMsgToTgbot(chatId, msg)
+		return
+	}
+	if len(traffics) == 0 {
+		msg := "Your configuration is not found!\nPlease ask your Admin to use your telegram username in your configuration(s).\n\nYour username: <b>@" + tgUserName + "</b>"
+		t.SendMsgToTgbot(chatId, msg)
+	}
+	for _, traffic := range traffics {
+		expiryTime := ""
+		if traffic.ExpiryTime == 0 {
+			expiryTime = "♾Unlimited"
+		} else {
+			expiryTime = time.Unix((traffic.ExpiryTime / 1000), 0).Format("2006-01-02 15:04:05")
+		}
+		total := ""
+		if traffic.Total == 0 {
+			total = "♾Unlimited"
+		} else {
+			total = common.FormatTraffic((traffic.Total))
+		}
+		output := fmt.Sprintf("💡 Active: %t\r\n📧 Email: %s\r\n🔼 Upload↑: %s\r\n🔽 Download↓: %s\r\n🔄 Total: %s / %s\r\n📅 Expire in: %s\r\n",
+			traffic.Enable, traffic.Email, common.FormatTraffic(traffic.Up), common.FormatTraffic(traffic.Down), common.FormatTraffic((traffic.Up + traffic.Down)),
+			total, expiryTime)
+		t.SendMsgToTgbot(chatId, output)
+	}
+	t.SendAnswer(chatId, "Please choose:", false)
+}
+
+func (t *Tgbot) searchClient(chatId int64, email string) {
+	traffics, err := t.inboundService.GetClientTrafficByEmail(email)
+	if err != nil {
+		logger.Warning(err)
+		msg := "❌ Something went wrong!"
+		t.SendMsgToTgbot(chatId, msg)
+		return
+	}
+	if len(traffics) == 0 {
+		msg := "No result!"
+		t.SendMsgToTgbot(chatId, msg)
+		return
+	}
+	for _, traffic := range traffics {
+		expiryTime := ""
+		if traffic.ExpiryTime == 0 {
+			expiryTime = "♾Unlimited"
+		} else {
+			expiryTime = time.Unix((traffic.ExpiryTime / 1000), 0).Format("2006-01-02 15:04:05")
+		}
+		total := ""
+		if traffic.Total == 0 {
+			total = "♾Unlimited"
+		} else {
+			total = common.FormatTraffic((traffic.Total))
+		}
+		output := fmt.Sprintf("💡 Active: %t\r\n📧 Email: %s\r\n🔼 Upload↑: %s\r\n🔽 Download↓: %s\r\n🔄 Total: %s / %s\r\n📅 Expire in: %s\r\n",
+			traffic.Enable, traffic.Email, common.FormatTraffic(traffic.Up), common.FormatTraffic(traffic.Down), common.FormatTraffic((traffic.Up + traffic.Down)),
+			total, expiryTime)
+		t.SendMsgToTgbot(chatId, output)
+	}
+}
+
+func (t *Tgbot) searchForClient(chatId int64, query string) {
+	traffic, err := t.inboundService.SearchClientTraffic(query)
+	if err != nil {
+		logger.Warning(err)
+		msg := "❌ Something went wrong!"
+		t.SendMsgToTgbot(chatId, msg)
+		return
+	}
+	if traffic == nil {
+		msg := "No result!"
+		t.SendMsgToTgbot(chatId, msg)
+		return
+	}
+	expiryTime := ""
+	if traffic.ExpiryTime == 0 {
+		expiryTime = "♾Unlimited"
+	} else {
+		expiryTime = time.Unix((traffic.ExpiryTime / 1000), 0).Format("2006-01-02 15:04:05")
+	}
+	total := ""
+	if traffic.Total == 0 {
+		total = "♾Unlimited"
+	} else {
+		total = common.FormatTraffic((traffic.Total))
+	}
+	output := fmt.Sprintf("💡 Active: %t\r\n📧 Email: %s\r\n🔼 Upload↑: %s\r\n🔽 Download↓: %s\r\n🔄 Total: %s / %s\r\n📅 Expire in: %s\r\n",
+		traffic.Enable, traffic.Email, common.FormatTraffic(traffic.Up), common.FormatTraffic(traffic.Down), common.FormatTraffic((traffic.Up + traffic.Down)),
+		total, expiryTime)
+	t.SendMsgToTgbot(chatId, output)
+}
+
+func (t *Tgbot) getExhausted() string {
+	trDiff := int64(0)
+	exDiff := int64(0)
+	now := time.Now().Unix() * 1000
+	var exhaustedInbounds []model.Inbound
+	var exhaustedClients []xray.ClientTraffic
+	var disabledInbounds []model.Inbound
+	var disabledClients []xray.ClientTraffic
+	output := ""
+	TrafficThreshold, err := t.settingService.GetTgTrafficDiff()
+	if err == nil && TrafficThreshold > 0 {
+		trDiff = int64(TrafficThreshold) * 1073741824
+	}
+	ExpireThreshold, err := t.settingService.GetTgExpireDiff()
+	if err == nil && ExpireThreshold > 0 {
+		exDiff = int64(ExpireThreshold) * 84600
+	}
+	inbounds, err := t.inboundService.GetAllInbounds()
+	if err != nil {
+		logger.Warning("Unable to load Inbounds", err)
+	}
+	for _, inbound := range inbounds {
+		if inbound.Enable {
+			if (inbound.ExpiryTime > 0 && (now-inbound.ExpiryTime < exDiff)) ||
+				(inbound.Total > 0 && (inbound.Total-inbound.Up+inbound.Down < trDiff)) {
+				exhaustedInbounds = append(exhaustedInbounds, *inbound)
+			}
+			if len(inbound.ClientStats) > 0 {
+				for _, client := range inbound.ClientStats {
+					if client.Enable {
+						if (client.ExpiryTime > 0 && (now-client.ExpiryTime < exDiff)) ||
+							(client.Total > 0 && (client.Total-client.Up+client.Down < trDiff)) {
+							exhaustedClients = append(exhaustedClients, client)
+						}
+					} else {
+						disabledClients = append(disabledClients, client)
+					}
+				}
+			}
+		} else {
+			disabledInbounds = append(disabledInbounds, *inbound)
+		}
+	}
+	output += fmt.Sprintf("Exhausted Inbounds count:\r\n🛑 Disabled: %d\r\n🔜 Exhaust soon: %d\r\n \r\n", len(disabledInbounds), len(exhaustedInbounds))
+	if len(disabledInbounds)+len(exhaustedInbounds) > 0 {
+		output += "Exhausted Inbounds:\r\n"
+		for _, inbound := range exhaustedInbounds {
+			output += fmt.Sprintf("📍Inbound:%s\r\nPort:%d\r\nTraffic: %s (↑%s,↓%s)\r\n", inbound.Remark, inbound.Port, common.FormatTraffic((inbound.Up + inbound.Down)), common.FormatTraffic(inbound.Up), common.FormatTraffic(inbound.Down))
+			if inbound.ExpiryTime == 0 {
+				output += "Expire date: ♾Unlimited\r\n \r\n"
+			} else {
+				output += fmt.Sprintf("Expire date:%s\r\n \r\n", time.Unix((inbound.ExpiryTime/1000), 0).Format("2006-01-02 15:04:05"))
+			}
+		}
+	}
+	output += fmt.Sprintf("Exhausted Clients count:\r\n🛑 Disabled: %d\r\n🔜 Exhaust soon: %d\r\n \r\n", len(disabledClients), len(exhaustedClients))
+	if len(disabledClients)+len(exhaustedClients) > 0 {
+		output += "Exhausted Clients:\r\n"
+		for _, traffic := range exhaustedClients {
+			expiryTime := ""
+			if traffic.ExpiryTime == 0 {
+				expiryTime = "♾Unlimited"
+			} else {
+				expiryTime = time.Unix((traffic.ExpiryTime / 1000), 0).Format("2006-01-02 15:04:05")
+			}
+			total := ""
+			if traffic.Total == 0 {
+				total = "♾Unlimited"
+			} else {
+				total = common.FormatTraffic((traffic.Total))
+			}
+			output += fmt.Sprintf("💡 Active: %t\r\n📧 Email: %s\r\n🔼 Upload↑: %s\r\n🔽 Download↓: %s\r\n🔄 Total: %s / %s\r\n📅 Expire in: %s\r\n",
+				traffic.Enable, traffic.Email, common.FormatTraffic(traffic.Up), common.FormatTraffic(traffic.Down), common.FormatTraffic((traffic.Up + traffic.Down)),
+				total, expiryTime)
+		}
+	}
+
+	return output
+}
+
+func (t *Tgbot) sendBackup(chatId int64) {
+	sendingTime := time.Now().Format("2006-01-02 15:04:05")
+	t.SendMsgToTgbot(chatId, "Backup time: "+sendingTime)
+	file := tgbotapi.FilePath(config.GetDBPath())
+	msg := tgbotapi.NewDocument(chatId, file)
+	_, err := bot.Send(msg)
+	if err != nil {
+		logger.Warning("Error in uploading backup: ", err)
+	}
+}

+ 1 - 1
web/service/xray.go

@@ -160,5 +160,5 @@ func (s *XrayService) SetToNeedRestart() {
 }
 
 func (s *XrayService) IsNeedRestartAndSetFalse() bool {
-	return isNeedXrayRestart.CAS(true, false)
+	return isNeedXrayRestart.CompareAndSwap(true, false)
 }

+ 80 - 50
web/translation/translate.en_US.toml

@@ -1,5 +1,5 @@
-"username" = "username"
-"password" = "password"
+"username" = "Username"
+"password" = "Password"
 "login" = "Login"
 "confirm" = "Confirm"
 "cancel" = "Cancel"
@@ -10,6 +10,8 @@
 "remark" = "Remark"
 "enable" = "Enable"
 "protocol" = "Protocol"
+"search" = "Search"
+
 "loading" = "Loading"
 "second" = "Second"
 "minute" = "Minute"
@@ -20,6 +22,7 @@
 "unlimited" = "Unlimited"
 "none" = "None"
 "qrCode" = "QR Code"
+"info" = "More information"
 "edit" = "Edit"
 "delete" = "Delete"
 "reset" = "Reset"
@@ -32,19 +35,16 @@
 "camouflage" = "Camouflage"
 "enabled" = "Enabled"
 "disabled" = "Disabled"
-"domainName" = "Domain Name"
+"domainName" = "Domain name"
 "additional" = "Alter"
 "monitor" = "Listen IP"
-"certificate" = "Certificate"
+"certificate" = "Certificat"
 "fail" = "Fail"
-"success" = "Success"
-"getVersion" = "Get Version"
+"success" = " Success"
+"getVersion" = "Get version"
 "install" = "Install"
-"used" = "Used"
 "clients" = "Clients"
-"search" = "Search"
 "usage" = "Usage"
-"info" = "Details"
 
 [menu]
 "dashboard" = "System Status"
@@ -61,18 +61,17 @@
 "invalidFormData" = "Input Data Format Is Invalid"
 "emptyUsername" = "Please Enter Username"
 "emptyPassword" = "Please Enter Password"
-"wrongUsernameOrPassword" = "invalid username or password"
+"wrongUsernameOrPassword" = "Invalid username or password"
 "successLogin" = "Login"
 
-
 [pages.index]
 "title" = "System Status"
 "memory" = "Memory"
 "hard" = "Hard Disk"
 "xrayStatus" = "Xray Status"
-"xraySwitch" = "Switch Version"
-"restartXray" = "Restart"
 "stopXray" = "Stop"
+"restartXray" = "Restart"
+"xraySwitch" = "Switch Version"
 "xraySwitchClick" = "Click on the version you want to switch"
 "xraySwitchClickDesk" = "Please choose carefully, older versions may have incompatible configurations"
 "operationHours" = "Operation Hours"
@@ -84,16 +83,15 @@
 "downSpeed" = "Total download speed for all network cards"
 "totalSent" = "Total upload traffic of all network cards since system startup"
 "totalReceive" = "Total download traffic of all network cards since system startup"
-"xraySwitchVersionDialog" = "switch xray version"
-"xraySwitchVersionDialogDesc" = "whether to switch the xray version to"
+"xraySwitchVersionDialog" = "Switch xray version"
+"xraySwitchVersionDialogDesc" = "Whether to switch the xray version to"
 "dontRefreshh" = "Installation is in progress, please do not refresh this page"
 
 [pages.inbounds]
-"export" = "Export"
 "title" = "Inbounds"
-"totalDownUp" = "Total Uploads/Downloads"
-"totalUsage" = "Total Usage"
-"inboundCount" = "Number Of Inbound"
+"totalDownUp" = "Total uploads/downloads"
+"totalUsage" = "Total usage"
+"inboundCount" = "Number of inbound"
 "operate" = "Actions"
 "enable" = "Enable"
 "remark" = "Remark"
@@ -102,11 +100,11 @@
 "traffic" = "Traffic"
 "details" = "Details"
 "transportConfig" = "Transport"
-"expireDate" = "Expire Date"
-"resetTraffic" = "Reset Traffic"
+"expireDate" = "Expire date"
+"resetTraffic" = "Reset traffic"
 "addInbound" = "Add Inbound"
 "addTo" = "Add To"
-"revise" = "Save"
+"revise" = "Revise"
 "modifyInbound" = "Modify InBound"
 "deleteInbound" = "Delete Inbound"
 "deleteInboundContent" = "Are you sure you want to delete inbound?"
@@ -115,44 +113,56 @@
 "address" = "Address"
 "network" = "Network"
 "destinationPort" = "Destination port"
-"targetAddress" = "Target Address"
+"targetAddress" = "Target address"
 "disableInsecureEncryption" = "Disable insecure encryption"
 "monitorDesc" = "Leave blank by default"
-"meansNoLimit" = "Means No Limit"
-"totalFlow" = "Total Traffic"
+"meansNoLimit" = "Means no limit"
+"totalFlow" = "Total flow"
 "leaveBlankToNeverExpire" = "Leave blank to never expire"
 "noRecommendKeepDefault" = "There are no special requirements to keep the default"
-"certificatePath" = "Certificate File Path"
-"certificateContent" = "Certificate File Content"
-"publicKeyPath" = "Public Key Path"
-"publicKeyContent" = "public Key Content"
-"keyPath" = "Private key Path"
-"keyContent" = "Private Key Content"
+"certificatePath" = "Certificate file path"
+"certificateContent" = "Certificate file content"
+"publicKeyPath" = "Public key path"
+"publicKeyContent" = "Public key content"
+"keyPath" = "Private Key path"
+"keyContent" = "Private Key content"
+"clickOnQRcode" = "Click on QR Code to Copy"
 "client" = "Client"
-"uid" = "UID"
+"export" = "Export links"
 
+[pages.client]
+"add" = "Add client"
+"edit" = "Edit client"
+"submitAdd" = "Add client"
+"submitEdit" = "Save changes"
+"clientCount" = "Number of clients"
+"bulk" = "Add bulk"
+"method" = "Method"
+"first" = "First"
+"last" = "Last"
+"prefix" = "Prefix"
+"postfix" = "postfix"
 
 [pages.inbounds.toasts]
 "obtain" = "Obtain"
 
 [pages.inbounds.stream.general]
-"requestHeader" = "Request Header"
+"requestHeader" = "Request header"
 "name" = "Name"
 "value" = "Value"
 
 [pages.inbounds.stream.tcp]
-"requestVersion" = "Request Version"
-"requestMethod" = "Request Method"
-"requestPath" = "Request Path"
-"responseVersion" = "Response Version"
-"responseStatus" = "Response Status"
-"responseStatusDescription" = "Response Status Description"
-"responseHeader" = "Response Header"
+"requestVersion" = "Request version"
+"requestMethod" = "Request method"
+"requestPath" = "Request path"
+"responseVersion" = "Response version"
+"responseStatus" = "Response status"
+"responseStatusDescription" = "Response status description"
+"responseHeader" = "Response header"
 
 [pages.inbounds.stream.quic]
 "encryption" = "Encryption"
 
-
 [pages.setting]
 "title" = "Setting"
 "save" = "Save"
@@ -160,7 +170,7 @@
 "restartPanelDesc" = "Are you sure you want to restart the panel? Click OK to restart after 3 seconds. If you cannot access the panel after restarting, please go to the server to view the panel log information"
 "panelConfig" = "Panel Configuration"
 "userSetting" = "User Setting"
-"xrayConfiguration" = "Xray Configuration"
+"xrayConfiguration" = "xray Configuration"
 "TGReminder" = "TG Reminder Related Settings"
 "otherSetting" = "Other Setting"
 "panelListeningIP" = "Panel listening IP"
@@ -177,22 +187,42 @@
 "currentPassword" = "Current Password"
 "newUsername" = "New Username"
 "newPassword" = "New Password"
-"xrayConfigTemplate" = "xray Configuration Template"
-"xrayConfigTemplateDesc" = "Generate the final xray configuration file based on this template, restart the panel to take effect"
+"advancedTemplate" = "Advanced template parts"
+"completeTemplate" = "Complete template of Xray configuration"
+"xrayConfigTemplate" = "Xray Configuration Template"
+"xrayConfigTemplateDesc" = "Generate the final xray configuration file based on this template, restart the panel to take effect."
+"xrayConfigTorrent" = "Ban bittorrent usage"
+"xrayConfigTorrentDesc" = "Change the configuration temlate to avoid using bittorrent by users, restart the panel to take effect"
+"xrayConfigPrivateIp" = "Ban private ip range to connect"
+"xrayConfigPrivateIpDesc" = "Change the configuration temlate to avoid connecting with private IP ranges, restart the panel to take effect"
+"xrayConfigInbounds" = "Configuration of Inbounds"
+"xrayConfigInboundsDesc" = "Change the configuration temlate to accept special clients, restart the panel to take effect"
+"xrayConfigOutbounds" = "Configuration of Outbounds"
+"xrayConfigOutboundsDesc" = "Change the configuration temlate to define outgoing ways for this server, restart the panel to take effect"
+"xrayConfigRoutings" = "Configuration of Routing rules"
+"xrayConfigRoutingsDesc" = "Change the configuration temlate to define Routing rules for this server, restart the panel to take effect"
 "telegramBotEnable" = "Enable telegram bot"
 "telegramBotEnableDesc" = "Restart the panel to take effect"
 "telegramToken" = "Telegram Token"
 "telegramTokenDesc" = "Restart the panel to take effect"
-"telegramChatId" = "Telegram ChatId"
-"telegramChatIdDesc" = "Restart the panel to take effect"
+"telegramChatId" = "Telegram Admin ChatIds"
+"telegramChatIdDesc" = "Multi chatIDs separated by comma. Restart the panel to take effect"
 "telegramNotifyTime" = "Telegram bot notification time"
-"telegramNotifyTimeDesc" = "Using Crontab timing format, restart the panel to take effect"
+"telegramNotifyTimeDesc" = "Using Crontab timing format. Restart the panel to take effect"
+"tgNotifyBackup" = "Database backup"
+"tgNotifyBackupDesc" = "Sending database backup file with report notification. Restart the panel to take effect"
+"tgNotifyExpireTimeDiff" = "Remained time threshold"
+"tgNotifyExpireTimeDiffDesc" = "This telegram bot will send you a notification before expiration (unit:day)"
+"tgNotifyTrafficDiff" = "Remained traffic threshold"
+"tgNotifyTrafficDiffDesc" = "This telegram bot will send you a notification before finishing traffic (unit:GB)"
+"tgNotifyCpu" = "CPU percentage alert threshold"
+"tgNotifyCpuDesc" = "This telegram bot will send you a notification if CPU usage is more than this percentage (unit:%)"
 "timeZonee" = "Time Zone"
 "timeZoneDesc" = "The scheduled task runs according to the time in the time zone, and restarts the panel to take effect"
 
 [pages.setting.toasts]
-"modifySetting" = "modify setting"
-"getSetting" = "get setting"
-"modifyUser" = "modify user"
+"modifySetting" = "Modify setting"
+"getSetting" = "Get setting"
+"modifyUser" = "Modify user"
 "originalUserPassIncorrect" = "The original user name or original password is incorrect"
 "userPassMustBeNotEmpty" = "New username and new password cannot be empty"

+ 48 - 18
web/translation/translate.fa_IR.toml

@@ -10,6 +10,8 @@
 "remark" = "نام"
 "enable" = "فعال"
 "protocol" = "پروتکل"
+"search" = "جستجو"
+
 "loading" = "در حال بروزرسانی..."
 "second" = "ثانیه"
 "minute" = "دقیقه"
@@ -20,6 +22,7 @@
 "unlimited" = "نامحدود"
 "none" = "هیچ"
 "qrCode" = "QR کد"
+"info" = "اطلاعات بیشتر"
 "edit" = "ویرایش"
 "delete" = "حذف"
 "reset" = "ریست"
@@ -30,8 +33,8 @@
 "host" = "آدرس"
 "path" = "مسیر"
 "camouflage" = "استتار"
-"enabled" = "فعال شد"
-"disabled" = "غیرفعال شد"
+"enabled" = "فعال"
+"disabled" = "غیرفعال"
 "domainName" = "آدرس دامنه"
 "additional" = "آی دی جایگزین"
 "monitor" = "آی پی اتصال"
@@ -40,11 +43,8 @@
 "success" = " موفق"
 "getVersion" = "دریافت ورژن"
 "install" = "نصب"
-"used" = "استفاده شده"
 "clients" = "کاربران"
-"search" = "جستجو"
 "usage" = "استفاده"
-"info" = "جزئیات"
 
 [menu]
 "dashboard" = "وضعیت سیستم"
@@ -64,20 +64,19 @@
 "wrongUsernameOrPassword" = "نام کاربری و رمز عبور اشتباه میباشد"
 "successLogin" = "خوش آمدید"
 
-
 [pages.index]
 "title" = "وضعیت سیستم"
 "memory" = "حافظه رم"
 "hard" = "حافظه دیسک"
 "xrayStatus" = "وضعیت Xray"
-"xraySwitch" = "تغییر ورژن"
-"restartXray" = "راه اندازی مجدد"
 "stopXray" = "توقف"
+"restartXray" = "شروع مجدد"
+"xraySwitch" = "تغییر ورژن"
 "xraySwitchClick" = "ورژن مورد نظر را انتخاب کنید"
 "xraySwitchClickDesk" = "لطفا با دقت انتخاب کنید ، در صورت انتخاب اشتباه امکان قطعی سیستم وجود دارد ."
 "operationHours" = "ساعت فعال"
 "operationHoursDesc" = "ساعت فعال بعد از شروع سیستم"
-"systemLoad" = "سرعت لود سیستم"
+"systemLoad" = "بار روی سیستم"
 "connectionCount" = "تعداد کانکشن ها"
 "connectionCountDesc" = "تعداد کانکشن ها برای کل شبکه"
 "upSpeed" = "سرعت آپلود در حال حاضر سیستم"
@@ -88,9 +87,7 @@
 "xraySwitchVersionDialogDesc" = "آیا از تغییر ورژن مطمئن هستین"
 "dontRefreshh" = "در حال نصب ، لطفا رفرش نکنید "
 
-
 [pages.inbounds]
-"export" = "استخراج لینکها"
 "title" = "کاربران"
 "totalDownUp" = "جمع آپلود/دانلود"
 "totalUsage" = "جمع کل"
@@ -107,7 +104,7 @@
 "resetTraffic" = "ریست ترافیک"
 "addInbound" = "اضافه کردن سرویس"
 "addTo" = "اضافه کردن"
-"revise" = "ذخیره"
+"revise" = "ویرایش"
 "modifyInbound" = "ویرایش سرویس"
 "deleteInbound" = "حذف سرویس"
 "deleteInboundContent" = "آیا مطمئن به حذف سرویس هستید ؟"
@@ -129,8 +126,22 @@
 "publicKeyContent" = "محتوای Certificate.crt"
 "keyPath" = "مسیر فایل Private.key"
 "keyContent" = "محتوای Private.key"
+"clickOnQRcode" = "برای کپی بر روی کد تصویری کلیک کنید"
 "client" = "کاربر"
-"uid" = "UID"
+"export" = "استخراج لینک‌ها"
+
+[pages.client]
+"add" = "کاربر جدید"
+"edit" = "ویرایش کاربر"
+"submitAdd" = "اضافه کردن"
+"submitEdit" = "ذخیره تغییرات"
+"clientCount" = "تعداد کاربران"
+"bulk" = "انبوه سازی"
+"method" = "روش"
+"first" = "از"
+"last" = "تا"
+"prefix" = "پیشوند"
+"postfix" = "پسوند"
 
 [pages.inbounds.toasts]
 "obtain" = "Obtain"
@@ -152,7 +163,6 @@
 [pages.inbounds.stream.quic]
 "encryption" = "رمزنگاری"
 
-
 [pages.setting]
 "title" = "تنظیمات"
 "save" = "ذخیره"
@@ -177,16 +187,36 @@
 "currentPassword" = "رمز عبور فعلی"
 "newUsername" = "نام کاربری جدید"
 "newPassword" = "رمز عبور جدید"
-"xrayConfigTemplate" = "تنظیمات قالب Xray"
-"xrayConfigTemplateDesc" = "فایل پیکربندی xray نهایی را بر اساس این الگو ایجاد کنید. لطفاً این را تغییر ندهید مگر اینکه دقیقاً بدانید که چه کاری انجام می دهید! پنل را مجدداً راه اندازی کنید تا اعمال شود"
+"advancedTemplate" = "بخش های پیشرفته الگو"
+"completeTemplate" = "الگوی کامل تنظیمات ایکس ری"
+"xrayConfigTemplate" = "تنظیمات الگو ایکس ری"
+"xrayConfigTemplateDesc" = "فایل پیکربندی ایکس ری نهایی بر اساس این الگو ایجاد میشود. لطفاً این را تغییر ندهید مگر اینکه دقیقاً بدانید که چه کاری انجام می دهید! پنل را مجدداً راه اندازی کنید تا اعمال شود"
+"xrayConfigTorrent" = "فیلتر کردن بیت تورنت"
+"xrayConfigTorrentDesc" = "الگوی تنظیمات را برای فیلتر کردن پروتکل بیت تورنت برای کاربران تغییر میدهد.  پنل را مجدداً راه اندازی کنید تا اعمال شود"
+"xrayConfigPrivateIp" = "جلوگیری از اتصال آی پی های نامعتبر"
+"xrayConfigPrivateIpDesc" = "الگوی تنظیمات را برای فیلتر کردن اتصال آی پی های نامعتبر و بسته های سرگردان تغییر میدهد.  پنل را مجدداً راه اندازی کنید تا اعمال شود"
+"xrayConfigInbounds" = "تنظیمات ورودی"
+"xrayConfigInboundsDesc" = "میتوانید الگوی تنظیمات را برای ورودی های خاص تنظیم نمایید.  پنل را مجدداً راه اندازی کنید تا اعمال شود"
+"xrayConfigOutbounds" = "تنظیمات خروجی"
+"xrayConfigOutboundsDesc" = "میتوانید الگوی تنظیمات را برای خروجی اینترنت تنظیم نمایید.  پنل را مجدداً راه اندازی کنید تا اعمال شود"
+"xrayConfigRoutings" = "تنظیمات قوانین مسیریابی"
+"xrayConfigRoutingsDesc" = "میتوانید الگوی تنظیمات را برای مسیریابی تنظیم نمایید.  پنل را مجدداً راه اندازی کنید تا اعمال شود"
 "telegramBotEnable" = "فعالسازی ربات تلگرام"
 "telegramBotEnableDesc" = "پنل را مجدداً راه اندازی کنید تا اعمال شود"
 "telegramToken" = "توکن تلگرام"
 "telegramTokenDesc" = "پنل را مجدداً راه اندازی کنید تا اعمال شود"
-"telegramChatId" = "آی دی تلگرام مدیریت . از ربات  @getidsbot آی دی خود را دریافت کنید"
-"telegramChatIdDesc" = "پنل را مجدداً راه اندازی کنید تا اعمال شود"
+"telegramChatId" = "آی دی تلگرام مدیریت"
+"telegramChatIdDesc" = "با استفاده از کاما میتونید چند آی دی را از هم جدا کنید. پنل را مجدداً راه اندازی کنید تا اعمال شود"
 "telegramNotifyTime" = "مدت زمان نوتیفیکیشن ربات تلگرام"
 "telegramNotifyTimeDesc" = "از فرمت زمان بندی Crontab استفاده کنید . پنل را مجدداً راه اندازی کنید تا اعمال شود"
+"tgNotifyBackup" = "پشتیبان گیری از پایگاه داده"
+"tgNotifyBackupDesc" = "ارسال کپی فایل پایگاه داده به همراه گزارش دوره ای"
+"tgNotifyExpireTimeDiff" = "آستانه زمان باقی مانده"
+"tgNotifyExpireTimeDiffDesc" = "این ربات تلگرام قبل از انقضا برای شما پیام ارسال می کند (واحد: روز)"
+"tgNotifyTrafficDiff" = "آستانه ترافیک باقی مانده"
+"tgNotifyTrafficDiffDesc" = "این ربات تلگرام قبل از اتمام ترافیک برای شما پیام ارسال می کند (واحد: گیگابایت)"
+"tgNotifyCpu" = "آستانه هشدار درصد پردازنده"
+"tgNotifyCpuDesc" = "این ربات تلگرام در صورت استفاده پردازنده بیشتر از این درصد برای شما پیام ارسال می کند.(واحد: درصد)"
 "timeZonee" = "منظقه زمانی"
 "timeZoneDesc" = "وظایف برنامه ریزی شده بر اساس این منطقه زمانی اجرا می شوند. پنل را مجدداً راه اندازی می کند تا اعمال شود"
 

+ 42 - 12
web/translation/translate.zh_Hans.toml

@@ -10,6 +10,8 @@
 "remark" = "备注"
 "enable" = "启用"
 "protocol" = "协议"
+"search" = "搜尋"
+
 "loading" = "加载中"
 "second" = "秒"
 "minute" = "分钟"
@@ -20,6 +22,7 @@
 "unlimited" = "无限制"
 "none" = "无"
 "qrCode" = "二维码"
+"info" = "更多信息"
 "edit" = "编辑"
 "delete" = "删除"
 "reset" = "重置"
@@ -40,11 +43,8 @@
 "success" = "成功"
 "getVersion" = "获取版本"
 "install" = "安装"
-"used" = "用过的"
 "clients" = "客户端"
-"search" = "搜索"
 "usage" = "用法"
-"info" = "细节"
 
 [menu]
 "dashboard" = "系统状态"
@@ -69,9 +69,9 @@
 "memory" = "内存"
 "hard" = "硬盘"
 "xrayStatus" = "xray 状态"
+"stopXray" = "停止 Xray"
+"restartXray" = "重启 Xray"
 "xraySwitch" = "切换版本"
-"restartXray" = "重新开始"
-"stopXray" = "停止"
 "xraySwitchClick" = "点击你想切换的版本"
 "xraySwitchClickDesk" = "请谨慎选择,旧版本可能配置不兼容"
 "operationHours" = "运行时间"
@@ -87,9 +87,7 @@
 "xraySwitchVersionDialogDesc" = "是否切换 xray 版本至"
 "dontRefreshh" = "安装中,请不要刷新此页面"
 
-
 [pages.inbounds]
-"export" = "导出链接"
 "title" = "入站列表"
 "totalDownUp" = "总上传 / 下载"
 "totalUsage" = "总用量"
@@ -128,9 +126,22 @@
 "publicKeyContent" = "公钥内容"
 "keyPath" = "密钥文件路径"
 "keyContent" = "密钥内容"
+"clickOnQRcode" = "点击二维码复制"
 "client" = "客户"
-"uid" = "UID"
+"export" = "导出链接"
 
+[pages.client]
+"add" = "添加客户端"
+"edit" = "编辑客户"
+"submitAdd" = "添加客户端"
+"submitEdit" = "保存修改"
+"clientCount" = "客户数量"
+"bulk" = "批量创建"
+"method" = "方法"
+"first" = "第一"
+"last" = "最后"
+"prefix" = "前缀"
+"postfix" = "后缀"
 
 [pages.inbounds.toasts]
 "obtain" = "获取"
@@ -152,7 +163,6 @@
 [pages.inbounds.stream.quic]
 "encryption" = "加密"
 
-
 [pages.setting]
 "title" = "设置"
 "save" = "保存配置"
@@ -177,16 +187,36 @@
 "currentPassword" = "原密码"
 "newUsername" = "新用户名"
 "newPassword" = "新密码"
-"xrayConfigTemplate" = "xray 配置模版"
-"xrayConfigTemplateDesc" = "以该模版为基础生成最终的 xray 配置文件,重启面板生效"
+"advancedTemplate" = "高级模板部件"
+"completeTemplate" = "Xray 配置的完整模板"
+"xrayConfigTemplate" = "xray 配置模板"
+"xrayConfigTemplateDesc" = "以该模型为基础生成最终的xray配置文件,重新启动面板生成效率"
+"xrayConfigTorrent" = "禁止使用 bittorrent"
+"xrayConfigTorrentDesc" = "更改配置模板避免用户使用bittorrent,重启面板生效"
+"xrayConfigPrivateIp" = "禁止私人 ip 范围连接"
+"xrayConfigPrivateIpDesc" = "更改配置模板以避免连接私有 IP 范围,重启面板生效"
+"xrayConfigInbounds" = "入站配置"
+"xrayConfigInboundsDesc" = "更改配置模板接受特殊客户端,重启面板生效"
+"xrayConfigOutbounds" = "出站配置"
+"xrayConfigOutboundsDesc" = "更改配置模板定义此服务器的传出方式,重启面板生效"
+"xrayConfigRoutings" = "路由规则配置"
+"xrayConfigRoutingsDesc" = "更改配置模板为该服务器定义路由规则,重启面板生效"
 "telegramBotEnable" = "启用电报机器人"
 "telegramBotEnableDesc" = "重启面板生效"
 "telegramToken" = "电报机器人TOKEN"
 "telegramTokenDesc" = "重启面板生效"
-"telegramChatId" = "电报机器人ChatId"
+"telegramChatId" = "以逗号分隔的多个 chatID 重启面板生效"
 "telegramChatIdDesc" = "重启面板生效"
 "telegramNotifyTime" = "电报机器人通知时间"
 "telegramNotifyTimeDesc" = "采用Crontab定时格式,重启面板生效"
+"tgNotifyBackup" = "数据库备份"
+"tgNotifyBackupDesc" = "正在发送数据库备份文件和报告通知。重启面板生效"
+"tgNotifyExpireTimeDiff" = "剩余时间阈值"
+"tgNotifyExpireTimeDiffDesc" = "这个 talegram bot 会在到期前给你发送通知(单位:天)"
+"tgNotifyTrafficDiff" = "剩余流量阈值"
+"tgNotifyTrafficDiffDesc" = "这个 talegram bot 会在流量结束前给你发送通知(单位:GB)"
+"tgNotifyCpu" = "CPU 百分比警报阈值"
+"tgNotifyCpuDesc" = "如果 CPU 使用率超过此百分比(单位:%),此 talegram bot 将向您发送通知"
 "timeZonee" = "时区"
 "timeZoneDesc" = "定时任务按照该时区的时间运行,重启面板生效"
 

+ 19 - 5
web/web.go

@@ -21,11 +21,11 @@ import (
 	"x-ui/web/network"
 	"x-ui/web/service"
 
-	"github.com/pelletier/go-toml/v2"
 	"github.com/gin-contrib/sessions"
 	"github.com/gin-contrib/sessions/cookie"
 	"github.com/gin-gonic/gin"
 	"github.com/nicksnyder/go-i18n/v2/i18n"
+	"github.com/pelletier/go-toml/v2"
 	"github.com/robfig/cron/v3"
 	"golang.org/x/text/language"
 )
@@ -88,7 +88,7 @@ type Server struct {
 
 	xrayService    service.XrayService
 	settingService service.SettingService
-	inboundService service.InboundService
+	tgbotService   service.Tgbot
 
 	cron *cron.Cron
 
@@ -309,7 +309,7 @@ func (s *Server) startTask() {
 
 	// Check the inbound traffic every 30 seconds that the traffic exceeds and expires
 	s.cron.AddJob("@every 30s", job.NewCheckInboundJob())
-
+	
 	// check client ips from log file every 10 sec
 	s.cron.AddJob("@every 10s", job.NewCheckClientIpJob())
 
@@ -328,8 +328,13 @@ func (s *Server) startTask() {
 			logger.Warning("Add NewStatsNotifyJob error", err)
 			return
 		}
-		// listen for TG bot income messages
-		go job.NewStatsNotifyJob().OnReceive()
+
+		// Check CPU load and alarm to TgBot if threshold passes
+		cpuThreshold, err := s.settingService.GetTgCpu()
+		if (err == nil) && (cpuThreshold > 0) {
+			s.cron.AddJob("@every 10s", job.NewCheckCpuJob())
+		}
+
 	} else {
 		s.cron.Remove(entry)
 	}
@@ -406,6 +411,12 @@ func (s *Server) Start() (err error) {
 		s.httpServer.Serve(listener)
 	}()
 
+	isTgbotenabled, err := s.settingService.GetTgbotenabled()
+	if (err == nil) && (isTgbotenabled) {
+		tgBot := s.tgbotService.NewTgbot()
+		tgBot.Start()
+	}
+
 	return nil
 }
 
@@ -415,6 +426,9 @@ func (s *Server) Stop() error {
 	if s.cron != nil {
 		s.cron.Stop()
 	}
+	if s.tgbotService.IsRunnging() {
+		s.tgbotService.Stop()
+	}
 	var err1 error
 	var err2 error
 	if s.httpServer != nil {

Some files were not shown because too many files changed in this diff