XrayPage.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  1. import { useCallback, useEffect, useMemo, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { useLocation, useNavigate } from 'react-router';
  4. import {
  5. Alert,
  6. Button,
  7. Card,
  8. Col,
  9. ConfigProvider,
  10. FloatButton,
  11. Layout,
  12. message,
  13. Radio,
  14. Result,
  15. Row,
  16. Space,
  17. Spin,
  18. } from 'antd';
  19. import { useTheme } from '@/hooks/useTheme';
  20. import { useMediaQuery } from '@/hooks/useMediaQuery';
  21. import { useXraySetting } from '@/hooks/useXraySetting';
  22. import type { XraySettingsValue } from '@/hooks/useXraySetting';
  23. import AppSidebar from '@/layouts/AppSidebar';
  24. import { JsonEditor } from '@/components/form';
  25. import { setMessageInstance } from '@/utils/messageBus';
  26. import { BasicsTab } from './basics';
  27. import { propagateOutboundTagRename } from './basics/helpers';
  28. import { RoutingTab } from './routing';
  29. import { OutboundsTab } from './outbounds';
  30. import { BalancersTab } from './balancers';
  31. import {
  32. cleanupOrphanedBalancerLoopbacks,
  33. ensureMissingBalancerLoopbacks,
  34. detectBalancerCycles,
  35. } from './balancers/balancer-loopback';
  36. import { DnsTab } from './dns';
  37. import { WarpModal, NordModal, PiaModal } from './overrides';
  38. import './XrayPage.css';
  39. const SECTION_SLUGS = ['basic', 'routing', 'outbound', 'balancer', 'dns', 'advanced'];
  40. type AdvKey = 'xraySetting' | 'inboundSettings' | 'outboundSettings' | 'routingRuleSettings';
  41. export default function XrayPage() {
  42. const { t } = useTranslation();
  43. const { isDark, isUltra, antdThemeConfig } = useTheme();
  44. const { isMobile } = useMediaQuery();
  45. const [messageApi, messageContextHolder] = message.useMessage();
  46. useEffect(() => {
  47. setMessageInstance(messageApi);
  48. }, [messageApi]);
  49. const xs = useXraySetting();
  50. const {
  51. fetched,
  52. spinning,
  53. saveDisabled,
  54. fetchError,
  55. xraySetting,
  56. setXraySetting,
  57. templateSettings,
  58. setTemplateSettings,
  59. outboundTestUrl,
  60. setOutboundTestUrl,
  61. inboundTags,
  62. clientReverseTags,
  63. subscriptionOutbounds,
  64. subscriptionOutboundTags,
  65. outboundsTraffic,
  66. outboundTestStates,
  67. subscriptionTestStates,
  68. testingAll,
  69. fetchAll,
  70. resetOutboundsTraffic,
  71. testOutbound,
  72. testSubscriptionOutbound,
  73. testAllOutbounds,
  74. saveAll,
  75. resetToDefault,
  76. } = xs;
  77. const [warpOpen, setWarpOpen] = useState(false);
  78. const [nordOpen, setNordOpen] = useState(false);
  79. const [piaOpen, setPiaOpen] = useState(false);
  80. const [advSettings, setAdvSettings] = useState<AdvKey>('xraySetting');
  81. const location = useLocation();
  82. const navigate = useNavigate();
  83. const pathSection =
  84. location.pathname === '/outbound'
  85. ? 'outbound'
  86. : location.pathname === '/routing'
  87. ? 'routing'
  88. : '';
  89. const sectionSlug = pathSection || location.hash.replace(/^#/, '');
  90. const activeSection = SECTION_SLUGS.includes(sectionSlug) ? sectionSlug : 'basic';
  91. const mutate = useCallback(
  92. (mutator: (next: XraySettingsValue) => void) => {
  93. setTemplateSettings((prev) => {
  94. if (!prev) return prev;
  95. const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue;
  96. mutator(clone);
  97. return clone;
  98. });
  99. },
  100. [setTemplateSettings],
  101. );
  102. async function onTestOutbound(idx: number, mode: string) {
  103. const outbound = templateSettings?.outbounds?.[idx];
  104. if (outbound) await testOutbound(idx, outbound, mode);
  105. }
  106. async function onTestSubscription(outbound: Record<string, unknown>, mode: string) {
  107. const tag = typeof outbound?.tag === 'string' ? outbound.tag : '';
  108. if (tag) await testSubscriptionOutbound(tag, outbound, mode);
  109. }
  110. function onAddOutbound(outbound: Record<string, unknown>) {
  111. mutate((tt) => {
  112. if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
  113. tt.outbounds.push(outbound as never);
  114. });
  115. }
  116. function onResetOutbound(payload: {
  117. index: number;
  118. outbound: Record<string, unknown>;
  119. oldTag?: string;
  120. newTag?: string;
  121. }) {
  122. mutate((tt) => {
  123. if (!tt.outbounds || payload.index < 0) return;
  124. tt.outbounds[payload.index] = payload.outbound as never;
  125. if (payload.oldTag && payload.newTag) {
  126. propagateOutboundTagRename(tt, payload.oldTag, payload.newTag);
  127. }
  128. });
  129. }
  130. function onRemoveOutboundByTag(tag: string) {
  131. mutate((tt) => {
  132. if (!tt.outbounds) return;
  133. const idx = tt.outbounds.findIndex((o) => o?.tag === tag);
  134. if (idx >= 0) tt.outbounds.splice(idx, 1);
  135. });
  136. }
  137. function onRemoveOutboundByIndex(index: number) {
  138. mutate((tt) => {
  139. if (tt.outbounds && index >= 0) tt.outbounds.splice(index, 1);
  140. });
  141. }
  142. function onRemoveRoutingRules(payload: { prefix: string }) {
  143. mutate((tt) => {
  144. const rules = tt.routing?.rules;
  145. if (!Array.isArray(rules)) return;
  146. tt.routing!.rules = rules.filter((r) => !r?.outboundTag?.startsWith?.(payload.prefix));
  147. });
  148. }
  149. const advancedText = useMemo(() => {
  150. if (advSettings === 'xraySetting') return xraySetting;
  151. const tpl = templateSettings;
  152. if (!tpl) return '';
  153. try {
  154. switch (advSettings) {
  155. case 'inboundSettings':
  156. return JSON.stringify(tpl.inbounds || [], null, 2);
  157. case 'outboundSettings':
  158. return JSON.stringify(tpl.outbounds || [], null, 2);
  159. case 'routingRuleSettings':
  160. return JSON.stringify(tpl.routing?.rules || [], null, 2);
  161. default:
  162. return '';
  163. }
  164. } catch {
  165. return '';
  166. }
  167. }, [advSettings, xraySetting, templateSettings]);
  168. function onAdvancedTextChange(next: string) {
  169. if (advSettings === 'xraySetting') {
  170. setXraySetting(next);
  171. return;
  172. }
  173. let parsed;
  174. try {
  175. parsed = JSON.parse(next);
  176. } catch {
  177. return;
  178. }
  179. mutate((tt) => {
  180. switch (advSettings) {
  181. case 'inboundSettings':
  182. tt.inbounds = parsed;
  183. break;
  184. case 'outboundSettings':
  185. tt.outbounds = parsed;
  186. break;
  187. case 'routingRuleSettings':
  188. if (!tt.routing) tt.routing = {};
  189. tt.routing.rules = parsed;
  190. break;
  191. }
  192. });
  193. }
  194. function onSaveAll() {
  195. try {
  196. JSON.parse(xraySetting);
  197. } catch (e) {
  198. messageApi.error(`Advanced JSON: ${(e as Error).message}`);
  199. navigate('/xray#advanced');
  200. return;
  201. }
  202. if (templateSettings) {
  203. const clone = JSON.parse(JSON.stringify(templateSettings));
  204. ensureMissingBalancerLoopbacks(clone);
  205. cleanupOrphanedBalancerLoopbacks(clone);
  206. const cycles = detectBalancerCycles(clone);
  207. if (cycles.length > 0) {
  208. const names = cycles.map((c) => c.join(' → ')).join(', ');
  209. messageApi.error(t('pages.xray.balancer.balancerFallbackCycle') + ' (' + names + ')');
  210. return;
  211. }
  212. const serialized = JSON.stringify(clone, null, 2);
  213. setXraySetting(serialized);
  214. setTemplateSettings(clone);
  215. }
  216. saveAll();
  217. }
  218. const scrollTarget = () => document.getElementById('content-layout') || window;
  219. const pageClass = `xray-page ${isDark ? 'is-dark' : ''} ${isUltra ? 'is-ultra' : ''}`.trim();
  220. const sectionBody = (() => {
  221. switch (activeSection) {
  222. case 'routing':
  223. return (
  224. <RoutingTab
  225. templateSettings={templateSettings}
  226. setTemplateSettings={setTemplateSettings}
  227. inboundTags={inboundTags}
  228. clientReverseTags={clientReverseTags}
  229. subscriptionOutboundTags={subscriptionOutboundTags}
  230. isMobile={isMobile}
  231. />
  232. );
  233. case 'outbound':
  234. return (
  235. <OutboundsTab
  236. templateSettings={templateSettings}
  237. setTemplateSettings={setTemplateSettings}
  238. outboundsTraffic={outboundsTraffic}
  239. outboundTestStates={outboundTestStates}
  240. subscriptionTestStates={subscriptionTestStates}
  241. testingAll={testingAll}
  242. inboundTags={inboundTags}
  243. subscriptionOutbounds={subscriptionOutbounds}
  244. subscriptionOutboundTags={subscriptionOutboundTags}
  245. isMobile={isMobile}
  246. onResetTraffic={resetOutboundsTraffic}
  247. onTest={onTestOutbound}
  248. onTestSubscription={onTestSubscription}
  249. onTestAll={testAllOutbounds}
  250. onShowWarp={() => setWarpOpen(true)}
  251. onShowNord={() => setNordOpen(true)}
  252. onShowPia={() => setPiaOpen(true)}
  253. onRefreshXrayData={fetchAll}
  254. />
  255. );
  256. case 'balancer':
  257. return (
  258. <BalancersTab
  259. templateSettings={templateSettings}
  260. setTemplateSettings={setTemplateSettings}
  261. clientReverseTags={clientReverseTags}
  262. subscriptionOutboundTags={subscriptionOutboundTags}
  263. isMobile={isMobile}
  264. />
  265. );
  266. case 'dns':
  267. return (
  268. <DnsTab templateSettings={templateSettings} setTemplateSettings={setTemplateSettings} />
  269. );
  270. case 'advanced':
  271. return (
  272. <>
  273. <div className="advanced-meta">
  274. <h4>{t('pages.xray.Template')}</h4>
  275. <p>{t('pages.xray.TemplateDesc')}</p>
  276. </div>
  277. <Radio.Group
  278. value={advSettings}
  279. buttonStyle="solid"
  280. size={isMobile ? 'small' : 'middle'}
  281. style={{ margin: '12px 0' }}
  282. onChange={(e) => setAdvSettings(e.target.value)}
  283. >
  284. <Radio.Button value="xraySetting">{t('pages.xray.completeTemplate')}</Radio.Button>
  285. <Radio.Button value="inboundSettings">{t('pages.xray.Inbounds')}</Radio.Button>
  286. <Radio.Button value="outboundSettings">{t('pages.xray.Outbounds')}</Radio.Button>
  287. <Radio.Button value="routingRuleSettings">{t('pages.xray.Routings')}</Radio.Button>
  288. </Radio.Group>
  289. <JsonEditor
  290. value={advancedText}
  291. onChange={onAdvancedTextChange}
  292. minHeight="420px"
  293. maxHeight="720px"
  294. />
  295. </>
  296. );
  297. default:
  298. return (
  299. <BasicsTab
  300. templateSettings={templateSettings}
  301. setTemplateSettings={setTemplateSettings}
  302. outboundTestUrl={outboundTestUrl}
  303. onChangeOutboundTestUrl={setOutboundTestUrl}
  304. onResetDefault={resetToDefault}
  305. />
  306. );
  307. }
  308. })();
  309. return (
  310. <ConfigProvider theme={antdThemeConfig}>
  311. {messageContextHolder}
  312. <Layout className={pageClass}>
  313. <AppSidebar />
  314. <Layout className="content-shell">
  315. <Layout.Content id="content-layout" className="content-area">
  316. <Spin
  317. spinning={spinning || !fetched}
  318. delay={200}
  319. description={t('loading')}
  320. size="large"
  321. >
  322. {!fetched ? (
  323. <div className="loading-spacer" />
  324. ) : fetchError ? (
  325. <Result
  326. status="error"
  327. title={t('somethingWentWrong')}
  328. subTitle={fetchError}
  329. extra={
  330. <Button type="primary" onClick={fetchAll}>
  331. {t('check')}
  332. </Button>
  333. }
  334. />
  335. ) : (
  336. <Row gutter={[isMobile ? 8 : 16, isMobile ? 0 : 12]}>
  337. <Col span={24}>
  338. <Card hoverable>
  339. <Row className="header-row">
  340. <Col xs={24} sm={14} className="header-actions">
  341. <Space>
  342. <Button type="primary" disabled={saveDisabled} onClick={onSaveAll}>
  343. {t('pages.xray.save')}
  344. </Button>
  345. </Space>
  346. </Col>
  347. <Col xs={24} sm={10} className="header-info">
  348. <FloatButton.BackTop target={scrollTarget} visibilityHeight={200} />
  349. <Alert type="warning" showIcon title={t('pages.settings.infoDesc')} />
  350. </Col>
  351. </Row>
  352. </Card>
  353. </Col>
  354. <Col span={24}>
  355. <Card hoverable>{sectionBody}</Card>
  356. </Col>
  357. </Row>
  358. )}
  359. </Spin>
  360. </Layout.Content>
  361. </Layout>
  362. <WarpModal
  363. open={warpOpen}
  364. templateSettings={templateSettings}
  365. onClose={() => setWarpOpen(false)}
  366. onAddOutbound={onAddOutbound}
  367. onResetOutbound={onResetOutbound}
  368. onRemoveOutbound={onRemoveOutboundByTag}
  369. />
  370. <NordModal
  371. open={nordOpen}
  372. templateSettings={templateSettings}
  373. onClose={() => setNordOpen(false)}
  374. onAddOutbound={onAddOutbound}
  375. onResetOutbound={onResetOutbound}
  376. onRemoveOutbound={onRemoveOutboundByIndex}
  377. onRemoveRoutingRules={onRemoveRoutingRules}
  378. />
  379. <PiaModal
  380. open={piaOpen}
  381. templateSettings={templateSettings}
  382. onClose={() => setPiaOpen(false)}
  383. onAddOutbound={onAddOutbound}
  384. onResetOutbound={onResetOutbound}
  385. />
  386. </Layout>
  387. </ConfigProvider>
  388. );
  389. }