useInbounds.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
  2. import { useQuery, useQueryClient } from '@tanstack/react-query';
  3. import { HttpUtil } from '@/utils';
  4. import { DBInbound } from '@/models/dbinbound.js';
  5. import { Protocols } from '@/models/inbound.js';
  6. import { setDatepicker } from '@/hooks/useDatepicker';
  7. import { keys } from '@/api/queryKeys';
  8. export interface SubSettings {
  9. enable: boolean;
  10. subTitle: string;
  11. subURI: string;
  12. subJsonURI: string;
  13. subJsonEnable: boolean;
  14. }
  15. type DBInboundInstance = InstanceType<typeof DBInbound>;
  16. interface ClientRollup {
  17. clients: number;
  18. active: string[];
  19. deactive: string[];
  20. depleted: string[];
  21. expiring: string[];
  22. online: string[];
  23. comments: Map<string, string>;
  24. }
  25. interface ApiMsg<T = unknown> {
  26. success?: boolean;
  27. obj?: T;
  28. msg?: string;
  29. }
  30. interface DefaultsPayload {
  31. expireDiff?: number;
  32. trafficDiff?: number;
  33. tgBotEnable?: boolean;
  34. subEnable?: boolean;
  35. subTitle?: string;
  36. subURI?: string;
  37. subJsonURI?: string;
  38. subJsonEnable?: boolean;
  39. pageSize?: number;
  40. remarkModel?: string;
  41. datepicker?: string;
  42. ipLimitEnable?: boolean;
  43. }
  44. const TRACKED_PROTOCOLS = [
  45. Protocols.VMESS,
  46. Protocols.VLESS,
  47. Protocols.TROJAN,
  48. Protocols.SHADOWSOCKS,
  49. Protocols.HYSTERIA,
  50. ];
  51. async function fetchSlimInbounds(): Promise<unknown[]> {
  52. const msg = await HttpUtil.get('/panel/api/inbounds/list/slim', undefined, { silent: true }) as ApiMsg<unknown[]>;
  53. if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch inbounds');
  54. return Array.isArray(msg.obj) ? msg.obj : [];
  55. }
  56. async function fetchOnlineClients(): Promise<string[]> {
  57. const msg = await HttpUtil.post('/panel/api/clients/onlines', undefined, { silent: true }) as ApiMsg<string[]>;
  58. if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlines');
  59. return Array.isArray(msg.obj) ? msg.obj : [];
  60. }
  61. async function fetchLastOnlineMap(): Promise<Record<string, number>> {
  62. const msg = await HttpUtil.post('/panel/api/clients/lastOnline', undefined, { silent: true }) as ApiMsg<Record<string, number>>;
  63. if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch lastOnline');
  64. return (msg.obj && typeof msg.obj === 'object') ? msg.obj : {};
  65. }
  66. async function fetchDefaultSettings(): Promise<DefaultsPayload> {
  67. const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true }) as ApiMsg<DefaultsPayload>;
  68. if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
  69. return (msg.obj as DefaultsPayload) || {};
  70. }
  71. export function useInbounds() {
  72. const queryClient = useQueryClient();
  73. const slimQuery = useQuery({
  74. queryKey: keys.inbounds.slim(),
  75. queryFn: fetchSlimInbounds,
  76. staleTime: Infinity,
  77. });
  78. const onlinesQuery = useQuery({
  79. queryKey: keys.clients.onlines(),
  80. queryFn: fetchOnlineClients,
  81. staleTime: Infinity,
  82. });
  83. const lastOnlineQuery = useQuery({
  84. queryKey: keys.clients.lastOnline(),
  85. queryFn: fetchLastOnlineMap,
  86. staleTime: Infinity,
  87. });
  88. const defaultsQuery = useQuery({
  89. queryKey: keys.settings.defaults(),
  90. queryFn: fetchDefaultSettings,
  91. staleTime: Infinity,
  92. });
  93. const defaults = defaultsQuery.data ?? {};
  94. const expireDiff = (defaults.expireDiff ?? 0) * 86400000;
  95. const trafficDiff = (defaults.trafficDiff ?? 0) * 1073741824;
  96. const tgBotEnable = !!defaults.tgBotEnable;
  97. const ipLimitEnable = !!defaults.ipLimitEnable;
  98. const pageSize = defaults.pageSize ?? 0;
  99. const remarkModel = defaults.remarkModel || '-ieo';
  100. const datepicker = (defaults.datepicker as 'gregorian' | 'jalalian') || 'gregorian';
  101. const subSettings: SubSettings = useMemo(() => ({
  102. enable: !!defaults.subEnable,
  103. subTitle: defaults.subTitle || '',
  104. subURI: defaults.subURI || '',
  105. subJsonURI: defaults.subJsonURI || '',
  106. subJsonEnable: !!defaults.subJsonEnable,
  107. }), [defaults.subEnable, defaults.subTitle, defaults.subURI, defaults.subJsonURI, defaults.subJsonEnable]);
  108. useEffect(() => {
  109. if (defaults.datepicker) setDatepicker(datepicker);
  110. }, [datepicker, defaults.datepicker]);
  111. const expireDiffRef = useRef(expireDiff);
  112. expireDiffRef.current = expireDiff;
  113. const trafficDiffRef = useRef(trafficDiff);
  114. trafficDiffRef.current = trafficDiff;
  115. // dbInbounds mirrors the slim query data wrapped as DBInbound instances, but
  116. // stays mutable so the WS-driven applyClientStatsEvent / applyTrafficEvent
  117. // can merge per-row updates without invalidating the entire query.
  118. const [dbInbounds, setDbInbounds] = useState<DBInboundInstance[]>([]);
  119. const dbInboundsRef = useRef<DBInboundInstance[]>([]);
  120. dbInboundsRef.current = dbInbounds;
  121. const [clientCount, setClientCount] = useState<Record<number, ClientRollup>>({});
  122. const [statsVersion, setStatsVersion] = useState(0);
  123. const [onlineClients, setOnlineClients] = useState<string[]>([]);
  124. const onlineClientsRef = useRef<string[]>([]);
  125. onlineClientsRef.current = onlineClients;
  126. const [lastOnlineMap, setLastOnlineMap] = useState<Record<string, number>>({});
  127. const rollupClients = useCallback(
  128. (dbInbound: DBInboundInstance, inbound: { clients?: { email?: string; enable?: boolean; comment?: string }[] }): ClientRollup => {
  129. const clientStats = Array.isArray((dbInbound as { clientStats?: unknown }).clientStats)
  130. ? (dbInbound as unknown as { clientStats: { email: string; total: number; up: number; down: number; expiryTime: number }[] }).clientStats
  131. : [];
  132. const allClients = inbound?.clients || [];
  133. const statsEmails = new Set<string>();
  134. for (const s of clientStats) {
  135. if (s && s.email) statsEmails.add(s.email);
  136. }
  137. const clients = clientStats.length > 0
  138. ? allClients.filter((c) => c && c.email && statsEmails.has(c.email))
  139. : allClients;
  140. const active: string[] = [];
  141. const deactive: string[] = [];
  142. const depleted: string[] = [];
  143. const expiring: string[] = [];
  144. const online: string[] = [];
  145. const comments = new Map<string, string>();
  146. const now = Date.now();
  147. if (dbInbound.enable) {
  148. for (const client of clients) {
  149. if (client.comment && client.email) comments.set(client.email, client.comment);
  150. if (client.enable) {
  151. if (client.email) active.push(client.email);
  152. if (client.email && onlineClientsRef.current.includes(client.email)) online.push(client.email);
  153. } else if (client.email) {
  154. deactive.push(client.email);
  155. }
  156. }
  157. for (const stats of clientStats) {
  158. const exhausted = stats.total > 0 && stats.up + stats.down >= stats.total;
  159. const expired = stats.expiryTime > 0 && stats.expiryTime <= now;
  160. if (expired || exhausted) {
  161. depleted.push(stats.email);
  162. } else {
  163. const expiringSoon =
  164. (stats.expiryTime > 0 && stats.expiryTime - now < expireDiffRef.current) ||
  165. (stats.total > 0 && stats.total - (stats.up + stats.down) < trafficDiffRef.current);
  166. if (expiringSoon) expiring.push(stats.email);
  167. }
  168. }
  169. } else {
  170. for (const client of clients) {
  171. if (client.email) deactive.push(client.email);
  172. }
  173. }
  174. return {
  175. clients: clients.length,
  176. active,
  177. deactive,
  178. depleted,
  179. expiring,
  180. online,
  181. comments,
  182. };
  183. },
  184. [],
  185. );
  186. const rebuildClientCount = useCallback(() => {
  187. const counts: Record<number, ClientRollup> = {};
  188. for (const dbInbound of dbInboundsRef.current) {
  189. const parsed = (dbInbound as unknown as { toInbound: () => { clients?: unknown[]; isSSMultiUser?: boolean }; isSS: boolean; protocol: string }).toInbound();
  190. const protocol = (dbInbound as unknown as { protocol: string }).protocol;
  191. if (!TRACKED_PROTOCOLS.includes(protocol)) continue;
  192. const isSS = (dbInbound as unknown as { isSS: boolean }).isSS;
  193. if (isSS && !parsed.isSSMultiUser) continue;
  194. counts[(dbInbound as unknown as { id: number }).id] = rollupClients(dbInbound, parsed as { clients?: { email?: string; enable?: boolean; comment?: string }[] });
  195. }
  196. setClientCount(counts);
  197. }, [rollupClients]);
  198. // Seed dbInbounds + clientCount from the slim query. Runs on first fetch and
  199. // again every time the query refetches (e.g. invalidate from WS bridge).
  200. useEffect(() => {
  201. if (!slimQuery.data) return;
  202. const next: DBInboundInstance[] = [];
  203. const counts: Record<number, ClientRollup> = {};
  204. for (const row of slimQuery.data as { protocol: string; id: number }[]) {
  205. const dbInbound = new DBInbound(row) as DBInboundInstance;
  206. const parsed = (dbInbound as unknown as { toInbound: () => { clients?: unknown[]; isSSMultiUser?: boolean } }).toInbound();
  207. next.push(dbInbound);
  208. if (TRACKED_PROTOCOLS.includes(row.protocol)) {
  209. if ((dbInbound as unknown as { isSS: boolean }).isSS && !parsed.isSSMultiUser) continue;
  210. counts[row.id] = rollupClients(dbInbound, parsed as { clients?: { email?: string; enable?: boolean; comment?: string }[] });
  211. }
  212. }
  213. dbInboundsRef.current = next;
  214. setDbInbounds(next);
  215. setClientCount(counts);
  216. }, [slimQuery.data, rollupClients]);
  217. useEffect(() => {
  218. if (onlinesQuery.data) {
  219. onlineClientsRef.current = onlinesQuery.data;
  220. setOnlineClients(onlinesQuery.data);
  221. }
  222. }, [onlinesQuery.data]);
  223. useEffect(() => {
  224. if (lastOnlineQuery.data) setLastOnlineMap(lastOnlineQuery.data);
  225. }, [lastOnlineQuery.data]);
  226. const fetched = slimQuery.data !== undefined && defaultsQuery.data !== undefined;
  227. const refresh = useCallback(async () => {
  228. await Promise.all([
  229. queryClient.invalidateQueries({ queryKey: keys.inbounds.slim() }),
  230. queryClient.invalidateQueries({ queryKey: keys.clients.onlines() }),
  231. queryClient.invalidateQueries({ queryKey: keys.clients.lastOnline() }),
  232. ]);
  233. }, [queryClient]);
  234. // hydrateInbound fetches the full inbound (including settings.clients with
  235. // uuid/password/flow/etc.) and swaps it into the cached list. Use this
  236. // before opening edit / info / qr / export / clone flows — refresh() loads
  237. // the slim list which doesn't carry per-client secrets.
  238. const hydrateInbound = useCallback(async (id: number) => {
  239. const msg = await HttpUtil.get(`/panel/api/inbounds/get/${id}`);
  240. if (!msg?.success || !msg.obj) return null;
  241. const full = msg.obj as { id: number; protocol: string };
  242. const dbInbound = new DBInbound(full) 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. }