InboundList.tsx 23 KB

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