useInbounds.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
  2. import { useQuery, useQueryClient } from '@tanstack/react-query';
  3. import { HttpUtil } from '@/utils';
  4. import { parseMsg } from '@/utils/zodValidate';
  5. import { DBInbound, coerceInboundJsonField } from '@/models/dbinbound';
  6. import { Protocols } from '@/schemas/primitives';
  7. import { isSSMultiUser } from '@/lib/xray/protocol-capabilities';
  8. import { setDatepicker } from '@/hooks/useDatepicker';
  9. import { keys } from '@/api/queryKeys';
  10. import { SlimInboundListSchema, LastOnlineMapSchema, InboundDetailSchema } from '@/schemas/inbound';
  11. import { OnlinesSchema } from '@/schemas/client';
  12. import { DefaultsPayloadSchema, type DefaultsPayload } from '@/schemas/defaults';
  13. export interface SubSettings {
  14. enable: boolean;
  15. subTitle: string;
  16. subURI: string;
  17. subJsonURI: string;
  18. subJsonEnable: boolean;
  19. }
  20. type DBInboundInstance = InstanceType<typeof DBInbound>;
  21. interface ClientRollup {
  22. clients: number;
  23. active: string[];
  24. deactive: string[];
  25. depleted: string[];
  26. expiring: string[];
  27. online: string[];
  28. comments: Map<string, string>;
  29. }
  30. const TRACKED_PROTOCOLS: readonly string[] = [
  31. Protocols.VMESS,
  32. Protocols.VLESS,
  33. Protocols.TROJAN,
  34. Protocols.SHADOWSOCKS,
  35. Protocols.HYSTERIA,
  36. ];
  37. async function fetchSlimInbounds(): Promise<unknown[]> {
  38. const msg = await HttpUtil.get('/panel/api/inbounds/list/slim', undefined, { silent: true });
  39. if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch inbounds');
  40. const validated = parseMsg(msg, SlimInboundListSchema, 'inbounds/list/slim');
  41. return Array.isArray(validated.obj) ? validated.obj : [];
  42. }
  43. async function fetchOnlineClients(): Promise<string[]> {
  44. const msg = await HttpUtil.post('/panel/api/clients/onlines', undefined, { silent: true });
  45. if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlines');
  46. const validated = parseMsg(msg, OnlinesSchema, 'clients/onlines');
  47. return Array.isArray(validated.obj) ? validated.obj : [];
  48. }
  49. async function fetchLastOnlineMap(): Promise<Record<string, number>> {
  50. const msg = await HttpUtil.post('/panel/api/clients/lastOnline', undefined, { silent: true });
  51. if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch lastOnline');
  52. const validated = parseMsg(msg, LastOnlineMapSchema, 'clients/lastOnline');
  53. return (validated.obj && typeof validated.obj === 'object') ? validated.obj : {};
  54. }
  55. async function fetchDefaultSettings(): Promise<DefaultsPayload> {
  56. const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true });
  57. if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
  58. const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
  59. return validated.obj ?? {};
  60. }
  61. export function useInbounds() {
  62. const queryClient = useQueryClient();
  63. const slimQuery = useQuery({
  64. queryKey: keys.inbounds.slim(),
  65. queryFn: fetchSlimInbounds,
  66. staleTime: Infinity,
  67. });
  68. const onlinesQuery = useQuery({
  69. queryKey: keys.clients.onlines(),
  70. queryFn: fetchOnlineClients,
  71. staleTime: Infinity,
  72. });
  73. const lastOnlineQuery = useQuery({
  74. queryKey: keys.clients.lastOnline(),
  75. queryFn: fetchLastOnlineMap,
  76. staleTime: Infinity,
  77. });
  78. const defaultsQuery = useQuery({
  79. queryKey: keys.settings.defaults(),
  80. queryFn: fetchDefaultSettings,
  81. staleTime: Infinity,
  82. });
  83. const defaults = defaultsQuery.data ?? {};
  84. const expireDiff = (defaults.expireDiff ?? 0) * 86400000;
  85. const trafficDiff = (defaults.trafficDiff ?? 0) * 1073741824;
  86. const tgBotEnable = !!defaults.tgBotEnable;
  87. const ipLimitEnable = !!defaults.ipLimitEnable;
  88. const pageSize = defaults.pageSize ?? 0;
  89. const remarkModel = defaults.remarkModel || '-io';
  90. const datepicker = (defaults.datepicker as 'gregorian' | 'jalalian') || 'gregorian';
  91. const subSettings: SubSettings = useMemo(() => ({
  92. enable: !!defaults.subEnable,
  93. subTitle: defaults.subTitle || '',
  94. subURI: defaults.subURI || '',
  95. subJsonURI: defaults.subJsonURI || '',
  96. subJsonEnable: !!defaults.subJsonEnable,
  97. }), [defaults.subEnable, defaults.subTitle, defaults.subURI, defaults.subJsonURI, defaults.subJsonEnable]);
  98. useEffect(() => {
  99. if (defaults.datepicker) setDatepicker(datepicker);
  100. }, [datepicker, defaults.datepicker]);
  101. const expireDiffRef = useRef(expireDiff);
  102. expireDiffRef.current = expireDiff;
  103. const trafficDiffRef = useRef(trafficDiff);
  104. trafficDiffRef.current = trafficDiff;
  105. // dbInbounds mirrors the slim query data wrapped as DBInbound instances, but
  106. // stays mutable so the WS-driven applyClientStatsEvent / applyTrafficEvent
  107. // can merge per-row updates without invalidating the entire query.
  108. const [dbInbounds, setDbInbounds] = useState<DBInboundInstance[]>([]);
  109. const dbInboundsRef = useRef<DBInboundInstance[]>([]);
  110. dbInboundsRef.current = dbInbounds;
  111. const [clientCount, setClientCount] = useState<Record<number, ClientRollup>>({});
  112. const [statsVersion, setStatsVersion] = useState(0);
  113. const [onlineClients, setOnlineClients] = useState<string[]>([]);
  114. const onlineClientsRef = useRef<string[]>([]);
  115. onlineClientsRef.current = onlineClients;
  116. const [lastOnlineMap, setLastOnlineMap] = useState<Record<string, number>>({});
  117. const rollupClients = useCallback(
  118. (dbInbound: DBInboundInstance, inbound: { clients?: { email?: string; enable?: boolean; comment?: string }[] }): ClientRollup => {
  119. const clientStats = Array.isArray((dbInbound as { clientStats?: unknown }).clientStats)
  120. ? (dbInbound as unknown as { clientStats: { email: string; total: number; up: number; down: number; expiryTime: number }[] }).clientStats
  121. : [];
  122. const allClients = inbound?.clients || [];
  123. const statsEmails = new Set<string>();
  124. for (const s of clientStats) {
  125. if (s && s.email) statsEmails.add(s.email);
  126. }
  127. const clients = clientStats.length > 0
  128. ? allClients.filter((c) => c && c.email && statsEmails.has(c.email))
  129. : allClients;
  130. const active: string[] = [];
  131. const deactive: string[] = [];
  132. const depleted: string[] = [];
  133. const expiring: string[] = [];
  134. const online: string[] = [];
  135. const comments = new Map<string, string>();
  136. const now = Date.now();
  137. if (dbInbound.enable) {
  138. for (const client of clients) {
  139. if (client.comment && client.email) comments.set(client.email, client.comment);
  140. if (client.enable) {
  141. if (client.email) active.push(client.email);
  142. if (client.email && onlineClientsRef.current.includes(client.email)) online.push(client.email);
  143. } else if (client.email) {
  144. deactive.push(client.email);
  145. }
  146. }
  147. for (const stats of clientStats) {
  148. const exhausted = stats.total > 0 && stats.up + stats.down >= stats.total;
  149. const expired = stats.expiryTime > 0 && stats.expiryTime <= now;
  150. if (expired || exhausted) {
  151. depleted.push(stats.email);
  152. } else {
  153. const expiringSoon =
  154. (stats.expiryTime > 0 && stats.expiryTime - now < expireDiffRef.current) ||
  155. (stats.total > 0 && stats.total - (stats.up + stats.down) < trafficDiffRef.current);
  156. if (expiringSoon) expiring.push(stats.email);
  157. }
  158. }
  159. } else {
  160. for (const client of clients) {
  161. if (client.email) deactive.push(client.email);
  162. }
  163. }
  164. return {
  165. clients: clients.length,
  166. active,
  167. deactive,
  168. depleted,
  169. expiring,
  170. online,
  171. comments,
  172. };
  173. },
  174. [],
  175. );
  176. const rebuildClientCount = useCallback(() => {
  177. const counts: Record<number, ClientRollup> = {};
  178. for (const dbInbound of dbInboundsRef.current) {
  179. const protocol = dbInbound.protocol;
  180. if (!TRACKED_PROTOCOLS.includes(protocol)) continue;
  181. const settings = coerceInboundJsonField(dbInbound.settings) as {
  182. method?: string;
  183. clients?: Array<{ email?: string; enable?: boolean; comment?: string }>;
  184. };
  185. if (protocol === Protocols.SHADOWSOCKS && !isSSMultiUser({ protocol, settings })) continue;
  186. counts[dbInbound.id] = rollupClients(dbInbound, { clients: settings.clients });
  187. }
  188. setClientCount(counts);
  189. }, [rollupClients]);
  190. // Seed dbInbounds + clientCount from the slim query. Runs on first fetch and
  191. // again every time the query refetches (e.g. invalidate from WS bridge).
  192. useEffect(() => {
  193. if (!slimQuery.data) return;
  194. const next: DBInboundInstance[] = [];
  195. const counts: Record<number, ClientRollup> = {};
  196. for (const row of slimQuery.data as { protocol: string; id: number }[]) {
  197. const dbInbound = new DBInbound(row) as DBInboundInstance;
  198. next.push(dbInbound);
  199. if (TRACKED_PROTOCOLS.includes(row.protocol)) {
  200. const settings = coerceInboundJsonField(dbInbound.settings) as {
  201. method?: string;
  202. clients?: Array<{ email?: string; enable?: boolean; comment?: string }>;
  203. };
  204. if (row.protocol === Protocols.SHADOWSOCKS && !isSSMultiUser({ protocol: row.protocol, settings })) continue;
  205. counts[row.id] = rollupClients(dbInbound, { clients: settings.clients });
  206. }
  207. }
  208. dbInboundsRef.current = next;
  209. setDbInbounds(next);
  210. setClientCount(counts);
  211. }, [slimQuery.data, rollupClients]);
  212. useEffect(() => {
  213. if (onlinesQuery.data) {
  214. onlineClientsRef.current = onlinesQuery.data;
  215. setOnlineClients(onlinesQuery.data);
  216. }
  217. }, [onlinesQuery.data]);
  218. useEffect(() => {
  219. if (lastOnlineQuery.data) setLastOnlineMap(lastOnlineQuery.data);
  220. }, [lastOnlineQuery.data]);
  221. const fetched = slimQuery.data !== undefined && defaultsQuery.data !== undefined;
  222. const refresh = useCallback(async () => {
  223. // Invalidate at the inbounds root so both `slim` (this page's list)
  224. // and `options` (the Clients page's inbound picker) refetch. Without
  225. // the options bucket, a freshly-created inbound stays invisible in
  226. // the client add/edit modal until a full page reload.
  227. await Promise.all([
  228. queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
  229. queryClient.invalidateQueries({ queryKey: keys.clients.onlines() }),
  230. queryClient.invalidateQueries({ queryKey: keys.clients.lastOnline() }),
  231. ]);
  232. }, [queryClient]);
  233. // hydrateInbound fetches the full inbound (including settings.clients with
  234. // uuid/password/flow/etc.) and swaps it into the cached list. Use this
  235. // before opening edit / info / qr / export / clone flows — refresh() loads
  236. // the slim list which doesn't carry per-client secrets.
  237. const hydrateInbound = useCallback(async (id: number) => {
  238. const msg = await HttpUtil.get(`/panel/api/inbounds/get/${id}`);
  239. if (!msg?.success || !msg.obj) return null;
  240. const validated = parseMsg(msg, InboundDetailSchema, `inbounds/get/${id}`);
  241. if (!validated.obj) return null;
  242. const dbInbound = new DBInbound(validated.obj) as DBInboundInstance;
  243. setDbInbounds((prev) => {
  244. const next = prev.map((row) => (
  245. (row as unknown as { id: number }).id === id ? dbInbound : row
  246. ));
  247. dbInboundsRef.current = next;
  248. return next;
  249. });
  250. rebuildClientCount();
  251. return dbInbound;
  252. }, [rebuildClientCount]);
  253. const applyTrafficEvent = useCallback(
  254. (payload: unknown) => {
  255. if (!payload || typeof payload !== 'object') return;
  256. const p = payload as { onlineClients?: string[]; lastOnlineMap?: Record<string, number> };
  257. if (Array.isArray(p.onlineClients)) {
  258. onlineClientsRef.current = p.onlineClients;
  259. setOnlineClients(p.onlineClients);
  260. }
  261. if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
  262. setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
  263. }
  264. rebuildClientCount();
  265. },
  266. [rebuildClientCount],
  267. );
  268. const applyClientStatsEvent = useCallback(
  269. (payload: unknown) => {
  270. if (!payload || typeof payload !== 'object') return;
  271. const p = payload as {
  272. inbounds?: { id: number; up?: number; down?: number; total?: number; enable?: boolean }[];
  273. clients?: { email: string; up?: number; down?: number; total?: number; expiryTime?: number; enable?: boolean }[];
  274. };
  275. let touched = false;
  276. if (Array.isArray(p.inbounds) && p.inbounds.length > 0) {
  277. const byId = new Map<number, { id: number; up?: number; down?: number; total?: number; enable?: boolean }>();
  278. for (const row of p.inbounds) {
  279. if (row && row.id != null) byId.set(row.id, row);
  280. }
  281. for (const ib of dbInboundsRef.current) {
  282. const upd = byId.get((ib as unknown as { id: number }).id);
  283. if (!upd) continue;
  284. const ibRec = ib as unknown as { up: number; down: number; total: number; enable: boolean };
  285. if (typeof upd.up === 'number') ibRec.up = upd.up;
  286. if (typeof upd.down === 'number') ibRec.down = upd.down;
  287. if (typeof upd.total === 'number') ibRec.total = upd.total;
  288. if (typeof upd.enable === 'boolean') ibRec.enable = upd.enable;
  289. touched = true;
  290. }
  291. }
  292. if (Array.isArray(p.clients) && p.clients.length > 0) {
  293. const byEmail = new Map<string, { email: string; up?: number; down?: number; total?: number; expiryTime?: number; enable?: boolean }>();
  294. for (const row of p.clients) {
  295. if (row && row.email) byEmail.set(row.email, row);
  296. }
  297. for (const ib of dbInboundsRef.current) {
  298. const stats = (ib as unknown as { clientStats: { email: string; up: number; down: number; total: number; expiryTime: number; enable: boolean }[] }).clientStats;
  299. if (!Array.isArray(stats)) continue;
  300. for (let i = 0; i < stats.length; i++) {
  301. const stat = stats[i];
  302. const upd = byEmail.get(stat.email);
  303. if (!upd) continue;
  304. if (typeof upd.up === 'number') stat.up = upd.up;
  305. if (typeof upd.down === 'number') stat.down = upd.down;
  306. if (typeof upd.total === 'number') stat.total = upd.total;
  307. if (typeof upd.expiryTime === 'number') stat.expiryTime = upd.expiryTime;
  308. if (typeof upd.enable === 'boolean') stat.enable = upd.enable;
  309. touched = true;
  310. }
  311. }
  312. }
  313. if (touched) {
  314. setStatsVersion((v) => v + 1);
  315. setDbInbounds((prev) => {
  316. const next = [...prev];
  317. dbInboundsRef.current = next;
  318. return next;
  319. });
  320. rebuildClientCount();
  321. }
  322. },
  323. [rebuildClientCount],
  324. );
  325. const totals = useMemo(() => {
  326. let up = 0;
  327. let down = 0;
  328. for (const ib of dbInbounds) {
  329. const rec = ib as unknown as { up?: number; down?: number };
  330. up += rec.up || 0;
  331. down += rec.down || 0;
  332. }
  333. return { up, down };
  334. }, [dbInbounds]);
  335. return {
  336. fetched,
  337. dbInbounds,
  338. clientCount,
  339. onlineClients,
  340. lastOnlineMap,
  341. statsVersion,
  342. totals,
  343. expireDiff,
  344. trafficDiff,
  345. subSettings,
  346. remarkModel,
  347. datepicker,
  348. tgBotEnable,
  349. ipLimitEnable,
  350. pageSize,
  351. refresh,
  352. hydrateInbound,
  353. applyTrafficEvent,
  354. applyClientStatsEvent,
  355. };
  356. }