From 14de0557f90240db99cc712483d1d5cb31224525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rouzbeh=E2=80=A0?= <78313022+rqzbeh@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:55:08 +0200 Subject: [PATCH] feat(clients): bulk-set XTLS flow from the Adjust dialog (#5524) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- frontend/public/openapi.json | 5 +- frontend/src/hooks/useClients.ts | 6 +- frontend/src/pages/api-docs/endpoints.ts | 4 +- .../pages/clients/ClientBulkAdjustModal.tsx | 26 ++- frontend/src/pages/clients/ClientsPage.tsx | 4 +- frontend/src/schemas/client.ts | 3 +- internal/web/controller/client.go | 3 +- internal/web/service/client_bulk.go | 116 ++++++++-- internal/web/service/client_bulk_flow_test.go | 217 ++++++++++++++++++ .../web/service/sync_scale_postgres_test.go | 2 +- internal/web/translation/en-US.json | 5 +- 11 files changed, 360 insertions(+), 31 deletions(-) create mode 100644 internal/web/service/client_bulk_flow_test.go diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 6f609f89b..c071a9fe1 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -5476,7 +5476,7 @@ "tags": [ "Clients" ], - "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.", + "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.", "operationId": "post_panel_api_clients_bulkAdjust", "requestBody": { "required": true, @@ -5491,7 +5491,8 @@ "bob" ], "addDays": 30, - "addBytes": 53687091200 + "addBytes": 53687091200, + "flow": "xtls-rprx-vision" } } } diff --git a/frontend/src/hooks/useClients.ts b/frontend/src/hooks/useClients.ts index 891a9c3aa..92c8077a1 100644 --- a/frontend/src/hooks/useClients.ts +++ b/frontend/src/hooks/useClients.ts @@ -341,7 +341,7 @@ export function useClients() { }); const bulkAdjustMut = useMutation({ - mutationFn: async (payload: { emails: string[]; addDays: number; addBytes: number }): Promise> => { + mutationFn: async (payload: { emails: string[]; addDays: number; addBytes: number; flow: string }): Promise> => { 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); 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); diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index fb7168f43..960d5dd20 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -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}', }, { diff --git a/frontend/src/pages/clients/ClientBulkAdjustModal.tsx b/frontend/src/pages/clients/ClientBulkAdjustModal.tsx index 5fbab5a30..897a1a940 100644 --- a/frontend/src/pages/clients/ClientBulkAdjustModal.tsx +++ b/frontend/src/pages/clients/ClientBulkAdjustModal.tsx @@ -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(0); const [addGB, setAddGB] = useState(0); + const [flow, setFlow] = useState(''); 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} /> + +