useInboundColumns.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  1. import { useMemo, type ReactElement } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { Popover, Switch, Tag, Tooltip, type TableColumnType } from 'antd';
  4. import { TeamOutlined } from '@ant-design/icons';
  5. import { SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
  6. import { InfinityIcon } from '@/components/ui';
  7. import { useDatepicker } from '@/hooks/useDatepicker';
  8. import type { NodeRecord } from '@/api/queries/useNodesQuery';
  9. import { coerceInboundJsonField } from '@/models/dbinbound';
  10. import { RowActionsCell } from './RowActions';
  11. import {
  12. SPEED_COLUMN_WIDTH,
  13. SPEED_TAG_CLASS_NAME,
  14. SPEED_TAG_STYLE,
  15. } from '@/components/utility/speedTagStyle';
  16. import { InboundSpeedTag, isActiveSpeed } from './InboundSpeedTag';
  17. import {
  18. readStreamHints,
  19. networkLabel,
  20. networkL4,
  21. shadowsocksNetworkLabel,
  22. tunnelNetworkLabel,
  23. mixedNetworkLabel,
  24. } from './helpers';
  25. import type { ClientCountEntry, DBInboundRecord, InboundSpeedEntry, RowAction } from './types';
  26. interface UseInboundColumnsParams {
  27. hasAnyRemark: boolean;
  28. hasAnySubSortIndex: boolean;
  29. hasActiveNode: boolean;
  30. nodesById: Map<number, NodeRecord>;
  31. clientCount: Record<number, ClientCountEntry>;
  32. inboundSpeed: Record<number, InboundSpeedEntry>;
  33. subEnable: boolean;
  34. expireDiff: number;
  35. trafficDiff: number;
  36. onRowAction: (action: { key: RowAction; dbInbound: DBInboundRecord }) => void;
  37. onSwitchEnable: (dbInbound: DBInboundRecord, next: boolean) => void;
  38. }
  39. export function useInboundColumns({
  40. hasAnyRemark,
  41. hasAnySubSortIndex,
  42. hasActiveNode,
  43. nodesById,
  44. clientCount,
  45. inboundSpeed,
  46. subEnable,
  47. expireDiff,
  48. trafficDiff,
  49. onRowAction,
  50. onSwitchEnable,
  51. }: UseInboundColumnsParams): TableColumnType<DBInboundRecord>[] {
  52. const { t } = useTranslation();
  53. const { datepicker } = useDatepicker();
  54. return useMemo(() => {
  55. const compareText = (a: string | undefined | null, b: string | undefined | null) =>
  56. (a || '').localeCompare(b || '', undefined, { numeric: true, sensitivity: 'base' });
  57. const nodeName = (record: DBInboundRecord) => {
  58. if (record.nodeId == null) return t('pages.inbounds.localPanel');
  59. return nodesById.get(record.nodeId)?.name || `node #${record.nodeId}`;
  60. };
  61. const clientTotal = (record: DBInboundRecord) =>
  62. (clientCount[record.id] || fallbackClientCount(record))?.clients ?? 0;
  63. const speedTotal = (record: DBInboundRecord) => {
  64. const speed = inboundSpeed[record.id];
  65. return speed ? speed.up + speed.down : 0;
  66. };
  67. const expirySortValue = (record: DBInboundRecord) =>
  68. record.expiryTime > 0 ? record.expiryTime : Number.MAX_SAFE_INTEGER;
  69. const fallbackClientCount = (record: DBInboundRecord): ClientCountEntry | null => {
  70. const settings = coerceInboundJsonField(record.settings) as {
  71. clients?: { email?: string; enable?: boolean }[];
  72. };
  73. const clients = Array.isArray(settings.clients) ? settings.clients : [];
  74. if (clients.length === 0) return null;
  75. const active = clients
  76. .filter((client) => client.email && client.enable !== false)
  77. .map((client) => client.email!);
  78. const deactive = clients
  79. .filter((client) => client.email && client.enable === false)
  80. .map((client) => client.email!);
  81. return {
  82. clients: clients.length,
  83. active,
  84. deactive,
  85. depleted: [],
  86. expiring: [],
  87. online: [],
  88. };
  89. };
  90. const cols: TableColumnType<DBInboundRecord>[] = [
  91. {
  92. title: 'ID',
  93. dataIndex: 'id',
  94. key: 'id',
  95. align: 'right',
  96. width: 60,
  97. sorter: (a, b) => a.id - b.id,
  98. },
  99. {
  100. title: t('pages.inbounds.operate'),
  101. key: 'action',
  102. align: 'center',
  103. width: 70,
  104. render: (_, record) => (
  105. <RowActionsCell
  106. record={record}
  107. subEnable={subEnable}
  108. hasClients={(clientCount[record.id]?.clients || 0) > 0}
  109. onClick={(key) => onRowAction({ key, dbInbound: record })}
  110. />
  111. ),
  112. },
  113. {
  114. title: t('pages.inbounds.enable'),
  115. key: 'enable',
  116. align: 'center',
  117. width: 80,
  118. render: (_, record) => (
  119. <Switch checked={record.enable} onChange={(next) => onSwitchEnable(record, next)} />
  120. ),
  121. },
  122. ];
  123. if (hasAnyRemark) {
  124. cols.push({
  125. title: t('pages.inbounds.remark'),
  126. dataIndex: 'remark',
  127. key: 'remark',
  128. align: 'center',
  129. width: 90,
  130. sorter: (a, b) => compareText(a.remark, b.remark),
  131. });
  132. }
  133. if (hasActiveNode) {
  134. cols.push({
  135. title: t('pages.inbounds.node'),
  136. key: 'node',
  137. align: 'center',
  138. width: 130,
  139. sorter: (a, b) => compareText(nodeName(a), nodeName(b)),
  140. render: (_, record) => {
  141. if (record.nodeId == null) {
  142. return <Tag color="default">{t('pages.inbounds.localPanel')}</Tag>;
  143. }
  144. const node = nodesById.get(record.nodeId);
  145. if (!node) {
  146. return <Tag color="orange">node #{record.nodeId}</Tag>;
  147. }
  148. return <Tag color={node.status === 'online' ? 'blue' : 'red'}>{node.name}</Tag>;
  149. },
  150. });
  151. }
  152. if (hasAnySubSortIndex) {
  153. cols.push({
  154. title: (
  155. <Tooltip title={t('pages.inbounds.form.subSortIndex')}>
  156. {t('pages.inbounds.subSortIndex')}
  157. </Tooltip>
  158. ),
  159. dataIndex: 'subSortIndex',
  160. key: 'subSortIndex',
  161. align: 'right',
  162. width: 90,
  163. sorter: (a, b) => (a.subSortIndex ?? 1) - (b.subSortIndex ?? 1),
  164. });
  165. }
  166. cols.push(
  167. {
  168. title: t('pages.inbounds.port'),
  169. dataIndex: 'port',
  170. key: 'port',
  171. align: 'center',
  172. width: 80,
  173. sorter: (a, b) => a.port - b.port,
  174. },
  175. {
  176. title: t('pages.inbounds.protocol'),
  177. key: 'protocol',
  178. align: 'left',
  179. width: 190,
  180. sorter: (a, b) => compareText(a.protocol, b.protocol),
  181. render: (_, record) => {
  182. const tags: ReactElement[] = [
  183. <Tag key="p" color="purple">
  184. {record.protocol}
  185. </Tag>,
  186. ];
  187. if (record.isWireguard || record.isAmneziawg || record.isHysteria) {
  188. tags.push(
  189. <Tag key="n" color="green">
  190. UDP
  191. </Tag>,
  192. );
  193. } else if (record.isSS) {
  194. const stream = readStreamHints(record.streamSettings);
  195. tags.push(
  196. <Tag key="n" color="green">
  197. {shadowsocksNetworkLabel(record.settings)}
  198. </Tag>,
  199. );
  200. if (stream.isTls)
  201. tags.push(
  202. <Tag key="tls" color="blue">
  203. TLS
  204. </Tag>,
  205. );
  206. } else if (record.isTunnel) {
  207. tags.push(
  208. <Tag key="n" color="green">
  209. {tunnelNetworkLabel(record.settings)}
  210. </Tag>,
  211. );
  212. } else if (record.isMixed) {
  213. tags.push(
  214. <Tag key="n" color="green">
  215. {mixedNetworkLabel(record.settings)}
  216. </Tag>,
  217. );
  218. } else if (record.isVMess || record.isVLess || record.isTrojan) {
  219. const stream = readStreamHints(record.streamSettings);
  220. tags.push(
  221. <Tag key="n" color="green">
  222. {networkLabel(stream.network)}
  223. </Tag>,
  224. );
  225. const l4 = networkL4(stream.network);
  226. if (l4)
  227. tags.push(
  228. <Tag key="l4" color="green">
  229. {l4}
  230. </Tag>,
  231. );
  232. if (stream.isTls)
  233. tags.push(
  234. <Tag key="tls" color="blue">
  235. TLS
  236. </Tag>,
  237. );
  238. if (stream.isReality)
  239. tags.push(
  240. <Tag key="reality" color="blue">
  241. Reality
  242. </Tag>,
  243. );
  244. }
  245. return <div className="protocol-tags">{tags}</div>;
  246. },
  247. },
  248. {
  249. title: t('clients'),
  250. key: 'clients',
  251. align: 'left',
  252. width: 200,
  253. sorter: (a, b) => clientTotal(a) - clientTotal(b),
  254. render: (_, record) => {
  255. const cc = clientCount[record.id] || fallbackClientCount(record);
  256. if (!cc) return null;
  257. return (
  258. <>
  259. <Tag
  260. className="client-count-tag"
  261. style={{ margin: 0, marginRight: 4, padding: '0 2px' }}
  262. >
  263. <TeamOutlined /> {cc.clients}
  264. </Tag>
  265. {cc.active.length > 0 ? (
  266. <Popover
  267. title={t('subscription.active')}
  268. content={
  269. <div className="client-email-list">
  270. {cc.active.map((e) => (
  271. <div key={e}>{e}</div>
  272. ))}
  273. </div>
  274. }
  275. >
  276. <Tag
  277. color="green"
  278. className="client-count-tag"
  279. style={{ margin: 0, marginRight: 4, padding: '0 2px' }}
  280. >
  281. {cc.active.length}
  282. </Tag>
  283. </Popover>
  284. ) : (
  285. <Tag
  286. color="green"
  287. className="client-count-tag"
  288. style={{ margin: 0, marginRight: 4, padding: '0 2px' }}
  289. >
  290. 0
  291. </Tag>
  292. )}
  293. {cc.deactive.length > 0 && (
  294. <Popover
  295. title={t('disabled')}
  296. content={
  297. <div className="client-email-list">
  298. {cc.deactive.map((e) => (
  299. <div key={e}>{e}</div>
  300. ))}
  301. </div>
  302. }
  303. >
  304. <Tag
  305. className="client-count-tag"
  306. style={{ margin: 0, marginRight: 4, padding: '0 2px' }}
  307. >
  308. {cc.deactive.length}
  309. </Tag>
  310. </Popover>
  311. )}
  312. {cc.depleted.length > 0 && (
  313. <Popover
  314. title={t('depleted')}
  315. content={
  316. <div className="client-email-list">
  317. {cc.depleted.map((e) => (
  318. <div key={e}>{e}</div>
  319. ))}
  320. </div>
  321. }
  322. >
  323. <Tag
  324. color="red"
  325. className="client-count-tag"
  326. style={{ margin: 0, marginRight: 4, padding: '0 2px' }}
  327. >
  328. {cc.depleted.length}
  329. </Tag>
  330. </Popover>
  331. )}
  332. {cc.online.length > 0 && (
  333. <Popover
  334. title={t('online')}
  335. content={
  336. <div className="client-email-list">
  337. {cc.online.map((e) => (
  338. <div key={e}>{e}</div>
  339. ))}
  340. </div>
  341. }
  342. >
  343. <Tag
  344. color="blue"
  345. className="client-count-tag"
  346. style={{ margin: 0, padding: '0 2px' }}
  347. >
  348. {cc.online.length}
  349. </Tag>
  350. </Popover>
  351. )}
  352. </>
  353. );
  354. },
  355. },
  356. {
  357. title: t('pages.inbounds.traffic'),
  358. key: 'traffic',
  359. align: 'center',
  360. width: 140,
  361. sorter: (a, b) => a.up + a.down - (b.up + b.down),
  362. render: (_, record) => (
  363. <Popover
  364. content={
  365. <table cellPadding={2}>
  366. <tbody>
  367. <tr>
  368. <td>↑ {SizeFormatter.sizeFormat(record.up)}</td>
  369. <td>↓ {SizeFormatter.sizeFormat(record.down)}</td>
  370. </tr>
  371. {record.total > 0 && record.up + record.down < record.total && (
  372. <tr>
  373. <td>{t('remained')}</td>
  374. <td>{SizeFormatter.sizeFormat(record.total - record.up - record.down)}</td>
  375. </tr>
  376. )}
  377. </tbody>
  378. </table>
  379. }
  380. >
  381. <Tag color={ColorUtils.usageColor(record.up + record.down, trafficDiff, record.total)}>
  382. {SizeFormatter.sizeFormat(record.up + record.down)} /{' '}
  383. {record.total > 0 ? SizeFormatter.sizeFormat(record.total) : <InfinityIcon />}
  384. </Tag>
  385. </Popover>
  386. ),
  387. },
  388. {
  389. title: t('pages.inbounds.speed'),
  390. key: 'speed',
  391. align: 'center',
  392. width: SPEED_COLUMN_WIDTH,
  393. sorter: (a, b) => speedTotal(a) - speedTotal(b),
  394. render: (_, record) => {
  395. const speed = inboundSpeed[record.id];
  396. if (!isActiveSpeed(speed)) {
  397. return (
  398. <Tag color="default" className={SPEED_TAG_CLASS_NAME} style={SPEED_TAG_STYLE}>
  399. </Tag>
  400. );
  401. }
  402. return <InboundSpeedTag speed={speed} withTooltip tableCell />;
  403. },
  404. },
  405. {
  406. title: t('pages.inbounds.expireDate'),
  407. key: 'expiryTime',
  408. align: 'center',
  409. width: 100,
  410. sorter: (a, b) => expirySortValue(a) - expirySortValue(b),
  411. render: (_, record) => {
  412. if (record.expiryTime > 0) {
  413. return (
  414. <Popover content={IntlUtil.formatDate(record.expiryTime, datepicker)}>
  415. <Tag
  416. color={ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)}
  417. style={{ minWidth: 50 }}
  418. >
  419. {IntlUtil.formatRelativeTime(record.expiryTime)}
  420. </Tag>
  421. </Popover>
  422. );
  423. }
  424. return (
  425. <Tag color="purple">
  426. <InfinityIcon />
  427. </Tag>
  428. );
  429. },
  430. },
  431. );
  432. return cols;
  433. }, [
  434. t,
  435. hasAnyRemark,
  436. hasAnySubSortIndex,
  437. hasActiveNode,
  438. nodesById,
  439. clientCount,
  440. inboundSpeed,
  441. subEnable,
  442. expireDiff,
  443. trafficDiff,
  444. datepicker,
  445. onRowAction,
  446. onSwitchEnable,
  447. ]);
  448. }