useClients.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674
  1. import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
  2. import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
  3. import { HttpUtil, Msg } from '@/utils';
  4. import { parseMsg } from '@/utils/zodValidate';
  5. import { keys } from '@/api/queryKeys';
  6. import { markLocalInvalidate } from '@/api/invalidationTracker';
  7. import {
  8. ClientHydrateSchema,
  9. ClientPageResponseSchema,
  10. InboundOptionsSchema,
  11. OnlinesSchema,
  12. BulkAdjustResultSchema,
  13. BulkAttachResultSchema,
  14. BulkCreateResultSchema,
  15. BulkDeleteResultSchema,
  16. BulkSetEnableResultSchema,
  17. BulkDetachResultSchema,
  18. DelDepletedResultSchema,
  19. type ClientHydrate,
  20. type ClientRecord,
  21. type ClientTraffic,
  22. type ClientsSummary,
  23. type ClientPageResponse,
  24. type InboundOption,
  25. type ExternalLink,
  26. type BulkAdjustResult,
  27. type BulkAttachResult,
  28. type BulkCreateResult,
  29. type BulkDeleteResult,
  30. type BulkSetEnableResult,
  31. type BulkDetachResult,
  32. } from '@/schemas/client';
  33. import { DefaultsPayloadSchema } from '@/schemas/defaults';
  34. import { TRAFFIC_POLL_INTERVAL_S } from '@/lib/traffic/poll-interval';
  35. // One row sent to POST /clients/:email/externalLinks.
  36. export type ExternalLinkInput = {
  37. kind: 'link' | 'subscription';
  38. value: string;
  39. remark: string;
  40. enable: boolean;
  41. expiryTime: number;
  42. namePrefix: string;
  43. };
  44. export type { ClientRecord, ClientTraffic, ClientsSummary, InboundOption, ExternalLink };
  45. const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
  46. interface SubSettings {
  47. enable: boolean;
  48. subURI: string;
  49. subJsonURI: string;
  50. subJsonEnable: boolean;
  51. subClashURI: string;
  52. subClashEnable: boolean;
  53. publicHost: string;
  54. }
  55. export interface ClientQueryParams {
  56. page: number;
  57. pageSize: number;
  58. search?: string;
  59. // CSV strings — frontend joins arrays on ',', backend splits the same way.
  60. filter?: string;
  61. protocol?: string;
  62. inbound?: string;
  63. sort?: string;
  64. order?: 'ascend' | 'descend';
  65. expiryFrom?: number;
  66. expiryTo?: number;
  67. usageFrom?: number;
  68. usageTo?: number;
  69. autoRenew?: 'on' | 'off' | '';
  70. hasTgId?: 'yes' | 'no' | '';
  71. hasComment?: 'yes' | 'no' | '';
  72. group?: string;
  73. }
  74. const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 };
  75. const DEFAULT_SUMMARY: ClientsSummary = {
  76. total: 0, active: 0,
  77. onlineCount: 0, depletedCount: 0, expiringCount: 0, deactiveCount: 0,
  78. online: [], depleted: [], expiring: [], deactive: [],
  79. };
  80. export interface ClientSpeedEntry {
  81. up: number;
  82. down: number;
  83. }
  84. type ClientStatRow = ClientTraffic & { email?: string };
  85. export function sameSpeedMap(
  86. a: Record<string, ClientSpeedEntry>,
  87. b: Record<string, ClientSpeedEntry>,
  88. ): boolean {
  89. const aKeys = Object.keys(a);
  90. if (aKeys.length !== Object.keys(b).length) return false;
  91. for (const key of aKeys) {
  92. const left = a[key];
  93. const right = b[key];
  94. if (!right || left.up !== right.up || left.down !== right.down) return false;
  95. }
  96. return true;
  97. }
  98. function buildQS(p: ClientQueryParams): string {
  99. const sp = new URLSearchParams();
  100. sp.set('page', String(p.page || 1));
  101. sp.set('pageSize', String(p.pageSize || DEFAULT_QUERY.pageSize));
  102. if (p.search) sp.set('search', p.search);
  103. if (p.filter) sp.set('filter', p.filter);
  104. if (p.protocol) sp.set('protocol', p.protocol);
  105. if (p.inbound) sp.set('inbound', p.inbound);
  106. if (p.sort) sp.set('sort', p.sort);
  107. if (p.order) sp.set('order', p.order);
  108. if (p.expiryFrom && p.expiryFrom > 0) sp.set('expiryFrom', String(p.expiryFrom));
  109. if (p.expiryTo && p.expiryTo > 0) sp.set('expiryTo', String(p.expiryTo));
  110. if (p.usageFrom && p.usageFrom > 0) sp.set('usageFrom', String(p.usageFrom));
  111. if (p.usageTo && p.usageTo > 0) sp.set('usageTo', String(p.usageTo));
  112. if (p.autoRenew) sp.set('autoRenew', p.autoRenew);
  113. if (p.hasTgId) sp.set('hasTgId', p.hasTgId);
  114. if (p.hasComment) sp.set('hasComment', p.hasComment);
  115. if (p.group) sp.set('group', p.group);
  116. return sp.toString();
  117. }
  118. async function fetchClientPage(params: ClientQueryParams): Promise<ClientPageResponse> {
  119. const qs = buildQS(params);
  120. const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`, undefined, { silent: true });
  121. if (!msg?.success || !msg.obj) throw new Error(msg?.msg || 'Failed to fetch clients');
  122. const validated = parseMsg(msg, ClientPageResponseSchema, 'clients/list/paged', { strict: true });
  123. if (!validated.obj) throw new Error('Empty clients response');
  124. return validated.obj;
  125. }
  126. async function fetchInboundOptions(): Promise<InboundOption[]> {
  127. const msg = await HttpUtil.get('/panel/api/inbounds/options', undefined, { silent: true });
  128. if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch inbound options');
  129. const validated = parseMsg(msg, InboundOptionsSchema, 'inbounds/options');
  130. return Array.isArray(validated.obj) ? validated.obj : [];
  131. }
  132. async function fetchDefaults(): Promise<Record<string, unknown>> {
  133. const msg = await HttpUtil.post('/panel/api/setting/defaultSettings', undefined, { silent: true });
  134. if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
  135. const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
  136. return validated.obj || {};
  137. }
  138. export interface UseClientsOptions {
  139. // Callers that only need the mutations — the bulk modals, the groups page —
  140. // pass false. Mounting them used to start a second 5-second poll of the paged
  141. // list whose result they never read, which on a large panel means a full
  142. // summary aggregate every 5 seconds for nothing.
  143. list?: boolean;
  144. }
  145. export function useClients(options: UseClientsOptions = {}) {
  146. const withList = options.list ?? true;
  147. const queryClient = useQueryClient();
  148. // Null until the page has settled on a query. The clients page cannot build
  149. // one until the persisted sort and the panel's configured page size are both
  150. // known, and fetching before then cost three sequential requests per load —
  151. // the first two thrown away (#trace).
  152. const [query, setQueryState] = useState<ClientQueryParams | null>(null);
  153. // setQuery shallow-compares so callers can pass a fresh object every render
  154. // (the common React pattern) without triggering a re-fetch when nothing
  155. // actually changed.
  156. const setQuery = useCallback((next: ClientQueryParams) => {
  157. setQueryState((prev) => {
  158. if (
  159. prev
  160. && prev.page === next.page
  161. && prev.pageSize === next.pageSize
  162. && (prev.search ?? '') === (next.search ?? '')
  163. && (prev.filter ?? '') === (next.filter ?? '')
  164. && (prev.protocol ?? '') === (next.protocol ?? '')
  165. && (prev.inbound ?? '') === (next.inbound ?? '')
  166. && (prev.sort ?? '') === (next.sort ?? '')
  167. && (prev.order ?? '') === (next.order ?? '')
  168. && (prev.expiryFrom ?? 0) === (next.expiryFrom ?? 0)
  169. && (prev.expiryTo ?? 0) === (next.expiryTo ?? 0)
  170. && (prev.usageFrom ?? 0) === (next.usageFrom ?? 0)
  171. && (prev.usageTo ?? 0) === (next.usageTo ?? 0)
  172. && (prev.autoRenew ?? '') === (next.autoRenew ?? '')
  173. && (prev.hasTgId ?? '') === (next.hasTgId ?? '')
  174. && (prev.hasComment ?? '') === (next.hasComment ?? '')
  175. && (prev.group ?? '') === (next.group ?? '')
  176. ) return prev;
  177. return next;
  178. });
  179. }, []);
  180. const listQuery = useQuery({
  181. queryKey: keys.clients.list(query ?? DEFAULT_QUERY),
  182. queryFn: () => fetchClientPage(query ?? DEFAULT_QUERY),
  183. enabled: withList && query !== null,
  184. staleTime: Infinity,
  185. // List is sorted/paged server-side, so the WS patch can't add new or
  186. // re-sort rows; poll the current page to keep it live (pauses when hidden).
  187. refetchInterval: 5000,
  188. refetchOnWindowFocus: 'always',
  189. placeholderData: keepPreviousData,
  190. });
  191. const inboundOptionsQuery = useQuery({
  192. queryKey: keys.inbounds.options(),
  193. queryFn: fetchInboundOptions,
  194. enabled: withList,
  195. staleTime: Infinity,
  196. });
  197. const defaultsQuery = useQuery({
  198. queryKey: keys.settings.defaults(),
  199. queryFn: fetchDefaults,
  200. staleTime: Infinity,
  201. });
  202. const onlinesQuery = useQuery({
  203. queryKey: keys.clients.onlines(),
  204. queryFn: async () => {
  205. const msg = await HttpUtil.post('/panel/api/clients/onlines', undefined, { silent: true });
  206. if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlines');
  207. const validated = parseMsg(msg, OnlinesSchema, 'clients/onlines');
  208. return Array.isArray(validated.obj) ? validated.obj : [];
  209. },
  210. enabled: withList,
  211. staleTime: Infinity,
  212. });
  213. const clients = listQuery.data?.items ?? [];
  214. const total = listQuery.data?.total ?? 0;
  215. const filtered = listQuery.data?.filtered ?? 0;
  216. const allGroups = listQuery.data?.groups ?? [];
  217. const fetched = listQuery.data !== undefined || listQuery.isError;
  218. const fetchError = listQuery.error ? (listQuery.error as Error).message : '';
  219. // isFetching is deliberately NOT read here. Touching it makes it a tracked
  220. // property, so the 5s refetchInterval notifies twice per cycle — two whole
  221. // page renders even when structural sharing leaves the data identical, and
  222. // each one bumps rc-table's immutable mark and re-runs every cell renderer.
  223. // Callers that want a spinner for an explicit refresh drive it locally.
  224. // Showing kept-previous data for a new key (filter/sort/page) — drives the
  225. // table overlay so the 5s background poll doesn't flash it.
  226. const transitioning = listQuery.isPlaceholderData;
  227. const inbounds = inboundOptionsQuery.data ?? [];
  228. const onlines = useMemo(() => onlinesQuery.data ?? [], [onlinesQuery.data]);
  229. const defaults = defaultsQuery.data ?? {};
  230. const subSettings: SubSettings = useMemo(() => ({
  231. enable: !!defaults.subEnable,
  232. subURI: (defaults.subURI as string) || '',
  233. subJsonURI: (defaults.subJsonURI as string) || '',
  234. subJsonEnable: !!defaults.subJsonEnable,
  235. subClashURI: (defaults.subClashURI as string) || '',
  236. subClashEnable: !!defaults.subClashEnable,
  237. publicHost: (defaults.subDomain as string) || (defaults.webDomain as string) || '',
  238. }), [
  239. defaults.subEnable,
  240. defaults.subURI,
  241. defaults.subJsonURI,
  242. defaults.subJsonEnable,
  243. defaults.subClashURI,
  244. defaults.subClashEnable,
  245. defaults.subDomain,
  246. defaults.webDomain,
  247. ]);
  248. const ipLimitEnable = !!defaults.ipLimitEnable;
  249. const tgBotEnable = !!defaults.tgBotEnable;
  250. const expireDiff = ((defaults.expireDiff as number) ?? 0) * 86400000;
  251. const trafficDiff = ((defaults.trafficDiff as number) ?? 0) * 1073741824;
  252. const pageSize = (defaults.pageSize as number) ?? 0;
  253. // pageSize 0 means "one long page", which is indistinguishable from "the
  254. // settings have not arrived yet" — so callers need this flag to know when the
  255. // configured page size is real. isFetched (not isSuccess) so a failed
  256. // settings request still lets the page fall back and render.
  257. const settingsReady = defaultsQuery.isFetched;
  258. const [clientSpeed, setClientSpeed] = useState<Record<string, ClientSpeedEntry>>({});
  259. const summary = listQuery.data?.summary ?? DEFAULT_SUMMARY;
  260. const invalidateAll = useCallback(
  261. () => {
  262. markLocalInvalidate();
  263. return Promise.all([
  264. queryClient.invalidateQueries({ queryKey: keys.clients.root() }),
  265. queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
  266. queryClient.invalidateQueries({ queryKey: keys.xray.config() }),
  267. ]);
  268. },
  269. [queryClient],
  270. );
  271. const refresh = useCallback(async () => {
  272. await invalidateAll();
  273. }, [invalidateAll]);
  274. const hydrate = useCallback(async (email: string): Promise<ClientHydrate | null> => {
  275. if (!email) return null;
  276. const msg = await HttpUtil.get(`/panel/api/clients/get/${encodeURIComponent(email)}`);
  277. if (!msg?.success || !msg.obj) return null;
  278. const validated = parseMsg(msg, ClientHydrateSchema, 'clients/get');
  279. return validated.obj;
  280. }, []);
  281. const createMut = useMutation({
  282. mutationFn: (payload: unknown) =>
  283. HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS),
  284. onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
  285. });
  286. const bulkAddToGroupMut = useMutation({
  287. mutationFn: (body: { emails: string[]; group: string }) =>
  288. HttpUtil.post('/panel/api/clients/groups/bulkAdd', body, JSON_HEADERS),
  289. onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
  290. });
  291. const bulkRemoveFromGroupMut = useMutation({
  292. mutationFn: (body: { emails: string[] }) =>
  293. HttpUtil.post('/panel/api/clients/groups/bulkRemove', body, JSON_HEADERS),
  294. onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
  295. });
  296. const updateMut = useMutation({
  297. mutationFn: ({ email, client }: { email: string; client: unknown }) =>
  298. HttpUtil.post(`/panel/api/clients/update/${encodeURIComponent(email)}`, client, JSON_HEADERS),
  299. onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
  300. });
  301. const removeMut = useMutation({
  302. mutationFn: ({ email, keepTraffic }: { email: string; keepTraffic?: boolean }) => {
  303. const url = keepTraffic
  304. ? `/panel/api/clients/del/${encodeURIComponent(email)}?keepTraffic=1`
  305. : `/panel/api/clients/del/${encodeURIComponent(email)}`;
  306. return HttpUtil.post(url);
  307. },
  308. onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
  309. });
  310. const bulkDeleteMut = useMutation({
  311. mutationFn: async (payload: { emails: string[]; keepTraffic?: boolean }): Promise<Msg<BulkDeleteResult>> => {
  312. const raw = await HttpUtil.post('/panel/api/clients/bulkDel', payload, JSON_HEADERS);
  313. return parseMsg(raw, BulkDeleteResultSchema, 'clients/bulkDel');
  314. },
  315. onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
  316. });
  317. const bulkCreateMut = useMutation({
  318. mutationFn: async (payloads: unknown[]): Promise<Msg<BulkCreateResult>> => {
  319. const raw = await HttpUtil.post('/panel/api/clients/bulkCreate', payloads, JSON_HEADERS);
  320. return parseMsg(raw, BulkCreateResultSchema, 'clients/bulkCreate');
  321. },
  322. onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
  323. });
  324. const bulkAdjustMut = useMutation({
  325. mutationFn: async (payload: { emails: string[]; addDays: number; addBytes: number; flow: string }): Promise<Msg<BulkAdjustResult>> => {
  326. const raw = await HttpUtil.post('/panel/api/clients/bulkAdjust', payload, JSON_HEADERS);
  327. return parseMsg(raw, BulkAdjustResultSchema, 'clients/bulkAdjust');
  328. },
  329. onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
  330. });
  331. const bulkSetEnableMut = useMutation({
  332. mutationFn: async (payload: { emails: string[]; enable: boolean }): Promise<Msg<BulkSetEnableResult>> => {
  333. const path = payload.enable ? '/panel/api/clients/bulkEnable' : '/panel/api/clients/bulkDisable';
  334. const raw = await HttpUtil.post(path, { emails: payload.emails }, JSON_HEADERS);
  335. return parseMsg(raw, BulkSetEnableResultSchema, payload.enable ? 'clients/bulkEnable' : 'clients/bulkDisable');
  336. },
  337. onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
  338. });
  339. const attachMut = useMutation({
  340. mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) =>
  341. HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/attach`, { inboundIds }, { ...JSON_HEADERS, silentSuccess: true }),
  342. onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
  343. });
  344. const setExternalLinksMut = useMutation({
  345. mutationFn: ({ email, externalLinks }: { email: string; externalLinks: ExternalLinkInput[] }) =>
  346. HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/externalLinks`, { externalLinks }, { ...JSON_HEADERS, silentSuccess: true }),
  347. onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
  348. });
  349. const bulkAttachMut = useMutation({
  350. mutationFn: async (payload: { emails: string[]; inboundIds: number[] }): Promise<Msg<BulkAttachResult>> => {
  351. const raw = await HttpUtil.post('/panel/api/clients/bulkAttach', payload, JSON_HEADERS);
  352. return parseMsg(raw, BulkAttachResultSchema, 'clients/bulkAttach');
  353. },
  354. onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
  355. });
  356. const detachMut = useMutation({
  357. mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) =>
  358. HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/detach`, { inboundIds }, { ...JSON_HEADERS, silentSuccess: true }),
  359. onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
  360. });
  361. const bulkDetachMut = useMutation({
  362. mutationFn: async (payload: { emails: string[]; inboundIds: number[] }): Promise<Msg<BulkDetachResult>> => {
  363. const raw = await HttpUtil.post('/panel/api/clients/bulkDetach', payload, JSON_HEADERS);
  364. return parseMsg(raw, BulkDetachResultSchema, 'clients/bulkDetach');
  365. },
  366. onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
  367. });
  368. const resetTrafficMut = useMutation({
  369. mutationFn: (email: string) =>
  370. HttpUtil.post(`/panel/api/clients/resetTraffic/${encodeURIComponent(email)}`),
  371. onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
  372. });
  373. const resetAllTrafficsMut = useMutation({
  374. mutationFn: () => HttpUtil.post('/panel/api/clients/resetAllTraffics'),
  375. onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
  376. });
  377. const delDepletedMut = useMutation({
  378. mutationFn: async () => {
  379. const raw = await HttpUtil.post('/panel/api/clients/delDepleted');
  380. return parseMsg(raw, DelDepletedResultSchema, 'clients/delDepleted');
  381. },
  382. onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
  383. });
  384. const delOrphansMut = useMutation({
  385. mutationFn: async () => {
  386. const raw = await HttpUtil.post('/panel/api/clients/delOrphans');
  387. return parseMsg(raw, DelDepletedResultSchema, 'clients/delOrphans');
  388. },
  389. onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
  390. });
  391. const importClientsMut = useMutation({
  392. mutationFn: async (data: string): Promise<Msg<BulkCreateResult>> => {
  393. const raw = await HttpUtil.post('/panel/api/clients/import', { data }, JSON_HEADERS);
  394. return parseMsg(raw, BulkCreateResultSchema, 'clients/import');
  395. },
  396. onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
  397. });
  398. const create = useCallback((payload: unknown) => createMut.mutateAsync(payload), [createMut]);
  399. const update = useCallback((email: string, client: unknown) => {
  400. if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
  401. return updateMut.mutateAsync({ email, client });
  402. }, [updateMut]);
  403. const remove = useCallback((email: string, keepTraffic = false) => {
  404. if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
  405. return removeMut.mutateAsync({ email, keepTraffic });
  406. }, [removeMut]);
  407. const bulkDelete = useCallback((emails: string[], keepTraffic = false) => {
  408. if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkDeleteResult>);
  409. return bulkDeleteMut.mutateAsync({ emails, keepTraffic });
  410. }, [bulkDeleteMut]);
  411. const bulkCreate = useCallback((payloads: unknown[]) => {
  412. if (!Array.isArray(payloads) || payloads.length === 0) return Promise.resolve(null as unknown as Msg<BulkCreateResult>);
  413. return bulkCreateMut.mutateAsync(payloads);
  414. }, [bulkCreateMut]);
  415. const bulkAdjust = useCallback((emails: string[], addDays: number, addBytes: number, flow = '') => {
  416. if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
  417. return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes, flow });
  418. }, [bulkAdjustMut]);
  419. const bulkEnable = useCallback((emails: string[]) => {
  420. if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkSetEnableResult>);
  421. return bulkSetEnableMut.mutateAsync({ emails, enable: true });
  422. }, [bulkSetEnableMut]);
  423. const bulkDisable = useCallback((emails: string[]) => {
  424. if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkSetEnableResult>);
  425. return bulkSetEnableMut.mutateAsync({ emails, enable: false });
  426. }, [bulkSetEnableMut]);
  427. const bulkAddToGroup = useCallback((emails: string[], group: string) => {
  428. if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
  429. return bulkAddToGroupMut.mutateAsync({ emails, group });
  430. }, [bulkAddToGroupMut]);
  431. const bulkRemoveFromGroup = useCallback((emails: string[]) => {
  432. if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
  433. return bulkRemoveFromGroupMut.mutateAsync({ emails });
  434. }, [bulkRemoveFromGroupMut]);
  435. const attach = useCallback((email: string, inboundIds: number[]) => {
  436. if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
  437. return attachMut.mutateAsync({ email, inboundIds });
  438. }, [attachMut]);
  439. const setExternalLinks = useCallback((email: string, externalLinks: ExternalLinkInput[]) => {
  440. if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
  441. return setExternalLinksMut.mutateAsync({ email, externalLinks });
  442. }, [setExternalLinksMut]);
  443. const bulkAttach = useCallback((emails: string[], inboundIds: number[]) => {
  444. if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkAttachResult>);
  445. if (!Array.isArray(inboundIds) || inboundIds.length === 0) return Promise.resolve(null as unknown as Msg<BulkAttachResult>);
  446. return bulkAttachMut.mutateAsync({ emails, inboundIds });
  447. }, [bulkAttachMut]);
  448. const detach = useCallback((email: string, inboundIds: number[]) => {
  449. if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
  450. return detachMut.mutateAsync({ email, inboundIds });
  451. }, [detachMut]);
  452. const bulkDetach = useCallback((emails: string[], inboundIds: number[]) => {
  453. if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkDetachResult>);
  454. if (!Array.isArray(inboundIds) || inboundIds.length === 0) return Promise.resolve(null as unknown as Msg<BulkDetachResult>);
  455. return bulkDetachMut.mutateAsync({ emails, inboundIds });
  456. }, [bulkDetachMut]);
  457. const resetTraffic = useCallback((client: ClientRecord) => {
  458. if (!client?.email) return Promise.resolve(null as unknown as Msg<unknown>);
  459. return resetTrafficMut.mutateAsync(client.email);
  460. }, [resetTrafficMut]);
  461. const resetAllTraffics = useCallback(() => resetAllTrafficsMut.mutateAsync(), [resetAllTrafficsMut]);
  462. const delDepleted = useCallback(() => delDepletedMut.mutateAsync(), [delDepletedMut]);
  463. const delOrphans = useCallback(() => delOrphansMut.mutateAsync(), [delOrphansMut]);
  464. const importClients = useCallback((data: string) => importClientsMut.mutateAsync(data), [importClientsMut]);
  465. // Fetch the exported clients so the page can show them in a CodeMirror viewer
  466. // (Copy / Download), rather than triggering an immediate browser download.
  467. const exportClients = useCallback(async (): Promise<unknown[] | null> => {
  468. const msg = await HttpUtil.get('/panel/api/clients/export');
  469. if (!msg?.success) return null;
  470. return Array.isArray(msg.obj) ? msg.obj : [];
  471. }, []);
  472. const setEnable = useCallback(async (client: ClientRecord, enable: boolean) => {
  473. if (!client?.email) return null;
  474. const full = await hydrate(client.email);
  475. const base = full?.client;
  476. if (!base) return null;
  477. const payload: Record<string, unknown> = {
  478. email: base.email,
  479. subId: base.subId,
  480. id: base.uuid,
  481. password: base.password,
  482. auth: base.auth,
  483. flow: base.flow || '',
  484. security: base.security || 'auto',
  485. totalGB: base.totalGB || 0,
  486. expiryTime: base.expiryTime || 0,
  487. limitIp: base.limitIp || 0,
  488. limitHwid: base.limitHwid || 0,
  489. tgId: Number(base.tgId) || 0,
  490. reset: Number(base.reset) || 0,
  491. resetDay: Number(base.resetDay) || 0,
  492. resetMax: Number(base.resetMax) || 0,
  493. group: base.group || '',
  494. comment: base.comment || '',
  495. enable: !!enable,
  496. };
  497. if (base.reverse?.tag) {
  498. payload.reverse = { tag: base.reverse.tag };
  499. }
  500. return update(client.email, payload);
  501. }, [hydrate, update]);
  502. // WS-driven in-place merges. Page wires these via useWebSocket; the bridge
  503. // covers coarse 'invalidate' and 'inbounds' events centrally.
  504. const queryRef = useRef(query);
  505. queryRef.current = query;
  506. const applyTrafficEvent = useCallback((payload: unknown) => {
  507. if (!payload || typeof payload !== 'object') return;
  508. const p = payload as {
  509. onlineClients?: string[];
  510. clientTraffics?: { email: string; up: number; down: number }[];
  511. };
  512. if (Array.isArray(p.onlineClients)) {
  513. queryClient.setQueryData(keys.clients.onlines(), p.onlineClients);
  514. }
  515. if (Array.isArray(p.clientTraffics)) {
  516. // Xray reports a row per client whether or not it moved a byte, so most of
  517. // this map used to be zeros. A missing entry and a zero entry render
  518. // identically (isActiveSpeed treats both as inactive), so the zeros are
  519. // dropped and an unchanged result returns the previous object — which lets
  520. // React bail out of the update instead of re-rendering the table.
  521. const next: Record<string, ClientSpeedEntry> = {};
  522. for (const ct of p.clientTraffics) {
  523. if (!ct || !ct.email) continue;
  524. const up = ct.up || 0;
  525. const down = ct.down || 0;
  526. if (up === 0 && down === 0) continue;
  527. next[ct.email] = {
  528. up: up / TRAFFIC_POLL_INTERVAL_S,
  529. down: down / TRAFFIC_POLL_INTERVAL_S,
  530. };
  531. }
  532. setClientSpeed((prev) => (sameSpeedMap(prev, next) ? prev : next));
  533. }
  534. }, [queryClient]);
  535. const applyClientStatsEvent = useCallback((payload: unknown) => {
  536. if (!payload || typeof payload !== 'object') return;
  537. const p = payload as { clients?: ClientStatRow[] };
  538. if (!Array.isArray(p.clients) || p.clients.length === 0) return;
  539. const active = queryRef.current;
  540. if (!active) return;
  541. const byEmail = new Map<string, ClientTraffic>();
  542. for (const row of p.clients) {
  543. if (row && row.email) byEmail.set(row.email, row);
  544. }
  545. queryClient.setQueryData<ClientPageResponse>(keys.clients.list(active), (prev) => {
  546. if (!prev) return prev;
  547. let touched = false;
  548. const next = prev.items.slice();
  549. for (let i = 0; i < next.length; i++) {
  550. const row = next[i];
  551. const upd = byEmail.get(row?.email);
  552. if (!upd) continue;
  553. const merged: ClientTraffic = { ...(row.traffic || {}) };
  554. if (typeof upd.up === 'number') merged.up = upd.up;
  555. if (typeof upd.down === 'number') merged.down = upd.down;
  556. if (typeof upd.total === 'number') merged.total = upd.total;
  557. if (typeof upd.expiryTime === 'number') merged.expiryTime = upd.expiryTime;
  558. if (typeof upd.enable === 'boolean') merged.enable = upd.enable;
  559. if (typeof upd.lastOnline === 'number') merged.lastOnline = upd.lastOnline;
  560. next[i] = { ...row, traffic: merged };
  561. touched = true;
  562. }
  563. if (!touched) return prev;
  564. return { ...prev, items: next };
  565. });
  566. }, [queryClient]);
  567. useEffect(() => {
  568. queryRef.current = query;
  569. }, [query]);
  570. return {
  571. clients,
  572. total,
  573. filtered,
  574. summary,
  575. allGroups,
  576. hydrate,
  577. query,
  578. setQuery,
  579. inbounds,
  580. onlines,
  581. transitioning,
  582. fetched,
  583. fetchError,
  584. subSettings,
  585. ipLimitEnable,
  586. tgBotEnable,
  587. expireDiff,
  588. trafficDiff,
  589. pageSize,
  590. settingsReady,
  591. refresh,
  592. create,
  593. bulkCreate,
  594. update,
  595. remove,
  596. bulkDelete,
  597. bulkAdjust,
  598. bulkEnable,
  599. bulkDisable,
  600. bulkAddToGroup,
  601. bulkRemoveFromGroup,
  602. attach,
  603. setExternalLinks,
  604. bulkAttach,
  605. detach,
  606. bulkDetach,
  607. resetTraffic,
  608. resetAllTraffics,
  609. delDepleted,
  610. delOrphans,
  611. exportClients,
  612. importClients,
  613. setEnable,
  614. clientSpeed,
  615. applyTrafficEvent,
  616. applyClientStatsEvent,
  617. };
  618. }