mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 15:17:14 +00:00
d5ab84e8d5
* feat(amneziawg): add AmneziaWG as an outbound protocol - AmneziaWG outbound protocol end-to-end: config schema, socks bridge, netstack, panel UI - Route amneziawg outbounds to HTTP probe in TCP mode (backend + frontend classifiers) with pinning test - Add 2-minute idle read deadline to pumpUDPEgress to reap idle egress sessions - Require SOCKS5 username/password auth on the egress server (reject NO-AUTH with 0xFF) with test - Bound the egress TCP tunnel dial with portForwardDialTimeout (10s), matching portfwd.go - Resolve UDP domain targets off the association's reader loop via deliverUDPDatagram; race-safe getOrDial starts the reply pump at session creation; client passed by value into resolver goroutines (pinned by TestEgressUDPDatagramDomainInterleavedClients) - Reconcile early-returns on an empty desired set and closes the egress listener; EgressBasePort (64900) is reserved against local inbound port conflicts like the internal API port, with pinning tests for both the port reservation (TestCheckPortConflict_EgressPortBlockedLocal) and the Reconcile empty-desired Close/Listen lifecycle (TestOutboundManagerReconcileEmptyDesiredClosesEgress) - Eliminate acceptLoop shutdown race by validating listener != nil and registering to tracked under s.mu before wg.Add; bound pre-auth handshake with deadline (pinned by TestEgressServerCloseDuringConcurrentAccepts) - Support AAAA and dual-stack domain resolution in tunnel DNS resolver with v6 default fallback (DefaultTunnelDNSServerV6); add DNS field to frontend protocol form; avoid unneeded cache flushes on unchanged SetStack ticks * fix(amneziawg): resolve IPv6-only DNS default fallback and validate required keys - Default to IPv6 tunnel DNS on IPv6-only outbounds with blank dns - Require non-empty secretKey and peer publicKey in ValidateAmneziaWGOutbound - Add end-to-end IPv6 tunnel domain resolution test and test empty key rejection - Trim comment blocks exceeding 2 lines across modified files - Fix Storybook test execution on environments with POSIX locale Co-Authored-By: Claude Code <noreply@anthropic.com> --------- Co-authored-by: rqzbeh <rqzbeh@users.noreply.github.com> Co-authored-by: Claude Code <noreply@anthropic.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
550 lines
20 KiB
TypeScript
550 lines
20 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 } 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 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;
|
|
|
|
function normalizeOutboundTestUrl(url: string) {
|
|
return url || DEFAULT_TEST_URL;
|
|
}
|
|
|
|
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' ||
|
|
p === 'amneziawg' ||
|
|
n === 'hysteria' ||
|
|
n === 'kcp' ||
|
|
n === 'quic'
|
|
);
|
|
}
|
|
|
|
export type OutboundTestMode = 'tcp' | 'http' | 'real';
|
|
|
|
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[];
|
|
subscriptionOutbounds: unknown[];
|
|
subscriptionOutboundTags: string[];
|
|
outboundsTraffic: OutboundTrafficRow[];
|
|
outboundTestStates: Record<number, OutboundTestState>;
|
|
subscriptionTestStates: Record<string, OutboundTestState>;
|
|
testingAll: boolean;
|
|
fetchAll: () => Promise<void>;
|
|
fetchOutboundsTraffic: () => Promise<void>;
|
|
resetOutboundsTraffic: (tag: string) => Promise<void>;
|
|
testOutbound: (
|
|
index: number,
|
|
outbound: unknown,
|
|
mode?: string,
|
|
) => Promise<OutboundTestResult | null>;
|
|
testSubscriptionOutbound: (
|
|
tag: string,
|
|
outbound: unknown,
|
|
mode?: string,
|
|
) => Promise<OutboundTestResult | null>;
|
|
testAllOutbounds: (mode?: string) => Promise<void>;
|
|
saveAll: () => Promise<void>;
|
|
resetToDefault: () => Promise<void>;
|
|
}
|
|
|
|
type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
|
|
|
|
export async function fetchXrayConfig(): Promise<XrayConfigPayload> {
|
|
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<OutboundTrafficRow[]> {
|
|
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 [xraySetting, setXraySettingState] = useState('');
|
|
const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null);
|
|
const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
|
|
const [savedXraySetting, setSavedXraySetting] = useState('');
|
|
const [savedOutboundTestUrl, setSavedOutboundTestUrl] = useState(DEFAULT_TEST_URL);
|
|
const config = configQuery.data;
|
|
const inboundTags = useMemo(() => config?.inboundTags || [], [config]);
|
|
const clientReverseTags = useMemo(() => config?.clientReverseTags || [], [config]);
|
|
const subscriptionOutbounds = useMemo<unknown[]>(
|
|
() => config?.subscriptionOutbounds || [],
|
|
[config],
|
|
);
|
|
const subscriptionOutboundTags = useMemo(() => config?.subscriptionOutboundTags || [], [config]);
|
|
const [outboundTestStates, setOutboundTestStates] = useState<Record<number, OutboundTestState>>(
|
|
{},
|
|
);
|
|
// Subscription outbounds aren't in templateSettings.outbounds, so their test
|
|
// results are keyed by tag rather than by index.
|
|
const [subscriptionTestStates, setSubscriptionTestStates] = useState<
|
|
Record<string, OutboundTestState>
|
|
>({});
|
|
const [testingAll, setTestingAll] = useState(false);
|
|
|
|
const syncingRef = useRef(false);
|
|
const xraySettingRef = useRef('');
|
|
const outboundTestUrlRef = useRef(outboundTestUrl);
|
|
const savedXraySettingRef = useRef(savedXraySetting);
|
|
const savedOutboundTestUrlRef = useRef(savedOutboundTestUrl);
|
|
const templateSettingsRef = useRef<XraySettingsValue | null>(null);
|
|
const subscriptionOutboundsRef = useRef<unknown[]>([]);
|
|
|
|
const [syncedConfig, setSyncedConfig] = useState<XrayConfigPayload | undefined>();
|
|
|
|
useEffect(() => {
|
|
xraySettingRef.current = xraySetting;
|
|
outboundTestUrlRef.current = outboundTestUrl;
|
|
savedXraySettingRef.current = savedXraySetting;
|
|
savedOutboundTestUrlRef.current = savedOutboundTestUrl;
|
|
templateSettingsRef.current = templateSettings;
|
|
subscriptionOutboundsRef.current = subscriptionOutbounds;
|
|
});
|
|
|
|
// Adopt a fetched config during render, so the editor never paints one frame
|
|
// of the previous config after a refetch. Local edits win over the refetch.
|
|
if (config && config !== syncedConfig) {
|
|
setSyncedConfig(config);
|
|
const isDirty =
|
|
savedXraySetting !== xraySetting ||
|
|
savedOutboundTestUrl !== normalizeOutboundTestUrl(outboundTestUrl);
|
|
if (!isDirty) {
|
|
const pretty = JSON.stringify(config.xraySetting, null, 2);
|
|
const nextUrl = normalizeOutboundTestUrl(config.outboundTestUrl || '');
|
|
setXraySettingState(pretty);
|
|
setTemplateSettingsState(config.xraySetting);
|
|
setSavedXraySetting(pretty);
|
|
setOutboundTestUrlState(nextUrl);
|
|
setSavedOutboundTestUrl(nextUrl);
|
|
}
|
|
}
|
|
|
|
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 = normalizeOutboundTestUrl(outboundTestUrlRef.current);
|
|
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;
|
|
setSavedXraySetting(sentXraySetting);
|
|
setSavedOutboundTestUrl(sentTestUrl);
|
|
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<Msg<XraySettingsValue>> => {
|
|
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<OutboundTestResult[]> => {
|
|
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<OutboundTestResult | null> => {
|
|
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<OutboundTestResult | null> => {
|
|
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],
|
|
);
|
|
|
|
const saveDisabled =
|
|
savedXraySetting === xraySetting &&
|
|
savedOutboundTestUrl === normalizeOutboundTestUrl(outboundTestUrl);
|
|
|
|
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,
|
|
],
|
|
);
|
|
}
|