1
0

useXraySetting.ts 13 KB

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