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,
lastOnline: () => ['clients', 'lastOnline'] as const,
},
xray: {
root: () => ['xray'] as const,
config: () => ['xray', 'config'] as const,
outboundsTraffic: () => ['xray', 'outboundsTraffic'] as const,
},
} as const;

View file

@ -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) => {

View file

@ -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<void>;
fetchOutboundsTraffic: () => Promise<void>;
resetOutboundsTraffic: (tag: string) => Promise<void>;
applyOutboundsEvent: (payload: unknown) => void;
testOutbound: (
index: number,
outbound: unknown,
@ -82,18 +84,59 @@ export interface UseXraySettingResult {
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 {
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<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 [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
const [restartResult, setRestartResult] = useState('');
const [outboundsTraffic, setOutboundsTraffic] = useState<OutboundTrafficRow[]>([]);
const [outboundTestStates, setOutboundTestStates] = useState<Record<number, OutboundTestState>>({});
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',
outboundTestUrl: outboundTestUrlRef.current || DEFAULT_TEST_URL,
}) as Promise<ApiMsg>,
onSuccess: (msg) => {
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.config() });
},
});
if (msg?.success) await fetchAll();
} finally {
setSpinning(false);
const resetTrafficMut = useMutation({
mutationFn: (tag: string) =>
HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag }) as Promise<ApiMsg>,
onSuccess: (msg) => {
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
},
});
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<string>;
if (r?.success) setRestartResult(r.obj || '');
return msg;
},
});
const resetDefaultMut = useMutation({
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);
}
}, [fetchAll]);
},
});
const fetchOutboundsTraffic = useCallback(async () => {
const msg = await HttpUtil.get('/panel/xray/getOutboundsTraffic');
if (msg?.success) setOutboundsTraffic(msg.obj || []);
}, []);
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 resetOutboundsTraffic = useCallback(async (tag: string) => {
const msg = await HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag });
if (msg?.success) await fetchOutboundsTraffic();
}, [fetchOutboundsTraffic]);
const applyOutboundsEvent = useCallback((payload: unknown) => {
if (Array.isArray(payload)) setOutboundsTraffic(payload as OutboundTrafficRow[]);
}, []);
const spinning = saveMut.isPending || restartMut.isPending || resetDefaultMut.isPending;
const testOutbound = useCallback(
async (index: number, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
@ -212,11 +273,11 @@ export function useXraySetting(): UseXraySettingResult {
outbound: JSON.stringify(outbound),
allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
mode,
});
if (msg?.success) {
}) as ApiMsg<OutboundTestResult>;
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,

View file

@ -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);