3x-ui/frontend/src/hooks/useXraySetting.ts
MHSanaei b9612f1326
fix(xray): clear dirty state after saving unchanged config
Editing an outbound and re-saving it without real changes left the top Save button stuck enabled, and clicking it never cleared it. The form re-normalizes values into deeply-equal config, so react-query keeps the same configQuery.data reference on refetch and the seed effect that resets the dirty baseline never re-runs. Advance the baseline to the persisted value in saveMut.onSuccess instead of relying solely on the refetch.
2026-06-02 02:08:06 +02:00

401 lines
14 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { z } from 'zod';
import { HttpUtil, Msg, PromiseUtil } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { keys } from '@/api/queryKeys';
import {
OutboundTrafficListSchema,
OutboundTestResultSchema,
XrayConfigPayloadSchema,
XraySettingsValueSchema,
type OutboundTestResult,
type OutboundTrafficRow,
} from '@/schemas/xray';
const DIRTY_POLL_MS = 1000;
const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
export function isUdpOutbound(outbound: unknown): boolean {
const o = outbound as { protocol?: string; streamSettings?: { network?: string } } | null | undefined;
const p = o?.protocol;
const n = o?.streamSettings?.network;
return p === 'wireguard' || p === 'hysteria' || n === 'hysteria' || n === 'kcp' || n === 'quic';
}
export type { OutboundTrafficRow, OutboundTestResult };
export type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
export interface OutboundTestState {
testing?: boolean;
result?: OutboundTestResult | null;
mode?: string;
}
export type SetTemplate = (
next: XraySettingsValue | null | ((prev: XraySettingsValue | null) => XraySettingsValue | null),
) => void;
export interface UseXraySettingResult {
fetched: boolean;
spinning: boolean;
saveDisabled: boolean;
fetchError: string;
xraySetting: string;
setXraySetting: (next: string) => void;
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
outboundTestUrl: string;
setOutboundTestUrl: (v: string) => void;
inboundTags: string[];
clientReverseTags: string[];
restartResult: string;
outboundsTraffic: OutboundTrafficRow[];
outboundTestStates: Record<number, OutboundTestState>;
testingAll: boolean;
fetchAll: () => Promise<void>;
fetchOutboundsTraffic: () => Promise<void>;
resetOutboundsTraffic: (tag: string) => Promise<void>;
testOutbound: (
index: number,
outbound: unknown,
mode?: string,
) => Promise<OutboundTestResult | null>;
testAllOutbounds: (mode?: string) => Promise<void>;
saveAll: () => Promise<void>;
resetToDefault: () => Promise<void>;
restartXray: () => Promise<void>;
}
type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
async function fetchXrayConfig(): Promise<XrayConfigPayload> {
const msg = await HttpUtil.post('/panel/xray/', undefined, { silent: true });
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');
let parsed: unknown;
try {
parsed = JSON.parse(msg.obj);
} catch (e) {
const err = e as Error;
throw new Error(`Malformed xray config response: ${err.message}`, { cause: e });
}
const result = XrayConfigPayloadSchema.safeParse(parsed);
if (!result.success) {
console.warn('[zod] xray/ config payload failed validation', result.error.issues);
return parsed as XrayConfigPayload;
}
return result.data;
}
async function fetchOutboundsTraffic(): Promise<OutboundTrafficRow[]> {
const msg = await HttpUtil.get('/panel/xray/getOutboundsTraffic', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch outbounds traffic');
const validated = parseMsg(msg, OutboundTrafficListSchema, 'xray/getOutboundsTraffic');
return Array.isArray(validated.obj) ? validated.obj : [];
}
export function useXraySetting(): UseXraySettingResult {
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 [xraySetting, setXraySettingState] = useState('');
const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null);
const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
const [inboundTags, setInboundTags] = useState<string[]>([]);
const [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
const [restartResult, setRestartResult] = useState('');
const [outboundTestStates, setOutboundTestStates] = useState<Record<number, OutboundTestState>>({});
const [testingAll, setTestingAll] = useState(false);
const oldXraySettingRef = useRef('');
const oldOutboundTestUrlRef = useRef('');
const syncingRef = useRef(false);
const xraySettingRef = useRef('');
const outboundTestUrlRef = useRef(outboundTestUrl);
const templateSettingsRef = useRef<XraySettingsValue | null>(null);
xraySettingRef.current = xraySetting;
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;
try {
const parsed = JSON.parse(next);
syncingRef.current = true;
setTemplateSettingsState(parsed);
syncingRef.current = false;
} catch {
/* ignore — wait for user to finish */
}
}, []);
const setTemplateSettings: SetTemplate = useCallback((nextOrFn) => {
setTemplateSettingsState((prev) => {
const next = typeof nextOrFn === 'function' ? nextOrFn(prev) : nextOrFn;
if (next == null) return next;
if (!syncingRef.current) {
try {
syncingRef.current = true;
setXraySettingState(JSON.stringify(next, null, 2));
} finally {
syncingRef.current = false;
}
}
return next;
});
}, []);
const setOutboundTestUrl = useCallback((v: string) => {
setOutboundTestUrlState(v);
}, []);
const fetchAll = useCallback(async () => {
await queryClient.invalidateQueries({ queryKey: keys.xray.config() });
}, [queryClient]);
const fetchOutboundsTrafficCb = useCallback(async () => {
await queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
}, [queryClient]);
const saveMut = useMutation({
mutationFn: async () => {
const sentXraySetting = xraySettingRef.current;
const sentTestUrl = outboundTestUrlRef.current || DEFAULT_TEST_URL;
const msg = await HttpUtil.post('/panel/xray/update', {
xraySetting: sentXraySetting,
outboundTestUrl: sentTestUrl,
});
return { msg, sentXraySetting, sentTestUrl };
},
onSuccess: ({ msg, sentXraySetting, sentTestUrl }) => {
if (!msg?.success) return;
oldXraySettingRef.current = sentXraySetting;
oldOutboundTestUrlRef.current = sentTestUrl;
setSaveDisabled(true);
queryClient.invalidateQueries({ queryKey: keys.xray.config() });
},
});
const resetTrafficMut = useMutation({
mutationFn: (tag: string) =>
HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag }),
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');
if (!msg?.success) return msg;
await PromiseUtil.sleep(500);
const r = await HttpUtil.get('/panel/xray/getXrayResult');
const validated = parseMsg(r, z.string(), 'xray/getXrayResult');
if (validated?.success) setRestartResult(validated.obj || '');
return msg;
},
});
const resetDefaultMut = useMutation({
mutationFn: async (): Promise<Msg<XraySettingsValue>> => {
const raw = await HttpUtil.get('/panel/setting/getDefaultJsonConfig');
return parseMsg(raw, XraySettingsValueSchema, 'setting/getDefaultJsonConfig');
},
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<OutboundTestResult | null> => {
if (!outbound) return null;
const effMode = isUdpOutbound(outbound) ? 'http' : mode;
setOutboundTestStates((prev) => ({
...prev,
[index]: { testing: true, result: null, mode: effMode },
}));
try {
const raw = await HttpUtil.post('/panel/xray/testOutbound', {
outbound: JSON.stringify(outbound),
allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
mode: effMode,
});
const msg = parseMsg(raw, OutboundTestResultSchema, 'xray/testOutbound');
if (msg?.success && msg.obj) {
setOutboundTestStates((prev) => ({
...prev,
[index]: { testing: false, result: msg.obj },
}));
return msg.obj;
}
setOutboundTestStates((prev) => ({
...prev,
[index]: {
testing: false,
result: { success: false, error: msg?.msg || 'Unknown error', mode: effMode },
},
}));
} catch (e) {
setOutboundTestStates((prev) => ({
...prev,
[index]: {
testing: false,
result: { success: false, error: String(e), mode: effMode },
},
}));
}
return null;
},
[],
);
const testAllOutbounds = useCallback(async (mode = 'tcp') => {
const list = templateSettingsRef.current?.outbounds || [];
if (list.length === 0 || testingAll) return;
setTestingAll(true);
try {
const tcpQueue: { index: number; outbound: unknown }[] = [];
const httpQueue: { index: number; outbound: unknown }[] = [];
list.forEach((ob, i) => {
const tag = ob?.tag;
const proto = ob?.protocol;
if (proto === 'blackhole' || proto === 'loopback' || tag === 'blocked') return;
if (mode === 'tcp' && (proto === 'freedom' || proto === 'dns')) return;
if (mode === 'http' || isUdpOutbound(ob)) {
httpQueue.push({ index: i, outbound: ob });
} else {
tcpQueue.push({ index: i, outbound: ob });
}
});
const runLane = async (queue: { index: number; outbound: unknown }[], concurrency: number) => {
const worker = async () => {
while (queue.length > 0) {
const item = queue.shift();
if (!item) break;
await testOutbound(item.index, item.outbound, mode);
}
};
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, () => worker());
await Promise.all(workers);
};
await Promise.all([runLane(tcpQueue, 8), runLane(httpQueue, 1)]);
} finally {
setTestingAll(false);
}
}, [testingAll, testOutbound]);
useEffect(() => {
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);
}, []);
const outboundsTraffic = useMemo(() => trafficQuery.data ?? [], [trafficQuery.data]);
return useMemo(
() => ({
fetched,
spinning,
saveDisabled,
fetchError,
xraySetting,
setXraySetting,
templateSettings,
setTemplateSettings,
outboundTestUrl,
setOutboundTestUrl,
inboundTags,
clientReverseTags,
restartResult,
outboundsTraffic,
outboundTestStates,
testingAll,
fetchAll,
fetchOutboundsTraffic: fetchOutboundsTrafficCb,
resetOutboundsTraffic,
testOutbound,
testAllOutbounds,
saveAll,
resetToDefault,
restartXray,
}),
[
fetched,
spinning,
saveDisabled,
fetchError,
xraySetting,
setXraySetting,
templateSettings,
setTemplateSettings,
outboundTestUrl,
setOutboundTestUrl,
inboundTags,
clientReverseTags,
restartResult,
outboundsTraffic,
outboundTestStates,
testingAll,
fetchAll,
fetchOutboundsTrafficCb,
resetOutboundsTraffic,
testOutbound,
testAllOutbounds,
saveAll,
resetToDefault,
restartXray,
],
);
}