useInboundColumns.tsx 13 KB

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