fix(clients): bulk add/delete correctness + perf, working pagination, delayed-start in form

Bulk add/delete were serial on the frontend (one toast per call, N round-trips)
and the backend race exposed by parallelizing them lost client attachments and
hit UNIQUE constraint failed on client_inbounds. The single add/edit modal also
had no Start-After-First-Use option, and the table never showed the delayed
duration.

Backend (web/service/client.go):
- Per-inbound mutex on Add/Update/Del InboundClient so concurrent writers on
  the same inbound don't lose the read-modify-write of settings JSON.
- SyncInbound skips create+join when the email is tombstoned so a concurrent
  maintenance pass (adjustTraffics, autoRenewClients, markClientsDisabledIn-
  Settings) that did a stale RMW can't resurrect a just-deleted client with a
  fresh id.
- compactOrphans sweeps settings.clients entries whose ClientRecord no longer
  exists, applied in Add/DelInboundClient + DelInboundClientByEmail so each
  user-initiated mutation self-heals the inbound's settings.
- DelInboundClient uses Pluck instead of First for the stats lookup so a
  missing row doesn't abort the delete with a noisy ErrRecordNotFound log.

Frontend:
- HttpUtil.{get,post} accept a silent option that suppresses the auto-toast.
- ClientBulkAddModal fires creates in parallel + silent + one summary toast.
- useClients.removeMany runs deletes in parallel + silent and refreshes once;
  ClientsPage bulk delete uses it and shows one aggregate toast.
- useClients.applyInvalidate debounces 200 ms so the burst of N WebSocket
  invalidate events from the backend collapses into a single refresh.
- ClientsPage pagination is reactive (paginationState ref + tablePagination
  computed); onTableChange persists page-size and page changes.
- ClientFormModal gains a Start-After-First-Use switch + Duration days input
  alongside the existing Expiry Date picker; on edit-mode open a negative
  expiryTime is decoded back to delayed mode + days; on submit the payload
  sends -86400000 * days or the absolute timestamp.
- ClientsPage table shows the delayed-start duration (blue tag Nd, tooltip
  Start After First Use: Nd) instead of infinity.
- Telegram ID field in the form is hidden when /panel/setting/defaultSettings
  reports tgBotEnable=false; Comment then fills the row.
- Form row 3 collapses UUID (span 12) + Total GB (span 8) + Limit IP (span 4)
  when ipLimitEnable is on, else UUID + Total GB at 12/12.
- useInbounds.rollupClients counts only clients with a matching clientStats
  row, so orphans in settings.clients no longer inflate the inbound's count.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MHSanaei 2026-05-18 22:36:08 +02:00
parent 086a74328a
commit 072d266f50
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
7 changed files with 226 additions and 48 deletions

View file

@ -136,10 +136,9 @@ async function submit() {
if (emails.length === 0) return;
saving.value = true;
let ok = 0;
let failed = 0;
const silentJsonOpts = { ...JSON_HEADERS, silent: true };
try {
for (const email of emails) {
const results = await Promise.all(emails.map((email) => {
const client = {
email,
subId: form.subId || RandomUtil.randomLowerAndNum(16),
@ -154,14 +153,24 @@ async function submit() {
enable: true,
};
const payload = { client, inboundIds: form.inboundIds };
const msg = await HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS);
return HttpUtil.post('/panel/api/clients/add', payload, silentJsonOpts);
}));
let ok = 0;
let failed = 0;
let firstError = '';
for (const msg of results) {
if (msg?.success) ok++;
else failed++;
else {
failed++;
if (!firstError && msg?.msg) firstError = msg.msg;
}
}
if (failed === 0) {
message.success(t('pages.clients.toasts.bulkCreated', { count: ok }));
} else {
message.warning(t('pages.clients.toasts.bulkCreatedMixed', { ok, failed }));
message.warning(firstError
? `${t('pages.clients.toasts.bulkCreatedMixed', { ok, failed })} — ${firstError}`
: t('pages.clients.toasts.bulkCreatedMixed', { ok, failed }));
}
emit('saved');
close();

View file

@ -15,6 +15,7 @@ const props = defineProps({
inbounds: { type: Array, default: () => [] },
attachedIds: { type: Array, default: () => [] },
ipLimitEnable: { type: Boolean, default: false },
tgBotEnable: { type: Boolean, default: false },
save: { type: Function, required: true },
});
@ -34,7 +35,9 @@ function emptyForm() {
flow: '',
reverseTag: '',
totalGB: 0,
expiryTime: null,
expiryDate: null,
delayedStart: false,
delayedDays: 0,
limitIp: 0,
tgId: 0,
comment: '',
@ -59,7 +62,16 @@ watch(
form.flow = props.client.flow || '';
form.reverseTag = props.client.reverse?.tag || '';
form.totalGB = bytesToGB(props.client.totalGB || 0);
form.expiryTime = props.client.expiryTime ? dayjs(props.client.expiryTime) : null;
const et = Number(props.client.expiryTime) || 0;
if (et < 0) {
form.delayedStart = true;
form.delayedDays = Math.round(et / -86400000);
form.expiryDate = null;
} else {
form.delayedStart = false;
form.delayedDays = 0;
form.expiryDate = et > 0 ? dayjs(et) : null;
}
form.limitIp = props.client.limitIp || 0;
form.tgId = Number(props.client.tgId) || 0;
form.comment = props.client.comment || '';
@ -186,6 +198,14 @@ function regenerateEmail() {
form.email = RandomUtil.randomLowerAndNum(12);
}
function onDelayedStartToggle(next) {
if (next) {
form.expiryDate = null;
} else {
form.delayedDays = 0;
}
}
async function onSubmit() {
if (!form.email || form.email.trim() === '') {
message.error(`${t('pages.clients.email')} *`);
@ -195,6 +215,9 @@ async function onSubmit() {
message.error(t('pages.clients.selectInbound'));
return;
}
const expiryTime = form.delayedStart
? -86400000 * (Number(form.delayedDays) || 0)
: (form.expiryDate ? form.expiryDate.valueOf() : 0);
const clientPayload = {
email: form.email.trim(),
subId: form.subId,
@ -203,7 +226,7 @@ async function onSubmit() {
auth: form.auth,
flow: showFlow.value ? (form.flow || '') : '',
totalGB: gbToBytes(form.totalGB),
expiryTime: form.expiryTime ? form.expiryTime.valueOf() : 0,
expiryTime,
limitIp: Number(form.limitIp) || 0,
tgId: Number(form.tgId) || 0,
comment: form.comment,
@ -285,7 +308,7 @@ async function onSubmit() {
</a-row>
<a-row :gutter="16">
<a-col :span="ipLimitEnable ? 12 : 24">
<a-col :span="12">
<a-form-item :label="t('pages.clients.uuid')">
<a-input-group compact style="display: flex">
<a-input v-model:value="form.uuid" style="flex: 1" />
@ -293,7 +316,12 @@ async function onSubmit() {
</a-input-group>
</a-form-item>
</a-col>
<a-col v-if="ipLimitEnable" :span="12">
<a-col :span="ipLimitEnable ? 8 : 12">
<a-form-item :label="t('pages.clients.totalGB')">
<a-input-number v-model:value="form.totalGB" :min="0" :step="0.1" style="width: 100%" />
</a-form-item>
</a-col>
<a-col v-if="ipLimitEnable" :span="4">
<a-form-item :label="t('pages.clients.limitIp')">
<a-input-number v-model:value="form.limitIp" :min="0" style="width: 100%" />
</a-form-item>
@ -302,13 +330,16 @@ async function onSubmit() {
<a-row :gutter="16">
<a-col :span="12">
<a-form-item :label="t('pages.clients.totalGB')">
<a-input-number v-model:value="form.totalGB" :min="0" :step="0.1" style="width: 100%" />
<a-form-item v-if="form.delayedStart" :label="t('pages.clients.expireDays')">
<a-input-number v-model:value="form.delayedDays" :min="0" style="width: 100%" />
</a-form-item>
<a-form-item v-else :label="t('pages.clients.expiryTime')">
<a-date-picker v-model:value="form.expiryDate" show-time style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('pages.clients.expiryTime')">
<a-date-picker v-model:value="form.expiryTime" show-time style="width: 100%" />
<a-form-item :label="t('pages.clients.delayedStart')">
<a-switch v-model:checked="form.delayedStart" @change="onDelayedStartToggle" />
</a-form-item>
</a-col>
</a-row>
@ -330,14 +361,13 @@ async function onSubmit() {
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-col v-if="tgBotEnable" :span="12">
<a-form-item :label="t('pages.clients.telegramId')">
<a-input-number v-model:value="form.tgId" :min="0" :controls="false"
:placeholder="t('pages.clients.telegramIdPlaceholder')"
style="width: 100%" />
:placeholder="t('pages.clients.telegramIdPlaceholder')" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-col :span="tgBotEnable ? 12 : 24">
<a-form-item :label="t('pages.clients.comment')">
<a-input v-model:value="form.comment" />
</a-form-item>

View file

@ -40,11 +40,13 @@ const {
fetched,
subSettings,
ipLimitEnable,
tgBotEnable,
expireDiff,
trafficDiff,
create,
update,
remove,
removeMany,
attach,
detach,
resetTraffic,
@ -136,18 +138,24 @@ function onBulkDelete() {
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const results = await removeMany(emails);
selectedRowKeys.value = [];
let ok = 0;
let failed = 0;
for (const email of emails) {
const msg = await remove(email);
let firstError = '';
for (const msg of results) {
if (msg?.success) ok++;
else failed++;
else {
failed++;
if (!firstError && msg?.msg) firstError = msg.msg;
}
}
selectedRowKeys.value = [];
if (failed === 0) {
message.success(t('pages.clients.toasts.bulkDeleted', { count: ok }));
} else {
message.warning(t('pages.clients.toasts.bulkDeletedMixed', { ok, failed }));
message.warning(firstError
? `${t('pages.clients.toasts.bulkDeletedMixed', { ok, failed })} — ${firstError}`
: t('pages.clients.toasts.bulkDeletedMixed', { ok, failed }));
}
},
});
@ -405,17 +413,26 @@ function remainingColor(row) {
}
function expiryLabel(row) {
if (!row.expiryTime || row.expiryTime <= 0) return '∞';
if (!row.expiryTime) return '∞';
if (row.expiryTime < 0) {
const days = Math.round(row.expiryTime / -86400000);
return `${t('pages.clients.delayedStart')}: ${days}d`;
}
return IntlUtil.formatDate(row.expiryTime);
}
function expiryRelative(row) {
if (!row.expiryTime || row.expiryTime <= 0) return '';
if (!row.expiryTime) return '';
if (row.expiryTime < 0) {
const days = Math.round(row.expiryTime / -86400000);
return `${days}d`;
}
return IntlUtil.formatRelativeTime(row.expiryTime);
}
function expiryColor(row) {
if (!row.expiryTime || row.expiryTime <= 0) return 'purple';
if (!row.expiryTime) return 'purple';
if (row.expiryTime < 0) return 'blue';
const now = Date.now();
if (row.expiryTime <= now) return 'red';
if (row.expiryTime - now < 86400 * 1000 * 3) return 'orange';
@ -423,6 +440,7 @@ function expiryColor(row) {
}
const sortState = ref({ column: null, order: null });
const paginationState = ref({ current: 1, pageSize: 20 });
function sortableCol(col, key) {
return {
@ -465,13 +483,28 @@ const sortedClients = computed(() => {
return order === 'descend' ? sorted.reverse() : sorted;
});
function onTableChange(_pag, _filters, sorter) {
function onTableChange(pag, _filters, sorter) {
if (pag) {
paginationState.value = {
current: pag.current || 1,
pageSize: pag.pageSize || paginationState.value.pageSize,
};
}
sortState.value = {
column: sorter?.columnKey || sorter?.field || null,
order: sorter?.order || null,
};
}
const tablePagination = computed(() => ({
current: paginationState.value.current,
pageSize: paginationState.value.pageSize,
total: sortedClients.value.length,
showSizeChanger: sortedClients.value.length > 10,
pageSizeOptions: ['10', '20', '50', '100'],
hideOnSinglePage: sortedClients.value.length <= paginationState.value.pageSize,
}));
const columns = computed(() => [
{ title: t('pages.clients.actions'), key: 'actions', width: 200 },
sortableCol({ title: t('pages.clients.enabled'), key: 'enable', width: 80 }, 'enable'),
@ -638,9 +671,7 @@ const columns = computed(() => [
</div>
<a-table v-if="!isMobile" :columns="columns" :data-source="sortedClients" :loading="loading" row-key="email"
:row-selection="rowSelection"
:pagination="{ pageSize: 20, showSizeChanger: sortedClients.length > 10, pageSizeOptions: ['10', '20', '50', '100'], hideOnSinglePage: sortedClients.length <= 10 }"
size="small" @change="onTableChange">
:row-selection="rowSelection" :pagination="tablePagination" size="small" @change="onTableChange">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'email'">
<div class="email-cell">
@ -677,7 +708,7 @@ const columns = computed(() => [
<template v-else-if="column.key === 'expiryTime'">
<a-tooltip :title="expiryLabel(record)">
<a-tag :color="expiryColor(record)">
{{ record.expiryTime > 0 ? expiryRelative(record) : '∞' }}
{{ record.expiryTime ? expiryRelative(record) : '∞' }}
</a-tag>
</a-tooltip>
</template>
@ -792,7 +823,8 @@ const columns = computed(() => [
</a-layout>
<ClientFormModal v-model:open="formOpen" :mode="formMode" :client="editingClient"
:attached-ids="editingAttachedIds" :inbounds="inbounds" :ip-limit-enable="ipLimitEnable" :save="onSave" />
:attached-ids="editingAttachedIds" :inbounds="inbounds" :ip-limit-enable="ipLimitEnable"
:tg-bot-enable="tgBotEnable" :save="onSave" />
<ClientInfoModal v-model:open="infoOpen" :client="infoClient" :inbounds-by-id="inboundsById"
:is-online="infoClient ? isOnline(infoClient.email) : false" :sub-settings="subSettings" />
<ClientQrModal v-model:open="qrOpen" :client="qrClient" :sub-settings="subSettings" />

View file

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

View file

@ -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 = [];

View file

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

View file

@ -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(&notDepleted).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