OutboundsTab.tsx 19 KB

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