From ed66209e38ebf13b9a96a51a022f137cbf1c79ce Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Mon, 6 Jul 2026 08:35:48 +0200 Subject: [PATCH] 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. --- frontend/src/hooks/useXraySetting.ts | 17 ++-- frontend/src/pages/api-docs/endpoints.ts | 4 +- .../pages/xray/outbounds/OutboundCardList.tsx | 4 +- .../src/pages/xray/outbounds/OutboundsTab.tsx | 5 +- .../xray/outbounds/SubscriptionOutbounds.tsx | 9 +- .../xray/outbounds/TestResultPopover.tsx | 4 +- .../xray/outbounds/outbounds-tab-helpers.ts | 13 ++- .../xray/outbounds/useOutboundColumns.tsx | 9 +- internal/web/controller/xray_setting.go | 8 +- internal/web/service/outbound/probe_http.go | 44 +++++---- .../web/service/outbound/probe_http_test.go | 90 ++++++++++++++++++- internal/web/translation/ar-EG.json | 3 +- internal/web/translation/en-US.json | 3 +- internal/web/translation/es-ES.json | 3 +- internal/web/translation/fa-IR.json | 3 +- internal/web/translation/id-ID.json | 3 +- internal/web/translation/ja-JP.json | 3 +- internal/web/translation/pt-BR.json | 3 +- internal/web/translation/ru-RU.json | 3 +- internal/web/translation/tr-TR.json | 3 +- internal/web/translation/uk-UA.json | 3 +- internal/web/translation/vi-VN.json | 3 +- internal/web/translation/zh-CN.json | 3 +- internal/web/translation/zh-TW.json | 3 +- 24 files changed, 188 insertions(+), 58 deletions(-) diff --git a/frontend/src/hooks/useXraySetting.ts b/frontend/src/hooks/useXraySetting.ts index 1c9121b87..fb25fbaa4 100644 --- a/frontend/src/hooks/useXraySetting.ts +++ b/frontend/src/hooks/useXraySetting.ts @@ -29,6 +29,8 @@ export function isUdpOutbound(outbound: unknown): boolean { return p === 'wireguard' || p === 'hysteria' || n === 'hysteria' || n === 'kcp' || n === 'quic'; } +export type OutboundTestMode = 'tcp' | 'http' | 'real'; + export type { OutboundTrafficRow, OutboundTestResult }; export type XraySettingsValue = z.infer; @@ -289,7 +291,7 @@ export function useXraySetting(): UseXraySettingResult { const testOutbound = useCallback( async (index: number, outbound: unknown, mode = 'tcp'): Promise => { if (!outbound) return null; - const effMode = isUdpOutbound(outbound) ? 'http' : mode; + const effMode = mode === 'tcp' && isUdpOutbound(outbound) ? 'http' : mode; setOutboundTestStates((prev) => ({ ...prev, [index]: { testing: true, result: null, mode: effMode }, @@ -306,7 +308,7 @@ export function useXraySetting(): UseXraySettingResult { const testSubscriptionOutbound = useCallback( async (tag: string, outbound: unknown, mode = 'tcp'): Promise => { if (!outbound || !tag) return null; - const effMode = isUdpOutbound(outbound) ? 'http' : mode; + const effMode = mode === 'tcp' && isUdpOutbound(outbound) ? 'http' : mode; setSubscriptionTestStates((prev) => ({ ...prev, [tag]: { testing: true, result: null, mode: effMode }, @@ -334,6 +336,7 @@ export function useXraySetting(): UseXraySettingResult { // 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) => { @@ -342,7 +345,7 @@ export function useXraySetting(): UseXraySettingResult { // 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 === 'http' || isUdpOutbound(ob); + const toHttp = mode !== 'tcp' || isUdpOutbound(ob); if (kind === 'tpl') { if (toHttp) httpTplQueue.push({ index, outbound: ob }); else tcpQueue.push({ kind: 'tpl', index, outbound: ob }); @@ -376,10 +379,10 @@ export function useXraySetting(): UseXraySettingResult { 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: 'http' }; + 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), 'http'); + const results = await postOutboundTestBatch(chunk.map((c) => c.outbound), probeMode); setOutboundTestStates((prev) => { const next = { ...prev }; chunk.forEach((item, i) => { @@ -394,10 +397,10 @@ export function useXraySetting(): UseXraySettingResult { 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: 'http' }; + 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), 'http'); + const results = await postOutboundTestBatch(chunk.map((c) => c.outbound), probeMode); setSubscriptionTestStates((prev) => { const next = { ...prev }; chunk.forEach((item, i) => { diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 4aded9cb2..4cb22b2f3 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -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', }, diff --git a/frontend/src/pages/xray/outbounds/OutboundCardList.tsx b/frontend/src/pages/xray/outbounds/OutboundCardList.tsx index ec38027a6..9c8cf308e 100644 --- a/frontend/src/pages/xray/outbounds/OutboundCardList.tsx +++ b/frontend/src/pages/xray/outbounds/OutboundCardList.tsx @@ -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; setFirst: (idx: number) => void; diff --git a/frontend/src/pages/xray/outbounds/OutboundsTab.tsx b/frontend/src/pages/xray/outbounds/OutboundsTab.tsx index 890df06cc..817a9931d 100644 --- a/frontend/src/pages/xray/outbounds/OutboundsTab.tsx +++ b/frontend/src/pages/xray/outbounds/OutboundsTab.tsx @@ -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('tcp'); const [modalOpen, setModalOpen] = useState(false); const [editingOutbound, setEditingOutbound] = useState | null>(null); const [editingIndex, setEditingIndex] = useState(null); @@ -486,6 +486,7 @@ export default function OutboundsTab({ setTestMode(e.target.value)} buttonStyle="solid" size="small"> TCP HTTP + {t('pages.xray.outbound.modeRealDelay')}