import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { z } from 'zod'; import { HttpUtil, Msg } from '@/utils'; import { parseMsg } from '@/utils/zodValidate'; import { keys } from '@/api/queryKeys'; import { OutboundTrafficListSchema, OutboundTestResultListSchema, XrayConfigPayloadSchema, XraySettingsValueSchema, type OutboundTestResult, type OutboundTrafficRow, } from '@/schemas/xray'; const DIRTY_POLL_MS = 1000; const DEFAULT_TEST_URL = 'https://www.google.com/generate_204'; // One HTTP-mode batch request tests this many outbounds through a single // shared temp xray instance; chunking keeps responses bounded (~30s worst // case — each probe is a cold plus a warm request) and lands Test All // results progressively. const HTTP_BATCH_CHUNK = 16; 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 OutboundTestMode = 'tcp' | 'http' | 'real'; export type { OutboundTrafficRow, OutboundTestResult }; export type XraySettingsValue = z.infer; 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[]; subscriptionOutbounds: unknown[]; subscriptionOutboundTags: string[]; outboundsTraffic: OutboundTrafficRow[]; outboundTestStates: Record; subscriptionTestStates: Record; testingAll: boolean; fetchAll: () => Promise; fetchOutboundsTraffic: () => Promise; resetOutboundsTraffic: (tag: string) => Promise; testOutbound: ( index: number, outbound: unknown, mode?: string, ) => Promise; testSubscriptionOutbound: ( tag: string, outbound: unknown, mode?: string, ) => Promise; testAllOutbounds: (mode?: string) => Promise; saveAll: () => Promise; resetToDefault: () => Promise; } type XrayConfigPayload = z.infer; export async function fetchXrayConfig(): Promise { const msg = await HttpUtil.post('/panel/api/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 { const msg = await HttpUtil.get('/panel/api/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(null); const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL); const [inboundTags, setInboundTags] = useState([]); const [clientReverseTags, setClientReverseTags] = useState([]); const [subscriptionOutbounds, setSubscriptionOutbounds] = useState([]); const [subscriptionOutboundTags, setSubscriptionOutboundTags] = useState([]); const [outboundTestStates, setOutboundTestStates] = useState>({}); // Subscription outbounds aren't in templateSettings.outbounds, so their test // results are keyed by tag rather than by index. const [subscriptionTestStates, setSubscriptionTestStates] = useState>({}); 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(null); const subscriptionOutboundsRef = useRef([]); xraySettingRef.current = xraySetting; outboundTestUrlRef.current = outboundTestUrl; templateSettingsRef.current = templateSettings; subscriptionOutboundsRef.current = subscriptionOutbounds; // 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 || []); setSubscriptionOutbounds(obj.subscriptionOutbounds || []); setSubscriptionOutboundTags(obj.subscriptionOutboundTags || []); 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/api/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/api/xray/resetOutboundsTraffic', { tag }), onSuccess: (msg) => { if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() }); }, }); const resetDefaultMut = useMutation({ mutationFn: async (): Promise> => { const raw = await HttpUtil.get('/panel/api/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 resetToDefault = useCallback(async () => { await resetDefaultMut.mutateAsync(); }, [resetDefaultMut]); const spinning = saveMut.isPending || resetDefaultMut.isPending; // Shared POST + parse for a batch of outbound tests. The backend probes the // whole batch through one shared temp xray instance and returns results in // request order; this aligns them by index and shapes failures so every // input gets an OutboundTestResult. const postOutboundTestBatch = useCallback( async (outbounds: unknown[], effMode: string): Promise => { const failAll = (error: string): OutboundTestResult[] => outbounds.map(() => ({ success: false, error, mode: effMode })); try { const raw = await HttpUtil.post('/panel/api/xray/testOutbounds', { outbounds: JSON.stringify(outbounds), allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []), mode: effMode, }); const msg = parseMsg(raw, OutboundTestResultListSchema, 'xray/testOutbounds'); if (!msg?.success || !Array.isArray(msg.obj)) return failAll(msg?.msg || 'Unknown error'); const list = msg.obj; return outbounds.map((_ob, i) => list[i] ?? { success: false, error: 'Missing result', mode: effMode }); } catch (e) { return failAll(String(e)); } }, [], ); const testOutbound = useCallback( async (index: number, outbound: unknown, mode = 'tcp'): Promise => { if (!outbound) return null; const effMode = mode === 'tcp' && isUdpOutbound(outbound) ? 'http' : mode; setOutboundTestStates((prev) => ({ ...prev, [index]: { testing: true, result: null, mode: effMode }, })); const [result] = await postOutboundTestBatch([outbound], effMode); setOutboundTestStates((prev) => ({ ...prev, [index]: { testing: false, result } })); return result.success ? result : null; }, [postOutboundTestBatch], ); // Test a subscription outbound (not present in templateSettings.outbounds); // results are keyed by tag in subscriptionTestStates. const testSubscriptionOutbound = useCallback( async (tag: string, outbound: unknown, mode = 'tcp'): Promise => { if (!outbound || !tag) return null; const effMode = mode === 'tcp' && isUdpOutbound(outbound) ? 'http' : mode; setSubscriptionTestStates((prev) => ({ ...prev, [tag]: { testing: true, result: null, mode: effMode }, })); const [result] = await postOutboundTestBatch([outbound], effMode); setSubscriptionTestStates((prev) => ({ ...prev, [tag]: { testing: false, result } })); return result.success ? result : null; }, [postOutboundTestBatch], ); const testAllOutbounds = useCallback(async (mode = 'tcp') => { // Template outbounds key their results by index (outboundTestStates); // subscription outbounds aren't in the template, so they key by tag // (subscriptionTestStates). Both go through the same probe endpoint. const templateList = templateSettingsRef.current?.outbounds || []; const subList = (subscriptionOutboundsRef.current || []) as Array<{ tag?: string; protocol?: string }>; if ((templateList.length === 0 && subList.length === 0) || testingAll) return; setTestingAll(true); try { type TcpEntry = | { kind: 'tpl'; index: number; outbound: unknown } | { kind: 'sub'; tag: string; outbound: unknown }; const tcpQueue: TcpEntry[] = []; // HTTP batches stay homogeneous (all template or all subscription) so a // tag shared between a template and a subscription outbound can't collide // inside one batch, and each batch's results route to one state map. const probeMode = mode === 'real' ? 'real' : 'http'; const httpTplQueue: { index: number; outbound: unknown }[] = []; const httpSubQueue: { tag: string; outbound: unknown }[] = []; const enqueue = (ob: { tag?: string; protocol?: string }, kind: 'tpl' | 'sub', index: number, tag: string) => { const proto = ob?.protocol; if (proto === 'blackhole' || proto === 'loopback' || ob?.tag === 'blocked') return; // freedom ("direct") and dns aren't proxies — skip them in every mode. if (proto === 'freedom' || proto === 'dns') return; if (kind === 'sub' && !tag) return; const toHttp = mode !== 'tcp' || isUdpOutbound(ob); if (kind === 'tpl') { if (toHttp) httpTplQueue.push({ index, outbound: ob }); else tcpQueue.push({ kind: 'tpl', index, outbound: ob }); } else if (toHttp) { httpSubQueue.push({ tag, outbound: ob }); } else { tcpQueue.push({ kind: 'sub', tag, outbound: ob }); } }; templateList.forEach((ob, i) => enqueue(ob, 'tpl', i, '')); subList.forEach((ob) => enqueue(ob, 'sub', -1, typeof ob?.tag === 'string' ? ob.tag : '')); // TCP probes are dial-only and cheap server-side; per-item requests // keep results landing one by one, each routed to its own state map. const runTcpLane = async () => { const queue = [...tcpQueue]; const worker = async () => { while (queue.length > 0) { const item = queue.shift(); if (!item) break; if (item.kind === 'sub') await testSubscriptionOutbound(item.tag, item.outbound, mode); else await testOutbound(item.index, item.outbound, mode); } }; await Promise.all(Array.from({ length: Math.min(8, queue.length) }, () => worker())); }; // HTTP probes go out as chunked batches — one temp xray spawn per // chunk instead of one per outbound, with results landing per chunk. const runTplHttpLane = async () => { for (let at = 0; at < httpTplQueue.length; at += HTTP_BATCH_CHUNK) { const chunk = httpTplQueue.slice(at, at + HTTP_BATCH_CHUNK); setOutboundTestStates((prev) => { const next = { ...prev }; for (const item of chunk) next[item.index] = { testing: true, result: null, mode: probeMode }; return next; }); const results = await postOutboundTestBatch(chunk.map((c) => c.outbound), probeMode); setOutboundTestStates((prev) => { const next = { ...prev }; chunk.forEach((item, i) => { next[item.index] = { testing: false, result: results[i] }; }); return next; }); } }; const runSubHttpLane = async () => { for (let at = 0; at < httpSubQueue.length; at += HTTP_BATCH_CHUNK) { const chunk = httpSubQueue.slice(at, at + HTTP_BATCH_CHUNK); setSubscriptionTestStates((prev) => { const next = { ...prev }; for (const item of chunk) next[item.tag] = { testing: true, result: null, mode: probeMode }; return next; }); const results = await postOutboundTestBatch(chunk.map((c) => c.outbound), probeMode); setSubscriptionTestStates((prev) => { const next = { ...prev }; chunk.forEach((item, i) => { next[item.tag] = { testing: false, result: results[i] }; }); return next; }); } }; // HTTP batches must not overlap: the backend serialises them with a // non-blocking lock and rejects a second concurrent batch ("Another // outbound test is already running"). Run the template and subscription // HTTP lanes one after the other; TCP probes don't take that lock, so // they still run alongside. const runHttpLane = async () => { await runTplHttpLane(); await runSubHttpLane(); }; await Promise.all([runTcpLane(), runHttpLane()]); } finally { setTestingAll(false); } }, [testingAll, testOutbound, testSubscriptionOutbound, postOutboundTestBatch]); 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, subscriptionOutbounds, subscriptionOutboundTags, outboundsTraffic, outboundTestStates, subscriptionTestStates, testingAll, fetchAll, fetchOutboundsTraffic: fetchOutboundsTrafficCb, resetOutboundsTraffic, testOutbound, testSubscriptionOutbound, testAllOutbounds, saveAll, resetToDefault, }), [ fetched, spinning, saveDisabled, fetchError, xraySetting, setXraySetting, templateSettings, setTemplateSettings, outboundTestUrl, setOutboundTestUrl, inboundTags, clientReverseTags, subscriptionOutbounds, subscriptionOutboundTags, outboundsTraffic, outboundTestStates, subscriptionTestStates, testingAll, fetchAll, fetchOutboundsTrafficCb, resetOutboundsTraffic, testOutbound, testSubscriptionOutbound, testAllOutbounds, saveAll, resetToDefault, ], ); }