BasicsTab.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  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 routingScript = templateSettings?.routing?.script ?? '';
  151. const log = (templateSettings?.log || {}) as Record<string, unknown>;
  152. const policy = (templateSettings?.policy?.system || {}) as Record<string, boolean>;
  153. const level0 = (templateSettings?.policy?.levels?.['0'] || {}) as Record<string, unknown>;
  154. const items = [
  155. {
  156. key: '1',
  157. label: catTabLabel(<SettingOutlined />, t('pages.xray.generalConfigs'), isMobile),
  158. children: (
  159. <>
  160. <Alert
  161. type="warning"
  162. showIcon
  163. className="mb-12 hint-alert"
  164. title={t('pages.xray.generalConfigsDesc')}
  165. />
  166. <SettingListItem
  167. title={t('pages.xray.FreedomStrategy')}
  168. description={t('pages.xray.FreedomStrategyDesc')}
  169. paddings="small"
  170. control={
  171. <Select
  172. value={freedomStrategy}
  173. disabled={directTagTaken}
  174. style={{ width: '100%' }}
  175. options={OutboundDomainStrategies.map((s) => ({ value: s, label: s }))}
  176. onChange={(next) => mutate((tt) => setDirectFreedomStrategy(tt, next))}
  177. />
  178. }
  179. />
  180. <SettingListItem
  181. title={t('pages.xray.FreedomHappyEyeballs')}
  182. description={t('pages.xray.FreedomHappyEyeballsDesc')}
  183. paddings="small"
  184. control={
  185. <Switch
  186. checked={directHappyEyeballs != null}
  187. disabled={directTagTaken}
  188. onChange={(checked) => {
  189. setDirectHappyEyeballs(checked ? HappyEyeballsSchema.parse({}) : null);
  190. }}
  191. />
  192. }
  193. />
  194. {directHappyEyeballs != null && (
  195. <>
  196. <SettingListItem
  197. title={t('pages.inbounds.form.tryDelayMs')}
  198. description={t('pages.xray.FreedomHappyEyeballsTryDelayDesc')}
  199. paddings="small"
  200. control={
  201. <InputNumber
  202. min={0}
  203. style={{ width: '100%' }}
  204. value={directHappyEyeballs.tryDelayMs}
  205. placeholder="150"
  206. onChange={onNumber((v) =>
  207. setDirectHappyEyeballs({
  208. ...directHappyEyeballs,
  209. tryDelayMs: v,
  210. }),
  211. )}
  212. />
  213. }
  214. />
  215. <SettingListItem
  216. title={t('pages.inbounds.form.prioritizeIPv6')}
  217. paddings="small"
  218. control={
  219. <Switch
  220. checked={directHappyEyeballs.prioritizeIPv6}
  221. onChange={(checked) =>
  222. setDirectHappyEyeballs({
  223. ...directHappyEyeballs,
  224. prioritizeIPv6: checked,
  225. })
  226. }
  227. />
  228. }
  229. />
  230. </>
  231. )}
  232. <SettingListItem
  233. title={t('pages.xray.RoutingStrategy')}
  234. description={t('pages.xray.RoutingStrategyDesc')}
  235. paddings="small"
  236. control={
  237. <Select
  238. value={routingStrategy}
  239. style={{ width: '100%' }}
  240. options={ROUTING_DOMAIN_STRATEGIES.map((s) => ({ value: s, label: s }))}
  241. onChange={(next) =>
  242. mutate((tt) => {
  243. if (tt.routing) tt.routing.domainStrategy = next;
  244. })
  245. }
  246. />
  247. }
  248. />
  249. <SettingListItem
  250. title={t('pages.xray.routingScript')}
  251. description={t('pages.xray.routingScriptDesc')}
  252. paddings="small"
  253. control={
  254. <Input
  255. value={routingScript}
  256. placeholder="routing.lua"
  257. onChange={(e) => {
  258. const next = e.target.value;
  259. mutate((tt) => {
  260. if (!tt.routing) return;
  261. if (next.trim() === '') delete tt.routing.script;
  262. else tt.routing.script = next;
  263. });
  264. }}
  265. />
  266. }
  267. />
  268. <SettingListItem
  269. title={t('pages.xray.outboundTestUrl')}
  270. description={t('pages.xray.outboundTestUrlDesc')}
  271. paddings="small"
  272. control={
  273. <Input
  274. value={outboundTestUrl}
  275. onChange={(e) => onChangeOutboundTestUrl(e.target.value)}
  276. placeholder="https://www.google.com/generate_204"
  277. />
  278. }
  279. />
  280. </>
  281. ),
  282. },
  283. {
  284. key: '2',
  285. label: catTabLabel(<BarChartOutlined />, t('pages.xray.statistics'), isMobile),
  286. children: (
  287. <>
  288. {[
  289. ['statsInboundUplink', t('pages.xray.statsInboundUplink')],
  290. ['statsInboundDownlink', t('pages.xray.statsInboundDownlink')],
  291. ['statsOutboundUplink', t('pages.xray.statsOutboundUplink')],
  292. ['statsOutboundDownlink', t('pages.xray.statsOutboundDownlink')],
  293. ].map(([field, label]) => (
  294. <SettingListItem
  295. key={field}
  296. title={label}
  297. paddings="small"
  298. control={
  299. <Switch
  300. checked={!!policy[field]}
  301. onChange={(checked) =>
  302. mutate((tt) => {
  303. if (!tt.policy) tt.policy = {};
  304. if (!tt.policy.system) tt.policy.system = {};
  305. tt.policy.system[field] = checked;
  306. })
  307. }
  308. />
  309. }
  310. />
  311. ))}
  312. <SettingListItem
  313. title={t('pages.xray.metricsListen')}
  314. description={t('pages.xray.metricsListenDesc')}
  315. paddings="small"
  316. control={
  317. <Input
  318. value={metricsCfg?.listen ?? ''}
  319. onChange={(e) => setMetrics('listen', e.target.value)}
  320. placeholder="127.0.0.1:11111"
  321. />
  322. }
  323. />
  324. <SettingListItem
  325. title={t('pages.xray.metricsTag')}
  326. paddings="small"
  327. control={
  328. <Input
  329. value={metricsCfg?.tag ?? ''}
  330. onChange={(e) => setMetrics('tag', e.target.value)}
  331. placeholder="metrics_out"
  332. />
  333. }
  334. />
  335. </>
  336. ),
  337. },
  338. {
  339. key: 'connection',
  340. label: catTabLabel(<ClockCircleOutlined />, t('pages.xray.connectionLimits'), isMobile),
  341. children: (
  342. <>
  343. <Alert
  344. type="warning"
  345. showIcon
  346. className="mb-12 hint-alert"
  347. title={t('pages.xray.connectionLimitsDesc')}
  348. />
  349. <SettingListItem
  350. title={t('pages.xray.connIdle')}
  351. description={t('pages.xray.connIdleDesc')}
  352. paddings="small"
  353. control={
  354. <InputNumber
  355. value={typeof level0.connIdle === 'number' ? level0.connIdle : undefined}
  356. min={0}
  357. style={{ width: '100%' }}
  358. placeholder="300"
  359. suffix={t('pages.xray.seconds')}
  360. onChange={(v) => setLevel0('connIdle', v as number | null)}
  361. />
  362. }
  363. />
  364. <SettingListItem
  365. title={t('pages.xray.bufferSize')}
  366. description={t('pages.xray.bufferSizeDesc')}
  367. paddings="small"
  368. control={
  369. <InputNumber
  370. value={typeof level0.bufferSize === 'number' ? level0.bufferSize : undefined}
  371. min={0}
  372. style={{ width: '100%' }}
  373. placeholder={t('pages.xray.bufferSizePlaceholder')}
  374. suffix="KB"
  375. onChange={(v) => setLevel0('bufferSize', v as number | null)}
  376. />
  377. }
  378. />
  379. </>
  380. ),
  381. },
  382. {
  383. key: '3',
  384. label: catTabLabel(<FileTextOutlined />, t('pages.xray.logConfigs'), isMobile),
  385. children: (
  386. <>
  387. <Alert
  388. type="warning"
  389. showIcon
  390. className="mb-12 hint-alert"
  391. title={t('pages.xray.logConfigsDesc')}
  392. />
  393. <SettingListItem
  394. title={t('pages.xray.logLevel')}
  395. description={t('pages.xray.logLevelDesc')}
  396. paddings="small"
  397. control={
  398. <Select
  399. value={(log.loglevel as string) || 'warning'}
  400. style={{ width: '100%' }}
  401. options={LOG_LEVELS.map((s) => ({ value: s, label: s }))}
  402. onChange={(v) =>
  403. mutate((tt) => {
  404. if (tt.log) tt.log.loglevel = v;
  405. })
  406. }
  407. />
  408. }
  409. />
  410. <SettingListItem
  411. title={t('pages.xray.accessLog')}
  412. description={t('pages.xray.accessLogDesc')}
  413. paddings="small"
  414. control={
  415. <Select
  416. value={(log.access as string) || ''}
  417. style={{ width: '100%' }}
  418. options={ACCESS_LOG.map((s) => ({ value: s, label: s }))}
  419. onChange={(v) =>
  420. mutate((tt) => {
  421. if (tt.log) tt.log.access = v;
  422. })
  423. }
  424. />
  425. }
  426. />
  427. <SettingListItem
  428. title={t('pages.xray.errorLog')}
  429. description={t('pages.xray.errorLogDesc')}
  430. paddings="small"
  431. control={
  432. <Select
  433. value={(log.error as string) || ''}
  434. style={{ width: '100%' }}
  435. options={[
  436. { value: '', label: t('empty') },
  437. ...ERROR_LOG.map((s) => ({ value: s, label: s })),
  438. ]}
  439. onChange={(v) =>
  440. mutate((tt) => {
  441. if (tt.log) tt.log.error = v;
  442. })
  443. }
  444. />
  445. }
  446. />
  447. <SettingListItem
  448. title={t('pages.xray.maskAddress')}
  449. description={t('pages.xray.maskAddressDesc')}
  450. paddings="small"
  451. control={
  452. <Select
  453. value={(log.maskAddress as string) || ''}
  454. style={{ width: '100%' }}
  455. options={[
  456. { value: '', label: t('empty') },
  457. ...MASK_ADDRESS.map((s) => ({ value: s, label: s })),
  458. ]}
  459. onChange={(v) =>
  460. mutate((tt) => {
  461. if (tt.log) tt.log.maskAddress = v;
  462. })
  463. }
  464. />
  465. }
  466. />
  467. <SettingListItem
  468. title={t('pages.xray.dnsLog')}
  469. description={t('pages.xray.dnsLogDesc')}
  470. paddings="small"
  471. control={
  472. <Switch
  473. checked={!!log.dnsLog}
  474. onChange={(v) =>
  475. mutate((tt) => {
  476. if (tt.log) tt.log.dnsLog = v;
  477. })
  478. }
  479. />
  480. }
  481. />
  482. </>
  483. ),
  484. },
  485. {
  486. key: 'reset',
  487. label: catTabLabel(<ReloadOutlined />, t('pages.settings.resetDefaultConfig'), isMobile),
  488. children: (
  489. <Space style={{ padding: '0 20px' }}>
  490. <Button type="primary" danger icon={<ReloadOutlined />} onClick={confirmResetDefault}>
  491. {t('pages.settings.resetDefaultConfig')}
  492. </Button>
  493. </Space>
  494. ),
  495. },
  496. ];
  497. return (
  498. <>
  499. {modalContextHolder}
  500. <Tabs defaultActiveKey="1" items={items} />
  501. </>
  502. );
  503. }