@@ -677,7 +708,7 @@ const columns = computed(() => [
- {{ record.expiryTime > 0 ? expiryRelative(record) : '∞' }}
+ {{ record.expiryTime ? expiryRelative(record) : '∞' }}
@@ -792,7 +823,8 @@ const columns = computed(() => [
+ :attached-ids="editingAttachedIds" :inbounds="inbounds" :ip-limit-enable="ipLimitEnable"
+ :tg-bot-enable="tgBotEnable" :save="onSave" />
diff --git a/frontend/src/pages/clients/useClients.js b/frontend/src/pages/clients/useClients.js
index 7ca4b671..f0db1420 100644
--- a/frontend/src/pages/clients/useClients.js
+++ b/frontend/src/pages/clients/useClients.js
@@ -11,6 +11,7 @@ export function useClients() {
const fetched = ref(false);
const subSettings = ref({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false });
const ipLimitEnable = ref(false);
+ const tgBotEnable = ref(false);
const expireDiff = ref(0);
const trafficDiff = ref(0);
@@ -44,6 +45,7 @@ export function useClients() {
subJsonEnable: !!s.subJsonEnable,
};
ipLimitEnable.value = !!s.ipLimitEnable;
+ tgBotEnable.value = !!s.tgBotEnable;
expireDiff.value = (s.expireDiff ?? 0) * 86400000;
trafficDiff.value = (s.trafficDiff ?? 0) * 1073741824;
}
@@ -73,6 +75,18 @@ export function useClients() {
return msg;
}
+ async function removeMany(emails, keepTraffic = false) {
+ if (!Array.isArray(emails) || emails.length === 0) return [];
+ const suffix = keepTraffic ? '?keepTraffic=1' : '';
+ const silentOpts = { silent: true };
+ const results = await Promise.all(emails.map((email) => {
+ const url = `/panel/api/clients/del/${encodeURIComponent(email)}${suffix}`;
+ return HttpUtil.post(url, undefined, silentOpts);
+ }));
+ await refresh();
+ return results;
+ }
+
async function attach(email, inboundIds) {
if (!email) return null;
const encoded = encodeURIComponent(email);
@@ -159,11 +173,15 @@ export function useClients() {
if (touched) clients.value = [...next];
}
+ let invalidateTimer = null;
function applyInvalidate(payload) {
if (!payload || typeof payload !== 'object') return;
- if (payload.type === 'inbounds' || payload.type === 'clients') {
+ if (payload.type !== 'inbounds' && payload.type !== 'clients') return;
+ if (invalidateTimer) clearTimeout(invalidateTimer);
+ invalidateTimer = setTimeout(() => {
+ invalidateTimer = null;
refresh();
- }
+ }, 200);
}
onMounted(async () => {
@@ -178,12 +196,14 @@ export function useClients() {
fetched,
subSettings,
ipLimitEnable,
+ tgBotEnable,
expireDiff,
trafficDiff,
refresh,
create,
update,
remove,
+ removeMany,
attach,
detach,
resetTraffic,
diff --git a/frontend/src/pages/inbounds/useInbounds.js b/frontend/src/pages/inbounds/useInbounds.js
index b72c11a5..41fda433 100644
--- a/frontend/src/pages/inbounds/useInbounds.js
+++ b/frontend/src/pages/inbounds/useInbounds.js
@@ -55,7 +55,14 @@ export function useInbounds() {
// (HTTP, MIXED, WireGuard) since their settings have no client list.
function rollupClients(dbInbound, inbound) {
const clientStats = Array.isArray(dbInbound.clientStats) ? dbInbound.clientStats : [];
- const clients = inbound?.clients || [];
+ const allClients = inbound?.clients || [];
+ const statsEmails = new Set();
+ for (const s of clientStats) {
+ if (s && s.email) statsEmails.add(s.email);
+ }
+ const clients = clientStats.length > 0
+ ? allClients.filter((c) => c && c.email && statsEmails.has(c.email))
+ : allClients;
const active = [];
const deactive = [];
const depleted = [];
diff --git a/frontend/src/utils/index.js b/frontend/src/utils/index.js
index 589a1610..792574e0 100644
--- a/frontend/src/utils/index.js
+++ b/frontend/src/utils/index.js
@@ -33,29 +33,31 @@ export class HttpUtil {
}
static async get(url, params, options = {}) {
+ const { silent, ...axiosOpts } = options;
try {
- const resp = await axios.get(url, { params, ...options });
+ const resp = await axios.get(url, { params, ...axiosOpts });
const msg = this._respToMsg(resp);
- this._handleMsg(msg);
+ if (!silent) this._handleMsg(msg);
return msg;
} catch (error) {
console.error('GET request failed:', error);
const errorMsg = new Msg(false, error.response?.data?.message || error.message || 'Request failed');
- this._handleMsg(errorMsg);
+ if (!silent) this._handleMsg(errorMsg);
return errorMsg;
}
}
static async post(url, data, options = {}) {
+ const { silent, ...axiosOpts } = options;
try {
- const resp = await axios.post(url, data, options);
+ const resp = await axios.post(url, data, axiosOpts);
const msg = this._respToMsg(resp);
- this._handleMsg(msg);
+ if (!silent) this._handleMsg(msg);
return msg;
} catch (error) {
console.error('POST request failed:', error);
const errorMsg = new Msg(false, error.response?.data?.message || error.message || 'Request failed');
- this._handleMsg(errorMsg);
+ if (!silent) this._handleMsg(errorMsg);
return errorMsg;
}
}
diff --git a/web/service/client.go b/web/service/client.go
index 2012d5ad..70e9b516 100644
--- a/web/service/client.go
+++ b/web/service/client.go
@@ -83,6 +83,71 @@ var (
const deleteTombstoneTTL = 90 * time.Second
+var (
+ inboundMutationLocksMu sync.Mutex
+ inboundMutationLocks = map[int]*sync.Mutex{}
+)
+
+func lockInbound(inboundId int) *sync.Mutex {
+ inboundMutationLocksMu.Lock()
+ defer inboundMutationLocksMu.Unlock()
+ m, ok := inboundMutationLocks[inboundId]
+ if !ok {
+ m = &sync.Mutex{}
+ inboundMutationLocks[inboundId] = m
+ }
+ m.Lock()
+ return m
+}
+
+func compactOrphans(db *gorm.DB, clients []any) []any {
+ if len(clients) == 0 {
+ return clients
+ }
+ emails := make([]string, 0, len(clients))
+ for _, c := range clients {
+ cm, ok := c.(map[string]any)
+ if !ok {
+ continue
+ }
+ if e, _ := cm["email"].(string); e != "" {
+ emails = append(emails, e)
+ }
+ }
+ if len(emails) == 0 {
+ return clients
+ }
+ var existingEmails []string
+ if err := db.Model(&model.ClientRecord{}).Where("email IN ?", emails).Pluck("email", &existingEmails).Error; err != nil {
+ logger.Warning("compactOrphans pluck:", err)
+ return clients
+ }
+ if len(existingEmails) == len(emails) {
+ return clients
+ }
+ existing := make(map[string]struct{}, len(existingEmails))
+ for _, e := range existingEmails {
+ existing[e] = struct{}{}
+ }
+ out := make([]any, 0, len(existingEmails))
+ for _, c := range clients {
+ cm, ok := c.(map[string]any)
+ if !ok {
+ out = append(out, c)
+ continue
+ }
+ e, _ := cm["email"].(string)
+ if e == "" {
+ out = append(out, c)
+ continue
+ }
+ if _, ok := existing[e]; ok {
+ out = append(out, c)
+ }
+ }
+ return out
+}
+
func tombstoneClientEmail(email string) {
if email == "" {
return
@@ -138,6 +203,9 @@ func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.
return err
}
if errors.Is(err, gorm.ErrRecordNotFound) {
+ if isClientEmailTombstoned(email) {
+ continue
+ }
if err := tx.Create(incoming).Error; err != nil {
return err
}
@@ -887,6 +955,8 @@ func (s *ClientService) checkEmailsExistForClients(inboundSvc *InboundService, c
}
func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model.Inbound) (bool, error) {
+ defer lockInbound(data.Id).Unlock()
+
clients, err := inboundSvc.GetClients(data)
if err != nil {
return false, err
@@ -957,6 +1027,7 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
}
oldClients := oldSettings["clients"].([]any)
+ oldClients = compactOrphans(database.GetDB(), oldClients)
oldClients = append(oldClients, interfaceClients...)
oldSettings["clients"] = oldClients
@@ -1044,6 +1115,8 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
}
func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *model.Inbound, clientId string) (bool, error) {
+ defer lockInbound(data.Id).Unlock()
+
clients, err := inboundSvc.GetClients(data)
if err != nil {
return false, err
@@ -1295,6 +1368,8 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
}
func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId int, clientId string) (bool, error) {
+ defer lockInbound(inboundId).Unlock()
+
oldInbound, err := inboundSvc.GetInbound(inboundId)
if err != nil {
logger.Error("Load Old Data Error")
@@ -1337,6 +1412,8 @@ func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId i
return false, common.NewError("Client Not Found In Inbound For ID:", clientId)
}
+ db := database.GetDB()
+ newClients = compactOrphans(db, newClients)
if newClients == nil {
newClients = []any{}
}
@@ -1348,8 +1425,6 @@ func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId i
oldInbound.Settings = string(newSettings)
- db := database.GetDB()
-
emailShared, err := inboundSvc.emailUsedByOtherInbounds(email, inboundId)
if err != nil {
return false, err
@@ -1365,12 +1440,13 @@ func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId i
needRestart := false
if len(email) > 0 {
- notDepleted := true
- err = db.Model(xray.ClientTraffic{}).Select("enable").Where("email = ?", email).First(¬Depleted).Error
+ var enables []bool
+ err = db.Model(xray.ClientTraffic{}).Where("email = ?", email).Limit(1).Pluck("enable", &enables).Error
if err != nil {
logger.Error("Get stats error")
return false, err
}
+ notDepleted := len(enables) > 0 && enables[0]
if !emailShared {
err = inboundSvc.DelClientStat(db, email)
if err != nil {
@@ -1419,6 +1495,8 @@ func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId i
}
func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inboundId int, email string) (bool, error) {
+ defer lockInbound(inboundId).Unlock()
+
oldInbound, err := inboundSvc.GetInbound(inboundId)
if err != nil {
logger.Error("Load Old Data Error")
@@ -1455,6 +1533,8 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
if !found {
return false, common.NewError(fmt.Sprintf("client with email %s not found", email))
}
+ db := database.GetDB()
+ newClients = compactOrphans(db, newClients)
if newClients == nil {
newClients = []any{}
}
@@ -1466,8 +1546,6 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
oldInbound.Settings = string(newSettings)
- db := database.GetDB()
-
emailShared, err := inboundSvc.emailUsedByOtherInbounds(email, inboundId)
if err != nil {
return false, err