useInbounds.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489
  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, OnlineByNodeSchema, ActiveInboundsByNodeSchema } 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. // Configured public host (Sub Domain, else Web Domain) used as the share/QR
  20. // link host when the panel is reached on a loopback address. Empty if neither
  21. // is set.
  22. publicHost: string;
  23. }
  24. type DBInboundInstance = InstanceType<typeof DBInbound>;
  25. interface ClientRollup {
  26. clients: number;
  27. active: string[];
  28. deactive: string[];
  29. depleted: string[];
  30. expiring: string[];
  31. online: string[];
  32. comments: Map<string, string>;
  33. }
  34. const TRACKED_PROTOCOLS: readonly string[] = [
  35. Protocols.VMESS,
  36. Protocols.VLESS,
  37. Protocols.TROJAN,
  38. Protocols.SHADOWSOCKS,
  39. Protocols.HYSTERIA,
  40. ];
  41. async function fetchSlimInbounds(): Promise<unknown[]> {
  42. const msg = await HttpUtil.get('/panel/api/inbounds/list/slim', undefined, { silent: true });
  43. if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch inbounds');
  44. const validated = parseMsg(msg, SlimInboundListSchema, 'inbounds/list/slim');
  45. return Array.isArray(validated.obj) ? validated.obj : [];
  46. }
  47. async function fetchOnlineClients(): Promise<string[]> {
  48. const msg = await HttpUtil.post('/panel/api/clients/onlines', undefined, { silent: true });
  49. if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlines');
  50. const validated = parseMsg(msg, OnlinesSchema, 'clients/onlines');
  51. return Array.isArray(validated.obj) ? validated.obj : [];
  52. }
  53. // Online emails grouped by the panelGuid of the node that physically hosts each
  54. // client, used to scope the per-inbound online rollup so a client online on one
  55. // node is not shown online on every node's inbounds — and a client on a
  56. // sub-node is attributed to that sub-node, not the node it syncs through (#4983).
  57. async function fetchOnlineClientsByGuid(): Promise<Record<string, string[]>> {
  58. const msg = await HttpUtil.post('/panel/api/clients/onlinesByGuid', undefined, { silent: true });
  59. if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlinesByGuid');
  60. const validated = parseMsg(msg, OnlineByNodeSchema, 'clients/onlinesByGuid');
  61. return (validated.obj && typeof validated.obj === 'object') ? (validated.obj as Record<string, string[]>) : {};
  62. }
  63. // Inbound tags that carried traffic recently, grouped by node (local = key 0).
  64. // Pairs with the per-node online map so a client attached to several inbounds
  65. // is only marked online on the ones that actually moved bytes — Xray's
  66. // user-level stat can't attribute traffic to a single inbound on its own.
  67. async function fetchActiveInboundsByNode(): Promise<Record<string, string[]>> {
  68. const msg = await HttpUtil.post('/panel/api/clients/activeInbounds', undefined, { silent: true });
  69. if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch activeInbounds');
  70. const validated = parseMsg(msg, ActiveInboundsByNodeSchema, 'clients/activeInbounds');
  71. return (validated.obj && typeof validated.obj === 'object') ? (validated.obj as Record<string, string[]>) : {};
  72. }
  73. function toGuidOnlineMap(data: Record<string, string[]>): Map<string, Set<string>> {
  74. const map = new Map<string, Set<string>>();
  75. for (const [key, emails] of Object.entries(data)) {
  76. if (!Array.isArray(emails)) continue;
  77. map.set(key, new Set(emails));
  78. }
  79. return map;
  80. }
  81. async function fetchLastOnlineMap(): Promise<Record<string, number>> {
  82. const msg = await HttpUtil.post('/panel/api/clients/lastOnline', undefined, { silent: true });
  83. if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch lastOnline');
  84. const validated = parseMsg(msg, LastOnlineMapSchema, 'clients/lastOnline');
  85. return (validated.obj && typeof validated.obj === 'object') ? validated.obj : {};
  86. }
  87. async function fetchDefaultSettings(): Promise<DefaultsPayload> {
  88. const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true });
  89. if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
  90. const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
  91. return validated.obj ?? {};
  92. }
  93. export function useInbounds() {
  94. const queryClient = useQueryClient();
  95. const slimQuery = useQuery({
  96. queryKey: keys.inbounds.slim(),
  97. queryFn: fetchSlimInbounds,
  98. staleTime: Infinity,
  99. });
  100. const onlinesQuery = useQuery({
  101. queryKey: keys.clients.onlines(),
  102. queryFn: fetchOnlineClients,
  103. staleTime: Infinity,
  104. });
  105. const onlinesByGuidQuery = useQuery({
  106. queryKey: keys.clients.onlinesByGuid(),
  107. queryFn: fetchOnlineClientsByGuid,
  108. staleTime: Infinity,
  109. });
  110. const activeInboundsQuery = useQuery({
  111. queryKey: keys.clients.activeInbounds(),
  112. queryFn: fetchActiveInboundsByNode,
  113. staleTime: Infinity,
  114. });
  115. const lastOnlineQuery = useQuery({
  116. queryKey: keys.clients.lastOnline(),
  117. queryFn: fetchLastOnlineMap,
  118. staleTime: Infinity,
  119. });
  120. const defaultsQuery = useQuery({
  121. queryKey: keys.settings.defaults(),
  122. queryFn: fetchDefaultSettings,
  123. staleTime: Infinity,
  124. });
  125. const defaults = defaultsQuery.data ?? {};
  126. const expireDiff = (defaults.expireDiff ?? 0) * 86400000;
  127. const trafficDiff = (defaults.trafficDiff ?? 0) * 1073741824;
  128. const tgBotEnable = !!defaults.tgBotEnable;
  129. const ipLimitEnable = !!defaults.ipLimitEnable;
  130. const pageSize = defaults.pageSize ?? 0;
  131. const remarkModel = defaults.remarkModel || '-io';
  132. const datepicker = (defaults.datepicker as 'gregorian' | 'jalalian') || 'gregorian';
  133. const subSettings: SubSettings = useMemo(() => ({
  134. enable: !!defaults.subEnable,
  135. subTitle: defaults.subTitle || '',
  136. subURI: defaults.subURI || '',
  137. subJsonURI: defaults.subJsonURI || '',
  138. subJsonEnable: !!defaults.subJsonEnable,
  139. publicHost: defaults.subDomain || defaults.webDomain || '',
  140. }), [defaults.subEnable, defaults.subTitle, defaults.subURI, defaults.subJsonURI, defaults.subJsonEnable, defaults.subDomain, defaults.webDomain]);
  141. useEffect(() => {
  142. if (defaults.datepicker) setDatepicker(datepicker);
  143. }, [datepicker, defaults.datepicker]);
  144. const expireDiffRef = useRef(expireDiff);
  145. expireDiffRef.current = expireDiff;
  146. const trafficDiffRef = useRef(trafficDiff);
  147. trafficDiffRef.current = trafficDiff;
  148. // dbInbounds mirrors the slim query data wrapped as DBInbound instances, but
  149. // stays mutable so the WS-driven applyClientStatsEvent / applyTrafficEvent
  150. // can merge per-row updates without invalidating the entire query.
  151. const [dbInbounds, setDbInbounds] = useState<DBInboundInstance[]>([]);
  152. const dbInboundsRef = useRef<DBInboundInstance[]>([]);
  153. dbInboundsRef.current = dbInbounds;
  154. const [clientCount, setClientCount] = useState<Record<number, ClientRollup>>({});
  155. const [statsVersion, setStatsVersion] = useState(0);
  156. const [onlineClients, setOnlineClients] = useState<string[]>([]);
  157. const onlineClientsRef = useRef<string[]>([]);
  158. onlineClientsRef.current = onlineClients;
  159. // Online emails keyed by the hosting node's panelGuid. The rollup reads this
  160. // so each inbound only counts clients online on the node that physically
  161. // hosts it, attributing a sub-node's clients to that sub-node (#4983).
  162. const onlineByGuidRef = useRef<Map<string, Set<string>>>(new Map());
  163. // Recently-active inbound tags keyed by the hosting node's panelGuid. A GUID
  164. // missing from this map means "no per-inbound activity reported" (e.g. remote
  165. // nodes), so the rollup leaves that node's inbounds ungated and falls back to
  166. // the email signal. A present GUID gates: a client only counts online on an
  167. // inbound whose tag carried traffic this window.
  168. const activeByGuidRef = useRef<Map<string, Set<string>>>(new Map());
  169. const [lastOnlineMap, setLastOnlineMap] = useState<Record<string, number>>({});
  170. const rollupClients = useCallback(
  171. (dbInbound: DBInboundInstance, inbound: { clients?: { email?: string; enable?: boolean; comment?: string }[] }): ClientRollup => {
  172. const clientStats = Array.isArray((dbInbound as { clientStats?: unknown }).clientStats)
  173. ? (dbInbound as unknown as { clientStats: { email: string; total: number; up: number; down: number; expiryTime: number }[] }).clientStats
  174. : [];
  175. const clients = inbound?.clients || [];
  176. const active: string[] = [];
  177. const deactive: string[] = [];
  178. const depleted: string[] = [];
  179. const expiring: string[] = [];
  180. const online: string[] = [];
  181. const comments = new Map<string, string>();
  182. const now = Date.now();
  183. // Attribution key: the GUID of the node that physically hosts this
  184. // inbound. Local inbounds carry the panel's own GUID (filled server-side);
  185. // a node-managed inbound carries its origin node's GUID, or falls back to
  186. // the master-local synthetic id for an old-build node without one (#4983).
  187. const guid = dbInbound.originNodeGuid || (dbInbound.nodeId != null ? `node:${dbInbound.nodeId}` : '');
  188. const nodeOnline = onlineByGuidRef.current.get(guid);
  189. // A node absent from the active map reports no per-inbound activity, so
  190. // leave its inbounds ungated. When present, only mark a client online on
  191. // this inbound if its tag actually carried traffic — that's what stops a
  192. // multi-inbound client lighting up every inbound it's attached to.
  193. const activeForNode = activeByGuidRef.current.get(guid);
  194. const inboundActive = activeForNode === undefined || !dbInbound.tag || activeForNode.has(dbInbound.tag);
  195. if (dbInbound.enable) {
  196. for (const client of clients) {
  197. if (client.comment && client.email) comments.set(client.email, client.comment);
  198. if (client.enable) {
  199. if (client.email) active.push(client.email);
  200. if (client.email && inboundActive && nodeOnline?.has(client.email)) online.push(client.email);
  201. } else if (client.email) {
  202. deactive.push(client.email);
  203. }
  204. }
  205. for (const stats of clientStats) {
  206. const exhausted = stats.total > 0 && stats.up + stats.down >= stats.total;
  207. const expired = stats.expiryTime > 0 && stats.expiryTime <= now;
  208. if (expired || exhausted) {
  209. depleted.push(stats.email);
  210. } else {
  211. const expiringSoon =
  212. (stats.expiryTime > 0 && stats.expiryTime - now < expireDiffRef.current) ||
  213. (stats.total > 0 && stats.total - (stats.up + stats.down) < trafficDiffRef.current);
  214. if (expiringSoon) expiring.push(stats.email);
  215. }
  216. }
  217. } else {
  218. for (const client of clients) {
  219. if (client.email) deactive.push(client.email);
  220. }
  221. }
  222. return {
  223. clients: clients.length,
  224. active,
  225. deactive,
  226. depleted,
  227. expiring,
  228. online,
  229. comments,
  230. };
  231. },
  232. [],
  233. );
  234. const rebuildClientCount = useCallback(() => {
  235. const counts: Record<number, ClientRollup> = {};
  236. for (const dbInbound of dbInboundsRef.current) {
  237. const protocol = dbInbound.protocol;
  238. if (!TRACKED_PROTOCOLS.includes(protocol)) continue;
  239. const settings = coerceInboundJsonField(dbInbound.settings) as {
  240. method?: string;
  241. clients?: Array<{ email?: string; enable?: boolean; comment?: string }>;
  242. };
  243. if (protocol === Protocols.SHADOWSOCKS && !isSSMultiUser({ protocol, settings })) continue;
  244. counts[dbInbound.id] = rollupClients(dbInbound, { clients: settings.clients });
  245. }
  246. setClientCount(counts);
  247. }, [rollupClients]);
  248. // Seed dbInbounds + clientCount from the slim query. Runs on first fetch and
  249. // again every time the query refetches (e.g. invalidate from WS bridge).
  250. useEffect(() => {
  251. if (!slimQuery.data) return;
  252. const next: DBInboundInstance[] = [];
  253. const counts: Record<number, ClientRollup> = {};
  254. for (const row of slimQuery.data as { protocol: string; id: number }[]) {
  255. const dbInbound = new DBInbound(row) as DBInboundInstance;
  256. next.push(dbInbound);
  257. if (TRACKED_PROTOCOLS.includes(row.protocol)) {
  258. const settings = coerceInboundJsonField(dbInbound.settings) as {
  259. method?: string;
  260. clients?: Array<{ email?: string; enable?: boolean; comment?: string }>;
  261. };
  262. if (row.protocol === Protocols.SHADOWSOCKS && !isSSMultiUser({ protocol: row.protocol, settings })) continue;
  263. counts[row.id] = rollupClients(dbInbound, { clients: settings.clients });
  264. }
  265. }
  266. dbInboundsRef.current = next;
  267. setDbInbounds(next);
  268. setClientCount(counts);
  269. }, [slimQuery.data, rollupClients]);
  270. useEffect(() => {
  271. if (onlinesQuery.data) {
  272. onlineClientsRef.current = onlinesQuery.data;
  273. setOnlineClients(onlinesQuery.data);
  274. }
  275. }, [onlinesQuery.data]);
  276. useEffect(() => {
  277. if (onlinesByGuidQuery.data) {
  278. onlineByGuidRef.current = toGuidOnlineMap(onlinesByGuidQuery.data);
  279. rebuildClientCount();
  280. }
  281. }, [onlinesByGuidQuery.data, rebuildClientCount]);
  282. useEffect(() => {
  283. if (activeInboundsQuery.data) {
  284. activeByGuidRef.current = toGuidOnlineMap(activeInboundsQuery.data);
  285. rebuildClientCount();
  286. }
  287. }, [activeInboundsQuery.data, rebuildClientCount]);
  288. useEffect(() => {
  289. if (lastOnlineQuery.data) setLastOnlineMap(lastOnlineQuery.data);
  290. }, [lastOnlineQuery.data]);
  291. const fetched = (slimQuery.data !== undefined || slimQuery.isError) && (defaultsQuery.data !== undefined || defaultsQuery.isError);
  292. const fetchErrorSource = slimQuery.error || defaultsQuery.error;
  293. const fetchError = fetchErrorSource ? (fetchErrorSource as Error).message : '';
  294. const refresh = useCallback(async () => {
  295. // Invalidate at the inbounds root so both `slim` (this page's list)
  296. // and `options` (the Clients page's inbound picker) refetch. Without
  297. // the options bucket, a freshly-created inbound stays invisible in
  298. // the client add/edit modal until a full page reload. The xray config
  299. // response carries inboundTags for the routing-rule tag picker, so it
  300. // needs invalidating too or that list stays stale until a hard refresh.
  301. await Promise.all([
  302. queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
  303. queryClient.invalidateQueries({ queryKey: keys.clients.onlines() }),
  304. queryClient.invalidateQueries({ queryKey: keys.clients.onlinesByGuid() }),
  305. queryClient.invalidateQueries({ queryKey: keys.clients.activeInbounds() }),
  306. queryClient.invalidateQueries({ queryKey: keys.clients.lastOnline() }),
  307. queryClient.invalidateQueries({ queryKey: keys.xray.config() }),
  308. ]);
  309. }, [queryClient]);
  310. // hydrateInbound fetches the full inbound (including settings.clients with
  311. // uuid/password/flow/etc.) and swaps it into the cached list. Use this
  312. // before opening edit / info / qr / export / clone flows — refresh() loads
  313. // the slim list which doesn't carry per-client secrets.
  314. const hydrateInbound = useCallback(async (id: number) => {
  315. const msg = await HttpUtil.get(`/panel/api/inbounds/get/${id}`);
  316. if (!msg?.success || !msg.obj) return null;
  317. const validated = parseMsg(msg, InboundDetailSchema, `inbounds/get/${id}`);
  318. if (!validated.obj) return null;
  319. const dbInbound = new DBInbound(validated.obj) as DBInboundInstance;
  320. setDbInbounds((prev) => {
  321. const next = prev.map((row) => (
  322. (row as unknown as { id: number }).id === id ? dbInbound : row
  323. ));
  324. dbInboundsRef.current = next;
  325. return next;
  326. });
  327. rebuildClientCount();
  328. return dbInbound;
  329. }, [rebuildClientCount]);
  330. const applyTrafficEvent = useCallback(
  331. (payload: unknown) => {
  332. if (!payload || typeof payload !== 'object') return;
  333. const p = payload as { onlineClients?: string[]; onlineByGuid?: Record<string, string[]>; activeInbounds?: Record<string, string[]>; lastOnlineMap?: Record<string, number> };
  334. if (Array.isArray(p.onlineClients)) {
  335. onlineClientsRef.current = p.onlineClients;
  336. setOnlineClients(p.onlineClients);
  337. }
  338. if (p.onlineByGuid && typeof p.onlineByGuid === 'object') {
  339. onlineByGuidRef.current = toGuidOnlineMap(p.onlineByGuid);
  340. }
  341. if (p.activeInbounds && typeof p.activeInbounds === 'object') {
  342. activeByGuidRef.current = toGuidOnlineMap(p.activeInbounds);
  343. }
  344. if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
  345. setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
  346. }
  347. rebuildClientCount();
  348. },
  349. [rebuildClientCount],
  350. );
  351. const applyClientStatsEvent = useCallback(
  352. (payload: unknown) => {
  353. if (!payload || typeof payload !== 'object') return;
  354. const p = payload as {
  355. inbounds?: { id: number; up?: number; down?: number; total?: number; enable?: boolean }[];
  356. clients?: { email: string; up?: number; down?: number; total?: number; expiryTime?: number; enable?: boolean }[];
  357. };
  358. let touched = false;
  359. if (Array.isArray(p.inbounds) && p.inbounds.length > 0) {
  360. const byId = new Map<number, { id: number; up?: number; down?: number; total?: number; enable?: boolean }>();
  361. for (const row of p.inbounds) {
  362. if (row && row.id != null) byId.set(row.id, row);
  363. }
  364. for (const ib of dbInboundsRef.current) {
  365. const upd = byId.get((ib as unknown as { id: number }).id);
  366. if (!upd) continue;
  367. const ibRec = ib as unknown as { up: number; down: number; total: number; enable: boolean };
  368. if (typeof upd.up === 'number') ibRec.up = upd.up;
  369. if (typeof upd.down === 'number') ibRec.down = upd.down;
  370. if (typeof upd.total === 'number') ibRec.total = upd.total;
  371. if (typeof upd.enable === 'boolean') ibRec.enable = upd.enable;
  372. touched = true;
  373. }
  374. }
  375. if (Array.isArray(p.clients) && p.clients.length > 0) {
  376. const byEmail = new Map<string, { email: string; up?: number; down?: number; total?: number; expiryTime?: number; enable?: boolean }>();
  377. for (const row of p.clients) {
  378. if (row && row.email) byEmail.set(row.email, row);
  379. }
  380. for (const ib of dbInboundsRef.current) {
  381. const stats = (ib as unknown as { clientStats: { email: string; up: number; down: number; total: number; expiryTime: number; enable: boolean }[] }).clientStats;
  382. if (!Array.isArray(stats)) continue;
  383. for (let i = 0; i < stats.length; i++) {
  384. const stat = stats[i];
  385. const upd = byEmail.get(stat.email);
  386. if (!upd) continue;
  387. if (typeof upd.up === 'number') stat.up = upd.up;
  388. if (typeof upd.down === 'number') stat.down = upd.down;
  389. if (typeof upd.total === 'number') stat.total = upd.total;
  390. if (typeof upd.expiryTime === 'number') stat.expiryTime = upd.expiryTime;
  391. if (typeof upd.enable === 'boolean') stat.enable = upd.enable;
  392. touched = true;
  393. }
  394. }
  395. }
  396. if (touched) {
  397. setStatsVersion((v) => v + 1);
  398. setDbInbounds((prev) => {
  399. const next = [...prev];
  400. dbInboundsRef.current = next;
  401. return next;
  402. });
  403. rebuildClientCount();
  404. }
  405. },
  406. [rebuildClientCount],
  407. );
  408. const totals = useMemo(() => {
  409. let up = 0;
  410. let down = 0;
  411. for (const ib of dbInbounds) {
  412. const rec = ib as unknown as { up?: number; down?: number };
  413. up += rec.up || 0;
  414. down += rec.down || 0;
  415. }
  416. return { up, down };
  417. }, [dbInbounds]);
  418. return {
  419. fetched,
  420. fetchError,
  421. dbInbounds,
  422. clientCount,
  423. onlineClients,
  424. lastOnlineMap,
  425. statsVersion,
  426. totals,
  427. expireDiff,
  428. trafficDiff,
  429. subSettings,
  430. remarkModel,
  431. datepicker,
  432. tgBotEnable,
  433. ipLimitEnable,
  434. pageSize,
  435. refresh,
  436. hydrateInbound,
  437. applyTrafficEvent,
  438. applyClientStatsEvent,
  439. };
  440. }