useInbounds.ts 23 KB

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