Browse Source

Group Management of Subscription Clients (#2644)

* add group user with the same subscription id to all inbounds

* code format compare

* add await for reset client traffic

* en language changed

* added client traffic syncer job

* handle exist email duplicate in sub group

* multi reset and delete request for clients group

* add client traffic syncer setting option

* vi translate file updated

* auto open qr-modal bug fixed
Ali Rahimi 3 tháng trước cách đây
mục cha
commit
6e9180a665

+ 1 - 0
web/assets/js/model/setting.js

@@ -26,6 +26,7 @@ class AllSetting {
         this.xrayTemplateConfig = "";
         this.secretEnable = false;
         this.subEnable = false;
+        this.subSyncEnable = true;
         this.subListen = "";
         this.subPort = 2096;
         this.subPath = "/sub/";

+ 35 - 0
web/assets/js/util/utils.js

@@ -70,6 +70,41 @@ class HttpUtil {
         }
         return msg;
     }
+
+    static async jsonPost(url, data) {
+        let msg;
+        try {
+            const requestOptions = {
+                method: 'POST',
+                headers: {
+                    'Content-Type': 'application/json',
+                },
+                body: JSON.stringify(data),
+            };
+            const resp = await fetch(url, requestOptions);
+            const response = await resp.json();
+
+            msg = this._respToMsg({data : response});
+        } catch (e) {
+            msg = new Msg(false, e.toString());
+        }
+        this._handleMsg(msg);
+        return msg;
+    }
+
+    static async postWithModalJson(url, data, modal) {
+        if (modal) {
+            modal.loading(true);
+        }
+        const msg = await this.jsonPost(url, data);
+        if (modal) {
+            modal.loading(false);
+            if (msg instanceof Msg && msg.success) {
+                modal.close();
+            }
+        }
+        return msg;
+    }
 }
 
 class PromiseUtil {

+ 153 - 1
web/controller/inbound.go

@@ -1,10 +1,10 @@
 package controller
 
 import (
+    "errors"
 	"encoding/json"
 	"fmt"
 	"strconv"
-
 	"x-ui/database/model"
 	"x-ui/web/service"
 	"x-ui/web/session"
@@ -33,9 +33,13 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
 	g.POST("/clientIps/:email", a.getClientIps)
 	g.POST("/clearClientIps/:email", a.clearClientIps)
 	g.POST("/addClient", a.addInboundClient)
+	g.POST("/addGroupClient", a.addGroupInboundClient)
 	g.POST("/:id/delClient/:clientId", a.delInboundClient)
+	g.POST("/delGroupClients", a.delGroupClients)
 	g.POST("/updateClient/:clientId", a.updateInboundClient)
+	g.POST("/updateClients", a.updateGroupInboundClient)
 	g.POST("/:id/resetClientTraffic/:email", a.resetClientTraffic)
+	g.POST("/resetGroupClientTraffic", a.resetGroupClientTraffic)
 	g.POST("/resetAllTraffics", a.resetAllTraffics)
 	g.POST("/resetAllClientTraffics/:id", a.resetAllClientTraffics)
 	g.POST("/delDepletedClients/:id", a.delDepletedClients)
@@ -190,6 +194,34 @@ func (a *InboundController) addInboundClient(c *gin.Context) {
 	}
 }
 
+func (a *InboundController) addGroupInboundClient(c *gin.Context) {
+	var requestData []model.Inbound
+
+    err := c.ShouldBindJSON(&requestData)
+
+    if err != nil {
+        jsonMsg(c, I18nWeb(c, "pages.inbounds.update"), err)
+        return
+    }
+
+    needRestart := true
+
+    for _, data := range requestData {
+
+        needRestart, err = a.inboundService.AddInboundClient(&data)
+        if err != nil {
+            jsonMsg(c, "Something went wrong!", err)
+            return
+        }
+    }
+
+    jsonMsg(c, "Client(s) added", nil)
+    if err == nil && needRestart {
+        a.xrayService.SetToNeedRestart()
+    }
+
+}
+
 func (a *InboundController) delInboundClient(c *gin.Context) {
 	id, err := strconv.Atoi(c.Param("id"))
 	if err != nil {
@@ -211,6 +243,38 @@ func (a *InboundController) delInboundClient(c *gin.Context) {
 	}
 }
 
+func (a *InboundController) delGroupClients(c *gin.Context) {
+	var requestData []struct {
+        InboundID int `json:"inboundId"`
+        ClientID  string `json:"clientId"`
+    }
+
+    if err := c.ShouldBindJSON(&requestData); err != nil {
+        jsonMsg(c, "Invalid request data", err)
+        return
+    }
+
+    needRestart := false
+
+    for _, req := range requestData {
+        needRestartTmp, err := a.inboundService.DelInboundClient(req.InboundID, req.ClientID)
+        if err != nil {
+            jsonMsg(c, "Failed to delete client", err)
+            return
+        }
+
+        if needRestartTmp {
+            needRestart = true
+        }
+    }
+
+    jsonMsg(c, "Clients deleted successfully", nil)
+
+    if needRestart {
+		a.xrayService.SetToNeedRestart()
+    }
+}
+
 func (a *InboundController) updateInboundClient(c *gin.Context) {
 	clientId := c.Param("clientId")
 
@@ -234,6 +298,56 @@ func (a *InboundController) updateInboundClient(c *gin.Context) {
 	}
 }
 
+func (a *InboundController) updateGroupInboundClient(c *gin.Context) {
+    var requestData []map[string]interface{}
+
+    if err := c.ShouldBindJSON(&requestData); err != nil {
+        jsonMsg(c, I18nWeb(c, "pages.inbounds.update"), err)
+        return
+    }
+
+    needRestart := false
+
+    for _, item := range requestData {
+
+        inboundMap, ok := item["inbound"].(map[string]interface{})
+        if !ok {
+            jsonMsg(c, "Something went wrong!", errors.New("Failed to convert 'inbound' to map"))
+            return
+        }
+
+        clientId, ok := item["clientId"].(string)
+        if !ok {
+            jsonMsg(c, "Something went wrong!", errors.New("Failed to convert 'clientId' to string"))
+            return
+        }
+
+        inboundJSON, err := json.Marshal(inboundMap)
+        if err != nil {
+            jsonMsg(c, "Something went wrong!", err)
+            return
+        }
+
+        var inboundModel model.Inbound
+        if err := json.Unmarshal(inboundJSON, &inboundModel); err != nil {
+            jsonMsg(c, "Something went wrong!", err)
+            return
+        }
+
+        if restart, err := a.inboundService.UpdateInboundClient(&inboundModel, clientId); err != nil {
+            jsonMsg(c, "Something went wrong!", err)
+            return
+        } else {
+            needRestart = needRestart || restart
+        }
+    }
+
+    jsonMsg(c, "Client updated", nil)
+    if needRestart {
+        a.xrayService.SetToNeedRestart()
+    }
+}
+
 func (a *InboundController) resetClientTraffic(c *gin.Context) {
 	id, err := strconv.Atoi(c.Param("id"))
 	if err != nil {
@@ -253,6 +367,44 @@ func (a *InboundController) resetClientTraffic(c *gin.Context) {
 	}
 }
 
+func (a *InboundController) resetGroupClientTraffic(c *gin.Context) {
+	var requestData []struct {
+		InboundID int    `json:"inboundId"` // Map JSON "inboundId" to struct field "InboundID"
+		Email     string `json:"email"`    // Map JSON "email" to struct field "Email"
+	}
+
+	// Parse JSON body directly using ShouldBindJSON
+	if err := c.ShouldBindJSON(&requestData); err != nil {
+		jsonMsg(c, "Invalid request data", err)
+		return
+	}
+
+	needRestart := false
+
+	// Process each request data
+	for _, req := range requestData {
+		needRestartTmp, err := a.inboundService.ResetClientTraffic(req.InboundID, req.Email)
+		if err != nil {
+			jsonMsg(c, "Failed to reset client traffic", err)
+			return
+		}
+
+		// If any request requires a restart, set needRestart to true
+		if needRestartTmp {
+			needRestart = true
+		}
+	}
+
+	// Send response back to the client
+	jsonMsg(c, "Traffic reset for all clients", nil)
+
+	// Restart the service if required
+	if needRestart {
+		a.xrayService.SetToNeedRestart()
+	}
+}
+
+
 func (a *InboundController) resetAllTraffics(c *gin.Context) {
 	err := a.inboundService.ResetAllTraffics()
 	if err != nil {

+ 1 - 0
web/entity/entity.go

@@ -40,6 +40,7 @@ type AllSetting struct {
 	TimeLocation     string `json:"timeLocation" form:"timeLocation"`
 	SecretEnable     bool   `json:"secretEnable" form:"secretEnable"`
 	SubEnable        bool   `json:"subEnable" form:"subEnable"`
+	SubSyncEnable    bool   `json:"subSyncEnable" form:"subSyncEnable"`
 	SubListen        string `json:"subListen" form:"subListen"`
 	SubPort          int    `json:"subPort" form:"subPort"`
 	SubPath          string `json:"subPath" form:"subPath"`

+ 15 - 9
web/html/common/qrcode_modal.html

@@ -23,13 +23,15 @@
         </tr-qr-bg>
       </tr-qr-box>
     </template>
-    <template v-for="(row, index) in qrModal.qrcodes">
-      <tr-qr-box class="qr-box">
-        <a-tag color="green" class="qr-tag"><span>[[ row.remark ]]</span></a-tag>
-        <tr-qr-bg class="qr-bg">
-          <canvas @click="copyToClipboard('qrCode-'+index, row.link)" :id="'qrCode-'+index" class="qr-cv"></canvas>
-        </tr-qr-bg>
-      </tr-qr-box>
+    <template v-if="!isJustSub">
+        <template v-for="(row, index) in qrModal.qrcodes">
+          <tr-qr-box class="qr-box">
+            <a-tag color="green" class="qr-tag"><span>[[ row.remark ]]</span></a-tag>
+            <tr-qr-bg class="qr-bg">
+              <canvas @click="copyToClipboard('qrCode-'+index, row.link)" :id="'qrCode-'+index" class="qr-cv"></canvas>
+            </tr-qr-bg>
+          </tr-qr-box>
+        </template>
     </template>
   </tr-qr-modal>
 </a-modal>
@@ -43,12 +45,14 @@
     qrcodes: [],
     clipboard: null,
     visible: false,
+    isJustSub: false,
     subId: '',
-    show: function(title = '', dbInbound, client) {
+    show: function(title = '', dbInbound, client, isJustSub = false) {
       this.title = title;
       this.dbInbound = dbInbound;
       this.inbound = dbInbound.toInbound();
       this.client = client;
+      this.isJustSub = isJustSub;
       this.subId = '';
       this.qrcodes = [];
       if (this.inbound.protocol == Protocols.WIREGUARD) {
@@ -76,7 +80,9 @@
     delimiters: ['[[', ']]'],
     el: '#qrcode-modal',
     data: {
-      qrModal: qrModal,
+      qrModal: qrModal,get isJustSub(){
+               return qrModal.isJustSub
+            }
     },
     methods: {
       copyToClipboard(elementId, content) {

+ 170 - 33
web/html/xui/client_modal.html

@@ -16,7 +16,16 @@
         title: '',
         okText: '',
         isEdit: false,
+        group: {
+            canGroup: true,
+            isGroup: false,
+            currentClient: null,
+            inbounds: [],
+            clients: [],
+            editIds: []
+        },
         dbInbound: new DBInbound(),
+        dbInbounds: null,
         inbound: new Inbound(),
         clients: [],
         clientStats: [],
@@ -25,33 +34,121 @@
         clientIps: null,
         delayedStart: false,
         ok() {
-            if (clientModal.isEdit) {
-                ObjectUtil.execute(clientModal.confirm, clientModalApp.client, clientModal.dbInbound.id, clientModal.oldClientId);
+            if (app.subSettings.enable && clientModal.group.isGroup && clientModal.group.canGroup) {
+                const currentClient = clientModal.group.currentClient;
+                const { limitIp, totalGB, expiryTime, reset, enable, subId, tgId, flow } = currentClient;
+                const uniqueEmails = clientModalApp.makeGroupEmailsUnique(clientModal.dbInbounds, currentClient.email, clientModal.group.clients);
+
+                clientModal.group.clients.forEach((client, index) => {
+                    client.email = uniqueEmails[index];
+                    client.limitIp = limitIp;
+                    client.totalGB = totalGB;
+                    client.expiryTime = expiryTime;
+                    client.reset = reset;
+                    client.enable = enable;
+
+                    if (subId) {
+                        client.subId = subId;
+                    }
+                    if (tgId) {
+                        client.tgId = tgId;
+                    }
+                    if (flow) {
+                        client.flow = flow;
+                    }
+                });
+
+                if (clientModal.isEdit) {
+                    ObjectUtil.execute(clientModal.confirm, clientModal.group.clients, clientModal.group.inbounds, clientModal.group.editIds);
+                } else {
+                    ObjectUtil.execute(clientModal.confirm, clientModal.group.clients, clientModal.group.inbounds);
+                }
             } else {
-                ObjectUtil.execute(clientModal.confirm, clientModalApp.client, clientModal.dbInbound.id);
+                if (clientModal.isEdit) {
+                    ObjectUtil.execute(clientModal.confirm, clientModalApp.client, clientModal.dbInbound.id, clientModal.oldClientId);
+                } else {
+                    ObjectUtil.execute(clientModal.confirm, clientModalApp.client, clientModal.dbInbound.id);
+                }
             }
         },
-        show({ title = '', okText = '{{ i18n "sure" }}', index = null, dbInbound = null, confirm = () => { }, isEdit = false }) {
+        show({
+                 title = '',
+                 okText = '{{ i18n "sure" }}',
+                 index = null,
+                 dbInbound = null,
+                 dbInbounds = null,
+                 confirm = () => {
+                 },
+                 isEdit = false
+             }) {
+            this.group = {
+                canGroup: true,
+                isGroup: false,
+                currentClient: null,
+                inbounds: [],
+                clients: [],
+                editIds: []
+            }
+            this.dbInbounds = dbInbounds;
             this.visible = true;
             this.title = title;
             this.okText = okText;
             this.isEdit = isEdit;
+            if (app.subSettings.enable && dbInbounds !== null && Array.isArray(dbInbounds)) {
+                if (isEdit) {
+                    this.showProcess(dbInbound, index);
+                    let processSingleEdit = true
+                    if (this.group.canGroup) {
+                        this.group.currentClient = this.clients[this.index]
+                        const response = app.getSubGroupClients(dbInbounds, this.group.currentClient)
+                        if (response.clients.length > 1) {
+                            this.group.isGroup = true;
+                            this.group.inbounds = response.inbounds
+                            this.group.clients = response.clients
+                            this.group.editIds = response.editIds
+                            if (this.clients[index].expiryTime < 0) {
+                                this.delayedStart = true;
+                            }
+                            processSingleEdit = false
+                        }
+                    }
+                    if (processSingleEdit) {
+                        this.singleEditClientProcess(index)
+                    }
+                } else {
+                    this.group.isGroup = true;
+                    dbInbounds.forEach((dbInboundItem) => {
+                        this.showProcess(dbInboundItem);
+                        this.addClient(this.inbound.protocol, this.clients);
+                        this.group.inbounds.push(dbInboundItem.id)
+                        this.group.clients.push(this.clients[this.index])
+                    })
+                    this.group.currentClient = this.clients[this.index]
+                }
+            } else {
+                this.showProcess(dbInbound, index);
+                if (isEdit) {
+                    this.singleEditClientProcess(index)
+                } else {
+                    this.addClient(this.inbound.protocol, this.clients);
+                }
+            }
+            this.clientStats = this.dbInbound.clientStats.find(row => row.email === this.clients[this.index].email);
+            this.confirm = confirm;
+        },
+        showProcess(dbInbound, index = null) {
             this.dbInbound = new DBInbound(dbInbound);
             this.inbound = dbInbound.toInbound();
             this.clients = this.inbound.clients;
             this.index = index === null ? this.clients.length : index;
             this.delayedStart = false;
-            if (isEdit) {
-                if (this.clients[index].expiryTime < 0) {
-                    this.delayedStart = true;
-                }
-                this.oldClientId = this.getClientId(dbInbound.protocol, clients[index]);
-            } else {
-                this.addClient(this.inbound.protocol, this.clients);
+        },
+        singleEditClientProcess(index) {
+            if (this.clients[index].expiryTime < 0) {
+                this.delayedStart = true;
             }
-            this.clientStats = this.dbInbound.clientStats.find(row => row.email === this.clients[this.index].email);
-            this.confirm = confirm;
-        },  
+            this.oldClientId = this.getClientId(this.dbInbound.protocol, this.clients[index]);
+        },
         getClientId(protocol, client) {
             switch (protocol) {
                 case Protocols.TROJAN: return client.password;
@@ -72,7 +169,7 @@
             clientModal.visible = false;
             clientModal.loading(false);
         },
-        loading(loading=true) {
+        loading(loading = true) {
             clientModal.confirmLoading = loading;
         },
     };
@@ -94,6 +191,18 @@
             get isEdit() {
                 return this.clientModal.isEdit;
             },
+            get isGroup() {
+                return this.clientModal.group.isGroup;
+            },
+            get isGroupEdit() {
+                return this.clientModal.group.canGroup;
+            },
+            set isGroupEdit(value) {
+                this.clientModal.group.canGroup = value;
+                if (!value) {
+                    this.clientModal.singleEditClientProcess(this.clientModal.index)
+                }
+            },
             get datepicker() {
                 return app.datepicker;
             },
@@ -120,22 +229,35 @@
             },
         },
         methods: {
-            async getDBClientIps(email) {
-                const msg = await HttpUtil.post(`/panel/inbound/clientIps/${email}`);
-                if (!msg.success) {
-                    document.getElementById("clientIPs").value = msg.obj;
-                    return;
-                }
-                let ips = msg.obj;
-                if (typeof ips === 'string' && ips.startsWith('[') && ips.endsWith(']')) {
-                    try {
-                        ips = JSON.parse(ips);
-                        ips = Array.isArray(ips) ? ips.join("\n") : ips;
-                    } catch (e) {
-                        console.error('Error parsing JSON:', e);
-                    }
+            makeGroupEmailsUnique(dbInbounds, baseEmail, groupClients) {
+                // Extract the base part of the email (before the "__" if present)
+                const match = baseEmail.match(/^(.*?)__/);
+                const base = match ? match[1] : baseEmail;
+
+                // Generate initial emails for each client in the group
+                const generatedEmails = groupClients.map((_, index) => `${base}__${index + 1}`);
+
+                // Function to check if an email already exists in dbInbounds but belongs to a different subId
+                const isDuplicate = (emailToCheck, clientSubId) => {
+                    return dbInbounds.some((dbInbound) => {
+                        const settings = JSON.parse(dbInbound.settings);
+                        const clients = settings && settings.clients ? settings.clients : [];
+                        return clients.some(client => client.email === emailToCheck && client.subId !== clientSubId);
+                    });
+                };
+
+                // Check if any of the generated emails are duplicates
+                const hasDuplicates = generatedEmails.some((email, index) => {
+                    return isDuplicate(email, groupClients[index].subId);
+                });
+
+                // If duplicates exist, add a random string to the base email to ensure uniqueness
+                if (hasDuplicates) {
+                    const randomString = `-${RandomUtil.randomLowerAndNum(4)}`;
+                    return groupClients.map((_, index) => `${base}${randomString}__${index + 1}`);
                 }
-                document.getElementById("clientIPs").value = ips;
+
+                return generatedEmails;
             },
             async clearDBClientIps(email) {
                 try {
@@ -147,7 +269,22 @@
                 } catch (error) {
                 }
             },
-            resetClientTraffic(email, dbInboundId, iconElement) {
+            async resetClientTrafficHandler(client, dbInboundId, clients = []) {
+                if (clients.length > 0) {
+                    const resetRequests = clients
+                        .filter(client => {
+                            const inbound = clientModal.dbInbounds.find(inbound => inbound.id === client.inboundId);
+                            return inbound && app.hasClientStats(inbound, client.email);
+                        }).map(client => ({ inboundId: client.inboundId, email: client.email}));
+
+                    return HttpUtil.postWithModalJson('/panel/inbound/resetGroupClientTraffic', resetRequests, null)
+                } else {
+                    return HttpUtil.postWithModal('/panel/inbound/' + dbInboundId + '/resetClientTraffic/' + client.email)
+                }
+            },
+            resetClientTraffic(client, dbInboundId, iconElement) {
+                const subGroup = app.subSettings.enable && clientModal.group.isGroup && clientModal.group.canGroup && clientModal.dbInbounds && clientModal.dbInbounds.length > 0 ? app.getSubGroupClients(clientModal.dbInbounds, client) : [];
+                const clients = subGroup && subGroup.clients && subGroup.clients.length > 1 ? subGroup.clients : [];
                 this.$confirm({
                     title: '{{ i18n "pages.inbounds.resetTraffic"}}',
                     content: '{{ i18n "pages.inbounds.resetTrafficContent"}}',
@@ -156,8 +293,8 @@
                     cancelText: '{{ i18n "cancel"}}',
                     onOk: async () => {
                         iconElement.disabled = true;
-                        const msg = await HttpUtil.postWithModal('/panel/inbound/' + dbInboundId + '/resetClientTraffic/' + email);
-                        if (msg.success) {
+                        const msg = await this.resetClientTrafficHandler(client, dbInboundId, clients);
+                        if (msg && msg.success) {
                             this.clientModal.clientStats.up = 0;
                             this.clientModal.clientStats.down = 0;
                         }

+ 13 - 1
web/html/xui/form/client.html

@@ -3,6 +3,18 @@
     <a-form-item label='{{ i18n "pages.inbounds.enable" }}'>
         <a-switch v-model="client.enable"></a-switch>
     </a-form-item>
+    <a-form-item v-if="isEdit && app.subSettings.enable && isGroup">
+        <template slot="label">
+            <a-tooltip>
+                <template slot="title">
+                    <span>{{ i18n "pages.client.isGroupEditDesc" }}</span>
+                </template>
+                {{ i18n "pages.client.isGroupEdit" }}
+                <a-icon type="question-circle"></a-icon>
+            </a-tooltip>
+        </template>
+        <a-switch v-model="isGroupEdit"></a-switch>
+    </a-form-item>
     <a-form-item>
         <template slot="label">
             <a-tooltip>
@@ -134,7 +146,7 @@
         <a-tooltip>
             <template slot="title">{{ i18n "pages.inbounds.resetTraffic" }}</template>
             <a-icon type="retweet"
-                @click="resetClientTraffic(client.email,clientStats.inboundId,$event.target)"
+                @click="resetClientTraffic(client,clientStats.inboundId,$event.target)"
                 v-if="client.email.length > 0"></a-icon>
         </a-tooltip>
     </a-form-item>

+ 1 - 1
web/html/xui/inbound_client_table.html

@@ -12,7 +12,7 @@
     <template slot="title">{{ i18n "info" }}</template>
     <a-icon style="font-size: 24px;" class="normal-icon" type="info-circle" @click="showInfo(record.id,client);"></a-icon>
   </a-tooltip>
-  <a-tooltip>
+  <a-tooltip v-if="hasClientStats(record, client.email)">
     <template slot="title">{{ i18n "pages.inbounds.resetTraffic" }}</template>
     <a-popconfirm @confirm="resetClientTraffic(client,record.id,false)" title='{{ i18n "pages.inbounds.resetTrafficContent"}}' :overlay-class-name="themeSwitcher.currentTheme" ok-text='{{ i18n "reset"}}' cancel-text='{{ i18n "cancel"}}'>
       <a-icon slot="icon" type="question-circle-o" :style="themeSwitcher.isDarkTheme ? 'color: var(--color-primary-100)' : 'color: var(--color-primary-100)'"></a-icon>

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

@@ -14,6 +14,7 @@
         confirmLoading: false,
         okText: '{{ i18n "sure" }}',
         isEdit: false,
+        isGroup: false,
         confirm: null,
         inbound: new Inbound(),
         dbInbound: new DBInbound(),
@@ -61,6 +62,9 @@
             get isEdit() {
                 return inModal.isEdit;
             },
+            get isGroup() {
+                return inModal.isGroup;
+            },
             get client() {
                 return inModal.inbound.clients[0];
             },

+ 171 - 26
web/html/xui/inbounds.html

@@ -224,6 +224,10 @@
                         <a-icon type="rest"></a-icon>
                         {{ i18n "pages.inbounds.delDepletedClients" }}
                       </a-menu-item>
+                      <a-menu-item v-if="subSettings.enable && dbInbounds.length > 0" key="addGroupClient">
+                        <a-icon type="usergroup-add"></a-icon>
+                        {{ i18n "pages.client.groupAdd"}}
+                      </a-menu-item>
                     </a-menu>
                   </a-dropdown>
                 </a-col>
@@ -859,6 +863,9 @@
                     case "delDepletedClients":
                         this.delDepletedClients(-1)
                         break;
+                    case "addGroupClient":
+                        this.openGroupAddClient()
+                        break;
                 }
             },
             clickAction(action, dbInbound) {
@@ -1004,6 +1011,21 @@
 
                 await this.submit(`/panel/inbound/update/${dbInbound.id}`, data, inModal);
             },
+            openGroupAddClient() {
+                clientModal.show({
+                    title: '{{ i18n "pages.client.groupAdd"}}',
+                    okText: '{{ i18n "pages.client.submitAdd"}}',
+                    dbInbounds: this.dbInbounds,
+                    confirm: async (clients, dbInboundIds) => {
+                        await this.addGroupClient(clients, dbInboundIds, clientModal).then((res) => {
+                            if(res){
+                                this.showQrcode(dbInboundIds[0],clients[0], true)
+                            }
+                        });
+                    },
+                    isEdit: false
+                });
+            },
             openAddClient(dbInboundId) {
                 dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
                 clientModal.show({
@@ -1011,7 +1033,11 @@
                     okText: '{{ i18n "pages.client.submitAdd"}}',
                     dbInbound: dbInbound,
                     confirm: async (clients, dbInboundId) => {
-                        await this.addClient(clients, dbInboundId, clientModal);
+                        await this.addClient(clients, dbInboundId, clientModal).then((res) => {
+                            if(res){
+                                this.showQrcode(dbInboundId,clients)
+                            }
+                        });
                     },
                     isEdit: false
                 });
@@ -1034,6 +1060,7 @@
                 clientModal.show({
                     title: '{{ i18n "pages.client.edit"}}',
                     okText: '{{ i18n "pages.client.submitEdit"}}',
+                    dbInbounds: this.dbInbounds,
                     dbInbound: dbInbound,
                     index: index,
                     confirm: async (client, dbInboundId, clientId) => {
@@ -1059,12 +1086,36 @@
                 };
                 await this.submit(`/panel/inbound/addClient`, data, modal);
             },
+            async addGroupClient(clients, dbInboundIds, modal) {
+                const data = []
+                dbInboundIds.forEach((dbInboundId, index) => {
+                    data.push({
+                        id: dbInboundId,
+                        settings: '{"clients": [' + clients[index].toString() + ']}',
+                    })
+                })
+                return await this.submit(`/panel/inbound/addGroupClient`, data, modal, true)
+            },
             async updateClient(client, dbInboundId, clientId) {
-                const data = {
-                    id: dbInboundId,
-                    settings: '{"clients": [' + client.toString() + ']}',
-                };
-                await this.submit(`/panel/inbound/updateClient/${clientId}`, data, clientModal);
+                if (Array.isArray(client) && Array.isArray(dbInboundId) && Array.isArray(clientId)){
+                    const data = []
+                    client.forEach((client, index) => {
+                        data.push({
+                            clientId: clientId[index],
+                            inbound: {
+                              id: dbInboundId[index],
+                              settings: '{"clients": [' + client.toString() + ']}',
+                            }
+                        })
+                    })
+                    await this.submit(`/panel/inbound/updateClients`, data, clientModal, true);
+                }else{
+                    const data = {
+                        id: dbInboundId,
+                        settings: '{"clients": [' + client.toString() + ']}',
+                    };
+                    await this.submit(`/panel/inbound/updateClient/${clientId}`, data, clientModal);
+                }
             },
             resetTraffic(dbInboundId) {
                 dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
@@ -1092,22 +1143,87 @@
                     onOk: () => this.submit('/panel/inbound/del/' + dbInboundId),
                 });
             },
-            delClient(dbInboundId, client,confirmation = true) {
-                dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
-                clientId = this.getClientId(dbInbound.protocol, client);
+            async delClientHandler(dbInboundId, currentClient, clients = []) {
+                if (clients.length > 0) {
+                    const deleteRequestData = [];
+
+                    for (const client of clients) {
+                        const dbInbound = this.dbInbounds.find(inbound => inbound.id === client.inboundId);
+                        if (dbInbound) {
+                            const inbound = dbInbound.toInbound();
+                            if (inbound && inbound.clients && inbound.clients.length === 1) {
+                                let newClient = Inbound.Settings.getSettings(inbound.protocol).toString();
+                                newClient = JSON.parse(newClient);
+                                newClient = newClient && newClient.clients && newClient.clients.length > 0 ? JSON.stringify(newClient.clients[0], null, 2) : null;
+                                if (newClient) {
+                                    const data = {
+                                        id: client.inboundId,
+                                        settings: '{"clients": [' + newClient + ']}',
+                                    };
+                                    await this.submit(`/panel/inbound/addClient`, data, null);
+                                }
+                            }
+
+                            deleteRequestData.push({
+                                inboundId: client.inboundId,
+                                clientId: client.clientId,
+                            });
+                        }
+                    }
+                    await this.submit('/panel/inbound/delGroupClients', deleteRequestData, null, true);
+                } else {
+                    const dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
+                    const clientId = this.getClientId(dbInbound.protocol, currentClient);
+                    await this.submit(`/panel/inbound/${dbInboundId}/delClient/${clientId}`);
+                }
+            },
+            delClient(dbInboundId, currentClient, confirmation = true) {
+                const subGroup = this.subSettings.enable ? this.getSubGroupClients(this.dbInbounds, currentClient) : [];
+                const clients = subGroup && subGroup.clients && subGroup.clients.length > 1  ? subGroup.clients : [];
                 if (confirmation){
+                    const clientEmails = clients.length > 0 ? clients.map(item => item.email) : currentClient.email
                     this.$confirm({
-                        title: '{{ i18n "pages.inbounds.deleteClient"}}' + ' ' + client.email,
+                        title: '{{ i18n "pages.inbounds.deleteClient"}}' + ' ' + clientEmails,
                         content: '{{ i18n "pages.inbounds.deleteClientContent"}}',
                         class: themeSwitcher.currentTheme,
                         okText: '{{ i18n "delete"}}',
                         cancelText: '{{ i18n "cancel"}}',
-                        onOk: () => this.submit(`/panel/inbound/${dbInboundId}/delClient/${clientId}`),
+                        onOk: () => this.delClientHandler(dbInboundId, currentClient, clients),
                     });
                 } else {
-                    this.submit(`/panel/inbound/${dbInboundId}/delClient/${clientId}`);
+                    this.delClientHandler(dbInboundId, currentClient, clients)
                 }
             },
+            getSubGroupClients(dbInbounds, currentClient) {
+                const response = {
+                  inbounds: [],
+                  clients: [],
+                  editIds: []
+                }
+                if (dbInbounds && dbInbounds.length > 0 && currentClient) {
+                    dbInbounds.forEach((dbInboundItem) => {
+                        const dbInbound = new DBInbound(dbInboundItem);
+                        if (dbInbound) {
+                            const inbound = dbInbound.toInbound();
+                            if (inbound) {
+                                const clients = inbound.clients;
+                                if (clients.length > 0) {
+                                    clients.forEach((client) => {
+                                        if (client['subId'] === currentClient['subId']) {
+                                            client['inboundId'] = dbInboundItem.id
+                                            client['clientId'] = this.getClientId(dbInbound.protocol, client)
+                                            response.inbounds.push(dbInboundItem.id)
+                                            response.clients.push(client)
+                                            response.editIds.push(client['clientId'])
+                                        }
+                                    })
+                                }
+                            }
+                        }
+                    })
+                }
+                return response;
+            },
             getClientId(protocol, client) {
                 switch (protocol) {
                     case Protocols.TROJAN: return client.password;
@@ -1135,10 +1251,10 @@
                 }
                 return newDbInbound;
             },
-            showQrcode(dbInboundId, client) {
+            showQrcode(dbInboundId, client, isJustSub = false) {
                 dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
                 newDbInbound = this.checkFallback(dbInbound);
-                qrModal.show('{{ i18n "qrCode"}}', newDbInbound, client);
+                qrModal.show('{{ i18n "qrCode"}}', newDbInbound, client, isJustSub);
             },
             showInfo(dbInboundId, client) {
                 dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
@@ -1158,36 +1274,61 @@
             },
             async switchEnableClient(dbInboundId, client) {
                 this.loading()
-                dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
-                inbound = dbInbound.toInbound();
-                clients = inbound.clients;
-                index = this.findIndexOfClient(dbInbound.protocol, clients, client);
-                clients[index].enable = !clients[index].enable;
-                clientId = this.getClientId(dbInbound.protocol, clients[index]);
-                await this.updateClient(clients[index], dbInboundId, clientId);
+                const subGroup = this.subSettings.enable ? this.getSubGroupClients(this.dbInbounds, client) : [];
+                if (subGroup && subGroup.clients && subGroup.clients.length > 0){
+                    await this.updateClient(subGroup.clients.map(item => {
+                      item.enable = !item.enable
+                      return item
+                    }), subGroup.inbounds, subGroup.editIds);
+                }else{
+                    dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
+                    inbound = dbInbound.toInbound();
+                    clients = inbound.clients;
+                    index = this.findIndexOfClient(dbInbound.protocol, clients, client);
+                    clients[index].enable = !clients[index].enable;
+                    clientId = this.getClientId(dbInbound.protocol, clients[index]);
+                    await this.updateClient(clients[index], dbInboundId, clientId);
+                }
                 this.loading(false);
             },
-            async submit(url, data, modal) {
-                const msg = await HttpUtil.postWithModal(url, data, modal);
+            async submit(url, data, model, isJson = false) {
+                const msg = isJson ? await HttpUtil.postWithModalJson(url, data, model) : await HttpUtil.postWithModal(url, data, model);
                 if (msg.success) {
                     await this.getDBInbounds();
                 }
+                return msg
             },
             getInboundClients(dbInbound) {
                 return dbInbound.toInbound().clients;
             },
+            resetClientTrafficHandler(client, dbInboundId, clients = []) {
+                if (clients.length > 0){
+                    const resetRequests = clients
+                        .filter(client => {
+                            const inbound = this.dbInbounds.find(inbound => inbound.id === client.inboundId);
+                            return inbound && this.hasClientStats(inbound, client.email);
+                        }).map(client => ({ inboundId: client.inboundId, email: client.email}));
+
+                    this.submit('/panel/inbound/resetGroupClientTraffic', resetRequests, null, true)
+                }else {
+                    this.submit('/panel/inbound/' + dbInboundId + '/resetClientTraffic/' + client.email);
+                }
+            },
             resetClientTraffic(client, dbInboundId, confirmation = true) {
+                const subGroup = this.subSettings.enable ? this.getSubGroupClients(this.dbInbounds, client) : [];
+                const clients = subGroup && subGroup.clients && subGroup.clients.length > 1  ? subGroup.clients : [];
                 if (confirmation){
+                    const clientEmails = clients.length > 0 ? clients.map(item => item.email) : client.email
                     this.$confirm({
-                        title: '{{ i18n "pages.inbounds.resetTraffic"}}' + ' ' + client.email,
+                        title: '{{ i18n "pages.inbounds.resetTraffic"}}' + ' ' + clientEmails,
                         content: '{{ i18n "pages.inbounds.resetTrafficContent"}}',
                         class: themeSwitcher.currentTheme,
                         okText: '{{ i18n "reset"}}',
                         cancelText: '{{ i18n "cancel"}}',
-                        onOk: () => this.submit('/panel/inbound/' + dbInboundId + '/resetClientTraffic/' + client.email),
+                        onOk: () => this.resetClientTrafficHandler(client, dbInboundId, clients),
                     })
                 } else {
-                    this.submit('/panel/inbound/' + dbInboundId + '/resetClientTraffic/' + client.email);
+                    this.resetClientTrafficHandler(client, dbInboundId, clients);
                 }
             },
             resetAllTraffic() {
@@ -1223,6 +1364,10 @@
             isExpiry(dbInbound, index) {
                 return dbInbound.toInbound().isExpiry(index);
             },
+            hasClientStats(dbInbound, email) {
+                if (email.length == 0) return 0;
+                return !!dbInbound.clientStats.find(stats => stats.email === email);
+            },
             getUpStats(dbInbound, email) {
                 if (email.length == 0) return 0;
                 clientStats = dbInbound.clientStats.find(stats => stats.email === email);

+ 1 - 0
web/html/xui/settings.html

@@ -268,6 +268,7 @@
               <a-tab-pane key="4" tab='{{ i18n "pages.settings.subSettings" }}'>
                 <a-list item-layout="horizontal">
                   <setting-list-item type="switch" title='{{ i18n "pages.settings.subEnable"}}' desc='{{ i18n "pages.settings.subEnableDesc"}}' v-model="allSetting.subEnable"></setting-list-item>
+                  <setting-list-item type="switch" title='{{ i18n "pages.settings.subSyncEnable"}}' desc='{{ i18n "pages.settings.subSyncEnableDesc"}}' v-model="allSetting.subSyncEnable"></setting-list-item>
                   <setting-list-item type="switch" title='{{ i18n "pages.settings.subEncrypt"}}' desc='{{ i18n "pages.settings.subEncryptDesc"}}' v-model="allSetting.subEncrypt"></setting-list-item>
                   <setting-list-item type="switch" title='{{ i18n "pages.settings.subShowInfo"}}' desc='{{ i18n "pages.settings.subShowInfoDesc"}}' v-model="allSetting.subShowInfo"></setting-list-item>
                   <setting-list-item type="text" title='{{ i18n "pages.settings.subListen"}}' desc='{{ i18n "pages.settings.subListenDesc"}}' v-model="allSetting.subListen"></setting-list-item>

+ 178 - 0
web/job/client_traffic_sync_job.go

@@ -0,0 +1,178 @@
+package job
+
+import (
+	"encoding/json"
+	"fmt"
+	"time"
+
+	"x-ui/database"
+	"x-ui/database/model"
+	"x-ui/logger"
+	"x-ui/xray"
+
+	"gorm.io/gorm"
+)
+
+type SyncClientTrafficJob struct {
+	subClientsCollection map[string][]string
+}
+
+func NewClientTrafficSyncJob() *SyncClientTrafficJob {
+	return new(SyncClientTrafficJob)
+}
+func (j *SyncClientTrafficJob) Run() {
+	// Step 1: Group clients by SubID
+	subClientsCollection := j.collectClientsGroupedBySubId()
+
+	// Step 2: Sync client traffics for each SubID group
+	for subId, emails := range subClientsCollection {
+		err := j.syncClientTraffics(map[string][]string{subId: emails})
+		if err != nil {
+			logger.Error("Failed to sync traffics for SubID ", subId, ": ", err)
+		}
+	}
+}
+
+// collectClientsGroupedBySubId groups clients by their SubIDs
+func (j *SyncClientTrafficJob) collectClientsGroupedBySubId() map[string][]string {
+	db := database.GetDB()
+	result := make(map[string][]string)
+
+	// Fetch all inbounds
+	var inbounds []*model.Inbound
+	if err := db.Model(&model.Inbound{}).Find(&inbounds).Error; err != nil {
+		logger.Error("Error fetching inbounds: ", err)
+		return result // Return empty map on error
+	}
+
+	// Process each inbound
+	for _, inbound := range inbounds {
+		if inbound.Settings == "" {
+			continue
+		}
+
+		settingsMap, err := parseSettings(inbound.Settings, uint(inbound.Id))
+		if err != nil {
+			logger.Error(err)
+			continue
+		}
+
+		clients, ok := settingsMap["clients"].([]interface{})
+		if !ok {
+			continue
+		}
+
+		processClients(clients, result)
+	}
+
+	// Remove SubIDs with one or fewer emails
+	filterSingleEmailSubIDs(result)
+
+	return result
+}
+
+// parseSettings unmarshals the JSON settings and returns it as a map
+func parseSettings(settings string, inboundID uint) (map[string]interface{}, error) {
+	if !json.Valid([]byte(settings)) {
+		return nil, fmt.Errorf("Invalid JSON format in Settings for inbound ID %d", inboundID)
+	}
+
+	var tempData map[string]interface{}
+	if err := json.Unmarshal([]byte(settings), &tempData); err != nil {
+		return nil, fmt.Errorf("Error unmarshalling settings for inbound ID %d: %v", inboundID, err)
+	}
+
+	return tempData, nil
+}
+
+// processClients extracts SubID and email from the clients and populates the result map
+func processClients(clients []interface{}, result map[string][]string) {
+	for _, client := range clients {
+		clientMap, ok := client.(map[string]interface{})
+		if !ok {
+			continue
+		}
+
+		subId, ok := clientMap["subId"].(string)
+		if !ok || subId == "" {
+			continue
+		}
+
+		email, ok := clientMap["email"].(string)
+		if !ok || email == "" {
+			continue
+		}
+
+		result[subId] = append(result[subId], email)
+	}
+}
+
+// filterSingleEmailSubIDs removes SubIDs with one or fewer emails from the result map
+func filterSingleEmailSubIDs(result map[string][]string) {
+	for subId, emails := range result {
+		if len(emails) <= 1 {
+			delete(result, subId)
+		}
+	}
+}
+
+// syncClientTraffics synchronizes traffic data for each SubID group
+func (j *SyncClientTrafficJob) syncClientTraffics(result map[string][]string) error {
+	for subId, emails := range result {
+		db := database.GetDB()
+
+		// Step 1: Calculate maxUp and maxDown (outside transaction)
+		var maxUp, maxDown int64
+		err := calculateMaxTraffic(db, emails, &maxUp, &maxDown)
+		if err != nil {
+			logger.Error("Failed to calculate max traffic for SubID ", subId, ": ", err)
+			continue
+		}
+
+		// Step 2: Update traffic data with retry mechanism
+		err = retryOperation(func() error {
+			return updateTraffic(db, emails, maxUp, maxDown)
+		}, 5, 100*time.Millisecond)
+
+		if err != nil {
+			logger.Error("Failed to update client traffics for SubID ", subId, ": ", err)
+		}
+	}
+	return nil
+}
+
+// calculateMaxTraffic calculates max up and down traffic for a group of emails
+func calculateMaxTraffic(db *gorm.DB, emails []string, maxUp, maxDown *int64) error {
+	return db.Model(&xray.ClientTraffic{}).
+		Where("email IN ?", emails).
+		Select("MAX(up) AS max_up, MAX(down) AS max_down").
+		Row().
+		Scan(maxUp, maxDown)
+}
+
+// updateTraffic updates the traffic data in the database within a transaction
+func updateTraffic(db *gorm.DB, emails []string, maxUp, maxDown int64) error {
+	return db.Transaction(func(tx *gorm.DB) error {
+		return tx.Model(&xray.ClientTraffic{}).
+			Where("email IN ?", emails).
+			Updates(map[string]interface{}{
+				"up":   maxUp,
+				"down": maxDown,
+			}).Error
+	})
+}
+
+// retryOperation retries an operation multiple times with a delay
+func retryOperation(operation func() error, maxRetries int, delay time.Duration) error {
+	var err error
+	for i := 0; i < maxRetries; i++ {
+		err = operation()
+		if err == nil {
+			return nil
+		}
+		logger.Info(fmt.Sprintf("Retry %d/%d failed: %v", i+1, maxRetries, err))
+		time.Sleep(delay)
+	}
+	return err
+}
+

+ 10 - 0
web/service/setting.go

@@ -50,6 +50,7 @@ var defaultValueMap = map[string]string{
 	"tgLang":             "en-US",
 	"secretEnable":       "false",
 	"subEnable":          "false",
+	"subSyncEnable":      "true",
 	"subListen":          "",
 	"subPort":            "2096",
 	"subPath":            "/sub/",
@@ -416,6 +417,14 @@ func (s *SettingService) GetSubEnable() (bool, error) {
 	return s.getBool("subEnable")
 }
 
+func (s *SettingService) GetSubSyncEnable() (bool, error) {
+	return s.getBool("subSyncEnable")
+}
+
+func (s *SettingService) SetSubSyncEnable(value bool) error {
+	return s.setBool("subSyncEnable", value)
+}
+
 func (s *SettingService) GetSubListen() (string, error) {
 	return s.getString("subListen")
 }
@@ -544,6 +553,7 @@ func (s *SettingService) GetDefaultSettings(host string) (interface{}, error) {
 		"defaultKey":    func() (interface{}, error) { return s.GetKeyFile() },
 		"tgBotEnable":   func() (interface{}, error) { return s.GetTgbotEnabled() },
 		"subEnable":     func() (interface{}, error) { return s.GetSubEnable() },
+		"subSyncEnable": func() (interface{}, error) { return s.GetSubSyncEnable() },
 		"subURI":        func() (interface{}, error) { return s.GetSubURI() },
 		"subJsonURI":    func() (interface{}, error) { return s.GetSubJsonURI() },
 		"remarkModel":   func() (interface{}, error) { return s.GetRemarkModel() },

+ 5 - 0
web/translation/translate.en_US.toml

@@ -190,6 +190,9 @@
 
 [pages.client]
 "add" = "Add Client"
+"groupAdd" = "Add subscription user"
+"isGroupEdit" = "Group editing"
+"isGroupEditDesc" = "All clients with the same subscription are edited"
 "edit" = "Edit Client"
 "submitAdd" = "Add Client"
 "submitEdit" = "Save Changes"
@@ -287,6 +290,8 @@
 "subSettings" = "Subscription"
 "subEnable" = "Enable Subscription Service"
 "subEnableDesc" = "Enables the subscription service."
+"subSyncEnable" = "Enable Subscription Sync"
+"subSyncEnableDesc" = "Traffic from clients with the same subscription will be synchronized every 10 seconds."
 "subListen" = "Listen IP"
 "subListenDesc" = "The IP address for the subscription service. (leave blank to listen on all IPs)"
 "subPort" = "Listen Port"

+ 3 - 0
web/translation/translate.es_ES.toml

@@ -190,6 +190,7 @@
 
 [pages.client]
 "add" = "Agregar Cliente"
+"groupAdd" = "Agregar usuario de suscripción"
 "edit" = "Editar Cliente"
 "submitAdd" = "Agregar Cliente"
 "submitEdit" = "Guardar Cambios"
@@ -287,6 +288,8 @@
 "subSettings" = "Suscripción"
 "subEnable" = "Habilitar Servicio"
 "subEnableDesc" = "Función de suscripción con configuración separada."
+"subSyncEnable" = "Habilitar sincronización de suscripciones"
+"subSyncEnableDesc" = "El tráfico de los clientes con la misma suscripción se sincronizará cada 10 segundos."
 "subListen" = "Listening IP"
 "subListenDesc" = "Dejar en blanco por defecto para monitorear todas las IPs."
 "subPort" = "Puerto de Suscripción"

+ 5 - 0
web/translation/translate.fa_IR.toml

@@ -190,6 +190,9 @@
 
 [pages.client]
 "add" = "کاربر جدید"
+"groupAdd" = "افزودن کاربر سابسکریپشن"
+"isGroupEdit" = "ویرایش گروهی"
+"isGroupEditDesc" = "همه کاربران با سابسکریپشن یکسان ویرایش می شوند"
 "edit" = "ویرایش کاربر"
 "submitAdd" = "اضافه کردن"
 "submitEdit" = "ذخیره تغییرات"
@@ -287,6 +290,8 @@
 "subSettings" = "سابسکریپشن"
 "subEnable" = "فعال‌سازی سرویس سابسکریپشن"
 "subEnableDesc" = "سرویس سابسکریپشن‌ را فعال‌می‌کند"
+"subSyncEnable" = "فعال‌سازی همگام سازی سابسکریپشن"
+"subSyncEnableDesc" = "ترافیک کلاینت هایی که سابسکریپشن یکسان دارند هر ۱۰ ثانیه همگام می‌شوند."
 "subListen" = "آدرس آی‌پی"
 "subListenDesc" = "آدرس آی‌پی برای سرویس سابسکریپشن. برای گوش دادن به‌تمام آی‌پی‌ها خالی‌بگذارید"
 "subPort" = "پورت"

+ 5 - 0
web/translation/translate.id_ID.toml

@@ -190,6 +190,9 @@
 
 [pages.client]
 "add" = "Tambah Klien"
+"groupAdd" = "Tambahkan pengguna langganan"
+"isGroupEdit" = "Pengeditan grup"
+"isGroupEditDesc" = "Semua klien dengan langganan yang sama akan diedit"
 "edit" = "Edit Klien"
 "submitAdd" = "Tambah Klien"
 "submitEdit" = "Simpan Perubahan"
@@ -287,6 +290,8 @@
 "subSettings" = "Langganan"
 "subEnable" = "Aktifkan Layanan Langganan"
 "subEnableDesc" = "Mengaktifkan layanan langganan."
+"subSyncEnable" = "Aktifkan Sinkronisasi Langganan"
+"subSyncEnableDesc" = "Lalu lintas dari klien dengan langganan yang sama akan disinkronkan setiap 10 detik."
 "subListen" = "IP Pendengar"
 "subListenDesc" = "Alamat IP untuk layanan langganan. (biarkan kosong untuk mendengarkan semua IP)"
 "subPort" = "Port Pendengar"

+ 5 - 0
web/translation/translate.ja_JP.toml

@@ -190,6 +190,9 @@
 
 [pages.client]
 "add" = "クライアント追加"
+"groupAdd" = "サブスクリプション ユーザーの追加"
+"isGroupEdit" = "グループの編集"
+"isGroupEditDesc" = "同じサブスクリプションを持つすべてのクライアントが編集されます"
 "edit" = "クライアント編集"
 "submitAdd" = "クライアント追加"
 "submitEdit" = "変更を保存"
@@ -287,6 +290,8 @@
 "subSettings" = "サブスクリプション設定"
 "subEnable" = "サブスクリプションサービスを有効にする"
 "subEnableDesc" = "サブスクリプションサービス機能を有効にする"
+"subSyncEnable" = "サブスクリプション同期を有効にする"
+"subSyncEnableDesc" = "同じサブスクリプションを持つクライアントからのトラフィックは 10 秒ごとに同期されます。"
 "subListen" = "監視IP"
 "subListenDesc" = "サブスクリプションサービスが監視するIPアドレス(空白にするとすべてのIPを監視)"
 "subPort" = "監視ポート"

+ 5 - 0
web/translation/translate.pt_BR.toml

@@ -190,6 +190,9 @@
 
 [pages.client]
 "add" = "Adicionar Cliente"
+"groupAdd" = "Adicionar usuário de assinatura"
+"isGroupEdit" = "Edição de grupo"
+"isGroupEditDesc" = "Todos os clientes com a mesma assinatura são editados"
 "edit" = "Editar Cliente"
 "submitAdd" = "Adicionar Cliente"
 "submitEdit" = "Salvar Alterações"
@@ -287,6 +290,8 @@
 "subSettings" = "Assinatura"
 "subEnable" = "Ativar Serviço de Assinatura"
 "subEnableDesc" = "Ativa o serviço de assinatura."
+"subSyncEnable" = "Habilitar sincronização de assinatura"
+"subSyncEnableDesc" = "O tráfego de clientes com a mesma assinatura será sincronizado a cada 10 segundos."
 "subListen" = "IP de Escuta"
 "subListenDesc" = "O endereço IP para o serviço de assinatura. (deixe em branco para escutar em todos os IPs)"
 "subPort" = "Porta de Escuta"

+ 5 - 0
web/translation/translate.ru_RU.toml

@@ -190,6 +190,9 @@
 
 [pages.client]
 "add" = "Добавить пользователя"
+"groupAdd" = "Добавить пользователя подписки"
+"isGroupEdit" = "Групповое редактирование"
+"isGroupEditDesc" = "Все клиенты с одинаковой подпиской редактируются"
 "edit" = "Редактировать пользователя"
 "submitAdd" = "Добавить пользователя"
 "submitEdit" = "Сохранить изменения"
@@ -287,6 +290,8 @@
 "subSettings" = "Подписка"
 "subEnable" = "Включить службу"
 "subEnableDesc" = "Функция подписки с отдельной конфигурацией"
+"subSyncEnable" = "Включить синхронизацию подписки"
+"subSyncEnableDesc" = "Трафик от клиентов с одинаковой подпиской будет синхронизироваться каждые 10 секунд."
 "subListen" = "Прослушивание IP"
 "subListenDesc" = "Оставьте пустым по умолчанию, чтобы отслеживать все IP-адреса"
 "subPort" = "Порт подписки"

+ 5 - 0
web/translation/translate.tr_TR.toml

@@ -190,6 +190,9 @@
 
 [pages.client]
 "add" = "Müşteri Ekle"
+"groupAdd" = "Abonelik kullanıcısı ekle"
+"isGroupEdit" = "Grup düzenleme"
+"isGroupEditDesc" = "Aynı aboneliğe sahip tüm istemciler düzenlendi"
 "edit" = "Müşteriyi Düzenle"
 "submitAdd" = "Müşteri Ekle"
 "submitEdit" = "Değişiklikleri Kaydet"
@@ -287,6 +290,8 @@
 "subSettings" = "Abonelik"
 "subEnable" = "Abonelik Hizmetini Etkinleştir"
 "subEnableDesc" = "Abonelik hizmetini etkinleştirir."
+"subSyncEnable" = "Abonelik Senkronizasyonunu Etkinleştir"
+"subSyncEnableDesc" = "Aynı aboneliğe sahip istemcilerden gelen trafik her 10 saniyede bir senkronize edilecektir."
 "subListen" = "Dinleme IP"
 "subListenDesc" = "Abonelik hizmeti için IP adresi. (tüm IP'leri dinlemek için boş bırakın)"
 "subPort" = "Dinleme Portu"

+ 5 - 0
web/translation/translate.uk_UA.toml

@@ -190,6 +190,9 @@
 
 [pages.client]
 "add" = "Додати клієнта"
+"groupAdd" = "Додати підписаного користувача"
+"isGroupEdit" = "Редагування групи"
+"isGroupEditDesc" = "Всі клієнти з однаковою підпискою редагуються"
 "edit" = "Редагувати клієнта"
 "submitAdd" = "Додати клієнта"
 "submitEdit" = "Зберегти зміни"
@@ -287,6 +290,8 @@
 "subSettings" = "Підписка"
 "subEnable" = "Увімкнути службу підписки"
 "subEnableDesc" = "Вмикає службу підписки."
+"subSyncEnable" = "Увімкнути синхронізацію підписки"
+"subSyncEnableDesc" = "Трафік від клієнтів з однаковою підпискою буде синхронізовано кожні 10 секунд."
 "subListen" = "Слухати IP"
 "subListenDesc" = "IP-адреса для служби підписки. (залиште порожнім, щоб слухати всі IP-адреси)"
 "subPort" = "Слухати порт"

+ 5 - 0
web/translation/translate.vi_VN.toml

@@ -190,6 +190,9 @@
 
 [pages.client]
 "add" = "Thêm người dùng"
+"groupAdd" = "Thêm người dùng đăng ký"
+"isGroupEdit" = "Chỉnh sửa nhóm"
+"isGroupEditDesc" = "Tất cả người dùng có cùng đăng ký đều có thể được chỉnh sửa"
 "edit" = "Chỉnh sửa người dùng"
 "submitAdd" = "Thêm"
 "submitEdit" = "Lưu thay đổi"
@@ -287,6 +290,8 @@
 "subSettings" = "Gói đăng ký"
 "subEnable" = "Bật dịch vụ"
 "subEnableDesc" = "Tính năng gói đăng ký với cấu hình riêng"
+"subSyncEnable" = "Bật đồng bộ đăng ký"
+"subSyncEnableDesc" = "Lưu lượng truy cập từ các máy khách có cùng đăng ký sẽ được đồng bộ sau mỗi 10 giây."
 "subListen" = "Listening IP"
 "subListenDesc" = "Mặc định để trống để nghe tất cả các IP"
 "subPort" = "Cổng gói đăng ký"

+ 5 - 0
web/translation/translate.zh_CN.toml

@@ -190,6 +190,9 @@
 
 [pages.client]
 "add" = "添加客户端"
+"groupAdd" = "添加订阅用户"
+"isGroupEdit" = "群组编辑"
+"isGroupEditDesc" = "所有具有相同订阅的客户端均被编辑"
 "edit" = "编辑客户端"
 "submitAdd" = "添加客户端"
 "submitEdit" = "保存修改"
@@ -287,6 +290,8 @@
 "subSettings" = "订阅设置"
 "subEnable" = "启用订阅服务"
 "subEnableDesc" = "启用订阅服务功能"
+"subSyncEnable" = "启用订阅同步"
+"subSyncEnableDesc" = "具有相同订阅的客户端的流量将每 10 秒同步一次。"
 "subListen" = "监听 IP"
 "subListenDesc" = "订阅服务监听的 IP 地址(留空表示监听所有 IP)"
 "subPort" = "监听端口"

+ 5 - 0
web/translation/translate.zh_TW.toml

@@ -190,6 +190,9 @@
 
 [pages.client]
 "add" = "新增客戶端"
+"groupAdd" = "新增訂閱使用者"
+"isGroupEdit" = "群組編輯"
+"isGroupEditDesc" = "所有具有相同訂閱的用戶端都被編輯"
 "edit" = "編輯客戶端"
 "submitAdd" = "新增客戶端"
 "submitEdit" = "儲存修改"
@@ -287,6 +290,8 @@
 "subSettings" = "訂閱設定"
 "subEnable" = "啟用訂閱服務"
 "subEnableDesc" = "啟用訂閱服務功能"
+"subSyncEnable" = "啟用訂閱同步"
+"subSyncEnableDesc" = "來自具有相同訂閱的客戶端的流量將每 10 秒同步一次。"
 "subListen" = "監聽 IP"
 "subListenDesc" = "訂閱服務監聽的 IP 地址(留空表示監聽所有 IP)"
 "subPort" = "監聽埠"

+ 7 - 0
web/web.go

@@ -260,6 +260,13 @@ func (s *Server) startTask() {
 		s.cron.AddJob("@every 10s", job.NewXrayTrafficJob())
 	}()
 
+	isSubEnable, err1 := s.settingService.GetSubEnable()
+	isSubSyncEnable, err2 := s.settingService.GetSubSyncEnable()
+	if err1 == nil && err2 == nil && isSubEnable && isSubSyncEnable {
+		// Sync the client traffic with the same SubId every 10 seconds
+		s.cron.AddJob("@every 10s", job.NewClientTrafficSyncJob())
+	}
+
 	// check client ips from log file every 10 sec
 	s.cron.AddJob("@every 10s", job.NewCheckClientIpJob())