diff --git a/frontend/src/pages/api-docs/endpoints.js b/frontend/src/pages/api-docs/endpoints.js
index 0ad13d24..16966bb1 100644
--- a/frontend/src/pages/api-docs/endpoints.js
+++ b/frontend/src/pages/api-docs/endpoints.js
@@ -597,6 +597,12 @@ export const sections = [
summary: 'Reset the up/down counters for every client globally. Quotas and expiry are not affected. Triggers an Xray restart if any counter actually moved.',
response: '{\n "success": true\n}',
},
+ {
+ method: 'POST',
+ path: '/panel/api/clients/delDepleted',
+ summary: 'Delete every client whose traffic quota is exhausted (used >= total, when reset is disabled) or whose expiry has passed. Returns the deleted count and triggers an Xray restart when any client was on a running inbound.',
+ response: '{\n "success": true,\n "obj": {\n "deleted": 0\n }\n}',
+ },
],
},
diff --git a/frontend/src/pages/clients/ClientsPage.vue b/frontend/src/pages/clients/ClientsPage.vue
index 75a8189d..909db9b4 100644
--- a/frontend/src/pages/clients/ClientsPage.vue
+++ b/frontend/src/pages/clients/ClientsPage.vue
@@ -10,6 +10,7 @@ import {
InfoCircleOutlined,
QrcodeOutlined,
RetweetOutlined,
+ RestOutlined,
MoreOutlined,
UsergroupAddOutlined,
} from '@ant-design/icons-vue';
@@ -40,6 +41,7 @@ const {
detach,
resetTraffic,
resetAllTraffics,
+ delDepleted,
setEnable,
} = useClients();
@@ -140,6 +142,25 @@ async function onBulkAddSaved() {
bulkAddOpen.value = false;
}
+function onDelDepleted() {
+ Modal.confirm({
+ title: t('pages.clients.delDepletedConfirmTitle') || 'Delete depleted clients?',
+ content: t('pages.clients.delDepletedConfirmContent')
+ || 'Removes every client whose traffic quota is exhausted or whose expiry has passed. This cannot be undone.',
+ okText: t('delete'),
+ okType: 'danger',
+ cancelText: t('cancel'),
+ onOk: async () => {
+ const msg = await delDepleted();
+ if (msg?.success) {
+ const deleted = msg.obj?.deleted ?? 0;
+ message.success(t('pages.clients.toasts.delDepleted', { count: deleted })
+ || `${deleted} depleted clients deleted`);
+ }
+ },
+ });
+}
+
const onlineSet = computed(() => new Set(onlines.value || []));
const inboundsById = computed(() => {
const out = {};
@@ -343,6 +364,12 @@ const columns = computed(() => [
{{ t('pages.clients.resetAllTraffics') }}
+
+
+
+
+ {{ t('pages.clients.delDepleted') || 'Delete depleted' }}
+
diff --git a/frontend/src/pages/clients/useClients.js b/frontend/src/pages/clients/useClients.js
index d063af40..73c4cc6c 100644
--- a/frontend/src/pages/clients/useClients.js
+++ b/frontend/src/pages/clients/useClients.js
@@ -99,6 +99,12 @@ export function useClients() {
return msg;
}
+ async function delDepleted() {
+ const msg = await HttpUtil.post('/panel/api/clients/delDepleted');
+ if (msg?.success) await refresh();
+ return msg;
+ }
+
async function setEnable(client, enable) {
if (!client?.id) return null;
const payload = {
@@ -142,6 +148,7 @@ export function useClients() {
detach,
resetTraffic,
resetAllTraffics,
+ delDepleted,
setEnable,
};
}
diff --git a/web/controller/client.go b/web/controller/client.go
index 5b9c5b6f..5a8f9ab7 100644
--- a/web/controller/client.go
+++ b/web/controller/client.go
@@ -30,6 +30,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
g.POST("/:id/attach", a.attach)
g.POST("/:id/detach", a.detach)
g.POST("/resetAllTraffics", a.resetAllTraffics)
+ g.POST("/delDepleted", a.delDepleted)
}
func (a *ClientController) list(c *gin.Context) {
@@ -155,6 +156,18 @@ func (a *ClientController) resetAllTraffics(c *gin.Context) {
}
}
+func (a *ClientController) delDepleted(c *gin.Context) {
+ deleted, needRestart, err := a.clientService.DelDepleted(&a.inboundService)
+ if err != nil {
+ jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+ return
+ }
+ jsonObj(c, gin.H{"deleted": deleted}, nil)
+ if needRestart {
+ a.xrayService.SetToNeedRestart()
+ }
+}
+
func (a *ClientController) detach(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
diff --git a/web/service/client.go b/web/service/client.go
index e2026dcd..da5e85df 100644
--- a/web/service/client.go
+++ b/web/service/client.go
@@ -475,6 +475,48 @@ func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []
return needRestart, nil
}
+func (s *ClientService) DelDepleted(inboundSvc *InboundService) (int, bool, error) {
+ db := database.GetDB()
+ now := time.Now().UnixMilli()
+ depletedClause := "reset = 0 and ((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?))"
+
+ var rows []xray.ClientTraffic
+ if err := db.Where(depletedClause, now).Find(&rows).Error; err != nil {
+ return 0, false, err
+ }
+ if len(rows) == 0 {
+ return 0, false, nil
+ }
+
+ emails := make(map[string]struct{}, len(rows))
+ for _, r := range rows {
+ if r.Email != "" {
+ emails[r.Email] = struct{}{}
+ }
+ }
+
+ needRestart := false
+ deleted := 0
+ for email := range emails {
+ var rec model.ClientRecord
+ if err := db.Where("email = ?", email).First(&rec).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ continue
+ }
+ return deleted, needRestart, err
+ }
+ nr, err := s.Delete(inboundSvc, rec.Id, false)
+ if err != nil {
+ return deleted, needRestart, err
+ }
+ if nr {
+ needRestart = true
+ }
+ deleted++
+ }
+ return deleted, needRestart, nil
+}
+
func (s *ClientService) ResetAllTraffics() (bool, error) {
res := database.GetDB().Model(&xray.ClientTraffic{}).
Where("1 = 1").
diff --git a/web/translation/en-US.json b/web/translation/en-US.json
index 3a6d191c..180f6f18 100644
--- a/web/translation/en-US.json
+++ b/web/translation/en-US.json
@@ -440,12 +440,16 @@
"deleteSelected": "Delete ({count})",
"bulkDeleteConfirmTitle": "Delete {count} clients?",
"bulkDeleteConfirmContent": "Each selected client is removed from every attached inbound and its traffic record is dropped. This cannot be undone.",
+ "delDepleted": "Delete depleted",
+ "delDepletedConfirmTitle": "Delete depleted clients?",
+ "delDepletedConfirmContent": "Removes every client whose traffic quota is exhausted or whose expiry has passed. This cannot be undone.",
"toasts": {
"deleted": "Client deleted",
"trafficReset": "Traffic reset",
"allTrafficsReset": "All client traffic reset",
"bulkDeleted": "{count} clients deleted",
- "bulkCreated": "{count} clients created"
+ "bulkCreated": "{count} clients created",
+ "delDepleted": "{count} depleted clients deleted"
}
},
"nodes": {