feat(outbound): add real-delay connection test mode

The HTTP probe reports the warm per-request round-trip, which reads
lower than the delay figure client apps show for the same server. Add a
third "real" test mode that reuses the temp-instance HTTP probe but
reports the cold request's full elapsed time - tunnel establishment
included - and skips the warm request. UDP-transport outbounds forced
out of the TCP lane still report "http"; in real mode they report
"real". The mode joins the TCP/HTTP toggle on the outbounds tab, with
the label translated in all 13 locales.
This commit is contained in:
MHSanaei
2026-07-06 08:35:48 +02:00
parent 5a7b3b7370
commit ed66209e38
24 changed files with 188 additions and 58 deletions
+2 -2
View File
@@ -1305,7 +1305,7 @@ export const sections: readonly Section[] = [
params: [
{ name: 'outbound', in: 'body (form)', type: 'string', desc: 'JSON-encoded single outbound 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 a fast dial-only probe (parallel-safe). Default/empty uses a full HTTP probe through a temp xray instance.' },
{ name: 'mode', in: 'body (form)', type: 'string', desc: '"tcp" for a fast dial-only probe (parallel-safe), "real" for a real-delay probe whose delay is the full request time including tunnel establishment. Default/empty uses a full HTTP probe reporting the warm per-request round-trip. Both HTTP variants run through a temp xray instance.' },
],
body: 'outbound={"protocol":"freedom","settings":{}}&mode=tcp',
},
@@ -1316,7 +1316,7 @@ export const sections: readonly Section[] = [
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.' },
{ name: 'mode', in: 'body (form)', type: 'string', desc: '"tcp" for fast dial-only probes (UDP-transport outbounds are still probed over HTTP), "real" for real-delay probes whose delay is the full request time including tunnel establishment. Default/empty routes an HTTP request through each outbound and reports the warm per-request round-trip.' },
],
body: 'outbounds=[{"tag":"direct","protocol":"freedom","settings":{}}]&mode=http',
},
@@ -13,7 +13,7 @@ import {
import { SizeFormatter } from '@/utils';
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
import type { OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
import type { OutboundRow } from './outbounds-tab-types';
import TestResultPopover from './TestResultPopover';
@@ -28,7 +28,7 @@ import {
interface OutboundCardListProps {
rows: OutboundRow[];
testMode: 'tcp' | 'http';
testMode: OutboundTestMode;
outboundsTraffic: OutboundTrafficRow[];
outboundTestStates: Record<number, OutboundTestState>;
setFirst: (idx: number) => void;
@@ -45,7 +45,7 @@ import OutboundFormModal from './OutboundFormModal';
import { propagateOutboundTagRename } from '../basics/helpers';
import { planOutboundDeletion, applyOutboundDeletion } from '../reference-cleanup';
import DeletionImpactList from '../DeletionImpactList';
import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
import type { XraySettingsValue, SetTemplate, OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
import './OutboundsTab.css';
import type { OutboundRow } from './outbounds-tab-types';
@@ -110,7 +110,7 @@ export default function OutboundsTab({
const { t } = useTranslation();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
const [testMode, setTestMode] = useState<'tcp' | 'http'>('tcp');
const [testMode, setTestMode] = useState<OutboundTestMode>('tcp');
const [modalOpen, setModalOpen] = useState(false);
const [editingOutbound, setEditingOutbound] = useState<Record<string, unknown> | null>(null);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
@@ -486,6 +486,7 @@ export default function OutboundsTab({
<Radio.Group value={testMode} onChange={(e) => setTestMode(e.target.value)} buttonStyle="solid" size="small">
<Radio.Button value="tcp">TCP</Radio.Button>
<Radio.Button value="http">HTTP</Radio.Button>
<Radio.Button value="real">{t('pages.xray.outbound.modeRealDelay')}</Radio.Button>
</Radio.Group>
</Tooltip>
<Button type="primary" loading={testingAll} icon={<PlayCircleOutlined />} onClick={() => onTestAll(testMode)}>
@@ -6,16 +6,17 @@ import type { ColumnsType } from 'antd/es/table';
import { SizeFormatter } from '@/utils';
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
import { isUdpOutbound } from '@/hooks/useXraySetting';
import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
import type { OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
import type { OutboundRow } from './outbounds-tab-types';
import TestResultPopover from './TestResultPopover';
import {
effectiveTestMode,
isTesting,
isUntestable,
outboundAddresses,
showSecurity,
testModeLabel,
testResult,
trafficFor,
} from './outbounds-tab-helpers';
@@ -24,7 +25,7 @@ interface SubscriptionOutboundsProps {
subscriptionOutbounds: unknown[];
outboundsTraffic: OutboundTrafficRow[];
subscriptionTestStates: Record<string, OutboundTestState>;
testMode: 'tcp' | 'http';
testMode: OutboundTestMode;
isMobile: boolean;
onTestSubscription: (outbound: Record<string, unknown>, mode: string) => void;
}
@@ -104,7 +105,7 @@ export default function SubscriptionOutbounds({
const testButton = (record: OutboundRow) => {
const key = record.tag || '';
return (
<Tooltip title={`${t('check')} (${(isUdpOutbound(record) ? 'http' : testMode).toUpperCase()})`}>
<Tooltip title={`${t('check')} (${testModeLabel(effectiveTestMode(record, testMode), t)})`}>
<Button
aria-label={t('check')}
type="primary"
@@ -5,6 +5,8 @@ import { CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons';
import type { OutboundTestResult } from '@/hooks/useXraySetting';
import { testModeLabel } from './outbounds-tab-helpers';
interface TestResultPopoverProps {
result: OutboundTestResult;
// Custom trigger element; defaults to the ok/fail latency pill.
@@ -39,7 +41,7 @@ export default function TestResultPopover({ result: r, children }: TestResultPop
<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>}
{r.mode && <span className="mode-badge">{testModeLabel(String(r.mode), t)}</span>}
</div>
{(r.endpoints || []).map((ep) => (
<div key={ep.address} className="endpoint-row">
@@ -1,5 +1,8 @@
import type { TFunction } from 'i18next';
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
import { isUdpOutbound } from '@/hooks/useXraySetting';
import type { OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
import type { OutboundRow } from './outbounds-tab-types';
@@ -45,6 +48,14 @@ export function showSecurity(security?: string): boolean {
return security === 'tls' || security === 'reality';
}
export function effectiveTestMode(o: unknown, mode: OutboundTestMode): OutboundTestMode {
return mode === 'tcp' && isUdpOutbound(o) ? 'http' : mode;
}
export function testModeLabel(mode: string, t: TFunction): string {
return mode === 'real' ? t('pages.xray.outbound.modeRealDelay') : mode.toUpperCase();
}
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 };
@@ -16,22 +16,23 @@ import type { ColumnsType } from 'antd/es/table';
import { SizeFormatter } from '@/utils';
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
import { isUdpOutbound } from '@/hooks/useXraySetting';
import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
import type { OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
import type { OutboundRow } from './outbounds-tab-types';
import TestResultPopover from './TestResultPopover';
import {
effectiveTestMode,
isTesting,
isUntestable,
outboundAddresses,
showSecurity,
testModeLabel,
testResult,
trafficFor,
} from './outbounds-tab-helpers';
interface OutboundColumnsParams {
testMode: 'tcp' | 'http';
testMode: OutboundTestMode;
rows: OutboundRow[];
outboundsTraffic: OutboundTrafficRow[];
outboundTestStates: Record<number, OutboundTestState>;
@@ -167,7 +168,7 @@ export function useOutboundColumns({
align: 'center',
width: 80,
render: (_v, record, index) => (
<Tooltip title={`${t('check')} (${(isUdpOutbound(record) ? 'http' : testMode).toUpperCase()})`}>
<Tooltip title={`${t('check')} (${testModeLabel(effectiveTestMode(record, testMode), t)})`}>
<Button
type="primary"
shape="circle"