InboundsPage.tsx 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930
  1. import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import {
  4. Button,
  5. Card,
  6. Col,
  7. ConfigProvider,
  8. Layout,
  9. Modal,
  10. Result,
  11. Row,
  12. Spin,
  13. Statistic,
  14. message,
  15. } from 'antd';
  16. import { setMessageInstance } from '@/utils/messageBus';
  17. import {
  18. ArrowUpOutlined,
  19. ArrowDownOutlined,
  20. PieChartOutlined,
  21. BarsOutlined,
  22. } from '@ant-design/icons';
  23. import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
  24. import { buildClonePayload } from '@/lib/xray/inbound-clone';
  25. import { NODE_ELIGIBLE_PROTOCOLS } from '@/lib/xray/node-protocols';
  26. import {
  27. genAmneziaWGLinks,
  28. genInboundLinks,
  29. genWireguardLinks,
  30. preferPublicHost,
  31. } from '@/lib/xray/inbound-link';
  32. import { inboundFromDb } from '@/lib/xray/inbound-from-db';
  33. import { Protocols } from '@/schemas/primitives';
  34. import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
  35. import { useTheme } from '@/hooks/useTheme';
  36. import { useMediaQuery } from '@/hooks/useMediaQuery';
  37. import { useWebSocket } from '@/hooks/useWebSocket';
  38. import { useNodesQuery } from '@/api/queries/useNodesQuery';
  39. import { useHostsQuery } from '@/api/queries/useHostsQuery';
  40. import { withMtprotoHostEndpoints } from '@/lib/hosts/host-link';
  41. import AppSidebar from '@/layouts/AppSidebar';
  42. const TextModal = lazy(() => import('@/components/feedback/TextModal'));
  43. import type { TextModalTab } from '@/components/feedback/TextModal';
  44. const PromptModal = lazy(() => import('@/components/feedback/PromptModal'));
  45. import { useInbounds } from './useInbounds';
  46. import { InboundList } from './list';
  47. import { LazyMount } from '@/components/utility';
  48. const InboundFormModal = lazy(() => import('./form/InboundFormModal'));
  49. const CloneInboundModal = lazy(() => import('./CloneInboundModal'));
  50. const InboundInfoModal = lazy(() => import('./info/InboundInfoModal'));
  51. const QrCodeModal = lazy(() => import('./qr/QrCodeModal'));
  52. const AttachClientsModal = lazy(() => import('./clients/AttachClientsModal'));
  53. const AttachExistingClientsModal = lazy(() => import('./clients/AttachExistingClientsModal'));
  54. const DetachClientsModal = lazy(() => import('./clients/DetachClientsModal'));
  55. const AddClientsToGroupModal = lazy(() => import('./clients/AddClientsToGroupModal'));
  56. type RowAction =
  57. | 'edit'
  58. | 'showInfo'
  59. | 'qrcode'
  60. | 'export'
  61. | 'subs'
  62. | 'clipboard'
  63. | 'delete'
  64. | 'resetTraffic'
  65. | 'delAllClients'
  66. | 'attachClients'
  67. | 'attachExisting'
  68. | 'detachClients'
  69. | 'addToGroup'
  70. | 'clone';
  71. type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
  72. interface ClientMatchTarget {
  73. id?: string;
  74. email?: string;
  75. password?: string;
  76. }
  77. export default function InboundsPage() {
  78. const { t } = useTranslation();
  79. const { isDark, isUltra, antdThemeConfig } = useTheme();
  80. const { isMobile } = useMediaQuery();
  81. const {
  82. fetched,
  83. fetchError,
  84. dbInbounds,
  85. clientCount,
  86. onlineClients,
  87. lastOnlineMap,
  88. inboundSpeed,
  89. totals,
  90. expireDiff,
  91. trafficDiff,
  92. pageSize,
  93. subSettings,
  94. tgBotEnable,
  95. ipLimitEnable,
  96. refresh,
  97. hydrateInbound,
  98. applyTrafficEvent,
  99. applyClientStatsEvent,
  100. } = useInbounds();
  101. const [modal, modalContextHolder] = Modal.useModal();
  102. const [messageApi, messageContextHolder] = message.useMessage();
  103. useEffect(() => {
  104. setMessageInstance(messageApi);
  105. }, [messageApi]);
  106. const { nodes: nodesList, fetched: nodesFetched } = useNodesQuery();
  107. // MTProto share links are generated from this list, so an empty one must mean
  108. // "no hosts" and not "not loaded yet" — the gate below waits for it.
  109. const {
  110. hosts,
  111. fetched: hostsFetched,
  112. fetchError: hostsFetchError,
  113. refetch: refetchHosts,
  114. } = useHostsQuery();
  115. // A background refetch that fails while rows are still cached is not fatal.
  116. const hostsError = hosts.length > 0 ? '' : hostsFetchError;
  117. const nodesById = useMemo(() => {
  118. const map = new Map<number, ReturnType<typeof useNodesQuery>['nodes'][number]>();
  119. for (const n of nodesList || []) map.set(n.id, n);
  120. return map;
  121. }, [nodesList]);
  122. const hasActiveNode = useMemo(
  123. () => (nodesList || []).some((n) => n.enable && n.status === 'online'),
  124. [nodesList],
  125. );
  126. const hasNodeAttachedInbound = useMemo(
  127. () => (dbInbounds || []).some((ib) => ib?.nodeId != null),
  128. [dbInbounds],
  129. );
  130. const showNodeInfo = hasNodeAttachedInbound || hasActiveNode;
  131. // Ports already bound per clone target (0 = local panel, matching the
  132. // clients page node-filter sentinel), for the clone dialog's client-side
  133. // conflict pre-check.
  134. const clonePortsInUse = useMemo(() => {
  135. const map = new Map<number, Set<number>>();
  136. for (const ib of dbInbounds || []) {
  137. const key = ib.nodeId ?? 0;
  138. const ports = map.get(key) ?? new Set<number>();
  139. ports.add(ib.port);
  140. map.set(key, ports);
  141. }
  142. return map;
  143. }, [dbInbounds]);
  144. useWebSocket({
  145. traffic: applyTrafficEvent,
  146. client_stats: applyClientStatsEvent,
  147. });
  148. const [formOpen, setFormOpen] = useState(false);
  149. const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
  150. const [formDbInbound, setFormDbInbound] = useState<DBInbound | null>(null);
  151. const [infoOpen, setInfoOpen] = useState(false);
  152. const [infoDbInbound, setInfoDbInbound] = useState<DBInbound | null>(null);
  153. const [infoClientIndex, setInfoClientIndex] = useState(0);
  154. const [qrOpen, setQrOpen] = useState(false);
  155. const [qrDbInbound, setQrDbInbound] = useState<DBInbound | null>(null);
  156. const [attachOpen, setAttachOpen] = useState(false);
  157. const [attachSource, setAttachSource] = useState<DBInbound | null>(null);
  158. const [attachExistingOpen, setAttachExistingOpen] = useState(false);
  159. const [attachExistingTarget, setAttachExistingTarget] = useState<DBInbound | null>(null);
  160. const [detachOpen, setDetachOpen] = useState(false);
  161. const [detachSource, setDetachSource] = useState<DBInbound | null>(null);
  162. const [groupOpen, setGroupOpen] = useState(false);
  163. const [groupSource, setGroupSource] = useState<DBInbound | null>(null);
  164. const [cloneOpen, setCloneOpen] = useState(false);
  165. const [cloneSource, setCloneSource] = useState<DBInbound | null>(null);
  166. const [textOpen, setTextOpen] = useState(false);
  167. const [textTitle, setTextTitle] = useState('');
  168. const [textContent, setTextContent] = useState('');
  169. const [textFileName, setTextFileName] = useState('');
  170. const [textJson, setTextJson] = useState(false);
  171. const [textTabs, setTextTabs] = useState<TextModalTab[] | undefined>(undefined);
  172. const [promptOpen, setPromptOpen] = useState(false);
  173. const [promptTitle, setPromptTitle] = useState('');
  174. const [promptOkText, setPromptOkText] = useState('OK');
  175. const [promptType, setPromptType] = useState<'textarea' | 'input'>('textarea');
  176. const [promptInitial, setPromptInitial] = useState('');
  177. const [promptJson, setPromptJson] = useState(false);
  178. const [promptLoading, setPromptLoading] = useState(false);
  179. const [promptHandler, setPromptHandler] = useState<
  180. ((value: string) => Promise<boolean | void> | boolean | void) | null
  181. >(null);
  182. const hostOverrideFor = useCallback(
  183. (dbInbound: DBInbound | null) => {
  184. if (!dbInbound || dbInbound.nodeId == null) return '';
  185. return nodesById.get(dbInbound.nodeId)?.address || '';
  186. },
  187. [nodesById],
  188. );
  189. const infoNodeAddress = useMemo(
  190. () => hostOverrideFor(infoDbInbound),
  191. [infoDbInbound, hostOverrideFor],
  192. );
  193. const qrNodeAddress = useMemo(() => hostOverrideFor(qrDbInbound), [qrDbInbound, hostOverrideFor]);
  194. const openText = useCallback(
  195. (opts: {
  196. title: string;
  197. content: string;
  198. fileName?: string;
  199. json?: boolean;
  200. tabs?: TextModalTab[];
  201. }) => {
  202. setTextTitle(opts.title);
  203. setTextContent(opts.content);
  204. setTextFileName(opts.fileName || '');
  205. setTextJson(opts.json || false);
  206. setTextTabs(opts.tabs);
  207. setTextOpen(true);
  208. },
  209. [],
  210. );
  211. const openPrompt = useCallback(
  212. (opts: {
  213. title: string;
  214. okText?: string;
  215. type?: 'textarea' | 'input';
  216. value?: string;
  217. json?: boolean;
  218. confirm: (value: string) => Promise<boolean | void> | boolean | void;
  219. }) => {
  220. setPromptTitle(opts.title);
  221. setPromptOkText(opts.okText || t('confirm'));
  222. setPromptType(opts.type || 'textarea');
  223. setPromptInitial(opts.value || '');
  224. setPromptJson(opts.json || false);
  225. setPromptHandler(() => opts.confirm);
  226. setPromptOpen(true);
  227. },
  228. [t],
  229. );
  230. const onPromptConfirm = useCallback(
  231. async (value: string) => {
  232. if (!promptHandler) {
  233. setPromptOpen(false);
  234. return;
  235. }
  236. setPromptLoading(true);
  237. try {
  238. const ok = await promptHandler(value);
  239. if (ok !== false) setPromptOpen(false);
  240. } finally {
  241. setPromptLoading(false);
  242. }
  243. },
  244. [promptHandler],
  245. );
  246. const projectChildThroughMaster = useCallback(
  247. (child: DBInbound, master: DBInbound): DBInbound => {
  248. const projected = JSON.parse(JSON.stringify(child)) as DBInbound;
  249. projected.listen = master.listen;
  250. projected.port = master.port;
  251. const masterStream = coerceInboundJsonField(master.streamSettings) as Record<string, unknown>;
  252. const childStream = {
  253. ...(coerceInboundJsonField(child.streamSettings) as Record<string, unknown>),
  254. };
  255. childStream.security = masterStream.security;
  256. childStream.tlsSettings = masterStream.tlsSettings;
  257. childStream.realitySettings = masterStream.realitySettings;
  258. childStream.externalProxy = masterStream.externalProxy;
  259. projected.streamSettings = JSON.stringify(childStream);
  260. const Ctor = child.constructor as new (data: DBInbound) => DBInbound;
  261. return new Ctor(projected);
  262. },
  263. [],
  264. );
  265. const checkFallback = useCallback(
  266. (dbInbound: DBInbound): DBInbound => {
  267. const parent = dbInbound?.fallbackParent;
  268. if (parent?.masterId) {
  269. const master = dbInbounds.find((ib) => ib.id === parent.masterId);
  270. if (master) return projectChildThroughMaster(dbInbound, master);
  271. }
  272. if (!dbInbound?.listen?.startsWith?.('@')) return dbInbound;
  273. for (const candidate of dbInbounds) {
  274. if (candidate.id === dbInbound.id) continue;
  275. if (!['trojan', 'vless'].includes(candidate.protocol)) continue;
  276. const candStream = coerceInboundJsonField(candidate.streamSettings) as { network?: string };
  277. if (candStream.network !== 'tcp') continue;
  278. const candSettings = coerceInboundJsonField(candidate.settings) as {
  279. fallbacks?: { dest?: string }[];
  280. };
  281. const fallbacks = candSettings.fallbacks || [];
  282. if (!fallbacks.find((f) => f.dest === dbInbound.listen)) continue;
  283. return projectChildThroughMaster(dbInbound, candidate);
  284. }
  285. return dbInbound;
  286. },
  287. [dbInbounds, projectChildThroughMaster],
  288. );
  289. const findClientIndex = useCallback((dbInbound: DBInbound, client: ClientMatchTarget | null) => {
  290. if (!client) return 0;
  291. const settings = coerceInboundJsonField(dbInbound.settings) as {
  292. clients?: ClientMatchTarget[];
  293. };
  294. const clients = settings.clients || [];
  295. const idx = clients.findIndex((c) => {
  296. if (!c) return false;
  297. switch (dbInbound.protocol) {
  298. case 'trojan':
  299. case 'shadowsocks':
  300. return c.password === client.password && c.email === client.email;
  301. default:
  302. return c.id === client.id && c.email === client.email;
  303. }
  304. });
  305. return idx >= 0 ? idx : 0;
  306. }, []);
  307. const exportInboundLinks = useCallback(
  308. (dbInbound: DBInbound) => {
  309. const projected = checkFallback(dbInbound);
  310. const hostOverride = hostOverrideFor(dbInbound);
  311. const fallbackHostname = preferPublicHost(window.location.hostname, subSettings.publicHost);
  312. const genInput = {
  313. inbound: withMtprotoHostEndpoints(
  314. inboundFromDb(projected),
  315. dbInbound.id,
  316. hosts,
  317. hostOverride,
  318. fallbackHostname,
  319. ),
  320. remark: projected.remark,
  321. hostOverride,
  322. fallbackHostname,
  323. };
  324. const content = genInboundLinks(genInput);
  325. const tabs: TextModalTab[] | undefined = projected.isWireguard
  326. ? [
  327. { key: 'config', label: t('pages.clients.config'), content },
  328. {
  329. key: 'links',
  330. label: t('pages.clients.tabLinks'),
  331. content: genWireguardLinks(genInput),
  332. },
  333. ]
  334. : projected.protocol === Protocols.AMNEZIAWG
  335. ? [
  336. { key: 'config', label: t('pages.clients.config'), content },
  337. {
  338. key: 'links',
  339. label: t('pages.clients.tabLinks'),
  340. content: genAmneziaWGLinks(genInput),
  341. },
  342. ]
  343. : undefined;
  344. openText({
  345. title: t('pages.inbounds.exportLinksTitle'),
  346. content,
  347. fileName: projected.remark || 'inbound',
  348. tabs,
  349. });
  350. },
  351. [checkFallback, hostOverrideFor, hosts, subSettings.publicHost, openText, t],
  352. );
  353. const exportInboundClipboard = useCallback(
  354. (dbInbound: DBInbound) => {
  355. openText({
  356. title: t('pages.inbounds.inboundJsonTitle'),
  357. content: JSON.stringify(dbInbound, null, 2),
  358. json: true,
  359. });
  360. },
  361. [openText, t],
  362. );
  363. const exportInboundSubs = useCallback(
  364. (dbInbound: DBInbound) => {
  365. const settings = coerceInboundJsonField(dbInbound.settings) as {
  366. clients?: { subId?: string }[];
  367. };
  368. const clients = settings.clients || [];
  369. const subLinks: string[] = [];
  370. for (const c of clients) {
  371. if (c.subId && subSettings.subURI) {
  372. subLinks.push(subSettings.subURI + c.subId);
  373. }
  374. }
  375. openText({
  376. title: t('pages.inbounds.exportSubsTitle'),
  377. content: [...new Set(subLinks)].join('\n'),
  378. fileName: `${dbInbound.remark || 'inbound'}-Subs`,
  379. });
  380. },
  381. [subSettings, openText, t],
  382. );
  383. const exportAllLinks = useCallback(async () => {
  384. const msg = await HttpUtil.get('/panel/api/inbounds/allLinks');
  385. const links = msg?.success && Array.isArray(msg.obj) ? (msg.obj as string[]) : [];
  386. openText({
  387. title: t('pages.inbounds.exportAllLinksTitle'),
  388. content: links.join('\r\n'),
  389. fileName: t('pages.inbounds.exportAllLinksFileName'),
  390. });
  391. }, [openText, t]);
  392. const exportAllSubs = useCallback(async () => {
  393. const hydrated = await Promise.all(
  394. dbInbounds.map((ib) => hydrateInbound(ib.id).then((r) => r ?? ib)),
  395. );
  396. const out: string[] = [];
  397. for (const ib of hydrated) {
  398. const settings = coerceInboundJsonField(ib.settings) as { clients?: { subId?: string }[] };
  399. const clients = settings.clients || [];
  400. for (const c of clients) {
  401. if (c.subId && subSettings.subURI) {
  402. out.push(subSettings.subURI + c.subId);
  403. }
  404. }
  405. }
  406. openText({
  407. title: t('pages.inbounds.exportAllSubsTitle'),
  408. content: [...new Set(out)].join('\r\n'),
  409. fileName: t('pages.inbounds.exportAllSubsFileName'),
  410. });
  411. }, [dbInbounds, hydrateInbound, subSettings, openText, t]);
  412. const importInbound = useCallback(() => {
  413. openPrompt({
  414. title: t('pages.inbounds.importInbound'),
  415. okText: t('pages.inbounds.import'),
  416. type: 'textarea',
  417. value: '',
  418. json: true,
  419. confirm: async (value) => {
  420. const msg = await HttpUtil.post('/panel/api/inbounds/import', { data: value });
  421. if (msg?.success) {
  422. await refresh();
  423. return true;
  424. }
  425. return false;
  426. },
  427. });
  428. }, [openPrompt, refresh, t]);
  429. const onAddInbound = useCallback(() => {
  430. setFormMode('add');
  431. setFormDbInbound(null);
  432. setFormOpen(true);
  433. }, []);
  434. const openEdit = useCallback((dbInbound: DBInbound) => {
  435. setFormMode('edit');
  436. setFormDbInbound(dbInbound);
  437. setFormOpen(true);
  438. }, []);
  439. const confirmDelete = useCallback(
  440. (dbInbound: DBInbound) => {
  441. modal.confirm({
  442. title: t('pages.inbounds.deleteConfirmTitle', { remark: dbInbound.remark }),
  443. content: t('pages.inbounds.deleteConfirmContent'),
  444. okText: t('delete'),
  445. okType: 'danger',
  446. cancelText: t('cancel'),
  447. onOk: async () => {
  448. const msg = await HttpUtil.post(`/panel/api/inbounds/del/${dbInbound.id}`);
  449. if (msg?.success) await refresh();
  450. },
  451. });
  452. },
  453. [modal, refresh, t],
  454. );
  455. const confirmBulkDelete = useCallback(
  456. (ids: number[]) =>
  457. new Promise<boolean>((resolve) => {
  458. if (ids.length === 0) {
  459. resolve(false);
  460. return;
  461. }
  462. modal.confirm({
  463. title: t('pages.inbounds.bulkDeleteConfirmTitle', { count: ids.length }),
  464. content: t('pages.inbounds.bulkDeleteConfirmContent'),
  465. okText: t('delete'),
  466. okType: 'danger',
  467. cancelText: t('cancel'),
  468. onOk: async () => {
  469. const msg = await HttpUtil.post(
  470. '/panel/api/inbounds/bulkDel',
  471. { ids },
  472. { headers: { 'Content-Type': 'application/json' } },
  473. );
  474. const obj = (msg?.obj ?? {}) as {
  475. deleted?: number;
  476. skipped?: { id: number; reason: string }[];
  477. };
  478. const ok = obj.deleted ?? 0;
  479. const skipped = obj.skipped ?? [];
  480. if (msg?.success && skipped.length === 0) {
  481. messageApi.success(t('pages.inbounds.toasts.bulkDeleted', { count: ok }));
  482. } else {
  483. const firstError = skipped[0]?.reason ?? msg?.msg ?? '';
  484. const base = t('pages.inbounds.toasts.bulkDeletedMixed', {
  485. ok,
  486. failed: skipped.length,
  487. });
  488. messageApi.warning(firstError ? `${base} — ${firstError}` : base);
  489. }
  490. await refresh();
  491. resolve(true);
  492. },
  493. onCancel: () => resolve(false),
  494. });
  495. }),
  496. [modal, refresh, t, messageApi],
  497. );
  498. const confirmResetTraffic = useCallback(
  499. (dbInbound: DBInbound) => {
  500. modal.confirm({
  501. title: t('pages.inbounds.resetConfirmTitle', { remark: dbInbound.remark }),
  502. content: t('pages.inbounds.resetConfirmContent'),
  503. okText: t('reset'),
  504. cancelText: t('cancel'),
  505. onOk: async () => {
  506. const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/resetTraffic`);
  507. if (msg?.success) await refresh();
  508. },
  509. });
  510. },
  511. [modal, refresh, t],
  512. );
  513. const confirmDelAllClients = useCallback(
  514. (dbInbound: DBInbound) => {
  515. const count = clientCount[dbInbound.id]?.clients || 0;
  516. modal.confirm({
  517. title: t('pages.inbounds.delAllClientsConfirmTitle', { remark: dbInbound.remark, count }),
  518. content: t('pages.inbounds.delAllClientsConfirmContent'),
  519. okText: t('delete'),
  520. okType: 'danger',
  521. cancelText: t('cancel'),
  522. onOk: async () => {
  523. const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/delAllClients`);
  524. if (msg?.success) await refresh();
  525. },
  526. });
  527. },
  528. [modal, refresh, t, clientCount],
  529. );
  530. const confirmClone = useCallback(
  531. (dbInbound: DBInbound) => {
  532. // Node-eligible protocol with at least one deployable node → open the
  533. // target picker; anything else keeps the original one-click local clone.
  534. if (
  535. NODE_ELIGIBLE_PROTOCOLS[dbInbound.protocol] &&
  536. (nodesList || []).some((n) => n.enable && n.status === 'online')
  537. ) {
  538. setCloneSource(dbInbound);
  539. setCloneOpen(true);
  540. return;
  541. }
  542. modal.confirm({
  543. title: t('pages.inbounds.cloneConfirmTitle', { remark: dbInbound.remark }),
  544. content: t('pages.inbounds.cloneConfirmContent'),
  545. okText: t('pages.inbounds.clone'),
  546. cancelText: t('cancel'),
  547. onOk: async () => {
  548. const msg = await HttpUtil.post(
  549. '/panel/api/inbounds/add',
  550. buildClonePayload(dbInbound, RandomUtil.randomInteger(10000, 60000), null),
  551. );
  552. if (msg?.success) await refresh();
  553. },
  554. });
  555. },
  556. [modal, nodesList, refresh, t],
  557. );
  558. const onGeneralAction = useCallback(
  559. (key: GeneralAction) => {
  560. switch (key) {
  561. case 'import':
  562. importInbound();
  563. break;
  564. case 'export':
  565. exportAllLinks();
  566. break;
  567. case 'subs':
  568. exportAllSubs();
  569. break;
  570. case 'resetInbounds':
  571. modal.confirm({
  572. title: t('pages.inbounds.resetAllTrafficTitle'),
  573. okText: t('reset'),
  574. cancelText: t('cancel'),
  575. onOk: async () => {
  576. const msg = await HttpUtil.post('/panel/api/inbounds/resetAllTraffics');
  577. if (msg?.success) await refresh();
  578. },
  579. });
  580. break;
  581. default:
  582. messageApi.info(`General action "${key}" — coming in a later 5f subphase`);
  583. }
  584. },
  585. [modal, importInbound, exportAllLinks, exportAllSubs, refresh, messageApi, t],
  586. );
  587. const onRowAction = useCallback(
  588. async ({ key, dbInbound }: { key: RowAction; dbInbound: DBInbound }) => {
  589. // Actions that touch per-client secrets (uuid, password, flow, ...) need
  590. // the full payload that the slim list view does not ship. Hydrate first
  591. // and then operate on the rehydrated record.
  592. const hydratingKeys: RowAction[] = [
  593. 'edit',
  594. 'showInfo',
  595. 'qrcode',
  596. 'export',
  597. 'subs',
  598. 'clipboard',
  599. 'clone',
  600. 'attachClients',
  601. 'addToGroup',
  602. ];
  603. let target = dbInbound;
  604. if (hydratingKeys.includes(key)) {
  605. const hydrated = await hydrateInbound(dbInbound.id);
  606. if (hydrated) target = hydrated;
  607. }
  608. switch (key) {
  609. case 'edit':
  610. openEdit(target);
  611. break;
  612. case 'showInfo':
  613. setInfoDbInbound(checkFallback(target));
  614. setInfoClientIndex(findClientIndex(target, null));
  615. setInfoOpen(true);
  616. break;
  617. case 'qrcode':
  618. setQrDbInbound(checkFallback(target));
  619. setQrOpen(true);
  620. break;
  621. case 'export':
  622. exportInboundLinks(target);
  623. break;
  624. case 'subs':
  625. exportInboundSubs(target);
  626. break;
  627. case 'clipboard':
  628. exportInboundClipboard(target);
  629. break;
  630. case 'delete':
  631. confirmDelete(target);
  632. break;
  633. case 'resetTraffic':
  634. confirmResetTraffic(target);
  635. break;
  636. case 'delAllClients':
  637. confirmDelAllClients(target);
  638. break;
  639. case 'attachClients':
  640. setAttachSource(target);
  641. setAttachOpen(true);
  642. break;
  643. case 'attachExisting':
  644. setAttachExistingTarget(target);
  645. setAttachExistingOpen(true);
  646. break;
  647. case 'detachClients':
  648. setDetachSource(target);
  649. setDetachOpen(true);
  650. break;
  651. case 'addToGroup':
  652. setGroupSource(target);
  653. setGroupOpen(true);
  654. break;
  655. case 'clone':
  656. confirmClone(target);
  657. break;
  658. default:
  659. messageApi.info(`Action "${key}" — coming in a later 5f subphase`);
  660. }
  661. },
  662. [
  663. hydrateInbound,
  664. openEdit,
  665. checkFallback,
  666. findClientIndex,
  667. exportInboundLinks,
  668. exportInboundSubs,
  669. exportInboundClipboard,
  670. confirmDelete,
  671. confirmResetTraffic,
  672. confirmDelAllClients,
  673. confirmClone,
  674. messageApi,
  675. ],
  676. );
  677. return (
  678. <ConfigProvider theme={antdThemeConfig}>
  679. {messageContextHolder}
  680. {modalContextHolder}
  681. <Layout className={`inbounds-page${isDark ? ' is-dark' : ''}${isUltra ? ' is-ultra' : ''}`}>
  682. <AppSidebar />
  683. <Layout className="content-shell">
  684. <Layout.Content id="content-layout" className="content-area">
  685. <Spin
  686. spinning={!fetched || !hostsFetched}
  687. delay={200}
  688. description={t('loading')}
  689. size="large"
  690. >
  691. {!fetched || !hostsFetched ? (
  692. <div className="loading-spacer" />
  693. ) : fetchError || hostsError ? (
  694. <Result
  695. status="error"
  696. title={t('somethingWentWrong')}
  697. subTitle={fetchError || hostsError}
  698. extra={
  699. <Button
  700. type="primary"
  701. onClick={() => {
  702. void refresh();
  703. void refetchHosts();
  704. }}
  705. >
  706. {t('refresh')}
  707. </Button>
  708. }
  709. />
  710. ) : (
  711. <Row gutter={[isMobile ? 8 : 16, 12]}>
  712. <Col span={24}>
  713. <Card size="small" hoverable className="summary-card">
  714. <Row gutter={[16, 12]}>
  715. <Col xs={12} sm={12} md={8}>
  716. <Statistic
  717. title={t('pages.inbounds.totalDownUp')}
  718. value={0}
  719. formatter={() => (
  720. <span>
  721. <ArrowUpOutlined /> {SizeFormatter.sizeFormat(totals.up)}
  722. {' / '}
  723. <ArrowDownOutlined /> {SizeFormatter.sizeFormat(totals.down)}
  724. </span>
  725. )}
  726. />
  727. </Col>
  728. <Col xs={12} sm={12} md={8}>
  729. <Statistic
  730. title={t('pages.inbounds.totalUsage')}
  731. value={SizeFormatter.sizeFormat(totals.up + totals.down)}
  732. prefix={<PieChartOutlined />}
  733. />
  734. </Col>
  735. <Col xs={24} sm={24} md={8}>
  736. <Statistic
  737. title={t('pages.inbounds.inboundCount')}
  738. value={String(dbInbounds.length)}
  739. prefix={<BarsOutlined />}
  740. />
  741. </Col>
  742. </Row>
  743. </Card>
  744. </Col>
  745. <Col span={24}>
  746. <InboundList
  747. dbInbounds={dbInbounds}
  748. clientCount={clientCount}
  749. onlineClients={onlineClients}
  750. lastOnlineMap={lastOnlineMap}
  751. inboundSpeed={inboundSpeed}
  752. expireDiff={expireDiff}
  753. trafficDiff={trafficDiff}
  754. pageSize={pageSize}
  755. isMobile={isMobile}
  756. subEnable={subSettings.enable}
  757. nodesById={nodesById}
  758. hasActiveNode={showNodeInfo}
  759. onAddInbound={onAddInbound}
  760. onGeneralAction={onGeneralAction}
  761. onRowAction={({ key, dbInbound }) =>
  762. onRowAction({ key, dbInbound: dbInbound as unknown as DBInbound })
  763. }
  764. onBulkDelete={confirmBulkDelete}
  765. />
  766. </Col>
  767. </Row>
  768. )}
  769. </Spin>
  770. </Layout.Content>
  771. </Layout>
  772. <LazyMount when={formOpen}>
  773. <InboundFormModal
  774. open={formOpen}
  775. onClose={() => setFormOpen(false)}
  776. onSaved={refresh}
  777. mode={formMode}
  778. dbInbound={formDbInbound}
  779. dbInbounds={dbInbounds}
  780. availableNodes={nodesList}
  781. availableNodesFetched={nodesFetched}
  782. />
  783. </LazyMount>
  784. <LazyMount when={infoOpen}>
  785. <InboundInfoModal
  786. open={infoOpen}
  787. onClose={() => setInfoOpen(false)}
  788. dbInbound={infoDbInbound}
  789. clientIndex={infoClientIndex}
  790. expireDiff={expireDiff}
  791. trafficDiff={trafficDiff}
  792. ipLimitEnable={ipLimitEnable}
  793. tgBotEnable={tgBotEnable}
  794. subSettings={subSettings}
  795. hosts={hosts}
  796. lastOnlineMap={lastOnlineMap}
  797. nodeAddress={infoNodeAddress}
  798. />
  799. </LazyMount>
  800. <LazyMount when={qrOpen}>
  801. <QrCodeModal
  802. open={qrOpen}
  803. onClose={() => setQrOpen(false)}
  804. dbInbound={qrDbInbound}
  805. client={null}
  806. nodeAddress={qrNodeAddress}
  807. subSettings={subSettings}
  808. hosts={hosts}
  809. />
  810. </LazyMount>
  811. <LazyMount when={attachOpen}>
  812. <AttachClientsModal
  813. open={attachOpen}
  814. onClose={() => setAttachOpen(false)}
  815. onAttached={refresh}
  816. source={attachSource}
  817. dbInbounds={dbInbounds}
  818. />
  819. </LazyMount>
  820. <LazyMount when={attachExistingOpen}>
  821. <AttachExistingClientsModal
  822. open={attachExistingOpen}
  823. onClose={() => setAttachExistingOpen(false)}
  824. onAttached={refresh}
  825. target={attachExistingTarget}
  826. />
  827. </LazyMount>
  828. <LazyMount when={detachOpen}>
  829. <DetachClientsModal
  830. open={detachOpen}
  831. onClose={() => setDetachOpen(false)}
  832. onDetached={refresh}
  833. source={detachSource}
  834. />
  835. </LazyMount>
  836. <LazyMount when={groupOpen}>
  837. <AddClientsToGroupModal
  838. open={groupOpen}
  839. onClose={() => setGroupOpen(false)}
  840. onAdded={refresh}
  841. source={groupSource}
  842. />
  843. </LazyMount>
  844. <LazyMount when={cloneOpen}>
  845. <CloneInboundModal
  846. open={cloneOpen}
  847. onClose={() => setCloneOpen(false)}
  848. onCloned={refresh}
  849. dbInbound={cloneSource}
  850. nodes={nodesList || []}
  851. portsInUse={clonePortsInUse}
  852. />
  853. </LazyMount>
  854. <LazyMount when={textOpen}>
  855. <TextModal
  856. open={textOpen}
  857. onClose={() => setTextOpen(false)}
  858. title={textTitle}
  859. content={textContent}
  860. fileName={textFileName}
  861. json={textJson}
  862. tabs={textTabs}
  863. />
  864. </LazyMount>
  865. <LazyMount when={promptOpen}>
  866. <PromptModal
  867. open={promptOpen}
  868. onClose={() => setPromptOpen(false)}
  869. title={promptTitle}
  870. okText={promptOkText}
  871. type={promptType}
  872. initialValue={promptInitial}
  873. loading={promptLoading}
  874. json={promptJson}
  875. onConfirm={onPromptConfirm}
  876. />
  877. </LazyMount>
  878. </Layout>
  879. </ConfigProvider>
  880. );
  881. }