InboundsPage.tsx 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723
  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. SwapOutlined,
  19. PieChartOutlined,
  20. BarsOutlined,
  21. } from '@ant-design/icons';
  22. import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
  23. import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
  24. import { genInboundLinks, preferPublicHost } from '@/lib/xray/inbound-link';
  25. import { inboundFromDb } from '@/lib/xray/inbound-from-db';
  26. import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
  27. import { useTheme } from '@/hooks/useTheme';
  28. import { useMediaQuery } from '@/hooks/useMediaQuery';
  29. import { useWebSocket } from '@/hooks/useWebSocket';
  30. import { useNodesQuery } from '@/api/queries/useNodesQuery';
  31. import AppSidebar from '@/layouts/AppSidebar';
  32. const TextModal = lazy(() => import('@/components/feedback/TextModal'));
  33. const PromptModal = lazy(() => import('@/components/feedback/PromptModal'));
  34. import { useInbounds } from './useInbounds';
  35. import { InboundList } from './list';
  36. import { LazyMount } from '@/components/utility';
  37. const InboundFormModal = lazy(() => import('./form/InboundFormModal'));
  38. const InboundInfoModal = lazy(() => import('./info/InboundInfoModal'));
  39. const QrCodeModal = lazy(() => import('./qr/QrCodeModal'));
  40. const AttachClientsModal = lazy(() => import('./clients/AttachClientsModal'));
  41. const AttachExistingClientsModal = lazy(() => import('./clients/AttachExistingClientsModal'));
  42. const DetachClientsModal = lazy(() => import('./clients/DetachClientsModal'));
  43. const AddClientsToGroupModal = lazy(() => import('./clients/AddClientsToGroupModal'));
  44. type RowAction =
  45. | 'edit'
  46. | 'showInfo'
  47. | 'qrcode'
  48. | 'export'
  49. | 'subs'
  50. | 'clipboard'
  51. | 'delete'
  52. | 'resetTraffic'
  53. | 'delAllClients'
  54. | 'attachClients'
  55. | 'attachExisting'
  56. | 'detachClients'
  57. | 'addToGroup'
  58. | 'clone';
  59. type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
  60. interface ClientMatchTarget {
  61. id?: string;
  62. email?: string;
  63. password?: string;
  64. }
  65. export default function InboundsPage() {
  66. const { t } = useTranslation();
  67. const { isDark, isUltra, antdThemeConfig } = useTheme();
  68. const { isMobile } = useMediaQuery();
  69. const {
  70. fetched,
  71. fetchError,
  72. dbInbounds,
  73. clientCount,
  74. onlineClients,
  75. lastOnlineMap,
  76. totals,
  77. expireDiff,
  78. trafficDiff,
  79. pageSize,
  80. subSettings,
  81. tgBotEnable,
  82. ipLimitEnable,
  83. remarkModel,
  84. refresh,
  85. hydrateInbound,
  86. applyTrafficEvent,
  87. applyClientStatsEvent,
  88. } = useInbounds();
  89. const [modal, modalContextHolder] = Modal.useModal();
  90. const [messageApi, messageContextHolder] = message.useMessage();
  91. useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
  92. const { nodes: nodesList } = useNodesQuery();
  93. const nodesById = useMemo(() => {
  94. const map = new Map<number, ReturnType<typeof useNodesQuery>['nodes'][number]>();
  95. for (const n of nodesList || []) map.set(n.id, n);
  96. return map;
  97. }, [nodesList]);
  98. const hasActiveNode = useMemo(
  99. () => (nodesList || []).some((n) => n.enable && n.status === 'online'),
  100. [nodesList],
  101. );
  102. const hasNodeAttachedInbound = useMemo(
  103. () => (dbInbounds || []).some((ib) => ib?.nodeId != null),
  104. [dbInbounds],
  105. );
  106. const showNodeInfo = hasNodeAttachedInbound || hasActiveNode;
  107. useWebSocket({
  108. traffic: applyTrafficEvent,
  109. client_stats: applyClientStatsEvent,
  110. });
  111. const [formOpen, setFormOpen] = useState(false);
  112. const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
  113. const [formDbInbound, setFormDbInbound] = useState<DBInbound | null>(null);
  114. const [infoOpen, setInfoOpen] = useState(false);
  115. const [infoDbInbound, setInfoDbInbound] = useState<DBInbound | null>(null);
  116. const [infoClientIndex, setInfoClientIndex] = useState(0);
  117. const [qrOpen, setQrOpen] = useState(false);
  118. const [qrDbInbound, setQrDbInbound] = useState<DBInbound | null>(null);
  119. const [attachOpen, setAttachOpen] = useState(false);
  120. const [attachSource, setAttachSource] = useState<DBInbound | null>(null);
  121. const [attachExistingOpen, setAttachExistingOpen] = useState(false);
  122. const [attachExistingTarget, setAttachExistingTarget] = useState<DBInbound | null>(null);
  123. const [detachOpen, setDetachOpen] = useState(false);
  124. const [detachSource, setDetachSource] = useState<DBInbound | null>(null);
  125. const [groupOpen, setGroupOpen] = useState(false);
  126. const [groupSource, setGroupSource] = useState<DBInbound | null>(null);
  127. const [textOpen, setTextOpen] = useState(false);
  128. const [textTitle, setTextTitle] = useState('');
  129. const [textContent, setTextContent] = useState('');
  130. const [textFileName, setTextFileName] = useState('');
  131. const [promptOpen, setPromptOpen] = useState(false);
  132. const [promptTitle, setPromptTitle] = useState('');
  133. const [promptOkText, setPromptOkText] = useState('OK');
  134. const [promptType, setPromptType] = useState<'textarea' | 'input'>('textarea');
  135. const [promptInitial, setPromptInitial] = useState('');
  136. const [promptLoading, setPromptLoading] = useState(false);
  137. const [promptHandler, setPromptHandler] = useState<((value: string) => Promise<boolean | void> | boolean | void) | null>(null);
  138. const hostOverrideFor = useCallback((dbInbound: DBInbound | null) => {
  139. if (!dbInbound || dbInbound.nodeId == null) return '';
  140. return nodesById.get(dbInbound.nodeId)?.address || '';
  141. }, [nodesById]);
  142. const infoNodeAddress = useMemo(() => hostOverrideFor(infoDbInbound), [infoDbInbound, hostOverrideFor]);
  143. const qrNodeAddress = useMemo(() => hostOverrideFor(qrDbInbound), [qrDbInbound, hostOverrideFor]);
  144. const openText = useCallback((opts: { title: string; content: string; fileName?: string }) => {
  145. setTextTitle(opts.title);
  146. setTextContent(opts.content);
  147. setTextFileName(opts.fileName || '');
  148. setTextOpen(true);
  149. }, []);
  150. const openPrompt = useCallback((opts: {
  151. title: string;
  152. okText?: string;
  153. type?: 'textarea' | 'input';
  154. value?: string;
  155. confirm: (value: string) => Promise<boolean | void> | boolean | void;
  156. }) => {
  157. setPromptTitle(opts.title);
  158. setPromptOkText(opts.okText || t('confirm'));
  159. setPromptType(opts.type || 'textarea');
  160. setPromptInitial(opts.value || '');
  161. setPromptHandler(() => opts.confirm);
  162. setPromptOpen(true);
  163. }, [t]);
  164. const onPromptConfirm = useCallback(async (value: string) => {
  165. if (!promptHandler) {
  166. setPromptOpen(false);
  167. return;
  168. }
  169. setPromptLoading(true);
  170. try {
  171. const ok = await promptHandler(value);
  172. if (ok !== false) setPromptOpen(false);
  173. } finally {
  174. setPromptLoading(false);
  175. }
  176. }, [promptHandler]);
  177. const projectChildThroughMaster = useCallback((child: DBInbound, master: DBInbound): DBInbound => {
  178. const projected = JSON.parse(JSON.stringify(child)) as DBInbound;
  179. projected.listen = master.listen;
  180. projected.port = master.port;
  181. const masterStream = coerceInboundJsonField(master.streamSettings) as Record<string, unknown>;
  182. const childStream = { ...(coerceInboundJsonField(child.streamSettings) as Record<string, unknown>) };
  183. childStream.security = masterStream.security;
  184. childStream.tlsSettings = masterStream.tlsSettings;
  185. childStream.realitySettings = masterStream.realitySettings;
  186. childStream.externalProxy = masterStream.externalProxy;
  187. projected.streamSettings = JSON.stringify(childStream);
  188. const Ctor = child.constructor as new (data: DBInbound) => DBInbound;
  189. return new Ctor(projected);
  190. }, []);
  191. const checkFallback = useCallback((dbInbound: DBInbound): DBInbound => {
  192. const parent = dbInbound?.fallbackParent;
  193. if (parent?.masterId) {
  194. const master = dbInbounds.find((ib) => ib.id === parent.masterId);
  195. if (master) return projectChildThroughMaster(dbInbound, master);
  196. }
  197. if (!dbInbound?.listen?.startsWith?.('@')) return dbInbound;
  198. for (const candidate of dbInbounds) {
  199. if (candidate.id === dbInbound.id) continue;
  200. if (!['trojan', 'vless'].includes(candidate.protocol)) continue;
  201. const candStream = coerceInboundJsonField(candidate.streamSettings) as { network?: string };
  202. if (candStream.network !== 'tcp') continue;
  203. const candSettings = coerceInboundJsonField(candidate.settings) as { fallbacks?: { dest?: string }[] };
  204. const fallbacks = candSettings.fallbacks || [];
  205. if (!fallbacks.find((f) => f.dest === dbInbound.listen)) continue;
  206. return projectChildThroughMaster(dbInbound, candidate);
  207. }
  208. return dbInbound;
  209. }, [dbInbounds, projectChildThroughMaster]);
  210. const findClientIndex = useCallback((dbInbound: DBInbound, client: ClientMatchTarget | null) => {
  211. if (!client) return 0;
  212. const settings = coerceInboundJsonField(dbInbound.settings) as { clients?: ClientMatchTarget[] };
  213. const clients = settings.clients || [];
  214. const idx = clients.findIndex((c) => {
  215. if (!c) return false;
  216. switch (dbInbound.protocol) {
  217. case 'trojan':
  218. case 'shadowsocks':
  219. return c.password === client.password && c.email === client.email;
  220. default:
  221. return c.id === client.id && c.email === client.email;
  222. }
  223. });
  224. return idx >= 0 ? idx : 0;
  225. }, []);
  226. const exportInboundLinks = useCallback((dbInbound: DBInbound) => {
  227. const projected = checkFallback(dbInbound);
  228. openText({
  229. title: t('pages.inbounds.exportLinksTitle'),
  230. content: genInboundLinks({
  231. inbound: inboundFromDb(projected),
  232. remark: projected.remark,
  233. remarkModel,
  234. hostOverride: hostOverrideFor(dbInbound),
  235. fallbackHostname: preferPublicHost(window.location.hostname, subSettings.publicHost),
  236. }),
  237. fileName: projected.remark || 'inbound',
  238. });
  239. }, [checkFallback, remarkModel, hostOverrideFor, subSettings.publicHost, openText, t]);
  240. const exportInboundClipboard = useCallback((dbInbound: DBInbound) => {
  241. openText({ title: t('pages.inbounds.inboundJsonTitle'), content: JSON.stringify(dbInbound, null, 2) });
  242. }, [openText, t]);
  243. const exportInboundSubs = useCallback((dbInbound: DBInbound) => {
  244. const settings = coerceInboundJsonField(dbInbound.settings) as { clients?: { subId?: string }[] };
  245. const clients = settings.clients || [];
  246. const subLinks: string[] = [];
  247. for (const c of clients) {
  248. if (c.subId && subSettings.subURI) {
  249. subLinks.push(subSettings.subURI + c.subId);
  250. }
  251. }
  252. openText({
  253. title: t('pages.inbounds.exportSubsTitle'),
  254. content: [...new Set(subLinks)].join('\n'),
  255. fileName: `${dbInbound.remark || 'inbound'}-Subs`,
  256. });
  257. }, [subSettings, openText, t]);
  258. const exportAllLinks = useCallback(async () => {
  259. const hydrated = await Promise.all(
  260. dbInbounds.map((ib) => hydrateInbound(ib.id).then((r) => r ?? ib)),
  261. );
  262. const out: string[] = [];
  263. for (const ib of hydrated) {
  264. const projected = checkFallback(ib);
  265. out.push(genInboundLinks({
  266. inbound: inboundFromDb(projected),
  267. remark: projected.remark,
  268. remarkModel,
  269. hostOverride: hostOverrideFor(ib),
  270. fallbackHostname: preferPublicHost(window.location.hostname, subSettings.publicHost),
  271. }));
  272. }
  273. openText({ title: t('pages.inbounds.exportAllLinksTitle'), content: out.join('\r\n'), fileName: t('pages.inbounds.exportAllLinksFileName') });
  274. }, [dbInbounds, hydrateInbound, checkFallback, remarkModel, hostOverrideFor, subSettings.publicHost, openText, t]);
  275. const exportAllSubs = useCallback(async () => {
  276. const hydrated = await Promise.all(
  277. dbInbounds.map((ib) => hydrateInbound(ib.id).then((r) => r ?? ib)),
  278. );
  279. const out: string[] = [];
  280. for (const ib of hydrated) {
  281. const settings = coerceInboundJsonField(ib.settings) as { clients?: { subId?: string }[] };
  282. const clients = settings.clients || [];
  283. for (const c of clients) {
  284. if (c.subId && subSettings.subURI) {
  285. out.push(subSettings.subURI + c.subId);
  286. }
  287. }
  288. }
  289. openText({ title: t('pages.inbounds.exportAllSubsTitle'), content: [...new Set(out)].join('\r\n'), fileName: t('pages.inbounds.exportAllSubsFileName') });
  290. }, [dbInbounds, hydrateInbound, subSettings, openText, t]);
  291. const importInbound = useCallback(() => {
  292. openPrompt({
  293. title: t('pages.inbounds.importInbound'),
  294. okText: t('pages.inbounds.import'),
  295. type: 'textarea',
  296. value: '',
  297. confirm: async (value) => {
  298. const msg = await HttpUtil.post('/panel/api/inbounds/import', { data: value });
  299. if (msg?.success) {
  300. await refresh();
  301. return true;
  302. }
  303. return false;
  304. },
  305. });
  306. }, [openPrompt, refresh, t]);
  307. const onAddInbound = useCallback(() => {
  308. setFormMode('add');
  309. setFormDbInbound(null);
  310. setFormOpen(true);
  311. }, []);
  312. const openEdit = useCallback((dbInbound: DBInbound) => {
  313. setFormMode('edit');
  314. setFormDbInbound(dbInbound);
  315. setFormOpen(true);
  316. }, []);
  317. const confirmDelete = useCallback((dbInbound: DBInbound) => {
  318. modal.confirm({
  319. title: t('pages.inbounds.deleteConfirmTitle', { remark: dbInbound.remark }),
  320. content: t('pages.inbounds.deleteConfirmContent'),
  321. okText: t('delete'),
  322. okType: 'danger',
  323. cancelText: t('cancel'),
  324. onOk: async () => {
  325. const msg = await HttpUtil.post(`/panel/api/inbounds/del/${dbInbound.id}`);
  326. if (msg?.success) await refresh();
  327. },
  328. });
  329. }, [modal, refresh, t]);
  330. const confirmBulkDelete = useCallback((ids: number[]) => new Promise<boolean>((resolve) => {
  331. if (ids.length === 0) {
  332. resolve(false);
  333. return;
  334. }
  335. modal.confirm({
  336. title: t('pages.inbounds.bulkDeleteConfirmTitle', { count: ids.length }),
  337. content: t('pages.inbounds.bulkDeleteConfirmContent'),
  338. okText: t('delete'),
  339. okType: 'danger',
  340. cancelText: t('cancel'),
  341. onOk: async () => {
  342. const msg = await HttpUtil.post('/panel/api/inbounds/bulkDel', { ids }, { headers: { 'Content-Type': 'application/json' } });
  343. const obj = (msg?.obj ?? {}) as { deleted?: number; skipped?: { id: number; reason: string }[] };
  344. const ok = obj.deleted ?? 0;
  345. const skipped = obj.skipped ?? [];
  346. if (msg?.success && skipped.length === 0) {
  347. messageApi.success(t('pages.inbounds.toasts.bulkDeleted', { count: ok }));
  348. } else {
  349. const firstError = skipped[0]?.reason ?? msg?.msg ?? '';
  350. const base = t('pages.inbounds.toasts.bulkDeletedMixed', { ok, failed: skipped.length });
  351. messageApi.warning(firstError ? `${base} — ${firstError}` : base);
  352. }
  353. await refresh();
  354. resolve(true);
  355. },
  356. onCancel: () => resolve(false),
  357. });
  358. }), [modal, refresh, t, messageApi]);
  359. const confirmResetTraffic = useCallback((dbInbound: DBInbound) => {
  360. modal.confirm({
  361. title: t('pages.inbounds.resetConfirmTitle', { remark: dbInbound.remark }),
  362. content: t('pages.inbounds.resetConfirmContent'),
  363. okText: t('reset'),
  364. cancelText: t('cancel'),
  365. onOk: async () => {
  366. const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/resetTraffic`);
  367. if (msg?.success) await refresh();
  368. },
  369. });
  370. }, [modal, refresh, t]);
  371. const confirmDelAllClients = useCallback((dbInbound: DBInbound) => {
  372. const count = clientCount[dbInbound.id]?.clients || 0;
  373. modal.confirm({
  374. title: t('pages.inbounds.delAllClientsConfirmTitle', { remark: dbInbound.remark, count }),
  375. content: t('pages.inbounds.delAllClientsConfirmContent'),
  376. okText: t('delete'),
  377. okType: 'danger',
  378. cancelText: t('cancel'),
  379. onOk: async () => {
  380. const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/delAllClients`);
  381. if (msg?.success) await refresh();
  382. },
  383. });
  384. }, [modal, refresh, t, clientCount]);
  385. const confirmClone = useCallback((dbInbound: DBInbound) => {
  386. modal.confirm({
  387. title: t('pages.inbounds.cloneConfirmTitle', { remark: dbInbound.remark }),
  388. content: t('pages.inbounds.cloneConfirmContent'),
  389. okText: t('pages.inbounds.clone'),
  390. cancelText: t('cancel'),
  391. onOk: async () => {
  392. let clonedSettings: string;
  393. try {
  394. const raw = coerceInboundJsonField(dbInbound.settings);
  395. raw.clients = [];
  396. clonedSettings = JSON.stringify(raw);
  397. } catch {
  398. const fallback = createDefaultInboundSettings(dbInbound.protocol);
  399. clonedSettings = fallback ? JSON.stringify(fallback, null, 2) : '{}';
  400. }
  401. const streamSettingsString = typeof dbInbound.streamSettings === 'string'
  402. ? dbInbound.streamSettings
  403. : JSON.stringify(dbInbound.streamSettings ?? {});
  404. const sniffingString = typeof dbInbound.sniffing === 'string'
  405. ? dbInbound.sniffing
  406. : JSON.stringify(dbInbound.sniffing ?? {});
  407. const data = {
  408. up: 0,
  409. down: 0,
  410. total: 0,
  411. remark: `${dbInbound.remark} (clone)`,
  412. enable: false,
  413. expiryTime: 0,
  414. listen: '',
  415. port: RandomUtil.randomInteger(10000, 60000),
  416. protocol: dbInbound.protocol,
  417. settings: clonedSettings,
  418. streamSettings: streamSettingsString,
  419. sniffing: sniffingString,
  420. };
  421. const msg = await HttpUtil.post('/panel/api/inbounds/add', data);
  422. if (msg?.success) await refresh();
  423. },
  424. });
  425. }, [modal, refresh, t]);
  426. const onGeneralAction = useCallback((key: GeneralAction) => {
  427. switch (key) {
  428. case 'import': importInbound(); break;
  429. case 'export': exportAllLinks(); break;
  430. case 'subs': exportAllSubs(); break;
  431. case 'resetInbounds':
  432. modal.confirm({
  433. title: t('pages.inbounds.resetAllTrafficTitle'),
  434. okText: t('reset'),
  435. cancelText: t('cancel'),
  436. onOk: async () => {
  437. const msg = await HttpUtil.post('/panel/api/inbounds/resetAllTraffics');
  438. if (msg?.success) await refresh();
  439. },
  440. });
  441. break;
  442. default:
  443. messageApi.info(`General action "${key}" — coming in a later 5f subphase`);
  444. }
  445. }, [modal, importInbound, exportAllLinks, exportAllSubs, refresh, messageApi, t]);
  446. const onRowAction = useCallback(async ({ key, dbInbound }: { key: RowAction; dbInbound: DBInbound }) => {
  447. // Actions that touch per-client secrets (uuid, password, flow, ...) need
  448. // the full payload that the slim list view does not ship. Hydrate first
  449. // and then operate on the rehydrated record.
  450. const hydratingKeys: RowAction[] = ['edit', 'showInfo', 'qrcode', 'export', 'subs', 'clipboard', 'clone', 'attachClients', 'addToGroup'];
  451. let target = dbInbound;
  452. if (hydratingKeys.includes(key)) {
  453. const hydrated = await hydrateInbound(dbInbound.id);
  454. if (hydrated) target = hydrated;
  455. }
  456. switch (key) {
  457. case 'edit':
  458. openEdit(target);
  459. break;
  460. case 'showInfo':
  461. setInfoDbInbound(checkFallback(target));
  462. setInfoClientIndex(findClientIndex(target, null));
  463. setInfoOpen(true);
  464. break;
  465. case 'qrcode':
  466. setQrDbInbound(checkFallback(target));
  467. setQrOpen(true);
  468. break;
  469. case 'export':
  470. exportInboundLinks(target);
  471. break;
  472. case 'subs':
  473. exportInboundSubs(target);
  474. break;
  475. case 'clipboard':
  476. exportInboundClipboard(target);
  477. break;
  478. case 'delete':
  479. confirmDelete(target);
  480. break;
  481. case 'resetTraffic':
  482. confirmResetTraffic(target);
  483. break;
  484. case 'delAllClients':
  485. confirmDelAllClients(target);
  486. break;
  487. case 'attachClients':
  488. setAttachSource(target);
  489. setAttachOpen(true);
  490. break;
  491. case 'attachExisting':
  492. setAttachExistingTarget(target);
  493. setAttachExistingOpen(true);
  494. break;
  495. case 'detachClients':
  496. setDetachSource(target);
  497. setDetachOpen(true);
  498. break;
  499. case 'addToGroup':
  500. setGroupSource(target);
  501. setGroupOpen(true);
  502. break;
  503. case 'clone':
  504. confirmClone(target);
  505. break;
  506. default:
  507. messageApi.info(`Action "${key}" — coming in a later 5f subphase`);
  508. }
  509. }, [hydrateInbound, openEdit, checkFallback, findClientIndex, exportInboundLinks, exportInboundSubs, exportInboundClipboard, confirmDelete, confirmResetTraffic, confirmDelAllClients, confirmClone, messageApi]);
  510. return (
  511. <ConfigProvider theme={antdThemeConfig}>
  512. {messageContextHolder}
  513. {modalContextHolder}
  514. <Layout className={`inbounds-page${isDark ? ' is-dark' : ''}${isUltra ? ' is-ultra' : ''}`}>
  515. <AppSidebar />
  516. <Layout className="content-shell">
  517. <Layout.Content id="content-layout" className="content-area">
  518. <Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
  519. {!fetched ? (
  520. <div className="loading-spacer" />
  521. ) : fetchError ? (
  522. <Result
  523. status="error"
  524. title={t('somethingWentWrong')}
  525. subTitle={fetchError}
  526. extra={<Button type="primary" onClick={refresh}>{t('refresh')}</Button>}
  527. />
  528. ) : (
  529. <Row gutter={[isMobile ? 8 : 16, 12]}>
  530. <Col span={24}>
  531. <Card size="small" hoverable className="summary-card">
  532. <Row gutter={[16, 12]}>
  533. <Col xs={12} sm={12} md={8}>
  534. <Statistic
  535. title={t('pages.inbounds.totalDownUp')}
  536. value={`${SizeFormatter.sizeFormat(totals.up)} / ${SizeFormatter.sizeFormat(totals.down)}`}
  537. prefix={<SwapOutlined />}
  538. />
  539. </Col>
  540. <Col xs={12} sm={12} md={8}>
  541. <Statistic
  542. title={t('pages.inbounds.totalUsage')}
  543. value={SizeFormatter.sizeFormat(totals.up + totals.down)}
  544. prefix={<PieChartOutlined />}
  545. />
  546. </Col>
  547. <Col xs={24} sm={24} md={8}>
  548. <Statistic
  549. title={t('pages.inbounds.inboundCount')}
  550. value={String(dbInbounds.length)}
  551. prefix={<BarsOutlined />}
  552. />
  553. </Col>
  554. </Row>
  555. </Card>
  556. </Col>
  557. <Col span={24}>
  558. <InboundList
  559. dbInbounds={dbInbounds}
  560. clientCount={clientCount}
  561. onlineClients={onlineClients}
  562. lastOnlineMap={lastOnlineMap}
  563. expireDiff={expireDiff}
  564. trafficDiff={trafficDiff}
  565. pageSize={pageSize}
  566. isMobile={isMobile}
  567. subEnable={subSettings.enable}
  568. nodesById={nodesById}
  569. hasActiveNode={showNodeInfo}
  570. onAddInbound={onAddInbound}
  571. onGeneralAction={onGeneralAction}
  572. onRowAction={({ key, dbInbound }) => onRowAction({ key, dbInbound: dbInbound as unknown as DBInbound })}
  573. onBulkDelete={confirmBulkDelete}
  574. />
  575. </Col>
  576. </Row>
  577. )}
  578. </Spin>
  579. </Layout.Content>
  580. </Layout>
  581. <LazyMount when={formOpen}>
  582. <InboundFormModal
  583. open={formOpen}
  584. onClose={() => setFormOpen(false)}
  585. onSaved={refresh}
  586. mode={formMode}
  587. dbInbound={formDbInbound}
  588. dbInbounds={dbInbounds}
  589. availableNodes={nodesList}
  590. />
  591. </LazyMount>
  592. <LazyMount when={infoOpen}>
  593. <InboundInfoModal
  594. open={infoOpen}
  595. onClose={() => setInfoOpen(false)}
  596. dbInbound={infoDbInbound}
  597. clientIndex={infoClientIndex}
  598. remarkModel={remarkModel}
  599. expireDiff={expireDiff}
  600. trafficDiff={trafficDiff}
  601. ipLimitEnable={ipLimitEnable}
  602. tgBotEnable={tgBotEnable}
  603. subSettings={subSettings}
  604. lastOnlineMap={lastOnlineMap}
  605. nodeAddress={infoNodeAddress}
  606. />
  607. </LazyMount>
  608. <LazyMount when={qrOpen}>
  609. <QrCodeModal
  610. open={qrOpen}
  611. onClose={() => setQrOpen(false)}
  612. dbInbound={qrDbInbound}
  613. client={null}
  614. remarkModel={remarkModel}
  615. nodeAddress={qrNodeAddress}
  616. subSettings={subSettings}
  617. />
  618. </LazyMount>
  619. <LazyMount when={attachOpen}>
  620. <AttachClientsModal
  621. open={attachOpen}
  622. onClose={() => setAttachOpen(false)}
  623. onAttached={refresh}
  624. source={attachSource}
  625. dbInbounds={dbInbounds}
  626. />
  627. </LazyMount>
  628. <LazyMount when={attachExistingOpen}>
  629. <AttachExistingClientsModal
  630. open={attachExistingOpen}
  631. onClose={() => setAttachExistingOpen(false)}
  632. onAttached={refresh}
  633. target={attachExistingTarget}
  634. />
  635. </LazyMount>
  636. <LazyMount when={detachOpen}>
  637. <DetachClientsModal
  638. open={detachOpen}
  639. onClose={() => setDetachOpen(false)}
  640. onDetached={refresh}
  641. source={detachSource}
  642. />
  643. </LazyMount>
  644. <LazyMount when={groupOpen}>
  645. <AddClientsToGroupModal
  646. open={groupOpen}
  647. onClose={() => setGroupOpen(false)}
  648. onAdded={refresh}
  649. source={groupSource}
  650. />
  651. </LazyMount>
  652. <LazyMount when={textOpen}>
  653. <TextModal
  654. open={textOpen}
  655. onClose={() => setTextOpen(false)}
  656. title={textTitle}
  657. content={textContent}
  658. fileName={textFileName}
  659. />
  660. </LazyMount>
  661. <LazyMount when={promptOpen}>
  662. <PromptModal
  663. open={promptOpen}
  664. onClose={() => setPromptOpen(false)}
  665. title={promptTitle}
  666. okText={promptOkText}
  667. type={promptType}
  668. initialValue={promptInitial}
  669. loading={promptLoading}
  670. onConfirm={onPromptConfirm}
  671. />
  672. </LazyMount>
  673. </Layout>
  674. </ConfigProvider>
  675. );
  676. }