mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-25 13:56:10 +00:00
feat(outbound): batched connection tester with direct timed HTTP probes
Replace the per-outbound burstObservatory polling (one temp xray spawn + up to 15s of /debug/vars polling per outbound, serialised) with one shared temp xray instance per batch: every tested outbound gets its own loopback SOCKS inbound plus an inboundTag->outboundTag routing rule, and the panel times a real HTTP request through each one in parallel. The probe returns as soon as the response lands and records the HTTP status plus an httptrace breakdown (proxy connect / TLS via outbound / first byte) shown in the result popover. New POST /panel/api/xray/testOutbounds endpoint (array in, results in input order, max 50); the legacy /testOutbound endpoint now delegates to the same engine. Test All chunks HTTP probes 16 per request, and a batch whose shared process never comes up (one structurally-broken outbound poisons the config) retries each item in an isolated instance so the broken outbound reports xray's real error while the rest still test.
This commit is contained in:
@@ -7,7 +7,7 @@ import { parseMsg } from '@/utils/zodValidate';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
import {
|
||||
OutboundTrafficListSchema,
|
||||
OutboundTestResultSchema,
|
||||
OutboundTestResultListSchema,
|
||||
XrayConfigPayloadSchema,
|
||||
XraySettingsValueSchema,
|
||||
type OutboundTestResult,
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
|
||||
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 (~15s worst
|
||||
// case) 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;
|
||||
@@ -254,21 +258,26 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
|
||||
const spinning = saveMut.isPending || resetDefaultMut.isPending;
|
||||
|
||||
// Shared POST + parse for a single outbound test. Returns an OutboundTestResult
|
||||
// (success or a failure-shaped result); callers store it under their own key.
|
||||
const postOutboundTest = useCallback(
|
||||
async (outbound: unknown, effMode: string): Promise<OutboundTestResult> => {
|
||||
// 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/testOutbound', {
|
||||
outbound: JSON.stringify(outbound),
|
||||
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, OutboundTestResultSchema, 'xray/testOutbound');
|
||||
if (msg?.success && msg.obj) return msg.obj;
|
||||
return { success: false, error: msg?.msg || 'Unknown error', 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 { success: false, error: String(e), mode: effMode };
|
||||
return failAll(String(e));
|
||||
}
|
||||
},
|
||||
[],
|
||||
@@ -282,11 +291,11 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
...prev,
|
||||
[index]: { testing: true, result: null, mode: effMode },
|
||||
}));
|
||||
const result = await postOutboundTest(outbound, effMode);
|
||||
const [result] = await postOutboundTestBatch([outbound], effMode);
|
||||
setOutboundTestStates((prev) => ({ ...prev, [index]: { testing: false, result } }));
|
||||
return result.success ? result : null;
|
||||
},
|
||||
[postOutboundTest],
|
||||
[postOutboundTestBatch],
|
||||
);
|
||||
|
||||
// Test a subscription outbound (not present in templateSettings.outbounds);
|
||||
@@ -299,11 +308,11 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
...prev,
|
||||
[tag]: { testing: true, result: null, mode: effMode },
|
||||
}));
|
||||
const result = await postOutboundTest(outbound, effMode);
|
||||
const [result] = await postOutboundTestBatch([outbound], effMode);
|
||||
setSubscriptionTestStates((prev) => ({ ...prev, [tag]: { testing: false, result } }));
|
||||
return result.success ? result : null;
|
||||
},
|
||||
[postOutboundTest],
|
||||
[postOutboundTestBatch],
|
||||
);
|
||||
|
||||
const testAllOutbounds = useCallback(async (mode = 'tcp') => {
|
||||
@@ -324,7 +333,10 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
tcpQueue.push({ index: i, outbound: ob });
|
||||
}
|
||||
});
|
||||
const runLane = async (queue: { index: number; outbound: unknown }[], concurrency: number) => {
|
||||
// TCP probes are dial-only and cheap server-side; per-item requests
|
||||
// keep results landing one by one.
|
||||
const runTcpLane = async () => {
|
||||
const queue = [...tcpQueue];
|
||||
const worker = async () => {
|
||||
while (queue.length > 0) {
|
||||
const item = queue.shift();
|
||||
@@ -332,14 +344,33 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
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(Array.from({ length: Math.min(8, queue.length) }, () => worker()));
|
||||
};
|
||||
await Promise.all([runLane(tcpQueue, 8), runLane(httpQueue, 1)]);
|
||||
// HTTP probes go out as chunked batches — one temp xray spawn per
|
||||
// chunk instead of one per outbound, with results landing per chunk.
|
||||
const runHttpLane = async () => {
|
||||
for (let at = 0; at < httpQueue.length; at += HTTP_BATCH_CHUNK) {
|
||||
const chunk = httpQueue.slice(at, at + HTTP_BATCH_CHUNK);
|
||||
setOutboundTestStates((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const item of chunk) next[item.index] = { testing: true, result: null, mode: 'http' };
|
||||
return next;
|
||||
});
|
||||
const results = await postOutboundTestBatch(chunk.map((c) => c.outbound), 'http');
|
||||
setOutboundTestStates((prev) => {
|
||||
const next = { ...prev };
|
||||
chunk.forEach((item, i) => {
|
||||
next[item.index] = { testing: false, result: results[i] };
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
await Promise.all([runTcpLane(), runHttpLane()]);
|
||||
} finally {
|
||||
setTestingAll(false);
|
||||
}
|
||||
}, [testingAll, testOutbound]);
|
||||
}, [testingAll, testOutbound, postOutboundTestBatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
|
||||
@@ -1063,6 +1063,17 @@ export const sections: readonly Section[] = [
|
||||
],
|
||||
body: 'outbound={"protocol":"freedom","settings":{}}&mode=tcp',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/xray/testOutbounds',
|
||||
summary: 'Test a batch of outbounds (max 50) through one shared temp xray instance. Returns an array of results in input order, each with the outbound tag, delay, HTTP status and a connect/TLS/TTFB timing breakdown.',
|
||||
params: [
|
||||
{ name: 'outbounds', in: 'body (form)', type: 'string', desc: 'JSON array of outbound configs to test (required).' },
|
||||
{ name: 'allOutbounds', in: 'body (form)', type: 'string', desc: 'JSON array of all outbounds — used to resolve dialerProxy chains.' },
|
||||
{ name: 'mode', in: 'body (form)', type: 'string', desc: '"tcp" for fast dial-only probes (UDP-transport outbounds are still probed over HTTP). Default/empty routes a real HTTP request through each outbound.' },
|
||||
],
|
||||
body: 'outbounds=[{"tag":"direct","protocol":"freedom","settings":{}}]&mode=http',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/xray/balancerStatus',
|
||||
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
DeleteOutlined,
|
||||
VerticalAlignTopOutlined,
|
||||
ThunderboltOutlined,
|
||||
CheckCircleFilled,
|
||||
CloseCircleFilled,
|
||||
LoadingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
@@ -17,6 +15,7 @@ import { OutboundProtocols as Protocols } from '@/schemas/primitives';
|
||||
import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
|
||||
import type { OutboundRow } from './outbounds-tab-types';
|
||||
import TestResultPopover from './TestResultPopover';
|
||||
import {
|
||||
isTesting,
|
||||
isUntestable,
|
||||
@@ -102,10 +101,7 @@ export default function OutboundCardList({
|
||||
<span className="traffic-down">↓ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).down)}</span>
|
||||
<span className="card-test">
|
||||
{testResult(outboundTestStates, index) ? (
|
||||
<span className={testResult(outboundTestStates, index)!.success ? 'pill-ok' : 'pill-fail'}>
|
||||
{testResult(outboundTestStates, index)!.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
|
||||
{testResult(outboundTestStates, index)!.success ? <span>{testResult(outboundTestStates, index)!.delay} ms</span> : <span>failed</span>}
|
||||
</span>
|
||||
<TestResultPopover result={testResult(outboundTestStates, index)!} />
|
||||
) : isTesting(outboundTestStates, index) ? (
|
||||
<LoadingOutlined />
|
||||
) : null}
|
||||
|
||||
@@ -210,6 +210,25 @@
|
||||
color: #e04141;
|
||||
}
|
||||
|
||||
.outbound-test-popover .breakdown-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.outbound-test-popover .breakdown-row .bd-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.outbound-test-popover .breakdown-row .bd-value {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.subscription-outbounds-head {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Popover, Table, Tag, Tooltip } from 'antd';
|
||||
import {
|
||||
ThunderboltOutlined,
|
||||
CheckCircleFilled,
|
||||
CloseCircleFilled,
|
||||
LoadingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Button, Table, Tag, Tooltip } from 'antd';
|
||||
import { ThunderboltOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
import { SizeFormatter } from '@/utils';
|
||||
@@ -15,8 +10,8 @@ import { isUdpOutbound } from '@/hooks/useXraySetting';
|
||||
import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
|
||||
import type { OutboundRow } from './outbounds-tab-types';
|
||||
import TestResultPopover from './TestResultPopover';
|
||||
import {
|
||||
hasBreakdown,
|
||||
isTesting,
|
||||
isUntestable,
|
||||
outboundAddresses,
|
||||
@@ -103,36 +98,7 @@ export default function SubscriptionOutbounds({
|
||||
const key = record.tag || '';
|
||||
const r = testResult(subscriptionTestStates, key);
|
||||
if (!r) return isTesting(subscriptionTestStates, key) ? <LoadingOutlined /> : <span className="empty">—</span>;
|
||||
return (
|
||||
<Popover
|
||||
placement="topLeft"
|
||||
rootClassName="outbound-test-popover"
|
||||
content={
|
||||
<div className="timing-breakdown">
|
||||
<div className={`td-head ${r.success ? 'ok' : 'fail'}`}>
|
||||
{r.success ? <span>{r.delay} ms</span> : <span>{r.error || 'failed'}</span>}
|
||||
{r.mode && <span className="mode-badge">{String(r.mode).toUpperCase()}</span>}
|
||||
</div>
|
||||
{hasBreakdown(r) && (
|
||||
<>
|
||||
{(r.endpoints || []).map((ep) => (
|
||||
<div key={ep.address} className="endpoint-row">
|
||||
<span className={ep.success ? 'dot-ok' : 'dot-fail'}>●</span>
|
||||
<span className="ep-addr">{ep.address}</span>
|
||||
<span className="ep-meta">{ep.success ? `${ep.delay} ms` : ep.error || 'failed'}</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<span className={r.success ? 'pill-ok' : 'pill-fail'}>
|
||||
{r.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
|
||||
{r.success ? <span>{r.delay} ms</span> : <span>failed</span>}
|
||||
</span>
|
||||
</Popover>
|
||||
);
|
||||
return <TestResultPopover result={r} />;
|
||||
};
|
||||
|
||||
const testButton = (record: OutboundRow) => {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Popover } from 'antd';
|
||||
import { CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons';
|
||||
|
||||
import type { OutboundTestResult } from '@/hooks/useXraySetting';
|
||||
|
||||
interface TestResultPopoverProps {
|
||||
result: OutboundTestResult;
|
||||
// Custom trigger element; defaults to the ok/fail latency pill.
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
// Latency pill + detail popover for an outbound test result: per-endpoint
|
||||
// dial outcomes for TCP probes, HTTP status and the timing breakdown for
|
||||
// HTTP probes.
|
||||
export default function TestResultPopover({ result: r, children }: TestResultPopoverProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const breakdown: Array<{ key: string; label: string; value: string }> = [];
|
||||
if (typeof r.httpStatus === 'number') {
|
||||
breakdown.push({ key: 'status', label: t('pages.xray.outbound.httpStatus'), value: String(r.httpStatus) });
|
||||
}
|
||||
if (typeof r.connectMs === 'number') {
|
||||
breakdown.push({ key: 'connect', label: t('pages.xray.outbound.breakdownConnect'), value: `${r.connectMs} ms` });
|
||||
}
|
||||
if (typeof r.tlsMs === 'number') {
|
||||
breakdown.push({ key: 'tls', label: t('pages.xray.outbound.breakdownTls'), value: `${r.tlsMs} ms` });
|
||||
}
|
||||
if (typeof r.ttfbMs === 'number') {
|
||||
breakdown.push({ key: 'ttfb', label: t('pages.xray.outbound.breakdownTtfb'), value: `${r.ttfbMs} ms` });
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
placement="topLeft"
|
||||
rootClassName="outbound-test-popover"
|
||||
content={
|
||||
<div className="timing-breakdown">
|
||||
<div className={`td-head ${r.success ? 'ok' : 'fail'}`}>
|
||||
{r.success ? <span>{r.delay} ms</span> : <span>{r.error || 'failed'}</span>}
|
||||
{r.mode && <span className="mode-badge">{String(r.mode).toUpperCase()}</span>}
|
||||
</div>
|
||||
{(r.endpoints || []).map((ep) => (
|
||||
<div key={ep.address} className="endpoint-row">
|
||||
<span className={ep.success ? 'dot-ok' : 'dot-fail'}>●</span>
|
||||
<span className="ep-addr">{ep.address}</span>
|
||||
<span className="ep-meta">{ep.success ? `${ep.delay} ms` : ep.error || 'failed'}</span>
|
||||
</div>
|
||||
))}
|
||||
{breakdown.map((row) => (
|
||||
<div key={row.key} className="breakdown-row">
|
||||
<span className="bd-label">{row.label}</span>
|
||||
<span className="bd-value">{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{children ?? (
|
||||
<span className={r.success ? 'pill-ok' : 'pill-fail'}>
|
||||
{r.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
|
||||
{r.success ? <span>{r.delay} ms</span> : <span>failed</span>}
|
||||
</span>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -42,12 +42,6 @@ export function showSecurity(security?: string): boolean {
|
||||
return security === 'tls' || security === 'reality';
|
||||
}
|
||||
|
||||
export function hasBreakdown(r: { endpoints?: unknown[]; error?: string } | null | undefined): boolean {
|
||||
if (!r) return false;
|
||||
if (r.endpoints?.length) return true;
|
||||
return !!r.error;
|
||||
}
|
||||
|
||||
export function trafficFor(outboundsTraffic: OutboundTrafficRow[], o: OutboundRow): { up: number; down: number } {
|
||||
const tr = outboundsTraffic.find((x) => x.tag === o.tag);
|
||||
return { up: tr?.up || 0, down: tr?.down || 0 };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Dropdown, Popover, Tag, Tooltip } from 'antd';
|
||||
import { Button, Dropdown, Tag, Tooltip } from 'antd';
|
||||
import {
|
||||
RetweetOutlined,
|
||||
MoreOutlined,
|
||||
@@ -8,8 +8,6 @@ import {
|
||||
DeleteOutlined,
|
||||
VerticalAlignTopOutlined,
|
||||
ThunderboltOutlined,
|
||||
CheckCircleFilled,
|
||||
CloseCircleFilled,
|
||||
LoadingOutlined,
|
||||
ArrowUpOutlined,
|
||||
ArrowDownOutlined,
|
||||
@@ -22,8 +20,8 @@ import { isUdpOutbound } from '@/hooks/useXraySetting';
|
||||
import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
|
||||
import type { OutboundRow } from './outbounds-tab-types';
|
||||
import TestResultPopover from './TestResultPopover';
|
||||
import {
|
||||
hasBreakdown,
|
||||
isTesting,
|
||||
isUntestable,
|
||||
outboundAddresses,
|
||||
@@ -160,36 +158,7 @@ export function useOutboundColumns({
|
||||
render: (_v, _record, index) => {
|
||||
const r = testResult(outboundTestStates, index);
|
||||
if (!r) return isTesting(outboundTestStates, index) ? <LoadingOutlined /> : <span className="empty">—</span>;
|
||||
return (
|
||||
<Popover
|
||||
placement="topLeft"
|
||||
rootClassName="outbound-test-popover"
|
||||
content={
|
||||
<div className="timing-breakdown">
|
||||
<div className={`td-head ${r.success ? 'ok' : 'fail'}`}>
|
||||
{r.success ? <span>{r.delay} ms</span> : <span>{r.error || 'failed'}</span>}
|
||||
{r.mode && <span className="mode-badge">{String(r.mode).toUpperCase()}</span>}
|
||||
</div>
|
||||
{hasBreakdown(r) && (
|
||||
<>
|
||||
{(r.endpoints || []).map((ep) => (
|
||||
<div key={ep.address} className="endpoint-row">
|
||||
<span className={ep.success ? 'dot-ok' : 'dot-fail'}>●</span>
|
||||
<span className="ep-addr">{ep.address}</span>
|
||||
<span className="ep-meta">{ep.success ? `${ep.delay} ms` : ep.error || 'failed'}</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<span className={r.success ? 'pill-ok' : 'pill-fail'}>
|
||||
{r.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
|
||||
{r.success ? <span>{r.delay} ms</span> : <span>failed</span>}
|
||||
</span>
|
||||
</Popover>
|
||||
);
|
||||
return <TestResultPopover result={r} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -56,10 +56,18 @@ export const OutboundTrafficRowSchema = z.object({
|
||||
export const OutboundTrafficListSchema = z.array(OutboundTrafficRowSchema);
|
||||
|
||||
export const OutboundTestResultSchema = z.object({
|
||||
tag: z.string().optional(),
|
||||
success: z.boolean(),
|
||||
delay: z.number().optional(),
|
||||
error: z.string().optional(),
|
||||
mode: z.string().optional(),
|
||||
// HTTP-mode extras: status answered by the test URL plus the httptrace
|
||||
// timing breakdown (dial to local inbound / target TLS via the outbound /
|
||||
// time to first byte).
|
||||
httpStatus: z.number().optional(),
|
||||
connectMs: z.number().optional(),
|
||||
tlsMs: z.number().optional(),
|
||||
ttfbMs: z.number().optional(),
|
||||
endpoints: z
|
||||
.array(
|
||||
z.object({
|
||||
@@ -72,6 +80,9 @@ export const OutboundTestResultSchema = z.object({
|
||||
.optional(),
|
||||
}).loose();
|
||||
|
||||
// Batch results from /xray/testOutbounds, aligned with the request order.
|
||||
export const OutboundTestResultListSchema = z.array(OutboundTestResultSchema);
|
||||
|
||||
export const RuleFormSchema = z.object({
|
||||
domain: z.string(),
|
||||
ip: z.string(),
|
||||
|
||||
Reference in New Issue
Block a user