diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts index 5d223906..e1fe0980 100644 --- a/frontend/src/api/queryKeys.ts +++ b/frontend/src/api/queryKeys.ts @@ -22,4 +22,9 @@ export const keys = { onlines: () => ['clients', 'onlines'] as const, lastOnline: () => ['clients', 'lastOnline'] as const, }, + xray: { + root: () => ['xray'] as const, + config: () => ['xray', 'config'] as const, + outboundsTraffic: () => ['xray', 'outboundsTraffic'] as const, + }, } as const; diff --git a/frontend/src/api/websocketBridge.ts b/frontend/src/api/websocketBridge.ts index 5edfd34d..114e74d2 100644 --- a/frontend/src/api/websocketBridge.ts +++ b/frontend/src/api/websocketBridge.ts @@ -44,7 +44,7 @@ export function useWebSocketBridge() { }; const onOutbounds: Handler = (payload) => { - queryClient.setQueryData(['xray', 'outboundsTraffic'], payload); + queryClient.setQueryData(keys.xray.outboundsTraffic(), payload); }; const onNodes: Handler = (payload) => { diff --git a/frontend/src/hooks/useXraySetting.ts b/frontend/src/hooks/useXraySetting.ts index c195a2e4..6ae8bd5e 100644 --- a/frontend/src/hooks/useXraySetting.ts +++ b/frontend/src/hooks/useXraySetting.ts @@ -1,8 +1,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { HttpUtil, PromiseUtil } from '@/utils'; +import { keys } from '@/api/queryKeys'; const DIRTY_POLL_MS = 1000; +const DEFAULT_TEST_URL = 'https://www.google.com/generate_204'; export interface OutboundTrafficRow { tag: string; @@ -70,7 +73,6 @@ export interface UseXraySettingResult { fetchAll: () => Promise; fetchOutboundsTraffic: () => Promise; resetOutboundsTraffic: (tag: string) => Promise; - applyOutboundsEvent: (payload: unknown) => void; testOutbound: ( index: number, outbound: unknown, @@ -82,18 +84,59 @@ export interface UseXraySettingResult { restartXray: () => Promise; } +interface ApiMsg { + success?: boolean; + obj?: T; + msg?: string; +} + +interface XrayConfigPayload { + xraySetting: XraySettingsValue; + inboundTags?: string[]; + clientReverseTags?: string[]; + outboundTestUrl?: string; +} + +async function fetchXrayConfig(): Promise { + const msg = await HttpUtil.post('/panel/xray/', undefined, { silent: true }) as ApiMsg; + if (!msg?.success) throw new Error(msg?.msg || 'Failed to load xray config'); + if (typeof msg.obj !== 'string') throw new Error('Malformed xray config response: expected string'); + try { + return JSON.parse(msg.obj) as XrayConfigPayload; + } catch (e) { + const err = e as Error; + throw new Error(`Malformed xray config response: ${err.message}`, { cause: e }); + } +} + +async function fetchOutboundsTraffic(): Promise { + const msg = await HttpUtil.get('/panel/xray/getOutboundsTraffic', undefined, { silent: true }) as ApiMsg; + if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch outbounds traffic'); + return Array.isArray(msg.obj) ? msg.obj : []; +} + export function useXraySetting(): UseXraySettingResult { - const [fetched, setFetched] = useState(false); - const [spinning, setSpinning] = useState(false); + const queryClient = useQueryClient(); + + const configQuery = useQuery({ + queryKey: keys.xray.config(), + queryFn: fetchXrayConfig, + staleTime: Infinity, + }); + + const trafficQuery = useQuery({ + queryKey: keys.xray.outboundsTraffic(), + queryFn: fetchOutboundsTraffic, + staleTime: Infinity, + }); + const [saveDisabled, setSaveDisabled] = useState(true); - const [fetchError, setFetchError] = useState(''); const [xraySetting, setXraySettingState] = useState(''); const [templateSettings, setTemplateSettingsState] = useState(null); - const [outboundTestUrl, setOutboundTestUrlState] = useState('https://www.google.com/generate_204'); + const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL); const [inboundTags, setInboundTags] = useState([]); const [clientReverseTags, setClientReverseTags] = useState([]); const [restartResult, setRestartResult] = useState(''); - const [outboundsTraffic, setOutboundsTraffic] = useState([]); const [outboundTestStates, setOutboundTestStates] = useState>({}); const [testingAll, setTestingAll] = useState(false); @@ -108,6 +151,28 @@ export function useXraySetting(): UseXraySettingResult { outboundTestUrlRef.current = outboundTestUrl; templateSettingsRef.current = templateSettings; + // Seed local editor state from the config query. Runs on first fetch and + // every time the query refetches (e.g. after a successful save). + useEffect(() => { + if (!configQuery.data) return; + const obj = configQuery.data; + const pretty = JSON.stringify(obj.xraySetting, null, 2); + syncingRef.current = true; + setXraySettingState(pretty); + setTemplateSettingsState(obj.xraySetting); + oldXraySettingRef.current = pretty; + syncingRef.current = false; + setInboundTags(obj.inboundTags || []); + setClientReverseTags(obj.clientReverseTags || []); + const nextUrl = obj.outboundTestUrl || DEFAULT_TEST_URL; + setOutboundTestUrlState(nextUrl); + oldOutboundTestUrlRef.current = nextUrl; + setSaveDisabled(true); + }, [configQuery.data]); + + const fetched = configQuery.data !== undefined || configQuery.isError; + const fetchError = configQuery.error ? (configQuery.error as Error).message : ''; + const setXraySetting = useCallback((next: string) => { setXraySettingState(next); if (syncingRef.current) return; @@ -142,63 +207,59 @@ export function useXraySetting(): UseXraySettingResult { }, []); const fetchAll = useCallback(async () => { - setFetchError(''); - const msg = await HttpUtil.post('/panel/xray/'); - if (!msg?.success) { - setFetchError(msg?.msg || 'Failed to load xray config'); - setFetched(true); - return; - } - let obj; - try { - obj = JSON.parse(msg.obj); - } catch (e) { - const err = e as Error; - setFetchError(`Malformed xray config response: ${err?.message || String(err)}`); - setFetched(true); - return; - } - const pretty = JSON.stringify(obj.xraySetting, null, 2); - syncingRef.current = true; - setXraySettingState(pretty); - setTemplateSettingsState(obj.xraySetting); - oldXraySettingRef.current = pretty; - syncingRef.current = false; - setInboundTags(obj.inboundTags || []); - setClientReverseTags(obj.clientReverseTags || []); - const nextUrl = obj.outboundTestUrl || 'https://www.google.com/generate_204'; - setOutboundTestUrlState(nextUrl); - oldOutboundTestUrlRef.current = nextUrl; - setFetched(true); - setSaveDisabled(true); - }, []); + await queryClient.invalidateQueries({ queryKey: keys.xray.config() }); + }, [queryClient]); - const saveAll = useCallback(async () => { - setSpinning(true); - try { - const msg = await HttpUtil.post('/panel/xray/update', { + const fetchOutboundsTrafficCb = useCallback(async () => { + await queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() }); + }, [queryClient]); + + const saveMut = useMutation({ + mutationFn: async () => + HttpUtil.post('/panel/xray/update', { xraySetting: xraySettingRef.current, - outboundTestUrl: outboundTestUrlRef.current || 'https://www.google.com/generate_204', - }); - if (msg?.success) await fetchAll(); - } finally { - setSpinning(false); - } - }, [fetchAll]); + outboundTestUrl: outboundTestUrlRef.current || DEFAULT_TEST_URL, + }) as Promise, + onSuccess: (msg) => { + if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.config() }); + }, + }); - const fetchOutboundsTraffic = useCallback(async () => { - const msg = await HttpUtil.get('/panel/xray/getOutboundsTraffic'); - if (msg?.success) setOutboundsTraffic(msg.obj || []); - }, []); + const resetTrafficMut = useMutation({ + mutationFn: (tag: string) => + HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag }) as Promise, + onSuccess: (msg) => { + if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() }); + }, + }); - const resetOutboundsTraffic = useCallback(async (tag: string) => { - const msg = await HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag }); - if (msg?.success) await fetchOutboundsTraffic(); - }, [fetchOutboundsTraffic]); + const restartMut = useMutation({ + mutationFn: async () => { + const msg = await HttpUtil.post('/panel/api/server/restartXrayService') as ApiMsg; + if (!msg?.success) return msg; + await PromiseUtil.sleep(500); + const r = await HttpUtil.get('/panel/xray/getXrayResult') as ApiMsg; + if (r?.success) setRestartResult(r.obj || ''); + return msg; + }, + }); - const applyOutboundsEvent = useCallback((payload: unknown) => { - if (Array.isArray(payload)) setOutboundsTraffic(payload as OutboundTrafficRow[]); - }, []); + const resetDefaultMut = useMutation({ + mutationFn: async () => HttpUtil.get('/panel/setting/getDefaultJsonConfig') as Promise>, + onSuccess: (msg) => { + if (msg?.success && msg.obj) { + const cloned = JSON.parse(JSON.stringify(msg.obj)); + setTemplateSettings(cloned); + } + }, + }); + + const saveAll = useCallback(async () => { await saveMut.mutateAsync(); }, [saveMut]); + const resetOutboundsTraffic = useCallback(async (tag: string) => { await resetTrafficMut.mutateAsync(tag); }, [resetTrafficMut]); + const restartXray = useCallback(async () => { await restartMut.mutateAsync(); }, [restartMut]); + const resetToDefault = useCallback(async () => { await resetDefaultMut.mutateAsync(); }, [resetDefaultMut]); + + const spinning = saveMut.isPending || restartMut.isPending || resetDefaultMut.isPending; const testOutbound = useCallback( async (index: number, outbound: unknown, mode = 'tcp'): Promise => { @@ -212,11 +273,11 @@ export function useXraySetting(): UseXraySettingResult { outbound: JSON.stringify(outbound), allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []), mode, - }); - if (msg?.success) { + }) as ApiMsg; + if (msg?.success && msg.obj) { setOutboundTestStates((prev) => ({ ...prev, - [index]: { testing: false, result: msg.obj }, + [index]: { testing: false, result: msg.obj as OutboundTestResult }, })); return msg.obj; } @@ -273,43 +334,16 @@ export function useXraySetting(): UseXraySettingResult { } }, [testingAll, testOutbound]); - const resetToDefault = useCallback(async () => { - setSpinning(true); - try { - const msg = await HttpUtil.get('/panel/setting/getDefaultJsonConfig'); - if (msg?.success) { - const cloned = JSON.parse(JSON.stringify(msg.obj)); - setTemplateSettings(cloned); - } - } finally { - setSpinning(false); - } - }, [setTemplateSettings]); - - const restartXray = useCallback(async () => { - setSpinning(true); - try { - const msg = await HttpUtil.post('/panel/api/server/restartXrayService'); - if (msg?.success) { - await PromiseUtil.sleep(500); - const r = await HttpUtil.get('/panel/xray/getXrayResult'); - if (r?.success) setRestartResult(r.obj || ''); - } - } finally { - setSpinning(false); - } - }, []); - useEffect(() => { - fetchAll(); - fetchOutboundsTraffic(); const timer = window.setInterval(() => { const dirtyXray = oldXraySettingRef.current !== xraySettingRef.current; const dirtyUrl = oldOutboundTestUrlRef.current !== outboundTestUrlRef.current; setSaveDisabled(!(dirtyXray || dirtyUrl)); }, DIRTY_POLL_MS); return () => window.clearInterval(timer); - }, [fetchAll, fetchOutboundsTraffic]); + }, []); + + const outboundsTraffic = useMemo(() => trafficQuery.data ?? [], [trafficQuery.data]); return useMemo( () => ({ @@ -330,9 +364,8 @@ export function useXraySetting(): UseXraySettingResult { outboundTestStates, testingAll, fetchAll, - fetchOutboundsTraffic, + fetchOutboundsTraffic: fetchOutboundsTrafficCb, resetOutboundsTraffic, - applyOutboundsEvent, testOutbound, testAllOutbounds, saveAll, @@ -357,9 +390,8 @@ export function useXraySetting(): UseXraySettingResult { outboundTestStates, testingAll, fetchAll, - fetchOutboundsTraffic, + fetchOutboundsTrafficCb, resetOutboundsTraffic, - applyOutboundsEvent, testOutbound, testAllOutbounds, saveAll, diff --git a/frontend/src/pages/xray/XrayPage.tsx b/frontend/src/pages/xray/XrayPage.tsx index 28577aaf..a0e262c9 100644 --- a/frontend/src/pages/xray/XrayPage.tsx +++ b/frontend/src/pages/xray/XrayPage.tsx @@ -31,7 +31,6 @@ import { import { useTheme } from '@/hooks/useTheme'; import { useMediaQuery } from '@/hooks/useMediaQuery'; -import { useWebSocket } from '@/hooks/useWebSocket'; import { useXraySetting } from '@/hooks/useXraySetting'; import type { XraySettingsValue } from '@/hooks/useXraySetting'; import AppSidebar from '@/components/AppSidebar'; @@ -89,7 +88,6 @@ export default function XrayPage() { testingAll, fetchAll, resetOutboundsTraffic, - applyOutboundsEvent, testOutbound, testAllOutbounds, saveAll, @@ -97,8 +95,6 @@ export default function XrayPage() { restartXray, } = xs; - useWebSocket({ outbounds: applyOutboundsEvent as never }); - const [modal, modalContextHolder] = Modal.useModal(); const [warpOpen, setWarpOpen] = useState(false); const [nordOpen, setNordOpen] = useState(false);