feat(clients): bulk-set XTLS flow from the Adjust dialog (#5524)

* feat(clients): bulk-set XTLS flow from the Adjust dialog

Add a "Set flow" dropdown to the bulk Adjust dialog so an admin can set or
clear the XTLS flow on all selected clients at once, alongside the existing
days/traffic bumps. Empty by default (no effect on save); "Disable" clears
flow, and the two vision values mirror the per-client credential tab.

Flow rides the existing inbound-JSON -> SyncInbound path (ClientRecord.Flow +
client_inbounds.flow_override), so no new endpoint, DB column, or migration.
Setting a vision flow is gated by inboundCanEnableTlsFlow: ineligible inbounds
are left untouched and reported as skipped; clearing is always allowed. A real
flow change requests an xray restart (local) or a node reconcile (remote).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(clients): keep days/traffic write when bulk flow is ineligible

Address review on the bulk-flow-adjust PR:

- Blocking: a client adjusted with both a days/traffic delta and a flow
  directive on a flow-ineligible inbound had the flow-ineligibility recorded
  into the same skip set that gates the ClientTraffic write, so the inbound
  JSON / ClientRecord advanced but ClientTraffic did not — divergent stores,
  and the client misreported as skipped. Track flow ineligibility in its own
  map (bulkInboundAdjustResult.flowIneligible) so it only feeds the final
  Skipped report and never suppresses the expiry/total persistence.
- Drop the broad delete(skippedReasons, email): flow reasons no longer enter
  skippedReasons, so honoring a flow can no longer erase an unrelated skip
  reason (unlimited expiry, a real persistence error on another inbound).
- Drop the inline comment block from ClientBulkAdjustModal.tsx (file had none);
  move the whitelist-sync note next to bulkFlowAllowed, the source of truth.
- Document the optional flow field in the bulkAdjust API-docs example
  (endpoints.ts) and regenerate openapi.json.
- Add a regression test covering days+flow on an ineligible inbound.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rouzbeh†
2026-06-24 12:55:08 +02:00
committed by GitHub
parent c93beef267
commit 14de0557f9
11 changed files with 360 additions and 31 deletions
+3 -3
View File
@@ -341,7 +341,7 @@ export function useClients() {
});
const bulkAdjustMut = useMutation({
mutationFn: async (payload: { emails: string[]; addDays: number; addBytes: number }): Promise<Msg<BulkAdjustResult>> => {
mutationFn: async (payload: { emails: string[]; addDays: number; addBytes: number; flow: string }): Promise<Msg<BulkAdjustResult>> => {
const raw = await HttpUtil.post('/panel/api/clients/bulkAdjust', payload, JSON_HEADERS);
return parseMsg(raw, BulkAdjustResultSchema, 'clients/bulkAdjust');
},
@@ -435,9 +435,9 @@ export function useClients() {
if (!Array.isArray(payloads) || payloads.length === 0) return Promise.resolve(null as unknown as Msg<BulkCreateResult>);
return bulkCreateMut.mutateAsync(payloads);
}, [bulkCreateMut]);
const bulkAdjust = useCallback((emails: string[], addDays: number, addBytes: number) => {
const bulkAdjust = useCallback((emails: string[], addDays: number, addBytes: number, flow = '') => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes });
return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes, flow });
}, [bulkAdjustMut]);
const bulkAddToGroup = useCallback((emails: string[], group: string) => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
+2 -2
View File
@@ -635,8 +635,8 @@ export const sections: readonly Section[] = [
{
method: 'POST',
path: '/panel/api/clients/bulkAdjust',
summary: 'Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. Returns the adjusted count and per-email skip reasons.',
body: '{\n "emails": ["alice", "bob"],\n "addDays": 30,\n "addBytes": 53687091200\n}',
summary: 'Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. The optional flow directive sets the XTLS flow on every client: "none" clears it, "xtls-rprx-vision"/"xtls-rprx-vision-udp443" set it where the inbound supports it (omit or "" to leave it unchanged). Returns the adjusted count and per-email skip reasons.',
body: '{\n "emails": ["alice", "bob"],\n "addDays": 30,\n "addBytes": 53687091200,\n "flow": "xtls-rprx-vision"\n}',
response: '{\n "success": true,\n "obj": {\n "adjusted": 2,\n "skipped": [\n { "email": "carol", "reason": "unlimited expiry" }\n ]\n }\n}',
},
{
@@ -1,16 +1,19 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Form, InputNumber, Modal, message } from 'antd';
import { Alert, Form, InputNumber, Modal, Select, message } from 'antd';
import { ClientBulkAdjustFormSchema } from '@/schemas/client';
import { TLS_FLOW_CONTROL } from '@/schemas/primitives/flow';
const GB = 1024 * 1024 * 1024;
const FLOW_CLEAR = 'none';
interface ClientBulkAdjustModalProps {
open: boolean;
count: number;
onOpenChange: (open: boolean) => void;
onSubmit: (addDays: number, addBytes: number) => Promise<{ adjusted: number; skipped?: { email: string; reason: string }[] } | null>;
onSubmit: (addDays: number, addBytes: number, flow: string) => Promise<{ adjusted: number; skipped?: { email: string; reason: string }[] } | null>;
}
export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSubmit }: ClientBulkAdjustModalProps) {
@@ -18,12 +21,14 @@ export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSub
const [messageApi, messageContextHolder] = message.useMessage();
const [addDays, setAddDays] = useState<number>(0);
const [addGB, setAddGB] = useState<number>(0);
const [flow, setFlow] = useState<string>('');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (open) {
setAddDays(0);
setAddGB(0);
setFlow('');
}
}, [open]);
@@ -31,16 +36,17 @@ export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSub
const validated = ClientBulkAdjustFormSchema.safeParse({
addDays: Math.trunc(Number(addDays) || 0),
addGB: Number(addGB) || 0,
flow,
});
if (!validated.success) {
messageApi.warning(t(validated.error.issues[0]?.message ?? 'somethingWentWrong'));
return;
}
const { addDays: days, addGB: gb } = validated.data;
const { addDays: days, addGB: gb, flow: flowValue } = validated.data;
setSubmitting(true);
try {
const bytes = Math.trunc(gb * GB);
const result = await onSubmit(days, bytes);
const result = await onSubmit(days, bytes, flowValue);
if (!result) return;
const ok = result.adjusted ?? 0;
const skipped = result.skipped?.length ?? 0;
@@ -95,6 +101,18 @@ export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSub
step={1}
/>
</Form.Item>
<Form.Item label={t('pages.clients.bulkFlow')}>
<Select
value={flow}
onChange={setFlow}
style={{ width: '100%' }}
options={[
{ value: '', label: t('pages.clients.bulkFlowNoChange') },
{ value: FLOW_CLEAR, label: t('pages.clients.bulkFlowDisable') },
...Object.values(TLS_FLOW_CONTROL).map((k) => ({ value: k, label: k })),
]}
/>
</Form.Item>
</Form>
</Modal>
</>
+2 -2
View File
@@ -1418,8 +1418,8 @@ export default function ClientsPage() {
open={bulkAdjustOpen}
count={selectedRowKeys.length}
onOpenChange={setBulkAdjustOpen}
onSubmit={async (addDays, addBytes) => {
const msg = await bulkAdjust([...selectedRowKeys], addDays, addBytes);
onSubmit={async (addDays, addBytes, flow) => {
const msg = await bulkAdjust([...selectedRowKeys], addDays, addBytes, flow);
if (msg?.success) {
setSelectedRowKeys([]);
return msg.obj ?? { adjusted: 0 };
+2 -1
View File
@@ -188,8 +188,9 @@ export const ClientBulkAdjustFormSchema = z
.object({
addDays: z.number().int(),
addGB: z.number(),
flow: z.string().optional().default(''),
})
.refine((v) => v.addDays !== 0 || v.addGB !== 0, {
.refine((v) => v.addDays !== 0 || v.addGB !== 0 || v.flow !== '', {
message: 'pages.clients.bulkAdjustNothing',
});