BasicsTab.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498
  1. import { useCallback } from 'react';
  2. import { onNumber } from '@/utils/onNumber';
  3. import { useTranslation } from 'react-i18next';
  4. import { Alert, Button, Input, InputNumber, Modal, Select, Space, Switch, Tabs } from 'antd';
  5. import {
  6. BarChartOutlined,
  7. ClockCircleOutlined,
  8. FileTextOutlined,
  9. ReloadOutlined,
  10. SettingOutlined,
  11. } from '@ant-design/icons';
  12. import { OutboundDomainStrategies } from '@/schemas/primitives';
  13. import { HappyEyeballsSchema } from '@/schemas/protocols/stream/sockopt';
  14. import { SettingListItem } from '@/components/ui';
  15. import { useMediaQuery } from '@/hooks/useMediaQuery';
  16. import { catTabLabel } from '@/pages/settings/catTabLabel';
  17. import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
  18. import './BasicsTab.css';
  19. import {
  20. ACCESS_LOG,
  21. ERROR_LOG,
  22. LOG_LEVELS,
  23. MASK_ADDRESS,
  24. ROUTING_DOMAIN_STRATEGIES,
  25. } from './constants';
  26. import {
  27. directFreedomStrategy,
  28. ensureDirectFreedomOutbound,
  29. isDirectFreedomOutbound,
  30. isDirectTagTaken,
  31. setDirectFreedomStrategy,
  32. } from './helpers';
  33. interface BasicsTabProps {
  34. templateSettings: XraySettingsValue | null;
  35. setTemplateSettings: SetTemplate;
  36. outboundTestUrl: string;
  37. onChangeOutboundTestUrl: (v: string) => void;
  38. onResetDefault: () => void;
  39. }
  40. export default function BasicsTab({
  41. templateSettings,
  42. setTemplateSettings,
  43. outboundTestUrl,
  44. onChangeOutboundTestUrl,
  45. onResetDefault,
  46. }: BasicsTabProps) {
  47. const { t } = useTranslation();
  48. const { isMobile } = useMediaQuery();
  49. const [modal, modalContextHolder] = Modal.useModal();
  50. const mutate = useCallback(
  51. (mutator: (next: XraySettingsValue) => void) => {
  52. setTemplateSettings((prev) => {
  53. if (!prev) return prev;
  54. const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue;
  55. mutator(clone);
  56. return clone;
  57. });
  58. },
  59. [setTemplateSettings],
  60. );
  61. const setLevel0 = useCallback(
  62. (field: string, value: number | null) =>
  63. mutate((tt) => {
  64. if (!tt.policy) tt.policy = {};
  65. if (!tt.policy.levels) tt.policy.levels = {};
  66. if (!tt.policy.levels['0']) tt.policy.levels['0'] = {};
  67. if (value === null || value === undefined) {
  68. delete tt.policy.levels['0'][field];
  69. } else {
  70. tt.policy.levels['0'][field] = value;
  71. }
  72. }),
  73. [mutate],
  74. );
  75. const metricsCfg = (templateSettings as { metrics?: { tag?: string; listen?: string } } | null)
  76. ?.metrics;
  77. const setMetrics = useCallback(
  78. (field: 'tag' | 'listen', value: string) =>
  79. mutate((tt) => {
  80. const node = tt as {
  81. metrics?: { tag?: string; listen?: string };
  82. stats?: Record<string, unknown>;
  83. };
  84. const m: { tag?: string; listen?: string } = { ...(node.metrics ?? {}) };
  85. if (value.trim() === '') {
  86. delete m[field];
  87. } else {
  88. m[field] = value.trim();
  89. }
  90. if (!m.listen && !m.tag) {
  91. delete node.metrics;
  92. } else {
  93. node.metrics = m;
  94. // xray-core's metrics handler needs a stats object to populate.
  95. if (!node.stats) node.stats = {};
  96. }
  97. }),
  98. [mutate],
  99. );
  100. function confirmResetDefault() {
  101. modal.confirm({
  102. title: t('pages.settings.resetDefaultConfig'),
  103. okText: t('reset'),
  104. okType: 'danger',
  105. cancelText: t('cancel'),
  106. onOk: () => onResetDefault(),
  107. });
  108. }
  109. const freedomStrategy = directFreedomStrategy(templateSettings);
  110. const directFreedomOutbound = templateSettings?.outbounds?.find((o) =>
  111. isDirectFreedomOutbound(o),
  112. );
  113. const directTagTaken = isDirectTagTaken(templateSettings);
  114. const directHappyEyeballs = (() => {
  115. const sockopt = (
  116. directFreedomOutbound?.streamSettings as { sockopt?: { happyEyeballs?: unknown } } | undefined
  117. )?.sockopt;
  118. const raw = sockopt?.happyEyeballs;
  119. if (raw == null || typeof raw !== 'object') return null;
  120. const parsed = HappyEyeballsSchema.safeParse(raw);
  121. return parsed.success ? parsed.data : null;
  122. })();
  123. const setDirectHappyEyeballs = useCallback(
  124. (next: ReturnType<typeof HappyEyeballsSchema.parse> | null) => {
  125. mutate((tt) => {
  126. const ob = ensureDirectFreedomOutbound(tt);
  127. if (!ob) return;
  128. const stream = (ob.streamSettings ?? {}) as Record<string, unknown>;
  129. const sockopt = (stream.sockopt ?? {}) as Record<string, unknown>;
  130. if (next == null) {
  131. delete sockopt.happyEyeballs;
  132. } else {
  133. sockopt.happyEyeballs = next;
  134. }
  135. if (Object.keys(sockopt).length === 0) {
  136. delete stream.sockopt;
  137. } else {
  138. stream.sockopt = sockopt;
  139. }
  140. if (Object.keys(stream).length === 0) {
  141. delete ob.streamSettings;
  142. } else {
  143. ob.streamSettings = stream;
  144. }
  145. });
  146. },
  147. [mutate],
  148. );
  149. const routingStrategy = templateSettings?.routing?.domainStrategy ?? 'AsIs';
  150. const log = (templateSettings?.log || {}) as Record<string, unknown>;
  151. const policy = (templateSettings?.policy?.system || {}) as Record<string, boolean>;
  152. const level0 = (templateSettings?.policy?.levels?.['0'] || {}) as Record<string, unknown>;
  153. const items = [
  154. {
  155. key: '1',
  156. label: catTabLabel(<SettingOutlined />, t('pages.xray.generalConfigs'), isMobile),
  157. children: (
  158. <>
  159. <Alert
  160. type="warning"
  161. showIcon
  162. className="mb-12 hint-alert"
  163. title={t('pages.xray.generalConfigsDesc')}
  164. />
  165. <SettingListItem
  166. title={t('pages.xray.FreedomStrategy')}
  167. description={t('pages.xray.FreedomStrategyDesc')}
  168. paddings="small"
  169. control={
  170. <Select
  171. value={freedomStrategy}
  172. disabled={directTagTaken}
  173. style={{ width: '100%' }}
  174. options={OutboundDomainStrategies.map((s) => ({ value: s, label: s }))}
  175. onChange={(next) => mutate((tt) => setDirectFreedomStrategy(tt, next))}
  176. />
  177. }
  178. />
  179. <SettingListItem
  180. title={t('pages.xray.FreedomHappyEyeballs')}
  181. description={t('pages.xray.FreedomHappyEyeballsDesc')}
  182. paddings="small"
  183. control={
  184. <Switch
  185. checked={directHappyEyeballs != null}
  186. disabled={directTagTaken}
  187. onChange={(checked) => {
  188. setDirectHappyEyeballs(checked ? HappyEyeballsSchema.parse({}) : null);
  189. }}
  190. />
  191. }
  192. />
  193. {directHappyEyeballs != null && (
  194. <>
  195. <SettingListItem
  196. title={t('pages.inbounds.form.tryDelayMs')}
  197. description={t('pages.xray.FreedomHappyEyeballsTryDelayDesc')}
  198. paddings="small"
  199. control={
  200. <InputNumber
  201. min={0}
  202. style={{ width: '100%' }}
  203. value={directHappyEyeballs.tryDelayMs}
  204. placeholder="150"
  205. onChange={onNumber((v) =>
  206. setDirectHappyEyeballs({
  207. ...directHappyEyeballs,
  208. tryDelayMs: v,
  209. }),
  210. )}
  211. />
  212. }
  213. />
  214. <SettingListItem
  215. title={t('pages.inbounds.form.prioritizeIPv6')}
  216. paddings="small"
  217. control={
  218. <Switch
  219. checked={directHappyEyeballs.prioritizeIPv6}
  220. onChange={(checked) =>
  221. setDirectHappyEyeballs({
  222. ...directHappyEyeballs,
  223. prioritizeIPv6: checked,
  224. })
  225. }
  226. />
  227. }
  228. />
  229. </>
  230. )}
  231. <SettingListItem
  232. title={t('pages.xray.RoutingStrategy')}
  233. description={t('pages.xray.RoutingStrategyDesc')}
  234. paddings="small"
  235. control={
  236. <Select
  237. value={routingStrategy}
  238. style={{ width: '100%' }}
  239. options={ROUTING_DOMAIN_STRATEGIES.map((s) => ({ value: s, label: s }))}
  240. onChange={(next) =>
  241. mutate((tt) => {
  242. if (tt.routing) tt.routing.domainStrategy = next;
  243. })
  244. }
  245. />
  246. }
  247. />
  248. <SettingListItem
  249. title={t('pages.xray.outboundTestUrl')}
  250. description={t('pages.xray.outboundTestUrlDesc')}
  251. paddings="small"
  252. control={
  253. <Input
  254. value={outboundTestUrl}
  255. onChange={(e) => onChangeOutboundTestUrl(e.target.value)}
  256. placeholder="https://www.google.com/generate_204"
  257. />
  258. }
  259. />
  260. </>
  261. ),
  262. },
  263. {
  264. key: '2',
  265. label: catTabLabel(<BarChartOutlined />, t('pages.xray.statistics'), isMobile),
  266. children: (
  267. <>
  268. {[
  269. ['statsInboundUplink', t('pages.xray.statsInboundUplink')],
  270. ['statsInboundDownlink', t('pages.xray.statsInboundDownlink')],
  271. ['statsOutboundUplink', t('pages.xray.statsOutboundUplink')],
  272. ['statsOutboundDownlink', t('pages.xray.statsOutboundDownlink')],
  273. ].map(([field, label]) => (
  274. <SettingListItem
  275. key={field}
  276. title={label}
  277. paddings="small"
  278. control={
  279. <Switch
  280. checked={!!policy[field]}
  281. onChange={(checked) =>
  282. mutate((tt) => {
  283. if (!tt.policy) tt.policy = {};
  284. if (!tt.policy.system) tt.policy.system = {};
  285. tt.policy.system[field] = checked;
  286. })
  287. }
  288. />
  289. }
  290. />
  291. ))}
  292. <SettingListItem
  293. title={t('pages.xray.metricsListen')}
  294. description={t('pages.xray.metricsListenDesc')}
  295. paddings="small"
  296. control={
  297. <Input
  298. value={metricsCfg?.listen ?? ''}
  299. onChange={(e) => setMetrics('listen', e.target.value)}
  300. placeholder="127.0.0.1:11111"
  301. />
  302. }
  303. />
  304. <SettingListItem
  305. title={t('pages.xray.metricsTag')}
  306. paddings="small"
  307. control={
  308. <Input
  309. value={metricsCfg?.tag ?? ''}
  310. onChange={(e) => setMetrics('tag', e.target.value)}
  311. placeholder="metrics_out"
  312. />
  313. }
  314. />
  315. </>
  316. ),
  317. },
  318. {
  319. key: 'connection',
  320. label: catTabLabel(<ClockCircleOutlined />, t('pages.xray.connectionLimits'), isMobile),
  321. children: (
  322. <>
  323. <Alert
  324. type="warning"
  325. showIcon
  326. className="mb-12 hint-alert"
  327. title={t('pages.xray.connectionLimitsDesc')}
  328. />
  329. <SettingListItem
  330. title={t('pages.xray.connIdle')}
  331. description={t('pages.xray.connIdleDesc')}
  332. paddings="small"
  333. control={
  334. <InputNumber
  335. value={typeof level0.connIdle === 'number' ? level0.connIdle : undefined}
  336. min={0}
  337. style={{ width: '100%' }}
  338. placeholder="300"
  339. suffix={t('pages.xray.seconds')}
  340. onChange={(v) => setLevel0('connIdle', v as number | null)}
  341. />
  342. }
  343. />
  344. <SettingListItem
  345. title={t('pages.xray.bufferSize')}
  346. description={t('pages.xray.bufferSizeDesc')}
  347. paddings="small"
  348. control={
  349. <InputNumber
  350. value={typeof level0.bufferSize === 'number' ? level0.bufferSize : undefined}
  351. min={0}
  352. style={{ width: '100%' }}
  353. placeholder={t('pages.xray.bufferSizePlaceholder')}
  354. suffix="KB"
  355. onChange={(v) => setLevel0('bufferSize', v as number | null)}
  356. />
  357. }
  358. />
  359. </>
  360. ),
  361. },
  362. {
  363. key: '3',
  364. label: catTabLabel(<FileTextOutlined />, t('pages.xray.logConfigs'), isMobile),
  365. children: (
  366. <>
  367. <Alert
  368. type="warning"
  369. showIcon
  370. className="mb-12 hint-alert"
  371. title={t('pages.xray.logConfigsDesc')}
  372. />
  373. <SettingListItem
  374. title={t('pages.xray.logLevel')}
  375. description={t('pages.xray.logLevelDesc')}
  376. paddings="small"
  377. control={
  378. <Select
  379. value={(log.loglevel as string) || 'warning'}
  380. style={{ width: '100%' }}
  381. options={LOG_LEVELS.map((s) => ({ value: s, label: s }))}
  382. onChange={(v) =>
  383. mutate((tt) => {
  384. if (tt.log) tt.log.loglevel = v;
  385. })
  386. }
  387. />
  388. }
  389. />
  390. <SettingListItem
  391. title={t('pages.xray.accessLog')}
  392. description={t('pages.xray.accessLogDesc')}
  393. paddings="small"
  394. control={
  395. <Select
  396. value={(log.access as string) || ''}
  397. style={{ width: '100%' }}
  398. options={ACCESS_LOG.map((s) => ({ value: s, label: s }))}
  399. onChange={(v) =>
  400. mutate((tt) => {
  401. if (tt.log) tt.log.access = v;
  402. })
  403. }
  404. />
  405. }
  406. />
  407. <SettingListItem
  408. title={t('pages.xray.errorLog')}
  409. description={t('pages.xray.errorLogDesc')}
  410. paddings="small"
  411. control={
  412. <Select
  413. value={(log.error as string) || ''}
  414. style={{ width: '100%' }}
  415. options={[
  416. { value: '', label: t('empty') },
  417. ...ERROR_LOG.map((s) => ({ value: s, label: s })),
  418. ]}
  419. onChange={(v) =>
  420. mutate((tt) => {
  421. if (tt.log) tt.log.error = v;
  422. })
  423. }
  424. />
  425. }
  426. />
  427. <SettingListItem
  428. title={t('pages.xray.maskAddress')}
  429. description={t('pages.xray.maskAddressDesc')}
  430. paddings="small"
  431. control={
  432. <Select
  433. value={(log.maskAddress as string) || ''}
  434. style={{ width: '100%' }}
  435. options={[
  436. { value: '', label: t('empty') },
  437. ...MASK_ADDRESS.map((s) => ({ value: s, label: s })),
  438. ]}
  439. onChange={(v) =>
  440. mutate((tt) => {
  441. if (tt.log) tt.log.maskAddress = v;
  442. })
  443. }
  444. />
  445. }
  446. />
  447. <SettingListItem
  448. title={t('pages.xray.dnsLog')}
  449. description={t('pages.xray.dnsLogDesc')}
  450. paddings="small"
  451. control={
  452. <Switch
  453. checked={!!log.dnsLog}
  454. onChange={(v) =>
  455. mutate((tt) => {
  456. if (tt.log) tt.log.dnsLog = v;
  457. })
  458. }
  459. />
  460. }
  461. />
  462. </>
  463. ),
  464. },
  465. {
  466. key: 'reset',
  467. label: catTabLabel(<ReloadOutlined />, t('pages.settings.resetDefaultConfig'), isMobile),
  468. children: (
  469. <Space style={{ padding: '0 20px' }}>
  470. <Button type="primary" danger icon={<ReloadOutlined />} onClick={confirmResetDefault}>
  471. {t('pages.settings.resetDefaultConfig')}
  472. </Button>
  473. </Space>
  474. ),
  475. },
  476. ];
  477. return (
  478. <>
  479. {modalContextHolder}
  480. <Tabs defaultActiveKey="1" items={items} />
  481. </>
  482. );
  483. }