feat(clients): add Delete depleted action

Mirrors the legacy delDepletedClients action that lived under the
inbounds page, but as a first-class /panel/api/clients/delDepleted
endpoint backed by ClientService. The new path goes through
ClientService.Delete for each depleted email, so the new clients +
client_inbounds + xray_client_traffic tables stay consistent.

Adds a danger-styled toolbar button on the Clients page (next to
Reset all client traffic) with a confirm dialog and a toast
reporting the deleted count.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MHSanaei 2026-05-17 09:45:38 +02:00
parent c5217b9a78
commit c84799ea2b
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
6 changed files with 100 additions and 1 deletions

View file

@ -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}',
},
],
},

View file

@ -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(() => [
</template>
<template v-if="!isMobile">{{ t('pages.clients.resetAllTraffics') }}</template>
</a-button>
<a-button size="small" danger @click="onDelDepleted">
<template #icon>
<RestOutlined />
</template>
<template v-if="!isMobile">{{ t('pages.clients.delDepleted') || 'Delete depleted' }}</template>
</a-button>
</div>
</template>

View file

@ -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,
};
}

View file

@ -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 {

View file

@ -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").

View file

@ -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": {