mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-23 03:17:14 +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:
@@ -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} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user