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:
MHSanaei
2026-06-12 16:55:53 +02:00
parent 85983eec1a
commit 5716ae5987
27 changed files with 1333 additions and 416 deletions
+51 -20
View File
@@ -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(() => {
+11
View File
@@ -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}&nbsp;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}&nbsp;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}&nbsp;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}&nbsp;ms</span> : <span>failed</span>}
</span>
</Popover>
);
return <TestResultPopover result={r} />;
},
},
{
+11
View File
@@ -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(),