InboundsPage.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576
  1. import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import {
  4. Card,
  5. Col,
  6. ConfigProvider,
  7. Layout,
  8. Modal,
  9. Row,
  10. Spin,
  11. Statistic,
  12. message,
  13. } from 'antd';
  14. import { setMessageInstance } from '@/utils/messageBus';
  15. import {
  16. SwapOutlined,
  17. PieChartOutlined,
  18. BarsOutlined,
  19. } from '@ant-design/icons';
  20. import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
  21. import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
  22. import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
  23. import { useTheme } from '@/hooks/useTheme';
  24. import { useMediaQuery } from '@/hooks/useMediaQuery';
  25. import { useWebSocket } from '@/hooks/useWebSocket';
  26. import { useNodesQuery } from '@/api/queries/useNodesQuery';
  27. import AppSidebar from '@/components/AppSidebar';
  28. const TextModal = lazy(() => import('@/components/TextModal'));
  29. const PromptModal = lazy(() => import('@/components/PromptModal'));
  30. import { useInbounds } from './useInbounds';
  31. import InboundList from './InboundList';
  32. import LazyMount from '@/components/LazyMount';
  33. const InboundFormModal = lazy(() => import('./InboundFormModal'));
  34. const InboundInfoModal = lazy(() => import('./InboundInfoModal'));
  35. const QrCodeModal = lazy(() => import('./QrCodeModal'));
  36. type RowAction =
  37. | 'edit'
  38. | 'showInfo'
  39. | 'qrcode'
  40. | 'export'
  41. | 'subs'
  42. | 'clipboard'
  43. | 'delete'
  44. | 'resetTraffic'
  45. | 'clone';
  46. type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
  47. interface ClientMatchTarget {
  48. id?: string;
  49. email?: string;
  50. password?: string;
  51. }
  52. export default function InboundsPage() {
  53. const { t } = useTranslation();
  54. const { isDark, isUltra, antdThemeConfig } = useTheme();
  55. const { isMobile } = useMediaQuery();
  56. const {
  57. fetched,
  58. dbInbounds,
  59. clientCount,
  60. onlineClients,
  61. lastOnlineMap,
  62. totals,
  63. expireDiff,
  64. trafficDiff,
  65. pageSize,
  66. subSettings,
  67. tgBotEnable,
  68. ipLimitEnable,
  69. remarkModel,
  70. refresh,
  71. hydrateInbound,
  72. applyTrafficEvent,
  73. applyClientStatsEvent,
  74. } = useInbounds();
  75. const [modal, modalContextHolder] = Modal.useModal();
  76. const [messageApi, messageContextHolder] = message.useMessage();
  77. useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
  78. const { nodes: nodesList } = useNodesQuery();
  79. const nodesById = useMemo(() => {
  80. const map = new Map<number, ReturnType<typeof useNodesQuery>['nodes'][number]>();
  81. for (const n of nodesList || []) map.set(n.id, n);
  82. return map;
  83. }, [nodesList]);
  84. const hasActiveNode = useMemo(
  85. () => (nodesList || []).some((n) => n.enable && n.status === 'online'),
  86. [nodesList],
  87. );
  88. const hasNodeAttachedInbound = useMemo(
  89. () => (dbInbounds || []).some((ib) => ib?.nodeId != null),
  90. [dbInbounds],
  91. );
  92. const showNodeInfo = hasNodeAttachedInbound || hasActiveNode;
  93. useWebSocket({
  94. traffic: applyTrafficEvent,
  95. client_stats: applyClientStatsEvent,
  96. });
  97. const [formOpen, setFormOpen] = useState(false);
  98. const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
  99. const [formDbInbound, setFormDbInbound] = useState<DBInbound | null>(null);
  100. const [infoOpen, setInfoOpen] = useState(false);
  101. const [infoDbInbound, setInfoDbInbound] = useState<DBInbound | null>(null);
  102. const [infoClientIndex, setInfoClientIndex] = useState(0);
  103. const [qrOpen, setQrOpen] = useState(false);
  104. const [qrDbInbound, setQrDbInbound] = useState<DBInbound | null>(null);
  105. const [textOpen, setTextOpen] = useState(false);
  106. const [textTitle, setTextTitle] = useState('');
  107. const [textContent, setTextContent] = useState('');
  108. const [textFileName, setTextFileName] = useState('');
  109. const [promptOpen, setPromptOpen] = useState(false);
  110. const [promptTitle, setPromptTitle] = useState('');
  111. const [promptOkText, setPromptOkText] = useState('OK');
  112. const [promptType, setPromptType] = useState<'textarea' | 'input'>('textarea');
  113. const [promptInitial, setPromptInitial] = useState('');
  114. const [promptLoading, setPromptLoading] = useState(false);
  115. const [promptHandler, setPromptHandler] = useState<((value: string) => Promise<boolean | void> | boolean | void) | null>(null);
  116. const hostOverrideFor = useCallback((dbInbound: DBInbound | null) => {
  117. if (!dbInbound || dbInbound.nodeId == null) return '';
  118. return nodesById.get(dbInbound.nodeId)?.address || '';
  119. }, [nodesById]);
  120. const infoNodeAddress = useMemo(() => hostOverrideFor(infoDbInbound), [infoDbInbound, hostOverrideFor]);
  121. const qrNodeAddress = useMemo(() => hostOverrideFor(qrDbInbound), [qrDbInbound, hostOverrideFor]);
  122. const openText = useCallback((opts: { title: string; content: string; fileName?: string }) => {
  123. setTextTitle(opts.title);
  124. setTextContent(opts.content);
  125. setTextFileName(opts.fileName || '');
  126. setTextOpen(true);
  127. }, []);
  128. const openPrompt = useCallback((opts: {
  129. title: string;
  130. okText?: string;
  131. type?: 'textarea' | 'input';
  132. value?: string;
  133. confirm: (value: string) => Promise<boolean | void> | boolean | void;
  134. }) => {
  135. setPromptTitle(opts.title);
  136. setPromptOkText(opts.okText || 'OK');
  137. setPromptType(opts.type || 'textarea');
  138. setPromptInitial(opts.value || '');
  139. setPromptHandler(() => opts.confirm);
  140. setPromptOpen(true);
  141. }, []);
  142. const onPromptConfirm = useCallback(async (value: string) => {
  143. if (!promptHandler) {
  144. setPromptOpen(false);
  145. return;
  146. }
  147. setPromptLoading(true);
  148. try {
  149. const ok = await promptHandler(value);
  150. if (ok !== false) setPromptOpen(false);
  151. } finally {
  152. setPromptLoading(false);
  153. }
  154. }, [promptHandler]);
  155. const projectChildThroughMaster = useCallback((child: DBInbound, master: DBInbound): DBInbound => {
  156. const projected = JSON.parse(JSON.stringify(child)) as DBInbound;
  157. projected.listen = master.listen;
  158. projected.port = master.port;
  159. const masterStream = master.toInbound().stream;
  160. const childInbound = child.toInbound();
  161. childInbound.stream.security = masterStream.security;
  162. childInbound.stream.tls = masterStream.tls;
  163. childInbound.stream.reality = masterStream.reality;
  164. childInbound.stream.externalProxy = masterStream.externalProxy;
  165. projected.streamSettings = childInbound.stream.toString();
  166. const Ctor = child.constructor as new (data: DBInbound) => DBInbound;
  167. return new Ctor(projected);
  168. }, []);
  169. const checkFallback = useCallback((dbInbound: DBInbound): DBInbound => {
  170. const parent = dbInbound?.fallbackParent;
  171. if (parent?.masterId) {
  172. const master = dbInbounds.find((ib) => ib.id === parent.masterId);
  173. if (master) return projectChildThroughMaster(dbInbound, master);
  174. }
  175. if (!dbInbound?.listen?.startsWith?.('@')) return dbInbound;
  176. for (const candidate of dbInbounds) {
  177. if (candidate.id === dbInbound.id) continue;
  178. const parsed = candidate.toInbound();
  179. if (!parsed.isTcp) continue;
  180. if (!['trojan', 'vless'].includes(parsed.protocol)) continue;
  181. const fallbacks = parsed.settings.fallbacks || [];
  182. if (!fallbacks.find((f: { dest?: string }) => f.dest === dbInbound.listen)) continue;
  183. return projectChildThroughMaster(dbInbound, candidate);
  184. }
  185. return dbInbound;
  186. }, [dbInbounds, projectChildThroughMaster]);
  187. const findClientIndex = useCallback((dbInbound: DBInbound, client: ClientMatchTarget | null) => {
  188. if (!client) return 0;
  189. const inbound = dbInbound.toInbound();
  190. const clients = (inbound?.clients || []) as ClientMatchTarget[];
  191. const idx = clients.findIndex((c) => {
  192. if (!c) return false;
  193. switch (dbInbound.protocol) {
  194. case 'trojan':
  195. case 'shadowsocks':
  196. return c.password === client.password && c.email === client.email;
  197. default:
  198. return c.id === client.id && c.email === client.email;
  199. }
  200. });
  201. return idx >= 0 ? idx : 0;
  202. }, []);
  203. const exportInboundLinks = useCallback((dbInbound: DBInbound) => {
  204. const projected = checkFallback(dbInbound);
  205. openText({
  206. title: t('pages.inbounds.exportLinksTitle'),
  207. content: projected.genInboundLinks(remarkModel, hostOverrideFor(dbInbound)),
  208. fileName: projected.remark || 'inbound',
  209. });
  210. }, [checkFallback, remarkModel, hostOverrideFor, openText, t]);
  211. const exportInboundClipboard = useCallback((dbInbound: DBInbound) => {
  212. openText({ title: t('pages.inbounds.inboundJsonTitle'), content: JSON.stringify(dbInbound, null, 2) });
  213. }, [openText, t]);
  214. const exportInboundSubs = useCallback((dbInbound: DBInbound) => {
  215. const inbound = dbInbound.toInbound();
  216. const clients = (inbound?.clients || []) as { subId?: string }[];
  217. const subLinks: string[] = [];
  218. for (const c of clients) {
  219. if (c.subId && subSettings.subURI) {
  220. subLinks.push(subSettings.subURI + c.subId);
  221. }
  222. }
  223. openText({
  224. title: t('pages.inbounds.exportSubsTitle'),
  225. content: [...new Set(subLinks)].join('\n'),
  226. fileName: `${dbInbound.remark || 'inbound'}-Subs`,
  227. });
  228. }, [subSettings, openText, t]);
  229. const exportAllLinks = useCallback(async () => {
  230. const hydrated = await Promise.all(
  231. dbInbounds.map((ib) => hydrateInbound(ib.id).then((r) => r ?? ib)),
  232. );
  233. const out: string[] = [];
  234. for (const ib of hydrated) {
  235. const projected = checkFallback(ib);
  236. out.push(projected.genInboundLinks(remarkModel, hostOverrideFor(ib)));
  237. }
  238. openText({ title: t('pages.inbounds.exportAllLinksTitle'), content: out.join('\r\n'), fileName: 'All-Inbounds' });
  239. }, [dbInbounds, hydrateInbound, checkFallback, remarkModel, hostOverrideFor, openText, t]);
  240. const exportAllSubs = useCallback(async () => {
  241. const hydrated = await Promise.all(
  242. dbInbounds.map((ib) => hydrateInbound(ib.id).then((r) => r ?? ib)),
  243. );
  244. const out: string[] = [];
  245. for (const ib of hydrated) {
  246. const inbound = ib.toInbound();
  247. const clients = (inbound?.clients || []) as { subId?: string }[];
  248. for (const c of clients) {
  249. if (c.subId && subSettings.subURI) {
  250. out.push(subSettings.subURI + c.subId);
  251. }
  252. }
  253. }
  254. openText({ title: t('pages.inbounds.exportAllSubsTitle'), content: [...new Set(out)].join('\r\n'), fileName: 'All-Inbounds-Subs' });
  255. }, [dbInbounds, hydrateInbound, subSettings, openText, t]);
  256. const importInbound = useCallback(() => {
  257. openPrompt({
  258. title: 'Import inbound',
  259. okText: 'Import',
  260. type: 'textarea',
  261. value: '',
  262. confirm: async (value) => {
  263. const msg = await HttpUtil.post('/panel/api/inbounds/import', { data: value });
  264. if (msg?.success) {
  265. await refresh();
  266. return true;
  267. }
  268. return false;
  269. },
  270. });
  271. }, [openPrompt, refresh]);
  272. const onAddInbound = useCallback(() => {
  273. setFormMode('add');
  274. setFormDbInbound(null);
  275. setFormOpen(true);
  276. }, []);
  277. const openEdit = useCallback((dbInbound: DBInbound) => {
  278. setFormMode('edit');
  279. setFormDbInbound(dbInbound);
  280. setFormOpen(true);
  281. }, []);
  282. const confirmDelete = useCallback((dbInbound: DBInbound) => {
  283. modal.confirm({
  284. title: t('pages.inbounds.deleteConfirmTitle', { remark: dbInbound.remark }),
  285. content: t('pages.inbounds.deleteConfirmContent'),
  286. okText: t('delete'),
  287. okType: 'danger',
  288. cancelText: t('cancel'),
  289. onOk: async () => {
  290. const msg = await HttpUtil.post(`/panel/api/inbounds/del/${dbInbound.id}`);
  291. if (msg?.success) await refresh();
  292. },
  293. });
  294. }, [modal, refresh, t]);
  295. const confirmResetTraffic = useCallback((dbInbound: DBInbound) => {
  296. modal.confirm({
  297. title: t('pages.inbounds.resetConfirmTitle', { remark: dbInbound.remark }),
  298. content: t('pages.inbounds.resetConfirmContent'),
  299. okText: t('reset'),
  300. cancelText: t('cancel'),
  301. onOk: async () => {
  302. const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/resetTraffic`);
  303. if (msg?.success) await refresh();
  304. },
  305. });
  306. }, [modal, refresh, t]);
  307. const confirmClone = useCallback((dbInbound: DBInbound) => {
  308. modal.confirm({
  309. title: t('pages.inbounds.cloneConfirmTitle', { remark: dbInbound.remark }),
  310. content: t('pages.inbounds.cloneConfirmContent'),
  311. okText: t('pages.inbounds.clone'),
  312. cancelText: t('cancel'),
  313. onOk: async () => {
  314. const baseInbound = dbInbound.toInbound();
  315. let clonedSettings: string;
  316. try {
  317. const raw = coerceInboundJsonField(dbInbound.settings);
  318. raw.clients = [];
  319. clonedSettings = JSON.stringify(raw);
  320. } catch {
  321. const fallback = createDefaultInboundSettings(baseInbound.protocol);
  322. clonedSettings = fallback ? JSON.stringify(fallback, null, 2) : '{}';
  323. }
  324. const data = {
  325. up: 0,
  326. down: 0,
  327. total: 0,
  328. remark: `${dbInbound.remark} (clone)`,
  329. enable: false,
  330. expiryTime: 0,
  331. listen: '',
  332. port: RandomUtil.randomInteger(10000, 60000),
  333. protocol: baseInbound.protocol,
  334. settings: clonedSettings,
  335. streamSettings: baseInbound.stream.toString(),
  336. sniffing: baseInbound.sniffing.toString(),
  337. };
  338. const msg = await HttpUtil.post('/panel/api/inbounds/add', data);
  339. if (msg?.success) await refresh();
  340. },
  341. });
  342. }, [modal, refresh, t]);
  343. const onGeneralAction = useCallback((key: GeneralAction) => {
  344. switch (key) {
  345. case 'import': importInbound(); break;
  346. case 'export': exportAllLinks(); break;
  347. case 'subs': exportAllSubs(); break;
  348. case 'resetInbounds':
  349. modal.confirm({
  350. title: 'Reset all inbound traffic?',
  351. okText: 'Reset',
  352. cancelText: 'Cancel',
  353. onOk: async () => {
  354. const msg = await HttpUtil.post('/panel/api/inbounds/resetAllTraffics');
  355. if (msg?.success) await refresh();
  356. },
  357. });
  358. break;
  359. default:
  360. messageApi.info(`General action "${key}" — coming in a later 5f subphase`);
  361. }
  362. }, [modal, importInbound, exportAllLinks, exportAllSubs, refresh, messageApi]);
  363. const onRowAction = useCallback(async ({ key, dbInbound }: { key: RowAction; dbInbound: DBInbound }) => {
  364. // Actions that touch per-client secrets (uuid, password, flow, ...) need
  365. // the full payload that the slim list view does not ship. Hydrate first
  366. // and then operate on the rehydrated record.
  367. const hydratingKeys: RowAction[] = ['edit', 'showInfo', 'qrcode', 'export', 'subs', 'clipboard', 'clone'];
  368. let target = dbInbound;
  369. if (hydratingKeys.includes(key)) {
  370. const hydrated = await hydrateInbound(dbInbound.id);
  371. if (hydrated) target = hydrated;
  372. }
  373. switch (key) {
  374. case 'edit':
  375. openEdit(target);
  376. break;
  377. case 'showInfo':
  378. setInfoDbInbound(checkFallback(target));
  379. setInfoClientIndex(findClientIndex(target, null));
  380. setInfoOpen(true);
  381. break;
  382. case 'qrcode':
  383. setQrDbInbound(checkFallback(target));
  384. setQrOpen(true);
  385. break;
  386. case 'export':
  387. exportInboundLinks(target);
  388. break;
  389. case 'subs':
  390. exportInboundSubs(target);
  391. break;
  392. case 'clipboard':
  393. exportInboundClipboard(target);
  394. break;
  395. case 'delete':
  396. confirmDelete(target);
  397. break;
  398. case 'resetTraffic':
  399. confirmResetTraffic(target);
  400. break;
  401. case 'clone':
  402. confirmClone(target);
  403. break;
  404. default:
  405. messageApi.info(`Action "${key}" — coming in a later 5f subphase`);
  406. }
  407. }, [hydrateInbound, openEdit, checkFallback, findClientIndex, exportInboundLinks, exportInboundSubs, exportInboundClipboard, confirmDelete, confirmResetTraffic, confirmClone, messageApi]);
  408. return (
  409. <ConfigProvider theme={antdThemeConfig}>
  410. {messageContextHolder}
  411. {modalContextHolder}
  412. <Layout className={`inbounds-page${isDark ? ' is-dark' : ''}${isUltra ? ' is-ultra' : ''}`}>
  413. <AppSidebar />
  414. <Layout className="content-shell">
  415. <Layout.Content id="content-layout" className="content-area">
  416. <Spin spinning={!fetched} delay={200} description="Loading…" size="large">
  417. {!fetched ? (
  418. <div className="loading-spacer" />
  419. ) : (
  420. <Row gutter={[isMobile ? 8 : 16, 12]}>
  421. <Col span={24}>
  422. <Card size="small" hoverable className="summary-card">
  423. <Row gutter={[16, 12]}>
  424. <Col xs={12} sm={12} md={8}>
  425. <Statistic
  426. title={t('pages.inbounds.totalDownUp')}
  427. value={`${SizeFormatter.sizeFormat(totals.up)} / ${SizeFormatter.sizeFormat(totals.down)}`}
  428. prefix={<SwapOutlined />}
  429. />
  430. </Col>
  431. <Col xs={12} sm={12} md={8}>
  432. <Statistic
  433. title={t('pages.inbounds.totalUsage')}
  434. value={SizeFormatter.sizeFormat(totals.up + totals.down)}
  435. prefix={<PieChartOutlined />}
  436. />
  437. </Col>
  438. <Col xs={24} sm={24} md={8}>
  439. <Statistic
  440. title={t('pages.inbounds.inboundCount')}
  441. value={String(dbInbounds.length)}
  442. prefix={<BarsOutlined />}
  443. />
  444. </Col>
  445. </Row>
  446. </Card>
  447. </Col>
  448. <Col span={24}>
  449. <InboundList
  450. dbInbounds={dbInbounds}
  451. clientCount={clientCount}
  452. onlineClients={onlineClients}
  453. lastOnlineMap={lastOnlineMap}
  454. expireDiff={expireDiff}
  455. trafficDiff={trafficDiff}
  456. pageSize={pageSize}
  457. isMobile={isMobile}
  458. subEnable={subSettings.enable}
  459. nodesById={nodesById}
  460. hasActiveNode={showNodeInfo}
  461. onAddInbound={onAddInbound}
  462. onGeneralAction={onGeneralAction}
  463. onRowAction={({ key, dbInbound }) => onRowAction({ key, dbInbound: dbInbound as unknown as DBInbound })}
  464. />
  465. </Col>
  466. </Row>
  467. )}
  468. </Spin>
  469. </Layout.Content>
  470. </Layout>
  471. <LazyMount when={formOpen}>
  472. <InboundFormModal
  473. open={formOpen}
  474. onClose={() => setFormOpen(false)}
  475. onSaved={refresh}
  476. mode={formMode}
  477. dbInbound={formDbInbound}
  478. dbInbounds={dbInbounds}
  479. availableNodes={nodesList}
  480. />
  481. </LazyMount>
  482. <LazyMount when={infoOpen}>
  483. <InboundInfoModal
  484. open={infoOpen}
  485. onClose={() => setInfoOpen(false)}
  486. dbInbound={infoDbInbound}
  487. clientIndex={infoClientIndex}
  488. remarkModel={remarkModel}
  489. expireDiff={expireDiff}
  490. trafficDiff={trafficDiff}
  491. ipLimitEnable={ipLimitEnable}
  492. tgBotEnable={tgBotEnable}
  493. subSettings={subSettings}
  494. lastOnlineMap={lastOnlineMap}
  495. nodeAddress={infoNodeAddress}
  496. />
  497. </LazyMount>
  498. <LazyMount when={qrOpen}>
  499. <QrCodeModal
  500. open={qrOpen}
  501. onClose={() => setQrOpen(false)}
  502. dbInbound={qrDbInbound}
  503. client={null}
  504. remarkModel={remarkModel}
  505. nodeAddress={qrNodeAddress}
  506. subSettings={subSettings}
  507. />
  508. </LazyMount>
  509. <LazyMount when={textOpen}>
  510. <TextModal
  511. open={textOpen}
  512. onClose={() => setTextOpen(false)}
  513. title={textTitle}
  514. content={textContent}
  515. fileName={textFileName}
  516. />
  517. </LazyMount>
  518. <LazyMount when={promptOpen}>
  519. <PromptModal
  520. open={promptOpen}
  521. onClose={() => setPromptOpen(false)}
  522. title={promptTitle}
  523. okText={promptOkText}
  524. type={promptType}
  525. initialValue={promptInitial}
  526. loading={promptLoading}
  527. onConfirm={onPromptConfirm}
  528. />
  529. </LazyMount>
  530. </Layout>
  531. </ConfigProvider>
  532. );
  533. }