SubscriptionOutbounds.tsx 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. import { useMemo } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { Button, Popover, Table, Tag, Tooltip } from 'antd';
  4. import {
  5. ThunderboltOutlined,
  6. CheckCircleFilled,
  7. CloseCircleFilled,
  8. LoadingOutlined,
  9. } from '@ant-design/icons';
  10. import type { ColumnsType } from 'antd/es/table';
  11. import { SizeFormatter } from '@/utils';
  12. import { OutboundProtocols as Protocols } from '@/schemas/primitives';
  13. import { isUdpOutbound } from '@/hooks/useXraySetting';
  14. import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
  15. import type { OutboundRow } from './outbounds-tab-types';
  16. import {
  17. hasBreakdown,
  18. isTesting,
  19. isUntestable,
  20. outboundAddresses,
  21. showSecurity,
  22. testResult,
  23. trafficFor,
  24. } from './outbounds-tab-helpers';
  25. interface SubscriptionOutboundsProps {
  26. subscriptionOutbounds: unknown[];
  27. outboundsTraffic: OutboundTrafficRow[];
  28. subscriptionTestStates: Record<string, OutboundTestState>;
  29. testMode: 'tcp' | 'http';
  30. isMobile: boolean;
  31. onTestSubscription: (outbound: Record<string, unknown>, mode: string) => void;
  32. }
  33. // Read-only view of outbounds imported from active subscriptions. They are not
  34. // part of the editable template (so no edit/delete/move), but traffic is matched
  35. // by tag and they can be latency-tested via the same backend endpoint.
  36. export default function SubscriptionOutbounds({
  37. subscriptionOutbounds,
  38. outboundsTraffic,
  39. subscriptionTestStates,
  40. testMode,
  41. isMobile,
  42. onTestSubscription,
  43. }: SubscriptionOutboundsProps) {
  44. const { t } = useTranslation();
  45. const rows = useMemo<OutboundRow[]>(
  46. () => (subscriptionOutbounds || []).map((o, i) => ({ ...(o as object), key: i }) as OutboundRow),
  47. [subscriptionOutbounds],
  48. );
  49. if (rows.length === 0) return null;
  50. const identityCell = (record: OutboundRow) => (
  51. <div className="identity-cell">
  52. <Tooltip title={record.tag}>
  53. <span className="tag-name">{record.tag || '—'}</span>
  54. </Tooltip>
  55. <div className="protocol-line">
  56. <Tag color="green">{record.protocol}</Tag>
  57. {[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(record.protocol as never) && (
  58. <>
  59. <Tag>{record.streamSettings?.network}</Tag>
  60. {showSecurity(record.streamSettings?.security) && <Tag color="purple">{record.streamSettings?.security}</Tag>}
  61. </>
  62. )}
  63. </div>
  64. </div>
  65. );
  66. const addressCell = (record: OutboundRow) => {
  67. const addrs = outboundAddresses(record);
  68. return (
  69. <div className="address-list">
  70. {addrs.length === 0 ? (
  71. <span className="empty">—</span>
  72. ) : (
  73. addrs.map((addr) => (
  74. <Tooltip key={addr} title={addr}>
  75. <span className="address-pill">{addr}</span>
  76. </Tooltip>
  77. ))
  78. )}
  79. </div>
  80. );
  81. };
  82. const trafficCell = (record: OutboundRow) => {
  83. const tr = trafficFor(outboundsTraffic, record);
  84. return (
  85. <>
  86. <span className="traffic-up">↑ {SizeFormatter.sizeFormat(tr.up)}</span>
  87. <span className="traffic-sep" />
  88. <span className="traffic-down">↓ {SizeFormatter.sizeFormat(tr.down)}</span>
  89. </>
  90. );
  91. };
  92. const latencyCell = (record: OutboundRow) => {
  93. const key = record.tag || '';
  94. const r = testResult(subscriptionTestStates, key);
  95. if (!r) return isTesting(subscriptionTestStates, key) ? <LoadingOutlined /> : <span className="empty">—</span>;
  96. return (
  97. <Popover
  98. placement="topLeft"
  99. rootClassName="outbound-test-popover"
  100. content={
  101. <div className="timing-breakdown">
  102. <div className={`td-head ${r.success ? 'ok' : 'fail'}`}>
  103. {r.success ? <span>{r.delay} ms</span> : <span>{r.error || 'failed'}</span>}
  104. {r.mode && <span className="mode-badge">{String(r.mode).toUpperCase()}</span>}
  105. </div>
  106. {hasBreakdown(r) && (
  107. <>
  108. {(r.endpoints || []).map((ep) => (
  109. <div key={ep.address} className="endpoint-row">
  110. <span className={ep.success ? 'dot-ok' : 'dot-fail'}>●</span>
  111. <span className="ep-addr">{ep.address}</span>
  112. <span className="ep-meta">{ep.success ? `${ep.delay} ms` : ep.error || 'failed'}</span>
  113. </div>
  114. ))}
  115. </>
  116. )}
  117. </div>
  118. }
  119. >
  120. <span className={r.success ? 'pill-ok' : 'pill-fail'}>
  121. {r.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
  122. {r.success ? <span>{r.delay}&nbsp;ms</span> : <span>failed</span>}
  123. </span>
  124. </Popover>
  125. );
  126. };
  127. const testButton = (record: OutboundRow) => {
  128. const key = record.tag || '';
  129. return (
  130. <Tooltip title={`${t('check')} (${(isUdpOutbound(record) ? 'http' : testMode).toUpperCase()})`}>
  131. <Button
  132. type="primary"
  133. shape="circle"
  134. size={isMobile ? 'small' : undefined}
  135. loading={isTesting(subscriptionTestStates, key)}
  136. disabled={!record.tag || isUntestable(record, testMode) || isTesting(subscriptionTestStates, key)}
  137. icon={<ThunderboltOutlined />}
  138. onClick={() => onTestSubscription(record as unknown as Record<string, unknown>, testMode)}
  139. />
  140. </Tooltip>
  141. );
  142. };
  143. const header = (
  144. <div className="subscription-outbounds-head">
  145. <div className="subscription-outbounds-title">{t('pages.xray.outboundSub.fromSubsTitle')}</div>
  146. <div className="subscription-outbounds-desc">{t('pages.xray.outboundSub.fromSubsDesc')}</div>
  147. </div>
  148. );
  149. if (isMobile) {
  150. return (
  151. <div className="subscription-outbounds" style={{ marginTop: 16 }}>
  152. {header}
  153. {rows.map((record, index) => (
  154. <div key={record.key} className="outbound-card">
  155. <div className="card-head">
  156. <div className="card-identity">
  157. <span className="card-num">{index + 1}</span>
  158. {identityCell(record)}
  159. </div>
  160. {testButton(record)}
  161. </div>
  162. {outboundAddresses(record).length > 0 && addressCell(record)}
  163. <div className="card-foot">
  164. {trafficCell(record)}
  165. <span className="card-test">{latencyCell(record)}</span>
  166. </div>
  167. </div>
  168. ))}
  169. </div>
  170. );
  171. }
  172. const columns: ColumnsType<OutboundRow> = [
  173. {
  174. title: '#',
  175. key: 'num',
  176. align: 'center',
  177. width: 60,
  178. render: (_v, _record, index) => <span className="row-index">{index + 1}</span>,
  179. },
  180. { title: t('pages.xray.outbound.tag'), key: 'identity', align: 'left', render: (_v, record) => identityCell(record) },
  181. { title: t('pages.inbounds.address'), key: 'address', align: 'left', render: (_v, record) => addressCell(record) },
  182. { title: t('pages.inbounds.traffic'), key: 'traffic', align: 'left', width: 200, render: (_v, record) => trafficCell(record) },
  183. { title: t('pages.nodes.latency'), key: 'testResult', align: 'left', width: 140, render: (_v, record) => latencyCell(record) },
  184. { title: t('check'), key: 'test', align: 'center', width: 80, render: (_v, record) => testButton(record) },
  185. ];
  186. return (
  187. <div className="subscription-outbounds" style={{ marginTop: 16 }}>
  188. {header}
  189. <Table columns={columns} dataSource={rows} rowKey={(r) => r.key} pagination={false} size="small" />
  190. </div>
  191. );
  192. }