useInbounds.ts 24 KB

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