OutboundsTab.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516
  1. import { useCallback, useMemo, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import {
  4. Button,
  5. Col,
  6. Dropdown,
  7. Modal,
  8. Popconfirm,
  9. Popover,
  10. Radio,
  11. Row,
  12. Space,
  13. Table,
  14. Tag,
  15. Tooltip,
  16. } from 'antd';
  17. import {
  18. PlusOutlined,
  19. CloudOutlined,
  20. ApiOutlined,
  21. RetweetOutlined,
  22. MoreOutlined,
  23. EditOutlined,
  24. DeleteOutlined,
  25. VerticalAlignTopOutlined,
  26. ThunderboltOutlined,
  27. CheckCircleFilled,
  28. CloseCircleFilled,
  29. LoadingOutlined,
  30. ArrowUpOutlined,
  31. ArrowDownOutlined,
  32. PlayCircleOutlined,
  33. } from '@ant-design/icons';
  34. import type { ColumnsType } from 'antd/es/table';
  35. import { SizeFormatter } from '@/utils';
  36. import { OutboundProtocols as Protocols } from '@/schemas/primitives';
  37. import OutboundFormModal from './OutboundFormModal';
  38. import { isUdpOutbound } from '@/hooks/useXraySetting';
  39. import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
  40. import './OutboundsTab.css';
  41. interface OutboundsTabProps {
  42. templateSettings: XraySettingsValue | null;
  43. setTemplateSettings: SetTemplate;
  44. outboundsTraffic: OutboundTrafficRow[];
  45. outboundTestStates: Record<number, OutboundTestState>;
  46. testingAll: boolean;
  47. inboundTags: string[];
  48. isMobile: boolean;
  49. onResetTraffic: (tag: string) => void;
  50. onTest: (index: number, mode: string) => void;
  51. onTestAll: (mode: string) => void;
  52. onShowWarp: () => void;
  53. onShowNord: () => void;
  54. }
  55. interface OutboundRow {
  56. key: number;
  57. tag?: string;
  58. protocol?: string;
  59. streamSettings?: { network?: string; security?: string };
  60. settings?: Record<string, unknown>;
  61. }
  62. function outboundAddresses(o: OutboundRow): string[] {
  63. const settings = o.settings as Record<string, unknown> | undefined;
  64. switch (o.protocol) {
  65. case Protocols.VMess: {
  66. const serverObj = settings?.vnext as Array<{ address: string; port: number }> | undefined;
  67. return serverObj ? serverObj.map((s) => `${s.address}:${s.port}`) : [];
  68. }
  69. case Protocols.VLESS:
  70. return [`${settings?.address || ''}:${settings?.port || ''}`];
  71. case Protocols.HTTP:
  72. case Protocols.Socks:
  73. case Protocols.Shadowsocks:
  74. case Protocols.Trojan: {
  75. const serverObj = settings?.servers as Array<{ address: string; port: number }> | undefined;
  76. return serverObj ? serverObj.map((s) => `${s.address}:${s.port}`) : [];
  77. }
  78. case Protocols.DNS: {
  79. const addr = (settings?.rewriteAddress as string) || (settings?.address as string) || '';
  80. const port = (settings?.rewritePort as string | number) || (settings?.port as string | number) || '';
  81. return addr || port ? [`${addr}:${port}`] : [];
  82. }
  83. case Protocols.Wireguard:
  84. return (((settings?.peers as Array<{ endpoint?: string }>) || []).map((p) => p.endpoint || '').filter(Boolean));
  85. default:
  86. return [];
  87. }
  88. }
  89. function isUntestable(o: OutboundRow, mode: string): boolean {
  90. if (!o) return true;
  91. if (o.protocol === Protocols.Blackhole || o.protocol === Protocols.Loopback || o.tag === 'blocked') return true;
  92. if (mode === 'tcp' && (o.protocol === Protocols.Freedom || o.protocol === Protocols.DNS)) return true;
  93. return false;
  94. }
  95. function showSecurity(security?: string): boolean {
  96. return security === 'tls' || security === 'reality';
  97. }
  98. function hasBreakdown(r: { endpoints?: unknown[]; error?: string } | null | undefined): boolean {
  99. if (!r) return false;
  100. if (r.endpoints?.length) return true;
  101. return !!r.error;
  102. }
  103. export default function OutboundsTab({
  104. templateSettings,
  105. setTemplateSettings,
  106. outboundsTraffic,
  107. outboundTestStates,
  108. testingAll,
  109. inboundTags: _inboundTags,
  110. isMobile,
  111. onResetTraffic,
  112. onTest,
  113. onTestAll,
  114. onShowWarp,
  115. onShowNord,
  116. }: OutboundsTabProps) {
  117. const { t } = useTranslation();
  118. const [modal, modalContextHolder] = Modal.useModal();
  119. const [testMode, setTestMode] = useState<'tcp' | 'http'>('tcp');
  120. const [modalOpen, setModalOpen] = useState(false);
  121. const [editingOutbound, setEditingOutbound] = useState<Record<string, unknown> | null>(null);
  122. const [editingIndex, setEditingIndex] = useState<number | null>(null);
  123. const [existingTags, setExistingTags] = useState<string[]>([]);
  124. const outbounds = useMemo(
  125. () => (templateSettings?.outbounds || []) as unknown as OutboundRow[],
  126. [templateSettings?.outbounds],
  127. );
  128. const rows = useMemo(() => outbounds.map((o, i) => ({ ...o, key: i })), [outbounds]);
  129. const mutate = useCallback(
  130. (mutator: (next: XraySettingsValue) => void) => {
  131. setTemplateSettings((prev) => {
  132. if (!prev) return prev;
  133. const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue;
  134. mutator(clone);
  135. return clone;
  136. });
  137. },
  138. [setTemplateSettings],
  139. );
  140. function openAdd() {
  141. setEditingOutbound(null);
  142. setEditingIndex(null);
  143. setExistingTags((templateSettings?.outbounds || []).map((o) => o?.tag).filter((tg): tg is string => !!tg));
  144. setModalOpen(true);
  145. }
  146. function openEdit(idx: number) {
  147. setEditingOutbound((templateSettings?.outbounds || [])[idx] as Record<string, unknown>);
  148. setEditingIndex(idx);
  149. setExistingTags(
  150. (templateSettings?.outbounds || [])
  151. .filter((_, i) => i !== idx)
  152. .map((o) => o?.tag)
  153. .filter((tg): tg is string => !!tg),
  154. );
  155. setModalOpen(true);
  156. }
  157. function onConfirm(outbound: Record<string, unknown>) {
  158. mutate((tt) => {
  159. if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
  160. if (editingIndex == null) {
  161. if (!outbound.tag) return;
  162. tt.outbounds.push(outbound as never);
  163. } else {
  164. tt.outbounds[editingIndex] = outbound as never;
  165. }
  166. });
  167. setModalOpen(false);
  168. }
  169. function confirmDelete(idx: number) {
  170. modal.confirm({
  171. title: `${t('delete')} ${t('pages.xray.Outbounds')} #${idx + 1}?`,
  172. okText: t('delete'),
  173. okType: 'danger',
  174. cancelText: t('cancel'),
  175. onOk: () => {
  176. mutate((tt) => {
  177. tt.outbounds?.splice(idx, 1);
  178. });
  179. },
  180. });
  181. }
  182. function setFirst(idx: number) {
  183. mutate((tt) => {
  184. if (!tt.outbounds) return;
  185. const [moved] = tt.outbounds.splice(idx, 1);
  186. tt.outbounds.unshift(moved);
  187. });
  188. }
  189. function moveUp(idx: number) {
  190. if (idx <= 0) return;
  191. mutate((tt) => {
  192. if (!tt.outbounds) return;
  193. [tt.outbounds[idx - 1], tt.outbounds[idx]] = [tt.outbounds[idx], tt.outbounds[idx - 1]];
  194. });
  195. }
  196. function moveDown(idx: number) {
  197. mutate((tt) => {
  198. if (!tt.outbounds || idx >= tt.outbounds.length - 1) return;
  199. [tt.outbounds[idx + 1], tt.outbounds[idx]] = [tt.outbounds[idx], tt.outbounds[idx + 1]];
  200. });
  201. }
  202. function trafficFor(o: OutboundRow): { up: number; down: number } {
  203. const tr = outboundsTraffic.find((x) => x.tag === o.tag);
  204. return { up: tr?.up || 0, down: tr?.down || 0 };
  205. }
  206. function isTesting(idx: number): boolean {
  207. return !!outboundTestStates?.[idx]?.testing;
  208. }
  209. function testResult(idx: number) {
  210. return outboundTestStates?.[idx]?.result || null;
  211. }
  212. const columns: ColumnsType<OutboundRow> = useMemo(
  213. () => [
  214. {
  215. title: '#',
  216. key: 'action',
  217. align: 'center',
  218. width: 100,
  219. render: (_v, _record, index) => (
  220. <div className="action-cell">
  221. <span className="row-index">{index + 1}</span>
  222. <div className="action-buttons">
  223. <Button shape="circle" size="small" icon={<EditOutlined />} onClick={() => openEdit(index)} />
  224. <Dropdown
  225. trigger={['click']}
  226. menu={{
  227. items: [
  228. ...(index > 0
  229. ? [
  230. { key: 'top', label: <><VerticalAlignTopOutlined /> Move to top</>, onClick: () => setFirst(index) },
  231. ]
  232. : []),
  233. { key: 'up', label: <ArrowUpOutlined />, disabled: index === 0, onClick: () => moveUp(index) },
  234. { key: 'down', label: <ArrowDownOutlined />, disabled: index === rows.length - 1, onClick: () => moveDown(index) },
  235. { key: 'reset', label: <><RetweetOutlined /> Reset traffic</>, onClick: () => onResetTraffic(rows[index].tag || '') },
  236. { key: 'del', danger: true, label: <><DeleteOutlined /> Delete</>, onClick: () => confirmDelete(index) },
  237. ],
  238. }}
  239. >
  240. <Button shape="circle" size="small" icon={<MoreOutlined />} />
  241. </Dropdown>
  242. </div>
  243. </div>
  244. ),
  245. },
  246. {
  247. title: t('pages.xray.outbound.tag'),
  248. key: 'identity',
  249. align: 'left',
  250. render: (_v, record) => (
  251. <div className="identity-cell">
  252. <Tooltip title={record.tag}>
  253. <span className="tag-name">{record.tag}</span>
  254. </Tooltip>
  255. <div className="protocol-line">
  256. <Tag color="green">{record.protocol}</Tag>
  257. {[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(record.protocol as never) && (
  258. <>
  259. <Tag>{record.streamSettings?.network}</Tag>
  260. {showSecurity(record.streamSettings?.security) && <Tag color="purple">{record.streamSettings?.security}</Tag>}
  261. </>
  262. )}
  263. </div>
  264. </div>
  265. ),
  266. },
  267. {
  268. title: t('pages.inbounds.address'),
  269. key: 'address',
  270. align: 'left',
  271. render: (_v, record) => {
  272. const addrs = outboundAddresses(record);
  273. return (
  274. <div className="address-list">
  275. {addrs.length === 0 ? (
  276. <span className="empty">—</span>
  277. ) : (
  278. addrs.map((addr) => (
  279. <Tooltip key={addr} title={addr}>
  280. <span className="address-pill">{addr}</span>
  281. </Tooltip>
  282. ))
  283. )}
  284. </div>
  285. );
  286. },
  287. },
  288. {
  289. title: t('pages.inbounds.traffic'),
  290. key: 'traffic',
  291. align: 'left',
  292. width: 200,
  293. render: (_v, record) => {
  294. const tr = trafficFor(record);
  295. return (
  296. <>
  297. <span className="traffic-up">↑ {SizeFormatter.sizeFormat(tr.up)}</span>
  298. <span className="traffic-sep" />
  299. <span className="traffic-down">↓ {SizeFormatter.sizeFormat(tr.down)}</span>
  300. </>
  301. );
  302. },
  303. },
  304. {
  305. title: t('pages.nodes.latency'),
  306. key: 'testResult',
  307. align: 'left',
  308. width: 140,
  309. render: (_v, _record, index) => {
  310. const r = testResult(index);
  311. if (!r) return isTesting(index) ? <LoadingOutlined /> : <span className="empty">—</span>;
  312. return (
  313. <Popover
  314. placement="topLeft"
  315. rootClassName="outbound-test-popover"
  316. content={
  317. <div className="timing-breakdown">
  318. <div className={`td-head ${r.success ? 'ok' : 'fail'}`}>
  319. {r.success ? <span>{r.delay} ms</span> : <span>{r.error || 'failed'}</span>}
  320. {r.mode && <span className="mode-badge">{String(r.mode).toUpperCase()}</span>}
  321. </div>
  322. {hasBreakdown(r) && (
  323. <>
  324. {(r.endpoints || []).map((ep) => (
  325. <div key={ep.address} className="endpoint-row">
  326. <span className={ep.success ? 'dot-ok' : 'dot-fail'}>●</span>
  327. <span className="ep-addr">{ep.address}</span>
  328. <span className="ep-meta">{ep.success ? `${ep.delay} ms` : ep.error || 'failed'}</span>
  329. </div>
  330. ))}
  331. </>
  332. )}
  333. </div>
  334. }
  335. >
  336. <span className={r.success ? 'pill-ok' : 'pill-fail'}>
  337. {r.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
  338. {r.success ? <span>{r.delay}&nbsp;ms</span> : <span>failed</span>}
  339. </span>
  340. </Popover>
  341. );
  342. },
  343. },
  344. {
  345. title: t('check'),
  346. key: 'test',
  347. align: 'center',
  348. width: 80,
  349. render: (_v, record, index) => (
  350. <Tooltip title={`${t('check')} (${(isUdpOutbound(record) ? 'http' : testMode).toUpperCase()})`}>
  351. <Button
  352. type="primary"
  353. shape="circle"
  354. loading={isTesting(index)}
  355. disabled={isUntestable(record, testMode) || isTesting(index)}
  356. icon={<ThunderboltOutlined />}
  357. onClick={() => onTest(index, testMode)}
  358. />
  359. </Tooltip>
  360. ),
  361. },
  362. ],
  363. // eslint-disable-next-line react-hooks/exhaustive-deps
  364. [t, testMode, rows, outboundTestStates, outboundsTraffic],
  365. );
  366. return (
  367. <>
  368. {modalContextHolder}
  369. <Space orientation="vertical" size="middle" style={{ width: '100%' }}>
  370. <Row gutter={[12, 12]} align="middle" justify="space-between">
  371. <Col xs={24} sm={12}>
  372. <Space size="small" wrap>
  373. <Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
  374. {!isMobile && t('pages.xray.Outbounds')}
  375. </Button>
  376. <Button type="primary" icon={<CloudOutlined />} onClick={onShowWarp}>
  377. WARP
  378. </Button>
  379. <Button type="primary" icon={<ApiOutlined />} onClick={onShowNord}>
  380. NordVPN
  381. </Button>
  382. </Space>
  383. </Col>
  384. <Col xs={24} sm={12} className="toolbar-right">
  385. <Space size="small" wrap>
  386. <Tooltip title={t('pages.xray.outbound.testModeTooltip')}>
  387. <Radio.Group value={testMode} onChange={(e) => setTestMode(e.target.value)} buttonStyle="solid" size="small">
  388. <Radio.Button value="tcp">TCP</Radio.Button>
  389. <Radio.Button value="http">HTTP</Radio.Button>
  390. </Radio.Group>
  391. </Tooltip>
  392. <Button type="primary" loading={testingAll} icon={<PlayCircleOutlined />} onClick={() => onTestAll(testMode)}>
  393. {!isMobile && t('pages.xray.outbound.testAll')}
  394. </Button>
  395. <Popconfirm
  396. placement="topRight"
  397. okText={t('reset')}
  398. cancelText={t('cancel')}
  399. title={t('pages.inbounds.resetAllTrafficContent')}
  400. onConfirm={() => onResetTraffic('-alltags-')}
  401. >
  402. <Button icon={<RetweetOutlined />} />
  403. </Popconfirm>
  404. </Space>
  405. </Col>
  406. </Row>
  407. {isMobile ? (
  408. rows.length === 0 ? (
  409. <div className="card-empty">—</div>
  410. ) : (
  411. rows.map((record, index) => (
  412. <div key={record.key} className="outbound-card">
  413. <div className="card-head">
  414. <div className="card-identity">
  415. <span className="card-num">{index + 1}</span>
  416. <Tooltip title={record.tag}>
  417. <span className="tag-name">{record.tag}</span>
  418. </Tooltip>
  419. <Tag color="green">{record.protocol}</Tag>
  420. {[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(record.protocol as never) && (
  421. <>
  422. <Tag>{record.streamSettings?.network}</Tag>
  423. {showSecurity(record.streamSettings?.security) && <Tag color="purple">{record.streamSettings?.security}</Tag>}
  424. </>
  425. )}
  426. </div>
  427. <Dropdown
  428. trigger={['click']}
  429. menu={{
  430. items: [
  431. ...(index > 0
  432. ? [{ key: 'top', label: <VerticalAlignTopOutlined />, onClick: () => setFirst(index) }]
  433. : []),
  434. { key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEdit(index) },
  435. { key: 'reset', label: <><RetweetOutlined /> {t('pages.inbounds.resetTraffic')}</>, onClick: () => onResetTraffic(record.tag || '') },
  436. { key: 'del', danger: true, label: <><DeleteOutlined /> {t('delete')}</>, onClick: () => confirmDelete(index) },
  437. ],
  438. }}
  439. >
  440. <Button shape="circle" size="small" icon={<MoreOutlined />} />
  441. </Dropdown>
  442. </div>
  443. {outboundAddresses(record).length > 0 && (
  444. <div className="address-list">
  445. {outboundAddresses(record).map((addr) => (
  446. <Tooltip key={addr} title={addr}>
  447. <span className="address-pill">{addr}</span>
  448. </Tooltip>
  449. ))}
  450. </div>
  451. )}
  452. <div className="card-foot">
  453. <span className="traffic-up">↑ {SizeFormatter.sizeFormat(trafficFor(record).up)}</span>
  454. <span className="traffic-sep" />
  455. <span className="traffic-down">↓ {SizeFormatter.sizeFormat(trafficFor(record).down)}</span>
  456. <span className="card-test">
  457. {testResult(index) ? (
  458. <span className={testResult(index)!.success ? 'pill-ok' : 'pill-fail'}>
  459. {testResult(index)!.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
  460. {testResult(index)!.success ? <span>{testResult(index)!.delay}&nbsp;ms</span> : <span>failed</span>}
  461. </span>
  462. ) : isTesting(index) ? (
  463. <LoadingOutlined />
  464. ) : null}
  465. <Button
  466. type="primary"
  467. shape="circle"
  468. size="small"
  469. loading={isTesting(index)}
  470. disabled={isUntestable(record, testMode) || isTesting(index)}
  471. icon={<ThunderboltOutlined />}
  472. onClick={() => onTest(index, testMode)}
  473. />
  474. </span>
  475. </div>
  476. </div>
  477. ))
  478. )
  479. ) : (
  480. <Table
  481. columns={columns}
  482. dataSource={rows}
  483. rowKey={(r) => r.key}
  484. pagination={false}
  485. size="small"
  486. />
  487. )}
  488. <OutboundFormModal
  489. open={modalOpen}
  490. outbound={editingOutbound}
  491. existingTags={existingTags}
  492. onClose={() => setModalOpen(false)}
  493. onConfirm={onConfirm}
  494. />
  495. </Space>
  496. </>
  497. );
  498. }