mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
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:
parent
086a74328a
commit
072d266f50
7 changed files with 226 additions and 48 deletions
|
|
@ -136,10 +136,9 @@ async function submit() {
|
||||||
if (emails.length === 0) return;
|
if (emails.length === 0) return;
|
||||||
|
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
let ok = 0;
|
const silentJsonOpts = { ...JSON_HEADERS, silent: true };
|
||||||
let failed = 0;
|
|
||||||
try {
|
try {
|
||||||
for (const email of emails) {
|
const results = await Promise.all(emails.map((email) => {
|
||||||
const client = {
|
const client = {
|
||||||
email,
|
email,
|
||||||
subId: form.subId || RandomUtil.randomLowerAndNum(16),
|
subId: form.subId || RandomUtil.randomLowerAndNum(16),
|
||||||
|
|
@ -154,14 +153,24 @@ async function submit() {
|
||||||
enable: true,
|
enable: true,
|
||||||
};
|
};
|
||||||
const payload = { client, inboundIds: form.inboundIds };
|
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++;
|
if (msg?.success) ok++;
|
||||||
else failed++;
|
else {
|
||||||
|
failed++;
|
||||||
|
if (!firstError && msg?.msg) firstError = msg.msg;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (failed === 0) {
|
if (failed === 0) {
|
||||||
message.success(t('pages.clients.toasts.bulkCreated', { count: ok }));
|
message.success(t('pages.clients.toasts.bulkCreated', { count: ok }));
|
||||||
} else {
|
} 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');
|
emit('saved');
|
||||||
close();
|
close();
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ const props = defineProps({
|
||||||
inbounds: { type: Array, default: () => [] },
|
inbounds: { type: Array, default: () => [] },
|
||||||
attachedIds: { type: Array, default: () => [] },
|
attachedIds: { type: Array, default: () => [] },
|
||||||
ipLimitEnable: { type: Boolean, default: false },
|
ipLimitEnable: { type: Boolean, default: false },
|
||||||
|
tgBotEnable: { type: Boolean, default: false },
|
||||||
save: { type: Function, required: true },
|
save: { type: Function, required: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -34,7 +35,9 @@ function emptyForm() {
|
||||||
flow: '',
|
flow: '',
|
||||||
reverseTag: '',
|
reverseTag: '',
|
||||||
totalGB: 0,
|
totalGB: 0,
|
||||||
expiryTime: null,
|
expiryDate: null,
|
||||||
|
delayedStart: false,
|
||||||
|
delayedDays: 0,
|
||||||
limitIp: 0,
|
limitIp: 0,
|
||||||
tgId: 0,
|
tgId: 0,
|
||||||
comment: '',
|
comment: '',
|
||||||
|
|
@ -59,7 +62,16 @@ watch(
|
||||||
form.flow = props.client.flow || '';
|
form.flow = props.client.flow || '';
|
||||||
form.reverseTag = props.client.reverse?.tag || '';
|
form.reverseTag = props.client.reverse?.tag || '';
|
||||||
form.totalGB = bytesToGB(props.client.totalGB || 0);
|
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.limitIp = props.client.limitIp || 0;
|
||||||
form.tgId = Number(props.client.tgId) || 0;
|
form.tgId = Number(props.client.tgId) || 0;
|
||||||
form.comment = props.client.comment || '';
|
form.comment = props.client.comment || '';
|
||||||
|
|
@ -186,6 +198,14 @@ function regenerateEmail() {
|
||||||
form.email = RandomUtil.randomLowerAndNum(12);
|
form.email = RandomUtil.randomLowerAndNum(12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onDelayedStartToggle(next) {
|
||||||
|
if (next) {
|
||||||
|
form.expiryDate = null;
|
||||||
|
} else {
|
||||||
|
form.delayedDays = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
if (!form.email || form.email.trim() === '') {
|
if (!form.email || form.email.trim() === '') {
|
||||||
message.error(`${t('pages.clients.email')} *`);
|
message.error(`${t('pages.clients.email')} *`);
|
||||||
|
|
@ -195,6 +215,9 @@ async function onSubmit() {
|
||||||
message.error(t('pages.clients.selectInbound'));
|
message.error(t('pages.clients.selectInbound'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const expiryTime = form.delayedStart
|
||||||
|
? -86400000 * (Number(form.delayedDays) || 0)
|
||||||
|
: (form.expiryDate ? form.expiryDate.valueOf() : 0);
|
||||||
const clientPayload = {
|
const clientPayload = {
|
||||||
email: form.email.trim(),
|
email: form.email.trim(),
|
||||||
subId: form.subId,
|
subId: form.subId,
|
||||||
|
|
@ -203,7 +226,7 @@ async function onSubmit() {
|
||||||
auth: form.auth,
|
auth: form.auth,
|
||||||
flow: showFlow.value ? (form.flow || '') : '',
|
flow: showFlow.value ? (form.flow || '') : '',
|
||||||
totalGB: gbToBytes(form.totalGB),
|
totalGB: gbToBytes(form.totalGB),
|
||||||
expiryTime: form.expiryTime ? form.expiryTime.valueOf() : 0,
|
expiryTime,
|
||||||
limitIp: Number(form.limitIp) || 0,
|
limitIp: Number(form.limitIp) || 0,
|
||||||
tgId: Number(form.tgId) || 0,
|
tgId: Number(form.tgId) || 0,
|
||||||
comment: form.comment,
|
comment: form.comment,
|
||||||
|
|
@ -285,7 +308,7 @@ async function onSubmit() {
|
||||||
</a-row>
|
</a-row>
|
||||||
|
|
||||||
<a-row :gutter="16">
|
<a-row :gutter="16">
|
||||||
<a-col :span="ipLimitEnable ? 12 : 24">
|
<a-col :span="12">
|
||||||
<a-form-item :label="t('pages.clients.uuid')">
|
<a-form-item :label="t('pages.clients.uuid')">
|
||||||
<a-input-group compact style="display: flex">
|
<a-input-group compact style="display: flex">
|
||||||
<a-input v-model:value="form.uuid" style="flex: 1" />
|
<a-input v-model:value="form.uuid" style="flex: 1" />
|
||||||
|
|
@ -293,7 +316,12 @@ async function onSubmit() {
|
||||||
</a-input-group>
|
</a-input-group>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</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-form-item :label="t('pages.clients.limitIp')">
|
||||||
<a-input-number v-model:value="form.limitIp" :min="0" style="width: 100%" />
|
<a-input-number v-model:value="form.limitIp" :min="0" style="width: 100%" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
@ -302,13 +330,16 @@ async function onSubmit() {
|
||||||
|
|
||||||
<a-row :gutter="16">
|
<a-row :gutter="16">
|
||||||
<a-col :span="12">
|
<a-col :span="12">
|
||||||
<a-form-item :label="t('pages.clients.totalGB')">
|
<a-form-item v-if="form.delayedStart" :label="t('pages.clients.expireDays')">
|
||||||
<a-input-number v-model:value="form.totalGB" :min="0" :step="0.1" style="width: 100%" />
|
<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-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="12">
|
<a-col :span="12">
|
||||||
<a-form-item :label="t('pages.clients.expiryTime')">
|
<a-form-item :label="t('pages.clients.delayedStart')">
|
||||||
<a-date-picker v-model:value="form.expiryTime" show-time style="width: 100%" />
|
<a-switch v-model:checked="form.delayedStart" @change="onDelayedStartToggle" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
|
|
@ -330,14 +361,13 @@ async function onSubmit() {
|
||||||
</a-row>
|
</a-row>
|
||||||
|
|
||||||
<a-row :gutter="16">
|
<a-row :gutter="16">
|
||||||
<a-col :span="12">
|
<a-col v-if="tgBotEnable" :span="12">
|
||||||
<a-form-item :label="t('pages.clients.telegramId')">
|
<a-form-item :label="t('pages.clients.telegramId')">
|
||||||
<a-input-number v-model:value="form.tgId" :min="0" :controls="false"
|
<a-input-number v-model:value="form.tgId" :min="0" :controls="false"
|
||||||
:placeholder="t('pages.clients.telegramIdPlaceholder')"
|
:placeholder="t('pages.clients.telegramIdPlaceholder')" style="width: 100%" />
|
||||||
style="width: 100%" />
|
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="12">
|
<a-col :span="tgBotEnable ? 12 : 24">
|
||||||
<a-form-item :label="t('pages.clients.comment')">
|
<a-form-item :label="t('pages.clients.comment')">
|
||||||
<a-input v-model:value="form.comment" />
|
<a-input v-model:value="form.comment" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
|
||||||
|
|
@ -40,11 +40,13 @@ const {
|
||||||
fetched,
|
fetched,
|
||||||
subSettings,
|
subSettings,
|
||||||
ipLimitEnable,
|
ipLimitEnable,
|
||||||
|
tgBotEnable,
|
||||||
expireDiff,
|
expireDiff,
|
||||||
trafficDiff,
|
trafficDiff,
|
||||||
create,
|
create,
|
||||||
update,
|
update,
|
||||||
remove,
|
remove,
|
||||||
|
removeMany,
|
||||||
attach,
|
attach,
|
||||||
detach,
|
detach,
|
||||||
resetTraffic,
|
resetTraffic,
|
||||||
|
|
@ -136,18 +138,24 @@ function onBulkDelete() {
|
||||||
okType: 'danger',
|
okType: 'danger',
|
||||||
cancelText: t('cancel'),
|
cancelText: t('cancel'),
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
|
const results = await removeMany(emails);
|
||||||
|
selectedRowKeys.value = [];
|
||||||
let ok = 0;
|
let ok = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
for (const email of emails) {
|
let firstError = '';
|
||||||
const msg = await remove(email);
|
for (const msg of results) {
|
||||||
if (msg?.success) ok++;
|
if (msg?.success) ok++;
|
||||||
else failed++;
|
else {
|
||||||
|
failed++;
|
||||||
|
if (!firstError && msg?.msg) firstError = msg.msg;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
selectedRowKeys.value = [];
|
|
||||||
if (failed === 0) {
|
if (failed === 0) {
|
||||||
message.success(t('pages.clients.toasts.bulkDeleted', { count: ok }));
|
message.success(t('pages.clients.toasts.bulkDeleted', { count: ok }));
|
||||||
} else {
|
} 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) {
|
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);
|
return IntlUtil.formatDate(row.expiryTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
function expiryRelative(row) {
|
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);
|
return IntlUtil.formatRelativeTime(row.expiryTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
function expiryColor(row) {
|
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();
|
const now = Date.now();
|
||||||
if (row.expiryTime <= now) return 'red';
|
if (row.expiryTime <= now) return 'red';
|
||||||
if (row.expiryTime - now < 86400 * 1000 * 3) return 'orange';
|
if (row.expiryTime - now < 86400 * 1000 * 3) return 'orange';
|
||||||
|
|
@ -423,6 +440,7 @@ function expiryColor(row) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortState = ref({ column: null, order: null });
|
const sortState = ref({ column: null, order: null });
|
||||||
|
const paginationState = ref({ current: 1, pageSize: 20 });
|
||||||
|
|
||||||
function sortableCol(col, key) {
|
function sortableCol(col, key) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -465,13 +483,28 @@ const sortedClients = computed(() => {
|
||||||
return order === 'descend' ? sorted.reverse() : sorted;
|
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 = {
|
sortState.value = {
|
||||||
column: sorter?.columnKey || sorter?.field || null,
|
column: sorter?.columnKey || sorter?.field || null,
|
||||||
order: sorter?.order || 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(() => [
|
const columns = computed(() => [
|
||||||
{ title: t('pages.clients.actions'), key: 'actions', width: 200 },
|
{ title: t('pages.clients.actions'), key: 'actions', width: 200 },
|
||||||
sortableCol({ title: t('pages.clients.enabled'), key: 'enable', width: 80 }, 'enable'),
|
sortableCol({ title: t('pages.clients.enabled'), key: 'enable', width: 80 }, 'enable'),
|
||||||
|
|
@ -638,9 +671,7 @@ const columns = computed(() => [
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a-table v-if="!isMobile" :columns="columns" :data-source="sortedClients" :loading="loading" row-key="email"
|
<a-table v-if="!isMobile" :columns="columns" :data-source="sortedClients" :loading="loading" row-key="email"
|
||||||
:row-selection="rowSelection"
|
:row-selection="rowSelection" :pagination="tablePagination" size="small" @change="onTableChange">
|
||||||
:pagination="{ pageSize: 20, showSizeChanger: sortedClients.length > 10, pageSizeOptions: ['10', '20', '50', '100'], hideOnSinglePage: sortedClients.length <= 10 }"
|
|
||||||
size="small" @change="onTableChange">
|
|
||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record }">
|
||||||
<template v-if="column.key === 'email'">
|
<template v-if="column.key === 'email'">
|
||||||
<div class="email-cell">
|
<div class="email-cell">
|
||||||
|
|
@ -677,7 +708,7 @@ const columns = computed(() => [
|
||||||
<template v-else-if="column.key === 'expiryTime'">
|
<template v-else-if="column.key === 'expiryTime'">
|
||||||
<a-tooltip :title="expiryLabel(record)">
|
<a-tooltip :title="expiryLabel(record)">
|
||||||
<a-tag :color="expiryColor(record)">
|
<a-tag :color="expiryColor(record)">
|
||||||
{{ record.expiryTime > 0 ? expiryRelative(record) : '∞' }}
|
{{ record.expiryTime ? expiryRelative(record) : '∞' }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -792,7 +823,8 @@ const columns = computed(() => [
|
||||||
</a-layout>
|
</a-layout>
|
||||||
|
|
||||||
<ClientFormModal v-model:open="formOpen" :mode="formMode" :client="editingClient"
|
<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"
|
<ClientInfoModal v-model:open="infoOpen" :client="infoClient" :inbounds-by-id="inboundsById"
|
||||||
:is-online="infoClient ? isOnline(infoClient.email) : false" :sub-settings="subSettings" />
|
:is-online="infoClient ? isOnline(infoClient.email) : false" :sub-settings="subSettings" />
|
||||||
<ClientQrModal v-model:open="qrOpen" :client="qrClient" :sub-settings="subSettings" />
|
<ClientQrModal v-model:open="qrOpen" :client="qrClient" :sub-settings="subSettings" />
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ export function useClients() {
|
||||||
const fetched = ref(false);
|
const fetched = ref(false);
|
||||||
const subSettings = ref({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false });
|
const subSettings = ref({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false });
|
||||||
const ipLimitEnable = ref(false);
|
const ipLimitEnable = ref(false);
|
||||||
|
const tgBotEnable = ref(false);
|
||||||
const expireDiff = ref(0);
|
const expireDiff = ref(0);
|
||||||
const trafficDiff = ref(0);
|
const trafficDiff = ref(0);
|
||||||
|
|
||||||
|
|
@ -44,6 +45,7 @@ export function useClients() {
|
||||||
subJsonEnable: !!s.subJsonEnable,
|
subJsonEnable: !!s.subJsonEnable,
|
||||||
};
|
};
|
||||||
ipLimitEnable.value = !!s.ipLimitEnable;
|
ipLimitEnable.value = !!s.ipLimitEnable;
|
||||||
|
tgBotEnable.value = !!s.tgBotEnable;
|
||||||
expireDiff.value = (s.expireDiff ?? 0) * 86400000;
|
expireDiff.value = (s.expireDiff ?? 0) * 86400000;
|
||||||
trafficDiff.value = (s.trafficDiff ?? 0) * 1073741824;
|
trafficDiff.value = (s.trafficDiff ?? 0) * 1073741824;
|
||||||
}
|
}
|
||||||
|
|
@ -73,6 +75,18 @@ export function useClients() {
|
||||||
return msg;
|
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) {
|
async function attach(email, inboundIds) {
|
||||||
if (!email) return null;
|
if (!email) return null;
|
||||||
const encoded = encodeURIComponent(email);
|
const encoded = encodeURIComponent(email);
|
||||||
|
|
@ -159,11 +173,15 @@ export function useClients() {
|
||||||
if (touched) clients.value = [...next];
|
if (touched) clients.value = [...next];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let invalidateTimer = null;
|
||||||
function applyInvalidate(payload) {
|
function applyInvalidate(payload) {
|
||||||
if (!payload || typeof payload !== 'object') return;
|
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();
|
refresh();
|
||||||
}
|
}, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|
@ -178,12 +196,14 @@ export function useClients() {
|
||||||
fetched,
|
fetched,
|
||||||
subSettings,
|
subSettings,
|
||||||
ipLimitEnable,
|
ipLimitEnable,
|
||||||
|
tgBotEnable,
|
||||||
expireDiff,
|
expireDiff,
|
||||||
trafficDiff,
|
trafficDiff,
|
||||||
refresh,
|
refresh,
|
||||||
create,
|
create,
|
||||||
update,
|
update,
|
||||||
remove,
|
remove,
|
||||||
|
removeMany,
|
||||||
attach,
|
attach,
|
||||||
detach,
|
detach,
|
||||||
resetTraffic,
|
resetTraffic,
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,14 @@ export function useInbounds() {
|
||||||
// (HTTP, MIXED, WireGuard) since their settings have no client list.
|
// (HTTP, MIXED, WireGuard) since their settings have no client list.
|
||||||
function rollupClients(dbInbound, inbound) {
|
function rollupClients(dbInbound, inbound) {
|
||||||
const clientStats = Array.isArray(dbInbound.clientStats) ? dbInbound.clientStats : [];
|
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 active = [];
|
||||||
const deactive = [];
|
const deactive = [];
|
||||||
const depleted = [];
|
const depleted = [];
|
||||||
|
|
|
||||||
|
|
@ -33,29 +33,31 @@ export class HttpUtil {
|
||||||
}
|
}
|
||||||
|
|
||||||
static async get(url, params, options = {}) {
|
static async get(url, params, options = {}) {
|
||||||
|
const { silent, ...axiosOpts } = options;
|
||||||
try {
|
try {
|
||||||
const resp = await axios.get(url, { params, ...options });
|
const resp = await axios.get(url, { params, ...axiosOpts });
|
||||||
const msg = this._respToMsg(resp);
|
const msg = this._respToMsg(resp);
|
||||||
this._handleMsg(msg);
|
if (!silent) this._handleMsg(msg);
|
||||||
return msg;
|
return msg;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('GET request failed:', error);
|
console.error('GET request failed:', error);
|
||||||
const errorMsg = new Msg(false, error.response?.data?.message || error.message || 'Request failed');
|
const errorMsg = new Msg(false, error.response?.data?.message || error.message || 'Request failed');
|
||||||
this._handleMsg(errorMsg);
|
if (!silent) this._handleMsg(errorMsg);
|
||||||
return errorMsg;
|
return errorMsg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async post(url, data, options = {}) {
|
static async post(url, data, options = {}) {
|
||||||
|
const { silent, ...axiosOpts } = options;
|
||||||
try {
|
try {
|
||||||
const resp = await axios.post(url, data, options);
|
const resp = await axios.post(url, data, axiosOpts);
|
||||||
const msg = this._respToMsg(resp);
|
const msg = this._respToMsg(resp);
|
||||||
this._handleMsg(msg);
|
if (!silent) this._handleMsg(msg);
|
||||||
return msg;
|
return msg;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('POST request failed:', error);
|
console.error('POST request failed:', error);
|
||||||
const errorMsg = new Msg(false, error.response?.data?.message || error.message || 'Request failed');
|
const errorMsg = new Msg(false, error.response?.data?.message || error.message || 'Request failed');
|
||||||
this._handleMsg(errorMsg);
|
if (!silent) this._handleMsg(errorMsg);
|
||||||
return errorMsg;
|
return errorMsg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,71 @@ var (
|
||||||
|
|
||||||
const deleteTombstoneTTL = 90 * time.Second
|
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) {
|
func tombstoneClientEmail(email string) {
|
||||||
if email == "" {
|
if email == "" {
|
||||||
return
|
return
|
||||||
|
|
@ -138,6 +203,9 @@ func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
if isClientEmailTombstoned(email) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if err := tx.Create(incoming).Error; err != nil {
|
if err := tx.Create(incoming).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -887,6 +955,8 @@ func (s *ClientService) checkEmailsExistForClients(inboundSvc *InboundService, c
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model.Inbound) (bool, error) {
|
func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model.Inbound) (bool, error) {
|
||||||
|
defer lockInbound(data.Id).Unlock()
|
||||||
|
|
||||||
clients, err := inboundSvc.GetClients(data)
|
clients, err := inboundSvc.GetClients(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
|
|
@ -957,6 +1027,7 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
|
||||||
}
|
}
|
||||||
|
|
||||||
oldClients := oldSettings["clients"].([]any)
|
oldClients := oldSettings["clients"].([]any)
|
||||||
|
oldClients = compactOrphans(database.GetDB(), oldClients)
|
||||||
oldClients = append(oldClients, interfaceClients...)
|
oldClients = append(oldClients, interfaceClients...)
|
||||||
|
|
||||||
oldSettings["clients"] = oldClients
|
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) {
|
func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *model.Inbound, clientId string) (bool, error) {
|
||||||
|
defer lockInbound(data.Id).Unlock()
|
||||||
|
|
||||||
clients, err := inboundSvc.GetClients(data)
|
clients, err := inboundSvc.GetClients(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
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) {
|
func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId int, clientId string) (bool, error) {
|
||||||
|
defer lockInbound(inboundId).Unlock()
|
||||||
|
|
||||||
oldInbound, err := inboundSvc.GetInbound(inboundId)
|
oldInbound, err := inboundSvc.GetInbound(inboundId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Load Old Data Error")
|
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)
|
return false, common.NewError("Client Not Found In Inbound For ID:", clientId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
db := database.GetDB()
|
||||||
|
newClients = compactOrphans(db, newClients)
|
||||||
if newClients == nil {
|
if newClients == nil {
|
||||||
newClients = []any{}
|
newClients = []any{}
|
||||||
}
|
}
|
||||||
|
|
@ -1348,8 +1425,6 @@ func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId i
|
||||||
|
|
||||||
oldInbound.Settings = string(newSettings)
|
oldInbound.Settings = string(newSettings)
|
||||||
|
|
||||||
db := database.GetDB()
|
|
||||||
|
|
||||||
emailShared, err := inboundSvc.emailUsedByOtherInbounds(email, inboundId)
|
emailShared, err := inboundSvc.emailUsedByOtherInbounds(email, inboundId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
|
|
@ -1365,12 +1440,13 @@ func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId i
|
||||||
needRestart := false
|
needRestart := false
|
||||||
|
|
||||||
if len(email) > 0 {
|
if len(email) > 0 {
|
||||||
notDepleted := true
|
var enables []bool
|
||||||
err = db.Model(xray.ClientTraffic{}).Select("enable").Where("email = ?", email).First(¬Depleted).Error
|
err = db.Model(xray.ClientTraffic{}).Where("email = ?", email).Limit(1).Pluck("enable", &enables).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Get stats error")
|
logger.Error("Get stats error")
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
notDepleted := len(enables) > 0 && enables[0]
|
||||||
if !emailShared {
|
if !emailShared {
|
||||||
err = inboundSvc.DelClientStat(db, email)
|
err = inboundSvc.DelClientStat(db, email)
|
||||||
if err != nil {
|
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) {
|
func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inboundId int, email string) (bool, error) {
|
||||||
|
defer lockInbound(inboundId).Unlock()
|
||||||
|
|
||||||
oldInbound, err := inboundSvc.GetInbound(inboundId)
|
oldInbound, err := inboundSvc.GetInbound(inboundId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Load Old Data Error")
|
logger.Error("Load Old Data Error")
|
||||||
|
|
@ -1455,6 +1533,8 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
|
||||||
if !found {
|
if !found {
|
||||||
return false, common.NewError(fmt.Sprintf("client with email %s not found", email))
|
return false, common.NewError(fmt.Sprintf("client with email %s not found", email))
|
||||||
}
|
}
|
||||||
|
db := database.GetDB()
|
||||||
|
newClients = compactOrphans(db, newClients)
|
||||||
if newClients == nil {
|
if newClients == nil {
|
||||||
newClients = []any{}
|
newClients = []any{}
|
||||||
}
|
}
|
||||||
|
|
@ -1466,8 +1546,6 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
|
||||||
|
|
||||||
oldInbound.Settings = string(newSettings)
|
oldInbound.Settings = string(newSettings)
|
||||||
|
|
||||||
db := database.GetDB()
|
|
||||||
|
|
||||||
emailShared, err := inboundSvc.emailUsedByOtherInbounds(email, inboundId)
|
emailShared, err := inboundSvc.emailUsedByOtherInbounds(email, inboundId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue