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(() => [ + + + + 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": {