InboundList.tsx 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781
  1. import { useCallback, useMemo, useState, type ReactElement } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import {
  4. Button,
  5. Card,
  6. Dropdown,
  7. Modal,
  8. Popover,
  9. Space,
  10. Switch,
  11. Table,
  12. Tag,
  13. Tooltip,
  14. type TableColumnType,
  15. type MenuProps,
  16. } from 'antd';
  17. import {
  18. PlusOutlined,
  19. MenuOutlined,
  20. MoreOutlined,
  21. EditOutlined,
  22. QrcodeOutlined,
  23. CopyOutlined,
  24. ExportOutlined,
  25. ImportOutlined,
  26. ReloadOutlined,
  27. RetweetOutlined,
  28. BlockOutlined,
  29. DeleteOutlined,
  30. InfoCircleOutlined,
  31. TagsOutlined,
  32. UsergroupAddOutlined,
  33. UsergroupDeleteOutlined,
  34. } from '@ant-design/icons';
  35. import { HttpUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
  36. import InfinityIcon from '@/components/InfinityIcon';
  37. import { useDatepicker } from '@/hooks/useDatepicker';
  38. import type { NodeRecord } from '@/api/queries/useNodesQuery';
  39. import { isSSMultiUser } from '@/lib/xray/protocol-capabilities';
  40. import { coerceInboundJsonField } from '@/models/dbinbound';
  41. import './InboundList.css';
  42. interface StreamHints {
  43. network: string;
  44. isTls: boolean;
  45. isReality: boolean;
  46. }
  47. function readStreamHints(streamSettings: unknown): StreamHints {
  48. const stream = coerceInboundJsonField(streamSettings) as { network?: string; security?: string };
  49. return {
  50. network: stream.network ?? '',
  51. isTls: stream.security === 'tls',
  52. isReality: stream.security === 'reality',
  53. };
  54. }
  55. // Display label for a network value. All known transports render in
  56. // upper-case for visual consistency with the TCP/UDP/TLS/Reality tags
  57. // already shown alongside; compound names (`httpupgrade`, `splithttp`,
  58. // `xhttp`) get a tiny touch of casing so they don't read as one word.
  59. function networkLabel(network: string): string {
  60. const n = (network || '').toLowerCase();
  61. if (!n) return 'TCP';
  62. switch (n) {
  63. case 'httpupgrade': return 'HTTPUpgrade';
  64. case 'splithttp': return 'SplitHTTP';
  65. case 'xhttp': return 'XHTTP';
  66. }
  67. return n.toUpperCase();
  68. }
  69. // Returns the underlying L4 protocol for transports whose name isn't
  70. // already TCP/UDP. `kcp` and `quic` both ride on UDP; everything else
  71. // (`ws`, `grpc`, `http`, `httpupgrade`, `xhttp`) is TCP-based and gets
  72. // no extra tag (the transport name implies TCP).
  73. function networkL4(network: string): 'UDP' | '' {
  74. const n = (network || '').toLowerCase();
  75. if (n === 'kcp' || n === 'quic') return 'UDP';
  76. return '';
  77. }
  78. // Shadowsocks settings.network ("tcp" / "udp" / "tcp,udp") and Tunnel
  79. // settings.allowedNetwork (same shape, different field name) both carry
  80. // the L4 transport list independent of streamSettings. Returns a
  81. // comma-separated label.
  82. function commaNetworkLabel(raw: string): string {
  83. const parts = (raw || 'tcp').toLowerCase().split(',').map((p) => p.trim()).filter(Boolean);
  84. if (parts.length === 0) return 'TCP';
  85. return parts.map(networkLabel).join(',');
  86. }
  87. function shadowsocksNetworkLabel(settings: unknown): string {
  88. return commaNetworkLabel(readSettings(settings).network || '');
  89. }
  90. function tunnelNetworkLabel(settings: unknown): string {
  91. return commaNetworkLabel(readSettings(settings).allowedNetwork || '');
  92. }
  93. // Mixed (socks+http combo) is always TCP at L4; settings.udp=true adds
  94. // UDP-associate support on the same port (SOCKS5 UDP).
  95. function mixedNetworkLabel(settings: unknown): string {
  96. const st = coerceInboundJsonField(settings) as { udp?: boolean };
  97. return st.udp ? 'TCP,UDP' : 'TCP';
  98. }
  99. function readSettings(settings: unknown): { method?: string; network?: string; allowedNetwork?: string } {
  100. return coerceInboundJsonField(settings) as { method?: string; network?: string; allowedNetwork?: string };
  101. }
  102. export function isInboundMultiUser(record: { protocol: string; settings: unknown }): boolean {
  103. switch (record.protocol) {
  104. case 'vmess':
  105. case 'vless':
  106. case 'trojan':
  107. case 'hysteria':
  108. return true;
  109. case 'shadowsocks':
  110. return isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(record.settings) });
  111. default:
  112. return false;
  113. }
  114. }
  115. type ProtocolFlags = {
  116. isVMess?: boolean;
  117. isVLess?: boolean;
  118. isTrojan?: boolean;
  119. isSS?: boolean;
  120. isHysteria?: boolean;
  121. isMixed?: boolean;
  122. isHTTP?: boolean;
  123. isWireguard?: boolean;
  124. isTunnel?: boolean;
  125. };
  126. interface DBInboundRecord extends ProtocolFlags {
  127. id: number;
  128. enable: boolean;
  129. remark: string;
  130. port: number;
  131. protocol: string;
  132. up: number;
  133. down: number;
  134. total: number;
  135. expiryTime: number;
  136. _expiryTime: { valueOf(): number } | null;
  137. nodeId?: number | null;
  138. settings: unknown;
  139. streamSettings: unknown;
  140. }
  141. export interface ClientCountEntry {
  142. clients: number;
  143. active: string[];
  144. deactive: string[];
  145. depleted: string[];
  146. expiring: string[];
  147. online: string[];
  148. }
  149. export type RowAction =
  150. | 'edit'
  151. | 'showInfo'
  152. | 'qrcode'
  153. | 'export'
  154. | 'subs'
  155. | 'clipboard'
  156. | 'delete'
  157. | 'resetTraffic'
  158. | 'delAllClients'
  159. | 'clone';
  160. export type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
  161. interface InboundListProps {
  162. dbInbounds: DBInboundRecord[];
  163. clientCount: Record<number, ClientCountEntry>;
  164. onlineClients: string[];
  165. lastOnlineMap: Record<string, number>;
  166. expireDiff: number;
  167. trafficDiff: number;
  168. pageSize: number;
  169. isMobile: boolean;
  170. subEnable: boolean;
  171. nodesById: Map<number, NodeRecord>;
  172. hasActiveNode: boolean;
  173. onAddInbound: () => void;
  174. onGeneralAction: (key: GeneralAction) => void;
  175. onRowAction: (action: { key: RowAction; dbInbound: DBInboundRecord }) => void;
  176. }
  177. type SortKey =
  178. | 'id'
  179. | 'enable'
  180. | 'remark'
  181. | 'port'
  182. | 'protocol'
  183. | 'traffic'
  184. | 'expiryTime'
  185. | 'node'
  186. | 'clients';
  187. type SortOrder = 'ascend' | 'descend' | null;
  188. const SORT_FNS: Record<SortKey, (a: DBInboundRecord, b: DBInboundRecord, ctx: { nodesById: Map<number, NodeRecord>; clientCount: Record<number, ClientCountEntry> }) => number> = {
  189. id: (a, b) => a.id - b.id,
  190. enable: (a, b) => Number(a.enable) - Number(b.enable),
  191. remark: (a, b) => (a.remark || '').localeCompare(b.remark || ''),
  192. port: (a, b) => a.port - b.port,
  193. protocol: (a, b) => a.protocol.localeCompare(b.protocol),
  194. traffic: (a, b) => (a.up + a.down) - (b.up + b.down),
  195. expiryTime: (a, b) => (a.expiryTime || Infinity) - (b.expiryTime || Infinity),
  196. node: (a, b, ctx) => {
  197. const nameA = ctx.nodesById.get(a.nodeId ?? -1)?.name ?? (a.nodeId == null ? '￿' : `node #${a.nodeId}`);
  198. const nameB = ctx.nodesById.get(b.nodeId ?? -1)?.name ?? (b.nodeId == null ? '￿' : `node #${b.nodeId}`);
  199. return nameA.localeCompare(nameB);
  200. },
  201. clients: (a, b, ctx) => (ctx.clientCount[a.id]?.clients || 0) - (ctx.clientCount[b.id]?.clients || 0),
  202. };
  203. function showQrCodeMenu(dbInbound: DBInboundRecord): boolean {
  204. if (dbInbound.isWireguard) return true;
  205. if (dbInbound.isSS) {
  206. return !isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(dbInbound.settings) });
  207. }
  208. return false;
  209. }
  210. interface RowActionsMenuProps {
  211. record: DBInboundRecord;
  212. subEnable: boolean;
  213. hasClients: boolean;
  214. onClick: (key: RowAction) => void;
  215. isMobile?: boolean;
  216. }
  217. function buildRowActionsMenu({ record, subEnable, t, isMobile, hasClients }: { record: DBInboundRecord; subEnable: boolean; t: (k: string) => string; isMobile?: boolean; hasClients?: boolean }): MenuProps['items'] {
  218. const items: MenuProps['items'] = [];
  219. if (isMobile) {
  220. items.push({ key: 'edit', icon: <EditOutlined />, label: t('edit') });
  221. }
  222. if (showQrCodeMenu(record)) {
  223. items.push({ key: 'qrcode', icon: <QrcodeOutlined />, label: t('qrCode') });
  224. }
  225. if (isInboundMultiUser(record)) {
  226. items.push({ key: 'export', icon: <ExportOutlined />, label: t('pages.inbounds.export') });
  227. if (subEnable) {
  228. items.push({
  229. key: 'subs',
  230. icon: <ExportOutlined />,
  231. label: `${t('pages.inbounds.export')} — ${t('pages.settings.subSettings')}`,
  232. });
  233. }
  234. } else {
  235. items.push({ key: 'showInfo', icon: <InfoCircleOutlined />, label: t('pages.inbounds.inboundInfo') });
  236. }
  237. items.push({ key: 'clipboard', icon: <CopyOutlined />, label: t('pages.inbounds.exportInbound') });
  238. items.push({ key: 'resetTraffic', icon: <RetweetOutlined />, label: t('pages.inbounds.resetTraffic') });
  239. items.push({ key: 'clone', icon: <BlockOutlined />, label: t('pages.inbounds.clone') });
  240. if (isInboundMultiUser(record) && hasClients) {
  241. items.push({ key: 'attachClients', icon: <UsergroupAddOutlined />, label: t('pages.inbounds.attachClients') });
  242. items.push({ key: 'detachClients', icon: <UsergroupDeleteOutlined />, label: t('pages.inbounds.detachClients') });
  243. items.push({ key: 'addToGroup', icon: <TagsOutlined />, label: t('pages.inbounds.addClientsToGroup') });
  244. items.push({ type: 'divider' });
  245. items.push({ key: 'delAllClients', icon: <UsergroupDeleteOutlined />, danger: true, label: t('pages.inbounds.delAllClients') });
  246. } else {
  247. items.push({ type: 'divider' });
  248. }
  249. items.push({ key: 'delete', icon: <DeleteOutlined />, danger: true, label: t('delete') });
  250. return items;
  251. }
  252. function RowActionsCell({ record, subEnable, hasClients, onClick }: RowActionsMenuProps) {
  253. const { t } = useTranslation();
  254. return (
  255. <div className="action-buttons">
  256. <Button type="text" size="small" icon={<EditOutlined />} onClick={() => onClick('edit')} />
  257. <Dropdown
  258. trigger={['click']}
  259. menu={{
  260. items: buildRowActionsMenu({ record, subEnable, t, hasClients }),
  261. onClick: ({ key }) => onClick(key as RowAction),
  262. }}
  263. >
  264. <Button type="text" size="small" icon={<MoreOutlined />} />
  265. </Dropdown>
  266. </div>
  267. );
  268. }
  269. export default function InboundList({
  270. dbInbounds,
  271. clientCount,
  272. lastOnlineMap: _lastOnlineMap,
  273. expireDiff,
  274. trafficDiff,
  275. pageSize,
  276. isMobile,
  277. subEnable,
  278. nodesById,
  279. hasActiveNode,
  280. onAddInbound,
  281. onGeneralAction,
  282. onRowAction,
  283. }: InboundListProps) {
  284. const { t } = useTranslation();
  285. const { datepicker } = useDatepicker();
  286. const [sortKey, setSortKey] = useState<SortKey | null>(null);
  287. const [sortOrder, setSortOrder] = useState<SortOrder>(null);
  288. const [statsRecord, setStatsRecord] = useState<DBInboundRecord | null>(null);
  289. const onSwitchEnable = useCallback(async (dbInbound: DBInboundRecord, next: boolean) => {
  290. const previous = dbInbound.enable;
  291. dbInbound.enable = next;
  292. try {
  293. const formData = new FormData();
  294. formData.append('enable', String(next));
  295. const msg = await HttpUtil.post(`/panel/api/inbounds/setEnable/${dbInbound.id}`, formData);
  296. if (!msg?.success) dbInbound.enable = previous;
  297. } catch {
  298. dbInbound.enable = previous;
  299. }
  300. }, []);
  301. const sortedInbounds = useMemo(() => {
  302. if (!sortKey || !sortOrder) return dbInbounds;
  303. const fn = SORT_FNS[sortKey];
  304. if (!fn) return dbInbounds;
  305. const sorted = [...dbInbounds].sort((a, b) => fn(a, b, { nodesById, clientCount }));
  306. return sortOrder === 'descend' ? sorted.reverse() : sorted;
  307. }, [dbInbounds, sortKey, sortOrder, nodesById, clientCount]);
  308. const hasAnyRemark = useMemo(
  309. () => dbInbounds.some((i) => typeof i.remark === 'string' && i.remark.trim() !== ''),
  310. [dbInbounds],
  311. );
  312. const sorterFor = useCallback((key: SortKey) => ({
  313. sorter: true as const,
  314. showSorterTooltip: false,
  315. sortOrder: sortKey === key ? sortOrder : null,
  316. sortDirections: ['ascend' as const, 'descend' as const],
  317. }), [sortKey, sortOrder]);
  318. const columns: TableColumnType<DBInboundRecord>[] = useMemo(() => {
  319. const cols: TableColumnType<DBInboundRecord>[] = [
  320. {
  321. title: 'ID',
  322. dataIndex: 'id',
  323. key: 'id',
  324. align: 'right',
  325. width: 30,
  326. ...sorterFor('id'),
  327. },
  328. {
  329. title: t('pages.inbounds.operate'),
  330. key: 'action',
  331. align: 'center',
  332. width: 60,
  333. render: (_, record) => (
  334. <RowActionsCell
  335. record={record}
  336. subEnable={subEnable}
  337. hasClients={(clientCount[record.id]?.clients || 0) > 0}
  338. onClick={(key) => onRowAction({ key, dbInbound: record })}
  339. />
  340. ),
  341. },
  342. {
  343. title: t('pages.inbounds.enable'),
  344. key: 'enable',
  345. align: 'center',
  346. width: 35,
  347. ...sorterFor('enable'),
  348. render: (_, record) => (
  349. <Switch
  350. checked={record.enable}
  351. onChange={(next) => onSwitchEnable(record, next)}
  352. />
  353. ),
  354. },
  355. ];
  356. if (hasAnyRemark) {
  357. cols.push({
  358. title: t('pages.inbounds.remark'),
  359. dataIndex: 'remark',
  360. key: 'remark',
  361. align: 'center',
  362. width: 60,
  363. ...sorterFor('remark'),
  364. });
  365. }
  366. if (hasActiveNode) {
  367. cols.push({
  368. title: t('pages.inbounds.node'),
  369. key: 'node',
  370. align: 'center',
  371. width: 60,
  372. ...sorterFor('node'),
  373. render: (_, record) => {
  374. if (record.nodeId == null) {
  375. return <Tag color="default">{t('pages.inbounds.localPanel')}</Tag>;
  376. }
  377. const node = nodesById.get(record.nodeId);
  378. if (!node) {
  379. return <Tag color="orange">node #{record.nodeId}</Tag>;
  380. }
  381. return (
  382. <Tag color={node.status === 'online' ? 'blue' : 'red'}>{node.name}</Tag>
  383. );
  384. },
  385. });
  386. }
  387. cols.push(
  388. {
  389. title: t('pages.inbounds.port'),
  390. dataIndex: 'port',
  391. key: 'port',
  392. align: 'center',
  393. width: 40,
  394. ...sorterFor('port'),
  395. },
  396. {
  397. title: t('pages.inbounds.protocol'),
  398. key: 'protocol',
  399. align: 'left',
  400. width: 130,
  401. ...sorterFor('protocol'),
  402. render: (_, record) => {
  403. const tags: ReactElement[] = [<Tag key="p" color="purple">{record.protocol}</Tag>];
  404. if (record.isWireguard || record.isHysteria) {
  405. tags.push(<Tag key="n" color="green">UDP</Tag>);
  406. } else if (record.isSS) {
  407. const stream = readStreamHints(record.streamSettings);
  408. tags.push(<Tag key="n" color="green">{shadowsocksNetworkLabel(record.settings)}</Tag>);
  409. if (stream.isTls) tags.push(<Tag key="tls" color="blue">TLS</Tag>);
  410. } else if (record.isTunnel) {
  411. tags.push(<Tag key="n" color="green">{tunnelNetworkLabel(record.settings)}</Tag>);
  412. } else if (record.isMixed) {
  413. tags.push(<Tag key="n" color="green">{mixedNetworkLabel(record.settings)}</Tag>);
  414. } else if (record.isVMess || record.isVLess || record.isTrojan) {
  415. const stream = readStreamHints(record.streamSettings);
  416. tags.push(<Tag key="n" color="green">{networkLabel(stream.network)}</Tag>);
  417. const l4 = networkL4(stream.network);
  418. if (l4) tags.push(<Tag key="l4" color="green">{l4}</Tag>);
  419. if (stream.isTls) tags.push(<Tag key="tls" color="blue">TLS</Tag>);
  420. if (stream.isReality) tags.push(<Tag key="reality" color="blue">Reality</Tag>);
  421. }
  422. return <div className="protocol-tags">{tags}</div>;
  423. },
  424. },
  425. {
  426. title: t('clients'),
  427. key: 'clients',
  428. align: 'left',
  429. width: 50,
  430. ...sorterFor('clients'),
  431. render: (_, record) => {
  432. const cc = clientCount[record.id];
  433. if (!cc) return null;
  434. return (
  435. <>
  436. <Tag color="green" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>
  437. {cc.clients}
  438. </Tag>
  439. {cc.deactive.length > 0 && (
  440. <Popover
  441. title={t('disabled')}
  442. content={(
  443. <div className="client-email-list">
  444. {cc.deactive.map((e) => <div key={e}>{e}</div>)}
  445. </div>
  446. )}
  447. >
  448. <Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.deactive.length}</Tag>
  449. </Popover>
  450. )}
  451. {cc.depleted.length > 0 && (
  452. <Popover
  453. title={t('depleted')}
  454. content={(
  455. <div className="client-email-list">
  456. {cc.depleted.map((e) => <div key={e}>{e}</div>)}
  457. </div>
  458. )}
  459. >
  460. <Tag color="red" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.depleted.length}</Tag>
  461. </Popover>
  462. )}
  463. {cc.expiring.length > 0 && (
  464. <Popover
  465. title={t('depletingSoon')}
  466. content={(
  467. <div className="client-email-list">
  468. {cc.expiring.map((e) => <div key={e}>{e}</div>)}
  469. </div>
  470. )}
  471. >
  472. <Tag color="orange" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.expiring.length}</Tag>
  473. </Popover>
  474. )}
  475. {cc.online.length > 0 && (
  476. <Popover
  477. title={t('online')}
  478. content={(
  479. <div className="client-email-list">
  480. {cc.online.map((e) => <div key={e}>{e}</div>)}
  481. </div>
  482. )}
  483. >
  484. <Tag color="blue" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.online.length}</Tag>
  485. </Popover>
  486. )}
  487. </>
  488. );
  489. },
  490. },
  491. {
  492. title: t('pages.inbounds.traffic'),
  493. key: 'traffic',
  494. align: 'center',
  495. width: 90,
  496. ...sorterFor('traffic'),
  497. render: (_, record) => (
  498. <Popover
  499. content={(
  500. <table cellPadding={2}>
  501. <tbody>
  502. <tr>
  503. <td>↑ {SizeFormatter.sizeFormat(record.up)}</td>
  504. <td>↓ {SizeFormatter.sizeFormat(record.down)}</td>
  505. </tr>
  506. {record.total > 0 && record.up + record.down < record.total && (
  507. <tr>
  508. <td>{t('remained')}</td>
  509. <td>{SizeFormatter.sizeFormat(record.total - record.up - record.down)}</td>
  510. </tr>
  511. )}
  512. </tbody>
  513. </table>
  514. )}
  515. >
  516. <Tag color={ColorUtils.usageColor(record.up + record.down, trafficDiff, record.total)}>
  517. {SizeFormatter.sizeFormat(record.up + record.down)} /
  518. {' '}
  519. {record.total > 0 ? SizeFormatter.sizeFormat(record.total) : <InfinityIcon />}
  520. </Tag>
  521. </Popover>
  522. ),
  523. },
  524. {
  525. title: t('pages.inbounds.expireDate'),
  526. key: 'expiryTime',
  527. align: 'center',
  528. width: 40,
  529. ...sorterFor('expiryTime'),
  530. render: (_, record) => {
  531. if (record.expiryTime > 0) {
  532. return (
  533. <Popover content={IntlUtil.formatDate(record.expiryTime, datepicker)}>
  534. <Tag color={ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)} style={{ minWidth: 50 }}>
  535. {IntlUtil.formatRelativeTime(record.expiryTime)}
  536. </Tag>
  537. </Popover>
  538. );
  539. }
  540. return <Tag color="purple"><InfinityIcon /></Tag>;
  541. },
  542. },
  543. );
  544. return cols;
  545. }, [t, hasAnyRemark, hasActiveNode, nodesById, clientCount, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable, sorterFor]);
  546. const paginationFor = (rows: DBInboundRecord[]) => {
  547. const size = pageSize > 0 ? pageSize : rows.length || 1;
  548. return { pageSize: size, showSizeChanger: false, hideOnSinglePage: true };
  549. };
  550. const generalActionsMenu: MenuProps = {
  551. items: [
  552. { key: 'import', icon: <ImportOutlined />, label: t('pages.inbounds.importInbound') },
  553. { key: 'export', icon: <ExportOutlined />, label: t('pages.inbounds.export') },
  554. ...(subEnable
  555. ? [{ key: 'subs', icon: <ExportOutlined />, label: `${t('pages.inbounds.export')} — ${t('pages.settings.subSettings')}` }]
  556. : []),
  557. { key: 'resetInbounds', icon: <ReloadOutlined />, label: t('pages.inbounds.resetAllTraffic') },
  558. ],
  559. onClick: ({ key }) => onGeneralAction(key as GeneralAction),
  560. };
  561. return (
  562. <Card
  563. hoverable
  564. title={(
  565. <Space>
  566. <Button type="primary" onClick={onAddInbound} icon={<PlusOutlined />}>
  567. {!isMobile && t('pages.inbounds.addInbound')}
  568. </Button>
  569. <Dropdown trigger={['click']} menu={generalActionsMenu}>
  570. <Button type="primary" icon={<MenuOutlined />}>
  571. {!isMobile && t('pages.inbounds.generalActions')}
  572. </Button>
  573. </Dropdown>
  574. </Space>
  575. )}
  576. >
  577. <Space orientation="vertical" style={{ width: '100%' }}>
  578. {isMobile ? (
  579. <div className="inbound-cards">
  580. {sortedInbounds.length === 0 ? (
  581. <div className="card-empty">
  582. <ImportOutlined style={{ fontSize: 28, opacity: 0.5 }} />
  583. <div>{t('noData')}</div>
  584. </div>
  585. ) : (
  586. sortedInbounds.map((record) => (
  587. <div key={record.id} className="inbound-card">
  588. <div className="card-head">
  589. <span className="card-id">#{record.id}</span>
  590. <span className="tag-name">{record.remark}</span>
  591. <div className="card-actions" onClick={(e) => e.stopPropagation()}>
  592. <Tooltip title={t('pages.inbounds.inboundInfo')}>
  593. <InfoCircleOutlined className="row-action-trigger" onClick={() => setStatsRecord(record)} />
  594. </Tooltip>
  595. <Switch
  596. checked={record.enable}
  597. size="small"
  598. onChange={(next) => onSwitchEnable(record, next)}
  599. />
  600. <Dropdown
  601. trigger={['click']}
  602. placement="bottomRight"
  603. menu={{
  604. items: buildRowActionsMenu({ record, subEnable, t, isMobile: true, hasClients: (clientCount[record.id]?.clients || 0) > 0 }),
  605. onClick: ({ key }) => onRowAction({ key: key as RowAction, dbInbound: record }),
  606. }}
  607. >
  608. <MoreOutlined className="row-action-trigger" onClick={(e) => e.preventDefault()} />
  609. </Dropdown>
  610. </div>
  611. </div>
  612. </div>
  613. ))
  614. )}
  615. </div>
  616. ) : (
  617. <Table
  618. columns={columns}
  619. dataSource={sortedInbounds}
  620. rowKey={(r) => r.id}
  621. pagination={paginationFor(sortedInbounds)}
  622. scroll={{ x: 1000 }}
  623. style={{ marginTop: 10 }}
  624. size="small"
  625. locale={{
  626. emptyText: (
  627. <div className="card-empty">
  628. <ImportOutlined style={{ fontSize: 32, marginBottom: 8 }} />
  629. <div>{t('noData')}</div>
  630. </div>
  631. ),
  632. }}
  633. onChange={(_p, _f, sorter) => {
  634. const single = Array.isArray(sorter) ? sorter[0] : sorter;
  635. const colKey = (single?.columnKey || single?.field) as SortKey | undefined;
  636. setSortKey(colKey || null);
  637. setSortOrder((single?.order as SortOrder) || null);
  638. }}
  639. />
  640. )}
  641. </Space>
  642. <Modal
  643. open={isMobile && !!statsRecord}
  644. footer={null}
  645. width={360}
  646. centered
  647. title={statsRecord ? `#${statsRecord.id} ${statsRecord.remark || ''}`.trim() : ''}
  648. onCancel={() => setStatsRecord(null)}
  649. destroyOnHidden
  650. >
  651. {statsRecord && (
  652. <div className="card-stats">
  653. <div className="stat-row">
  654. <span className="stat-label">{t('pages.inbounds.protocol')}</span>
  655. <Tag color="purple">{statsRecord.protocol}</Tag>
  656. {(statsRecord.isWireguard || statsRecord.isHysteria) && (
  657. <Tag color="green">UDP</Tag>
  658. )}
  659. {statsRecord.isSS && (() => {
  660. const stream = readStreamHints(statsRecord.streamSettings);
  661. return (
  662. <>
  663. <Tag color="green">{shadowsocksNetworkLabel(statsRecord.settings)}</Tag>
  664. {stream.isTls && <Tag color="blue">TLS</Tag>}
  665. </>
  666. );
  667. })()}
  668. {statsRecord.isTunnel && (
  669. <Tag color="green">{tunnelNetworkLabel(statsRecord.settings)}</Tag>
  670. )}
  671. {statsRecord.isMixed && (
  672. <Tag color="green">{mixedNetworkLabel(statsRecord.settings)}</Tag>
  673. )}
  674. {(statsRecord.isVMess || statsRecord.isVLess || statsRecord.isTrojan) && (() => {
  675. const stream = readStreamHints(statsRecord.streamSettings);
  676. const l4 = networkL4(stream.network);
  677. return (
  678. <>
  679. <Tag color="green">{networkLabel(stream.network)}</Tag>
  680. {l4 && <Tag color="green">{l4}</Tag>}
  681. {stream.isTls && <Tag color="blue">TLS</Tag>}
  682. {stream.isReality && <Tag color="blue">Reality</Tag>}
  683. </>
  684. );
  685. })()}
  686. </div>
  687. <div className="stat-row">
  688. <span className="stat-label">{t('pages.inbounds.port')}</span>
  689. <Tag>{statsRecord.port}</Tag>
  690. </div>
  691. {hasActiveNode && (
  692. <div className="stat-row">
  693. <span className="stat-label">{t('pages.inbounds.node')}</span>
  694. {statsRecord.nodeId == null ? (
  695. <Tag color="default">{t('pages.inbounds.localPanel')}</Tag>
  696. ) : nodesById.get(statsRecord.nodeId) ? (
  697. <Tag color={nodesById.get(statsRecord.nodeId)!.status === 'online' ? 'blue' : 'red'}>
  698. {nodesById.get(statsRecord.nodeId)!.name}
  699. </Tag>
  700. ) : (
  701. <Tag color="orange">#{statsRecord.nodeId}</Tag>
  702. )}
  703. </div>
  704. )}
  705. <div className="stat-row">
  706. <span className="stat-label">{t('pages.inbounds.traffic')}</span>
  707. <Tag color={ColorUtils.usageColor(statsRecord.up + statsRecord.down, trafficDiff, statsRecord.total)}>
  708. {SizeFormatter.sizeFormat(statsRecord.up + statsRecord.down)} /
  709. {' '}
  710. {statsRecord.total > 0 ? SizeFormatter.sizeFormat(statsRecord.total) : <InfinityIcon />}
  711. </Tag>
  712. </div>
  713. {clientCount[statsRecord.id] && (
  714. <div className="stat-row">
  715. <span className="stat-label">{t('clients')}</span>
  716. <Tag color="green" className="client-count-tag">{clientCount[statsRecord.id].clients}</Tag>
  717. {clientCount[statsRecord.id].online.length > 0 && (
  718. <Tag color="blue">{clientCount[statsRecord.id].online.length} {t('online')}</Tag>
  719. )}
  720. {clientCount[statsRecord.id].depleted.length > 0 && (
  721. <Tag color="red">{clientCount[statsRecord.id].depleted.length} {t('depleted')}</Tag>
  722. )}
  723. {clientCount[statsRecord.id].expiring.length > 0 && (
  724. <Tag color="orange">{clientCount[statsRecord.id].expiring.length} {t('depletingSoon')}</Tag>
  725. )}
  726. </div>
  727. )}
  728. <div className="stat-row">
  729. <span className="stat-label">{t('pages.inbounds.expireDate')}</span>
  730. {statsRecord.expiryTime > 0 ? (
  731. <Tag color={ColorUtils.usageColor(Date.now(), expireDiff, statsRecord._expiryTime)}>
  732. {IntlUtil.formatRelativeTime(statsRecord.expiryTime)}
  733. </Tag>
  734. ) : (
  735. <Tag color="purple"><InfinityIcon /></Tag>
  736. )}
  737. </div>
  738. </div>
  739. )}
  740. </Modal>
  741. </Card>
  742. );
  743. }