InboundList.tsx 27 KB

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