feat(frontend): route useXraySetting fetches through TanStack Query

Keeps the bidirectional xraySetting ↔ templateSettings editor sync and
the 1s dirty-tracking interval intact (those are local editor state,
not server data). All seven server calls move:

- config + traffic → useQuery on ['xray', 'config'] and
  ['xray', 'outboundsTraffic']
- saveAll → useMutation that invalidates the config query
- resetOutboundsTraffic → useMutation that invalidates the traffic
  query
- restartXray → useMutation (fires the restart, then reads the
  result string)
- resetToDefault → useMutation (fetch default config, push it into
  the editor via setTemplateSettings)

The WebSocket 'outbounds' event already lands in
keys.xray.outboundsTraffic() via the bridge, so XrayPage drops its
useWebSocket({ outbounds: applyOutboundsEvent }) wiring entirely and
the hook no longer exposes applyOutboundsEvent.

A useEffect seeds xraySetting / templateSettings / tags / test URL
from query data on first fetch and on every refetch, mirroring what
the original fetchAll() did.
This commit is contained in:
MHSanaei 2026-05-24 19:26:08 +02:00
parent 967b9aba4b
commit 6a6f44c884
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
4 changed files with 133 additions and 100 deletions

View file

@ -22,4 +22,9 @@ export const keys = {
onlines: () => ['clients', 'onlines'] as const, onlines: () => ['clients', 'onlines'] as const,
lastOnline: () => ['clients', 'lastOnline'] as const, lastOnline: () => ['clients', 'lastOnline'] as const,
}, },
xray: {
root: () => ['xray'] as const,
config: () => ['xray', 'config'] as const,
outboundsTraffic: () => ['xray', 'outboundsTraffic'] as const,
},
} as const; } as const;

View file

@ -44,7 +44,7 @@ export function useWebSocketBridge() {
}; };
const onOutbounds: Handler = (payload) => { const onOutbounds: Handler = (payload) => {
queryClient.setQueryData(['xray', 'outboundsTraffic'], payload); queryClient.setQueryData(keys.xray.outboundsTraffic(), payload);
}; };
const onNodes: Handler = (payload) => { const onNodes: Handler = (payload) => {

View file

@ -1,8 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { HttpUtil, PromiseUtil } from '@/utils'; import { HttpUtil, PromiseUtil } from '@/utils';
import { keys } from '@/api/queryKeys';
const DIRTY_POLL_MS = 1000; const DIRTY_POLL_MS = 1000;
const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
export interface OutboundTrafficRow { export interface OutboundTrafficRow {
tag: string; tag: string;
@ -70,7 +73,6 @@ export interface UseXraySettingResult {
fetchAll: () => Promise<void>; fetchAll: () => Promise<void>;
fetchOutboundsTraffic: () => Promise<void>; fetchOutboundsTraffic: () => Promise<void>;
resetOutboundsTraffic: (tag: string) => Promise<void>; resetOutboundsTraffic: (tag: string) => Promise<void>;
applyOutboundsEvent: (payload: unknown) => void;
testOutbound: ( testOutbound: (
index: number, index: number,
outbound: unknown, outbound: unknown,
@ -82,18 +84,59 @@ export interface UseXraySettingResult {
restartXray: () => Promise<void>; restartXray: () => Promise<void>;
} }
interface ApiMsg<T = unknown> {
success?: boolean;
obj?: T;
msg?: string;
}
interface XrayConfigPayload {
xraySetting: XraySettingsValue;
inboundTags?: string[];
clientReverseTags?: string[];
outboundTestUrl?: string;
}
async function fetchXrayConfig(): Promise<XrayConfigPayload> {
const msg = await HttpUtil.post('/panel/xray/', undefined, { silent: true }) as ApiMsg<string>;
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<OutboundTrafficRow[]> {
const msg = await HttpUtil.get('/panel/xray/getOutboundsTraffic', undefined, { silent: true }) as ApiMsg<OutboundTrafficRow[]>;
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch outbounds traffic');
return Array.isArray(msg.obj) ? msg.obj : [];
}
export function useXraySetting(): UseXraySettingResult { export function useXraySetting(): UseXraySettingResult {
const [fetched, setFetched] = useState(false); const queryClient = useQueryClient();
const [spinning, setSpinning] = useState(false);
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 [saveDisabled, setSaveDisabled] = useState(true);
const [fetchError, setFetchError] = useState('');
const [xraySetting, setXraySettingState] = useState(''); const [xraySetting, setXraySettingState] = useState('');
const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null); const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null);
const [outboundTestUrl, setOutboundTestUrlState] = useState('https://www.google.com/generate_204'); const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
const [inboundTags, setInboundTags] = useState<string[]>([]); const [inboundTags, setInboundTags] = useState<string[]>([]);
const [clientReverseTags, setClientReverseTags] = useState<string[]>([]); const [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
const [restartResult, setRestartResult] = useState(''); const [restartResult, setRestartResult] = useState('');
const [outboundsTraffic, setOutboundsTraffic] = useState<OutboundTrafficRow[]>([]);
const [outboundTestStates, setOutboundTestStates] = useState<Record<number, OutboundTestState>>({}); const [outboundTestStates, setOutboundTestStates] = useState<Record<number, OutboundTestState>>({});
const [testingAll, setTestingAll] = useState(false); const [testingAll, setTestingAll] = useState(false);
@ -108,6 +151,28 @@ export function useXraySetting(): UseXraySettingResult {
outboundTestUrlRef.current = outboundTestUrl; outboundTestUrlRef.current = outboundTestUrl;
templateSettingsRef.current = templateSettings; 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) => { const setXraySetting = useCallback((next: string) => {
setXraySettingState(next); setXraySettingState(next);
if (syncingRef.current) return; if (syncingRef.current) return;
@ -142,63 +207,59 @@ export function useXraySetting(): UseXraySettingResult {
}, []); }, []);
const fetchAll = useCallback(async () => { const fetchAll = useCallback(async () => {
setFetchError(''); await queryClient.invalidateQueries({ queryKey: keys.xray.config() });
const msg = await HttpUtil.post('/panel/xray/'); }, [queryClient]);
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);
}, []);
const saveAll = useCallback(async () => { const fetchOutboundsTrafficCb = useCallback(async () => {
setSpinning(true); await queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
try { }, [queryClient]);
const msg = await HttpUtil.post('/panel/xray/update', {
const saveMut = useMutation({
mutationFn: async () =>
HttpUtil.post('/panel/xray/update', {
xraySetting: xraySettingRef.current, xraySetting: xraySettingRef.current,
outboundTestUrl: outboundTestUrlRef.current || 'https://www.google.com/generate_204', outboundTestUrl: outboundTestUrlRef.current || DEFAULT_TEST_URL,
}); }) as Promise<ApiMsg>,
if (msg?.success) await fetchAll(); onSuccess: (msg) => {
} finally { if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.config() });
setSpinning(false); },
} });
}, [fetchAll]);
const fetchOutboundsTraffic = useCallback(async () => { const resetTrafficMut = useMutation({
const msg = await HttpUtil.get('/panel/xray/getOutboundsTraffic'); mutationFn: (tag: string) =>
if (msg?.success) setOutboundsTraffic(msg.obj || []); HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag }) as Promise<ApiMsg>,
}, []); onSuccess: (msg) => {
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
},
});
const resetOutboundsTraffic = useCallback(async (tag: string) => { const restartMut = useMutation({
const msg = await HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag }); mutationFn: async () => {
if (msg?.success) await fetchOutboundsTraffic(); const msg = await HttpUtil.post('/panel/api/server/restartXrayService') as ApiMsg;
}, [fetchOutboundsTraffic]); if (!msg?.success) return msg;
await PromiseUtil.sleep(500);
const r = await HttpUtil.get('/panel/xray/getXrayResult') as ApiMsg<string>;
if (r?.success) setRestartResult(r.obj || '');
return msg;
},
});
const applyOutboundsEvent = useCallback((payload: unknown) => { const resetDefaultMut = useMutation({
if (Array.isArray(payload)) setOutboundsTraffic(payload as OutboundTrafficRow[]); mutationFn: async () => HttpUtil.get('/panel/setting/getDefaultJsonConfig') as Promise<ApiMsg<XraySettingsValue>>,
}, []); 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( const testOutbound = useCallback(
async (index: number, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => { async (index: number, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
@ -212,11 +273,11 @@ export function useXraySetting(): UseXraySettingResult {
outbound: JSON.stringify(outbound), outbound: JSON.stringify(outbound),
allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []), allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
mode, mode,
}); }) as ApiMsg<OutboundTestResult>;
if (msg?.success) { if (msg?.success && msg.obj) {
setOutboundTestStates((prev) => ({ setOutboundTestStates((prev) => ({
...prev, ...prev,
[index]: { testing: false, result: msg.obj }, [index]: { testing: false, result: msg.obj as OutboundTestResult },
})); }));
return msg.obj; return msg.obj;
} }
@ -273,43 +334,16 @@ export function useXraySetting(): UseXraySettingResult {
} }
}, [testingAll, testOutbound]); }, [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(() => { useEffect(() => {
fetchAll();
fetchOutboundsTraffic();
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
const dirtyXray = oldXraySettingRef.current !== xraySettingRef.current; const dirtyXray = oldXraySettingRef.current !== xraySettingRef.current;
const dirtyUrl = oldOutboundTestUrlRef.current !== outboundTestUrlRef.current; const dirtyUrl = oldOutboundTestUrlRef.current !== outboundTestUrlRef.current;
setSaveDisabled(!(dirtyXray || dirtyUrl)); setSaveDisabled(!(dirtyXray || dirtyUrl));
}, DIRTY_POLL_MS); }, DIRTY_POLL_MS);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [fetchAll, fetchOutboundsTraffic]); }, []);
const outboundsTraffic = useMemo(() => trafficQuery.data ?? [], [trafficQuery.data]);
return useMemo( return useMemo(
() => ({ () => ({
@ -330,9 +364,8 @@ export function useXraySetting(): UseXraySettingResult {
outboundTestStates, outboundTestStates,
testingAll, testingAll,
fetchAll, fetchAll,
fetchOutboundsTraffic, fetchOutboundsTraffic: fetchOutboundsTrafficCb,
resetOutboundsTraffic, resetOutboundsTraffic,
applyOutboundsEvent,
testOutbound, testOutbound,
testAllOutbounds, testAllOutbounds,
saveAll, saveAll,
@ -357,9 +390,8 @@ export function useXraySetting(): UseXraySettingResult {
outboundTestStates, outboundTestStates,
testingAll, testingAll,
fetchAll, fetchAll,
fetchOutboundsTraffic, fetchOutboundsTrafficCb,
resetOutboundsTraffic, resetOutboundsTraffic,
applyOutboundsEvent,
testOutbound, testOutbound,
testAllOutbounds, testAllOutbounds,
saveAll, saveAll,

View file

@ -31,7 +31,6 @@ import {
import { useTheme } from '@/hooks/useTheme'; import { useTheme } from '@/hooks/useTheme';
import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useWebSocket } from '@/hooks/useWebSocket';
import { useXraySetting } from '@/hooks/useXraySetting'; import { useXraySetting } from '@/hooks/useXraySetting';
import type { XraySettingsValue } from '@/hooks/useXraySetting'; import type { XraySettingsValue } from '@/hooks/useXraySetting';
import AppSidebar from '@/components/AppSidebar'; import AppSidebar from '@/components/AppSidebar';
@ -89,7 +88,6 @@ export default function XrayPage() {
testingAll, testingAll,
fetchAll, fetchAll,
resetOutboundsTraffic, resetOutboundsTraffic,
applyOutboundsEvent,
testOutbound, testOutbound,
testAllOutbounds, testAllOutbounds,
saveAll, saveAll,
@ -97,8 +95,6 @@ export default function XrayPage() {
restartXray, restartXray,
} = xs; } = xs;
useWebSocket({ outbounds: applyOutboundsEvent as never });
const [modal, modalContextHolder] = Modal.useModal(); const [modal, modalContextHolder] = Modal.useModal();
const [warpOpen, setWarpOpen] = useState(false); const [warpOpen, setWarpOpen] = useState(false);
const [nordOpen, setNordOpen] = useState(false); const [nordOpen, setNordOpen] = useState(false);