useXraySetting.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
  2. import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
  3. import { z } from 'zod';
  4. import { HttpUtil, Msg, PromiseUtil } from '@/utils';
  5. import { parseMsg } from '@/utils/zodValidate';
  6. import { keys } from '@/api/queryKeys';
  7. import {
  8. OutboundTrafficListSchema,
  9. OutboundTestResultSchema,
  10. XrayConfigPayloadSchema,
  11. XraySettingsValueSchema,
  12. type OutboundTestResult,
  13. type OutboundTrafficRow,
  14. } from '@/schemas/xray';
  15. const DIRTY_POLL_MS = 1000;
  16. const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
  17. export function isUdpOutbound(outbound: unknown): boolean {
  18. const o = outbound as { protocol?: string; streamSettings?: { network?: string } } | null | undefined;
  19. const p = o?.protocol;
  20. const n = o?.streamSettings?.network;
  21. return p === 'wireguard' || p === 'hysteria' || n === 'hysteria' || n === 'kcp' || n === 'quic';
  22. }
  23. export type { OutboundTrafficRow, OutboundTestResult };
  24. export type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
  25. export interface OutboundTestState {
  26. testing?: boolean;
  27. result?: OutboundTestResult | null;
  28. mode?: string;
  29. }
  30. export type SetTemplate = (
  31. next: XraySettingsValue | null | ((prev: XraySettingsValue | null) => XraySettingsValue | null),
  32. ) => void;
  33. export interface UseXraySettingResult {
  34. fetched: boolean;
  35. spinning: boolean;
  36. saveDisabled: boolean;
  37. fetchError: string;
  38. xraySetting: string;
  39. setXraySetting: (next: string) => void;
  40. templateSettings: XraySettingsValue | null;
  41. setTemplateSettings: SetTemplate;
  42. outboundTestUrl: string;
  43. setOutboundTestUrl: (v: string) => void;
  44. inboundTags: string[];
  45. clientReverseTags: string[];
  46. restartResult: string;
  47. outboundsTraffic: OutboundTrafficRow[];
  48. outboundTestStates: Record<number, OutboundTestState>;
  49. testingAll: boolean;
  50. fetchAll: () => Promise<void>;
  51. fetchOutboundsTraffic: () => Promise<void>;
  52. resetOutboundsTraffic: (tag: string) => Promise<void>;
  53. testOutbound: (
  54. index: number,
  55. outbound: unknown,
  56. mode?: string,
  57. ) => Promise<OutboundTestResult | null>;
  58. testAllOutbounds: (mode?: string) => Promise<void>;
  59. saveAll: () => Promise<void>;
  60. resetToDefault: () => Promise<void>;
  61. restartXray: () => Promise<void>;
  62. }
  63. type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
  64. async function fetchXrayConfig(): Promise<XrayConfigPayload> {
  65. const msg = await HttpUtil.post('/panel/xray/', undefined, { silent: true });
  66. if (!msg?.success) throw new Error(msg?.msg || 'Failed to load xray config');
  67. if (typeof msg.obj !== 'string') throw new Error('Malformed xray config response: expected string');
  68. let parsed: unknown;
  69. try {
  70. parsed = JSON.parse(msg.obj);
  71. } catch (e) {
  72. const err = e as Error;
  73. throw new Error(`Malformed xray config response: ${err.message}`, { cause: e });
  74. }
  75. const result = XrayConfigPayloadSchema.safeParse(parsed);
  76. if (!result.success) {
  77. console.warn('[zod] xray/ config payload failed validation', result.error.issues);
  78. return parsed as XrayConfigPayload;
  79. }
  80. return result.data;
  81. }
  82. async function fetchOutboundsTraffic(): Promise<OutboundTrafficRow[]> {
  83. const msg = await HttpUtil.get('/panel/xray/getOutboundsTraffic', undefined, { silent: true });
  84. if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch outbounds traffic');
  85. const validated = parseMsg(msg, OutboundTrafficListSchema, 'xray/getOutboundsTraffic');
  86. return Array.isArray(validated.obj) ? validated.obj : [];
  87. }
  88. export function useXraySetting(): UseXraySettingResult {
  89. const queryClient = useQueryClient();
  90. const configQuery = useQuery({
  91. queryKey: keys.xray.config(),
  92. queryFn: fetchXrayConfig,
  93. staleTime: Infinity,
  94. });
  95. const trafficQuery = useQuery({
  96. queryKey: keys.xray.outboundsTraffic(),
  97. queryFn: fetchOutboundsTraffic,
  98. staleTime: Infinity,
  99. });
  100. const [saveDisabled, setSaveDisabled] = useState(true);
  101. const [xraySetting, setXraySettingState] = useState('');
  102. const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null);
  103. const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
  104. const [inboundTags, setInboundTags] = useState<string[]>([]);
  105. const [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
  106. const [restartResult, setRestartResult] = useState('');
  107. const [outboundTestStates, setOutboundTestStates] = useState<Record<number, OutboundTestState>>({});
  108. const [testingAll, setTestingAll] = useState(false);
  109. const oldXraySettingRef = useRef('');
  110. const oldOutboundTestUrlRef = useRef('');
  111. const syncingRef = useRef(false);
  112. const xraySettingRef = useRef('');
  113. const outboundTestUrlRef = useRef(outboundTestUrl);
  114. const templateSettingsRef = useRef<XraySettingsValue | null>(null);
  115. xraySettingRef.current = xraySetting;
  116. outboundTestUrlRef.current = outboundTestUrl;
  117. templateSettingsRef.current = templateSettings;
  118. // Seed local editor state from the config query. Runs on first fetch and
  119. // every time the query refetches (e.g. after a successful save).
  120. useEffect(() => {
  121. if (!configQuery.data) return;
  122. const obj = configQuery.data;
  123. const pretty = JSON.stringify(obj.xraySetting, null, 2);
  124. syncingRef.current = true;
  125. setXraySettingState(pretty);
  126. setTemplateSettingsState(obj.xraySetting);
  127. oldXraySettingRef.current = pretty;
  128. syncingRef.current = false;
  129. setInboundTags(obj.inboundTags || []);
  130. setClientReverseTags(obj.clientReverseTags || []);
  131. const nextUrl = obj.outboundTestUrl || DEFAULT_TEST_URL;
  132. setOutboundTestUrlState(nextUrl);
  133. oldOutboundTestUrlRef.current = nextUrl;
  134. setSaveDisabled(true);
  135. }, [configQuery.data]);
  136. const fetched = configQuery.data !== undefined || configQuery.isError;
  137. const fetchError = configQuery.error ? (configQuery.error as Error).message : '';
  138. const setXraySetting = useCallback((next: string) => {
  139. setXraySettingState(next);
  140. if (syncingRef.current) return;
  141. try {
  142. const parsed = JSON.parse(next);
  143. syncingRef.current = true;
  144. setTemplateSettingsState(parsed);
  145. syncingRef.current = false;
  146. } catch {
  147. /* ignore — wait for user to finish */
  148. }
  149. }, []);
  150. const setTemplateSettings: SetTemplate = useCallback((nextOrFn) => {
  151. setTemplateSettingsState((prev) => {
  152. const next = typeof nextOrFn === 'function' ? nextOrFn(prev) : nextOrFn;
  153. if (next == null) return next;
  154. if (!syncingRef.current) {
  155. try {
  156. syncingRef.current = true;
  157. setXraySettingState(JSON.stringify(next, null, 2));
  158. } finally {
  159. syncingRef.current = false;
  160. }
  161. }
  162. return next;
  163. });
  164. }, []);
  165. const setOutboundTestUrl = useCallback((v: string) => {
  166. setOutboundTestUrlState(v);
  167. }, []);
  168. const fetchAll = useCallback(async () => {
  169. await queryClient.invalidateQueries({ queryKey: keys.xray.config() });
  170. }, [queryClient]);
  171. const fetchOutboundsTrafficCb = useCallback(async () => {
  172. await queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
  173. }, [queryClient]);
  174. const saveMut = useMutation({
  175. mutationFn: async () =>
  176. HttpUtil.post('/panel/xray/update', {
  177. xraySetting: xraySettingRef.current,
  178. outboundTestUrl: outboundTestUrlRef.current || DEFAULT_TEST_URL,
  179. }),
  180. onSuccess: (msg) => {
  181. if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.config() });
  182. },
  183. });
  184. const resetTrafficMut = useMutation({
  185. mutationFn: (tag: string) =>
  186. HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag }),
  187. onSuccess: (msg) => {
  188. if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
  189. },
  190. });
  191. const restartMut = useMutation({
  192. mutationFn: async () => {
  193. const msg = await HttpUtil.post('/panel/api/server/restartXrayService');
  194. if (!msg?.success) return msg;
  195. await PromiseUtil.sleep(500);
  196. const r = await HttpUtil.get('/panel/xray/getXrayResult');
  197. const validated = parseMsg(r, z.string(), 'xray/getXrayResult');
  198. if (validated?.success) setRestartResult(validated.obj || '');
  199. return msg;
  200. },
  201. });
  202. const resetDefaultMut = useMutation({
  203. mutationFn: async (): Promise<Msg<XraySettingsValue>> => {
  204. const raw = await HttpUtil.get('/panel/setting/getDefaultJsonConfig');
  205. return parseMsg(raw, XraySettingsValueSchema, 'setting/getDefaultJsonConfig');
  206. },
  207. onSuccess: (msg) => {
  208. if (msg?.success && msg.obj) {
  209. const cloned = JSON.parse(JSON.stringify(msg.obj));
  210. setTemplateSettings(cloned);
  211. }
  212. },
  213. });
  214. const saveAll = useCallback(async () => { await saveMut.mutateAsync(); }, [saveMut]);
  215. const resetOutboundsTraffic = useCallback(async (tag: string) => { await resetTrafficMut.mutateAsync(tag); }, [resetTrafficMut]);
  216. const restartXray = useCallback(async () => { await restartMut.mutateAsync(); }, [restartMut]);
  217. const resetToDefault = useCallback(async () => { await resetDefaultMut.mutateAsync(); }, [resetDefaultMut]);
  218. const spinning = saveMut.isPending || restartMut.isPending || resetDefaultMut.isPending;
  219. const testOutbound = useCallback(
  220. async (index: number, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
  221. if (!outbound) return null;
  222. const effMode = isUdpOutbound(outbound) ? 'http' : mode;
  223. setOutboundTestStates((prev) => ({
  224. ...prev,
  225. [index]: { testing: true, result: null, mode: effMode },
  226. }));
  227. try {
  228. const raw = await HttpUtil.post('/panel/xray/testOutbound', {
  229. outbound: JSON.stringify(outbound),
  230. allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
  231. mode: effMode,
  232. });
  233. const msg = parseMsg(raw, OutboundTestResultSchema, 'xray/testOutbound');
  234. if (msg?.success && msg.obj) {
  235. setOutboundTestStates((prev) => ({
  236. ...prev,
  237. [index]: { testing: false, result: msg.obj },
  238. }));
  239. return msg.obj;
  240. }
  241. setOutboundTestStates((prev) => ({
  242. ...prev,
  243. [index]: {
  244. testing: false,
  245. result: { success: false, error: msg?.msg || 'Unknown error', mode: effMode },
  246. },
  247. }));
  248. } catch (e) {
  249. setOutboundTestStates((prev) => ({
  250. ...prev,
  251. [index]: {
  252. testing: false,
  253. result: { success: false, error: String(e), mode: effMode },
  254. },
  255. }));
  256. }
  257. return null;
  258. },
  259. [],
  260. );
  261. const testAllOutbounds = useCallback(async (mode = 'tcp') => {
  262. const list = templateSettingsRef.current?.outbounds || [];
  263. if (list.length === 0 || testingAll) return;
  264. setTestingAll(true);
  265. try {
  266. const tcpQueue: { index: number; outbound: unknown }[] = [];
  267. const httpQueue: { index: number; outbound: unknown }[] = [];
  268. list.forEach((ob, i) => {
  269. const tag = ob?.tag;
  270. const proto = ob?.protocol;
  271. if (proto === 'blackhole' || proto === 'loopback' || tag === 'blocked') return;
  272. if (mode === 'tcp' && (proto === 'freedom' || proto === 'dns')) return;
  273. if (mode === 'http' || isUdpOutbound(ob)) {
  274. httpQueue.push({ index: i, outbound: ob });
  275. } else {
  276. tcpQueue.push({ index: i, outbound: ob });
  277. }
  278. });
  279. const runLane = async (queue: { index: number; outbound: unknown }[], concurrency: number) => {
  280. const worker = async () => {
  281. while (queue.length > 0) {
  282. const item = queue.shift();
  283. if (!item) break;
  284. await testOutbound(item.index, item.outbound, mode);
  285. }
  286. };
  287. const workers = Array.from({ length: Math.min(concurrency, queue.length) }, () => worker());
  288. await Promise.all(workers);
  289. };
  290. await Promise.all([runLane(tcpQueue, 8), runLane(httpQueue, 1)]);
  291. } finally {
  292. setTestingAll(false);
  293. }
  294. }, [testingAll, testOutbound]);
  295. useEffect(() => {
  296. const timer = window.setInterval(() => {
  297. const dirtyXray = oldXraySettingRef.current !== xraySettingRef.current;
  298. const dirtyUrl = oldOutboundTestUrlRef.current !== outboundTestUrlRef.current;
  299. setSaveDisabled(!(dirtyXray || dirtyUrl));
  300. }, DIRTY_POLL_MS);
  301. return () => window.clearInterval(timer);
  302. }, []);
  303. const outboundsTraffic = useMemo(() => trafficQuery.data ?? [], [trafficQuery.data]);
  304. return useMemo(
  305. () => ({
  306. fetched,
  307. spinning,
  308. saveDisabled,
  309. fetchError,
  310. xraySetting,
  311. setXraySetting,
  312. templateSettings,
  313. setTemplateSettings,
  314. outboundTestUrl,
  315. setOutboundTestUrl,
  316. inboundTags,
  317. clientReverseTags,
  318. restartResult,
  319. outboundsTraffic,
  320. outboundTestStates,
  321. testingAll,
  322. fetchAll,
  323. fetchOutboundsTraffic: fetchOutboundsTrafficCb,
  324. resetOutboundsTraffic,
  325. testOutbound,
  326. testAllOutbounds,
  327. saveAll,
  328. resetToDefault,
  329. restartXray,
  330. }),
  331. [
  332. fetched,
  333. spinning,
  334. saveDisabled,
  335. fetchError,
  336. xraySetting,
  337. setXraySetting,
  338. templateSettings,
  339. setTemplateSettings,
  340. outboundTestUrl,
  341. setOutboundTestUrl,
  342. inboundTags,
  343. clientReverseTags,
  344. restartResult,
  345. outboundsTraffic,
  346. outboundTestStates,
  347. testingAll,
  348. fetchAll,
  349. fetchOutboundsTrafficCb,
  350. resetOutboundsTraffic,
  351. testOutbound,
  352. testAllOutbounds,
  353. saveAll,
  354. resetToDefault,
  355. restartXray,
  356. ],
  357. );
  358. }