BasicsTab.tsx 16 KB

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