Sfoglia il codice sorgente

feat(xray/outbounds): TCP probe mode + Test All + timing breakdown

- service.TestOutbound now dispatches on `mode`:
  - "tcp": parallel net.DialTimeout to every server/peer endpoint
    (vmess/vless/trojan/ss/socks/http/wireguard). No xray spin-up,
    no semaphore — safe to run concurrently across outbounds.
  - "http" (default): existing temp-xray + SOCKS path, now with an
    httptrace.ClientTrace breakdown (DNS / Connect / TLS / TTFB)
    alongside the total delay and status code.
- testSemaphore renamed to httpTestSemaphore — only HTTP probes
  serialise, TCP runs free.
- TestOutboundResult carries the per-mode extras: timing fields for
  HTTP, per-endpoint dial list for TCP, plus a `mode` echo.
- Controller reads `mode` from the form and passes it through.
- useXraySetting: testOutbound accepts mode (default "tcp"); new
  testAllOutbounds(mode) runs a worker pool (concurrency 8 for TCP,
  1 for HTTP) and skips blackhole / loopback / blocked outbounds —
  also skips freedom / dns under TCP since they have no endpoint.
- OutboundsTab: TCP/HTTP radio toggle and a Test All button land in
  the toolbar; the per-row ⚡ now uses the selected mode. Results
  surface in a popover with the full timing breakdown plus the
  endpoint list for TCP probes. Latency header replaces the duplicate
  "check" column title.

Practical effect: testing ten outbounds in TCP mode drops from ~50–100s
(serial HTTP) to ~1–2s (parallel dial × 8). HTTP mode stays as the
authoritative probe and now shows where the latency actually lives.
MHSanaei 1 giorno fa
parent
commit
8834e5fbbe

+ 166 - 36
frontend/src/pages/xray/OutboundsTab.vue

@@ -16,6 +16,7 @@ import {
   LoadingOutlined,
   ArrowUpOutlined,
   ArrowDownOutlined,
+  PlayCircleOutlined,
 } from '@ant-design/icons-vue';
 import { Modal } from 'ant-design-vue';
 
@@ -25,16 +26,11 @@ import OutboundFormModal from './OutboundFormModal.vue';
 
 const { t } = useI18n();
 
-// Outbounds tab — list + actions over templateSettings.outbounds.
-// Mirrors the legacy outbound table layout (identity / address /
-// traffic / test result / test button) plus the row action menu
-// (set first / edit / reset traffic / delete). Mobile collapses to
-// a card list.
-
 const props = defineProps({
   templateSettings: { type: Object, default: null },
   outboundsTraffic: { type: Array, default: () => [] },
   outboundTestStates: { type: Object, default: () => ({}) },
+  testingAll: { type: Boolean, default: false },
   inboundTags: { type: Array, default: () => [] },
   isMobile: { type: Boolean, default: false },
 });
@@ -48,7 +44,9 @@ const inboundTagOptions = computed(() => {
   return [...out];
 });
 
-const emit = defineEmits(['reset-traffic', 'test', 'show-warp', 'show-nord', 'delete']);
+const emit = defineEmits(['reset-traffic', 'test', 'test-all', 'show-warp', 'show-nord', 'delete']);
+
+const testMode = ref('tcp');
 
 // === Modal state ====================================================
 const modalOpen = ref(false);
@@ -141,10 +139,13 @@ function outboundAddresses(o) {
   return serverObj ? serverObj.map((s) => `${s.address}:${s.port}`) : [];
 }
 
-function isUntestable(o) {
-  return o.protocol === Protocols.Blackhole
+function isUntestable(o, mode = testMode.value) {
+  if (!o) return true;
+  if (o.protocol === Protocols.Blackhole
     || o.protocol === Protocols.Loopback
-    || o.tag === 'blocked';
+    || o.tag === 'blocked') return true;
+  if (mode === 'tcp' && (o.protocol === Protocols.Freedom || o.protocol === Protocols.DNS)) return true;
+  return false;
 }
 function isTesting(idx) {
   return !!props.outboundTestStates?.[idx]?.testing;
@@ -156,6 +157,12 @@ function showSecurity(security) {
   return security === 'tls' || security === 'reality';
 }
 
+function hasBreakdown(r) {
+  if (!r) return false;
+  if (r.endpoints?.length) return true;
+  return !!(r.ttfbMs || r.tlsMs || r.connectMs || r.dnsMs || r.statusCode || r.error);
+}
+
 // === Columns ========================================================
 // Computed so titles re-render after a locale swap.
 const columns = computed(() => [
@@ -163,7 +170,7 @@ const columns = computed(() => [
   { title: 'Tag', key: 'identity', align: 'left', width: 220 },
   { title: t('pages.inbounds.address'), key: 'address', align: 'left', width: 230 },
   { title: t('pages.inbounds.traffic'), key: 'traffic', align: 'left', width: 200 },
-  { title: t('check'), key: 'testResult', align: 'left', width: 140 },
+  { title: t('pages.xray.latency') !== 'pages.xray.latency' ? t('pages.xray.latency') : 'Latency', key: 'testResult', align: 'left', width: 140 },
   { title: t('check'), key: 'test', align: 'center', width: 80 },
 ]);
 
@@ -177,8 +184,8 @@ const rows = computed(() => {
   <a-space direction="vertical" size="middle" :style="{ width: '100%' }">
     <!-- Toolbar -->
     <a-row :gutter="[12, 12]" align="middle" justify="space-between">
-      <a-col :xs="24" :sm="14">
-        <a-space size="small">
+      <a-col :xs="24" :sm="12">
+        <a-space size="small" wrap>
           <a-button type="primary" @click="openAdd">
             <template #icon>
               <PlusOutlined />
@@ -199,15 +206,29 @@ const rows = computed(() => {
           </a-button>
         </a-space>
       </a-col>
-      <a-col :xs="24" :sm="10" class="toolbar-right">
-        <a-popconfirm placement="topRight" :ok-text="t('reset')" :cancel-text="t('cancel')"
-          :title="t('pages.inbounds.resetAllTrafficContent')" @confirm="emit('reset-traffic', '-alltags-')">
-          <a-button>
+      <a-col :xs="24" :sm="12" class="toolbar-right">
+        <a-space size="small" wrap>
+          <a-tooltip :title="t('pages.xray.testModeHint') !== 'pages.xray.testModeHint' ? t('pages.xray.testModeHint') : 'TCP: fast dial-only probe. HTTP: full request through xray.'">
+            <a-radio-group v-model:value="testMode" size="small" button-style="solid">
+              <a-radio-button value="tcp">TCP</a-radio-button>
+              <a-radio-button value="http">HTTP</a-radio-button>
+            </a-radio-group>
+          </a-tooltip>
+          <a-button type="primary" :loading="testingAll" @click="emit('test-all', testMode)">
             <template #icon>
-              <RetweetOutlined />
+              <PlayCircleOutlined />
             </template>
+            <span v-if="!isMobile">{{ t('pages.xray.testAll') !== 'pages.xray.testAll' ? t('pages.xray.testAll') : 'Test all' }}</span>
           </a-button>
-        </a-popconfirm>
+          <a-popconfirm placement="topRight" :ok-text="t('reset')" :cancel-text="t('cancel')"
+            :title="t('pages.inbounds.resetAllTrafficContent')" @confirm="emit('reset-traffic', '-alltags-')">
+            <a-button>
+              <template #icon>
+                <RetweetOutlined />
+              </template>
+            </a-button>
+          </a-popconfirm>
+        </a-space>
       </a-col>
     </a-row>
 
@@ -262,15 +283,39 @@ const rows = computed(() => {
           <span class="traffic-sep" />
           <span class="traffic-down">↓ {{ SizeFormatter.sizeFormat(trafficFor(record).down) }}</span>
           <span class="card-test">
-            <span v-if="testResult(index)" :class="testResult(index).success ? 'pill-ok' : 'pill-fail'">
-              <CheckCircleFilled v-if="testResult(index).success" />
-              <CloseCircleFilled v-else />
-              <span v-if="testResult(index).success">{{ testResult(index).delay }}&nbsp;ms</span>
-              <span v-else>failed</span>
-            </span>
+            <a-popover v-if="testResult(index)" placement="topRight"
+              :overlay-class-name="'outbound-test-popover'">
+              <template #content>
+                <div class="timing-breakdown">
+                  <div class="td-head" :class="testResult(index).success ? 'ok' : 'fail'">
+                    <span v-if="testResult(index).success">{{ testResult(index).delay }} ms</span>
+                    <span v-else>{{ testResult(index).error || 'failed' }}</span>
+                    <span v-if="testResult(index).mode" class="mode-badge">{{ testResult(index).mode.toUpperCase() }}</span>
+                  </div>
+                  <template v-if="hasBreakdown(testResult(index))">
+                    <div v-if="testResult(index).ttfbMs">TTFB: {{ testResult(index).ttfbMs }} ms</div>
+                    <div v-if="testResult(index).tlsMs">TLS: {{ testResult(index).tlsMs }} ms</div>
+                    <div v-if="testResult(index).connectMs">Connect: {{ testResult(index).connectMs }} ms</div>
+                    <div v-if="testResult(index).dnsMs">DNS: {{ testResult(index).dnsMs }} ms</div>
+                    <div v-if="testResult(index).statusCode">HTTP {{ testResult(index).statusCode }}</div>
+                    <div v-for="ep in testResult(index).endpoints || []" :key="ep.address" class="endpoint-row">
+                      <span :class="ep.success ? 'dot-ok' : 'dot-fail'">●</span>
+                      <span class="ep-addr">{{ ep.address }}</span>
+                      <span class="ep-meta">{{ ep.success ? `${ep.delay} ms` : (ep.error || 'failed') }}</span>
+                    </div>
+                  </template>
+                </div>
+              </template>
+              <span :class="testResult(index).success ? 'pill-ok' : 'pill-fail'">
+                <CheckCircleFilled v-if="testResult(index).success" />
+                <CloseCircleFilled v-else />
+                <span v-if="testResult(index).success">{{ testResult(index).delay }}&nbsp;ms</span>
+                <span v-else>failed</span>
+              </span>
+            </a-popover>
             <LoadingOutlined v-else-if="isTesting(index)" />
             <a-button type="primary" shape="circle" size="small" :loading="isTesting(index)"
-              :disabled="isUntestable(record) || isTesting(index)" @click="emit('test', index)">
+              :disabled="isUntestable(record, testMode) || isTesting(index)" @click="emit('test', index, testMode)">
               <template #icon>
                 <ThunderboltOutlined />
               </template>
@@ -350,22 +395,44 @@ const rows = computed(() => {
         </template>
 
         <template v-else-if="column.key === 'testResult'">
-          <span v-if="testResult(index)" :class="testResult(index).success ? 'pill-ok' : 'pill-fail'">
-            <CheckCircleFilled v-if="testResult(index).success" />
-            <CloseCircleFilled v-else />
-            <span v-if="testResult(index).success">{{ testResult(index).delay }}&nbsp;ms</span>
-            <a-tooltip v-else :title="testResult(index).error">
-              <span>failed</span>
-            </a-tooltip>
-          </span>
+          <a-popover v-if="testResult(index)" placement="topLeft"
+            :overlay-class-name="'outbound-test-popover'">
+            <template #content>
+              <div class="timing-breakdown">
+                <div class="td-head" :class="testResult(index).success ? 'ok' : 'fail'">
+                  <span v-if="testResult(index).success">{{ testResult(index).delay }} ms</span>
+                  <span v-else>{{ testResult(index).error || 'failed' }}</span>
+                  <span v-if="testResult(index).mode" class="mode-badge">{{ testResult(index).mode.toUpperCase() }}</span>
+                </div>
+                <template v-if="hasBreakdown(testResult(index))">
+                  <div v-if="testResult(index).ttfbMs">TTFB: {{ testResult(index).ttfbMs }} ms</div>
+                  <div v-if="testResult(index).tlsMs">TLS: {{ testResult(index).tlsMs }} ms</div>
+                  <div v-if="testResult(index).connectMs">Connect: {{ testResult(index).connectMs }} ms</div>
+                  <div v-if="testResult(index).dnsMs">DNS: {{ testResult(index).dnsMs }} ms</div>
+                  <div v-if="testResult(index).statusCode">HTTP {{ testResult(index).statusCode }}</div>
+                  <div v-for="ep in testResult(index).endpoints || []" :key="ep.address" class="endpoint-row">
+                    <span :class="ep.success ? 'dot-ok' : 'dot-fail'">●</span>
+                    <span class="ep-addr">{{ ep.address }}</span>
+                    <span class="ep-meta">{{ ep.success ? `${ep.delay} ms` : (ep.error || 'failed') }}</span>
+                  </div>
+                </template>
+              </div>
+            </template>
+            <span :class="testResult(index).success ? 'pill-ok' : 'pill-fail'">
+              <CheckCircleFilled v-if="testResult(index).success" />
+              <CloseCircleFilled v-else />
+              <span v-if="testResult(index).success">{{ testResult(index).delay }}&nbsp;ms</span>
+              <span v-else>failed</span>
+            </span>
+          </a-popover>
           <LoadingOutlined v-else-if="isTesting(index)" />
           <span v-else class="empty">—</span>
         </template>
 
         <template v-else-if="column.key === 'test'">
-          <a-tooltip :title="t('check')">
+          <a-tooltip :title="`${t('check')} (${testMode.toUpperCase()})`">
             <a-button type="primary" shape="circle" :loading="isTesting(index)"
-              :disabled="isUntestable(record) || isTesting(index)" @click="emit('test', index)">
+              :disabled="isUntestable(record, testMode) || isTesting(index)" @click="emit('test', index, testMode)">
               <template #icon>
                 <ThunderboltOutlined />
               </template>
@@ -532,3 +599,66 @@ const rows = computed(() => {
   color: #ff4d4f;
 }
 </style>
+
+<style>
+.outbound-test-popover .timing-breakdown {
+  font-size: 12px;
+  line-height: 1.6;
+  min-width: 180px;
+  max-width: 320px;
+}
+
+.outbound-test-popover .td-head {
+  font-weight: 600;
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  margin-bottom: 4px;
+}
+
+.outbound-test-popover .td-head.ok {
+  color: #008771;
+}
+
+.outbound-test-popover .td-head.fail {
+  color: #e04141;
+}
+
+.outbound-test-popover .mode-badge {
+  font-size: 10px;
+  font-weight: 500;
+  padding: 0 6px;
+  border-radius: 8px;
+  background: rgba(22, 119, 255, 0.12);
+  color: #1677ff;
+  margin-left: auto;
+}
+
+.outbound-test-popover .endpoint-row {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 11px;
+  white-space: nowrap;
+}
+
+.outbound-test-popover .endpoint-row .ep-addr {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  flex: 1;
+  min-width: 0;
+}
+
+.outbound-test-popover .endpoint-row .ep-meta {
+  opacity: 0.75;
+}
+
+.outbound-test-popover .dot-ok {
+  color: #008771;
+}
+
+.outbound-test-popover .dot-fail {
+  color: #e04141;
+}
+</style>

+ 12 - 5
frontend/src/pages/xray/XrayPage.vue

@@ -40,21 +40,26 @@ const {
   restartResult,
   outboundsTraffic,
   outboundTestStates,
+  testingAll,
   fetchAll,
   resetOutboundsTraffic,
   testOutbound,
+  testAllOutbounds,
   saveAll,
   resetToDefault,
   restartXray,
   applyOutboundsEvent,
 } = useXraySetting();
 
-// Live outbounds traffic — pushed by xray_traffic_job every ~10s.
 useWebSocket({ outbounds: applyOutboundsEvent });
 
-async function onTestOutbound(idx) {
+async function onTestOutbound(idx, mode = 'tcp') {
   const outbound = templateSettings.value?.outbounds?.[idx];
-  if (outbound) await testOutbound(idx, outbound);
+  if (outbound) await testOutbound(idx, outbound, mode);
+}
+
+async function onTestAllOutbounds(mode = 'tcp') {
+  await testAllOutbounds(mode);
 }
 
 function onDeleteOutbound(idx) {
@@ -278,8 +283,10 @@ function confirmRestart() {
                         <UploadOutlined /> <span>{{ t('pages.xray.Outbounds') }}</span>
                       </template>
                       <OutboundsTab :template-settings="templateSettings" :outbounds-traffic="outboundsTraffic"
-                        :outbound-test-states="outboundTestStates" :inbound-tags="inboundTags" :is-mobile="isMobile"
-                        @reset-traffic="resetOutboundsTraffic" @test="onTestOutbound" @delete="onDeleteOutbound"
+                        :outbound-test-states="outboundTestStates" :testing-all="testingAll"
+                        :inbound-tags="inboundTags" :is-mobile="isMobile"
+                        @reset-traffic="resetOutboundsTraffic" @test="onTestOutbound"
+                        @test-all="onTestAllOutbounds" @delete="onDeleteOutbound"
                         @show-warp="showWarp" @show-nord="showNord" />
                     </a-tab-pane>
 

+ 39 - 40
frontend/src/pages/xray/useXraySetting.js

@@ -1,50 +1,24 @@
-// Drives the xray page's fetch / dirty / save lifecycle. The Go side
-// returns the live xraySetting (the full JSON config), the inboundTags
-// list, and a few sidecar values (clientReverseTags, outboundTestUrl)
-// the structured tabs need. We keep the JSON as a string here — pretty-
-// printed for the textarea; tabs that want a parsed view can JSON.parse
-// it themselves.
 
 import { onMounted, onUnmounted, ref, watch } from 'vue';
 import { HttpUtil, PromiseUtil } from '@/utils';
 
 const DIRTY_POLL_MS = 1000;
 
-// Hoists the parsed `templateSettings` alongside the JSON string so
-// structured tabs (Basics/Routing/Outbounds/etc.) can mutate fields
-// directly while the Advanced (JSON) tab edits the same data as text.
-// We keep both in sync with two cooperating watches:
-//   • mutating templateSettings re-stringifies into xraySetting;
-//   • editing the JSON text re-parses into templateSettings (only on
-//     valid JSON — invalid edits leave templateSettings untouched
-//     so the structured tabs don't blow up while the user types).
 let syncing = false;
 
 export function useXraySetting() {
   const fetched = ref(false);
   const spinning = ref(false);
   const saveDisabled = ref(true);
-  // Holds a user-facing message when fetchAll fails; lets the page
-  // render an error UI instead of an endless spinner.
   const fetchError = ref('');
-
   const xraySetting = ref('');
   const oldXraySetting = ref('');
-
-  // Parsed mirror — null until first successful fetch / parse.
   const templateSettings = ref(null);
-
   const outboundTestUrl = ref('https://www.google.com/generate_204');
   const oldOutboundTestUrl = ref('');
-
   const inboundTags = ref([]);
   const clientReverseTags = ref([]);
   const restartResult = ref('');
-
-  // Outbounds tab data — traffic stats + per-row test state. Test
-  // states are keyed by outbound index (sparse object), each entry
-  // is `{ testing, result }` where result is the wire response from
-  // /panel/xray/testOutbound or null while the test is in flight.
   const outboundsTraffic = ref([]);
   const outboundTestStates = ref({});
 
@@ -53,7 +27,6 @@ export function useXraySetting() {
     const msg = await HttpUtil.post('/panel/xray/');
     if (!msg?.success) {
       fetchError.value = msg?.msg || 'Failed to load xray config';
-      // Mark as fetched so the spinner clears and the error UI renders.
       fetched.value = true;
       return;
     }
@@ -79,8 +52,7 @@ export function useXraySetting() {
     saveDisabled.value = true;
   }
 
-  // Structured tabs mutate templateSettings deeply. Re-stringify on
-  // change so the Advanced JSON view + the dirty-poll see the edits.
+
   watch(
     templateSettings,
     (next) => {
@@ -95,8 +67,6 @@ export function useXraySetting() {
     { deep: true },
   );
 
-  // Advanced JSON edits — only refresh templateSettings when the text
-  // parses, so structured tabs stay readable mid-edit.
   watch(xraySetting, (next) => {
     if (syncing) return;
     try {
@@ -133,21 +103,19 @@ export function useXraySetting() {
     if (msg?.success) await fetchOutboundsTraffic();
   }
 
-  // Merges a WebSocket `outbounds` event into outboundsTraffic in place.
-  // The xray traffic job pushes the full snapshot every ~10s so the user
-  // doesn't have to click the (now-removed) refresh button.
   function applyOutboundsEvent(payload) {
     if (Array.isArray(payload)) outboundsTraffic.value = payload;
   }
 
-  async function testOutbound(index, outbound) {
+  async function testOutbound(index, outbound, mode = 'tcp') {
     if (!outbound) return null;
     if (!outboundTestStates.value[index]) outboundTestStates.value[index] = {};
-    outboundTestStates.value[index] = { testing: true, result: null };
+    outboundTestStates.value[index] = { testing: true, result: null, mode };
     try {
       const msg = await HttpUtil.post('/panel/xray/testOutbound', {
         outbound: JSON.stringify(outbound),
         allOutbounds: JSON.stringify(templateSettings.value?.outbounds || []),
+        mode,
       });
       if (msg?.success) {
         outboundTestStates.value[index] = { testing: false, result: msg.obj };
@@ -155,24 +123,53 @@ export function useXraySetting() {
       }
       outboundTestStates.value[index] = {
         testing: false,
-        result: { success: false, error: msg?.msg || 'Unknown error' },
+        result: { success: false, error: msg?.msg || 'Unknown error', mode },
       };
     } catch (e) {
       outboundTestStates.value[index] = {
         testing: false,
-        result: { success: false, error: String(e) },
+        result: { success: false, error: String(e), mode },
       };
     }
     return null;
   }
 
+  const testingAll = ref(false);
+  async function testAllOutbounds(mode = 'tcp') {
+    const list = templateSettings.value?.outbounds || [];
+    if (list.length === 0 || testingAll.value) return;
+    testingAll.value = true;
+    try {
+      const concurrency = mode === 'tcp' ? 8 : 1;
+      const queue = list
+        .map((ob, i) => ({ index: i, outbound: ob }))
+        .filter(({ outbound }) => {
+          const tag = outbound?.tag;
+          const proto = outbound?.protocol;
+          if (proto === 'blackhole' || proto === 'loopback' || tag === 'blocked') return false;
+          if (mode === 'tcp' && (proto === 'freedom' || proto === 'dns')) return false;
+          return true;
+        });
+      async function worker() {
+        while (queue.length > 0) {
+          const item = queue.shift();
+          if (!item) break;
+          await testOutbound(item.index, item.outbound, mode);
+        }
+      }
+      const workers = Array.from({ length: Math.min(concurrency, queue.length) }, () => worker());
+      await Promise.all(workers);
+    } finally {
+      testingAll.value = false;
+    }
+  }
+
   async function resetToDefault() {
     spinning.value = true;
     try {
       const msg = await HttpUtil.get('/panel/setting/getDefaultJsonConfig');
       if (msg?.success) {
-        // Mutate templateSettings — the watch above re-stringifies into
-        // xraySetting so the Advanced JSON tab and dirty-poll see it.
+
         templateSettings.value = JSON.parse(JSON.stringify(msg.obj));
       }
     } finally {
@@ -234,11 +231,13 @@ export function useXraySetting() {
     restartResult,
     outboundsTraffic,
     outboundTestStates,
+    testingAll,
     fetchAll,
     fetchOutboundsTraffic,
     resetOutboundsTraffic,
     applyOutboundsEvent,
     testOutbound,
+    testAllOutbounds,
     saveAll,
     resetToDefault,
     restartXray,

+ 4 - 1
web/controller/xray_setting.go

@@ -199,9 +199,12 @@ func (a *XraySettingController) resetOutboundsTraffic(c *gin.Context) {
 
 // testOutbound tests an outbound configuration and returns the delay/response time.
 // Optional form "allOutbounds": JSON array of all outbounds; used to resolve sockopt.dialerProxy dependencies.
+// Optional form "mode": "tcp" for a fast dial-only probe (parallel-safe),
+// anything else (default) for a full HTTP probe through a temp xray instance.
 func (a *XraySettingController) testOutbound(c *gin.Context) {
 	outboundJSON := c.PostForm("outbound")
 	allOutboundsJSON := c.PostForm("allOutbounds")
+	mode := c.PostForm("mode")
 
 	if outboundJSON == "" {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("outbound parameter is required"))
@@ -211,7 +214,7 @@ func (a *XraySettingController) testOutbound(c *gin.Context) {
 	// Load the test URL from server settings to prevent SSRF via user-controlled URLs
 	testURL, _ := a.SettingService.GetXrayOutboundTestUrl()
 
-	result, err := a.OutboundService.TestOutbound(outboundJSON, testURL, allOutboundsJSON)
+	result, err := a.OutboundService.TestOutbound(outboundJSON, testURL, allOutboundsJSON, mode)
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		return

+ 264 - 110
web/service/outbound.go

@@ -1,13 +1,17 @@
 package service
 
 import (
+	"context"
+	"crypto/tls"
 	"encoding/json"
 	"fmt"
 	"io"
 	"net"
 	"net/http"
+	"net/http/httptrace"
 	"net/url"
 	"os"
+	"strconv"
 	"sync"
 	"time"
 
@@ -15,7 +19,6 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/database"
 	"github.com/mhsanaei/3x-ui/v3/database/model"
 	"github.com/mhsanaei/3x-ui/v3/logger"
-	"github.com/mhsanaei/3x-ui/v3/util/common"
 	"github.com/mhsanaei/3x-ui/v3/util/json_util"
 	"github.com/mhsanaei/3x-ui/v3/xray"
 
@@ -26,8 +29,10 @@ import (
 // It handles outbound traffic monitoring and statistics.
 type OutboundService struct{}
 
-// testSemaphore limits concurrent outbound tests to prevent resource exhaustion.
-var testSemaphore sync.Mutex
+// httpTestSemaphore serialises HTTP-mode probes (each one spawns a temp xray
+// instance, which is too expensive to run in parallel). TCP-mode probes are
+// dial-only and don't need the semaphore.
+var httpTestSemaphore sync.Mutex
 
 func (s *OutboundService) AddTraffic(traffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (error, bool) {
 	var err error
@@ -117,90 +122,230 @@ func (s *OutboundService) ResetOutboundTraffic(tag string) error {
 	return nil
 }
 
-// TestOutboundResult represents the result of testing an outbound
+// TestOutboundResult represents the result of testing an outbound.
+// Delay/timing fields are in milliseconds. Endpoints is only populated for
+// TCP-mode probes; the HTTP-mode timing breakdown lives in DNSMs/ConnectMs/
+// TLSMs/TTFBMs (any of these can be 0 if the underlying step was skipped —
+// e.g. a non-TLS target leaves TLSMs at 0).
 type TestOutboundResult struct {
 	Success    bool   `json:"success"`
-	Delay      int64  `json:"delay"` // Delay in milliseconds
+	Delay      int64  `json:"delay"`
 	Error      string `json:"error,omitempty"`
 	StatusCode int    `json:"statusCode,omitempty"`
+	Mode       string `json:"mode,omitempty"`
+
+	DNSMs     int64 `json:"dnsMs,omitempty"`
+	ConnectMs int64 `json:"connectMs,omitempty"`
+	TLSMs     int64 `json:"tlsMs,omitempty"`
+	TTFBMs    int64 `json:"ttfbMs,omitempty"`
+
+	Endpoints []TestEndpointResult `json:"endpoints,omitempty"`
+}
+
+// TestEndpointResult is one entry in a TCP-mode probe — the per-endpoint
+// dial outcome for outbounds that expose multiple servers/peers.
+type TestEndpointResult struct {
+	Address string `json:"address"`
+	Success bool   `json:"success"`
+	Delay   int64  `json:"delay"`
+	Error   string `json:"error,omitempty"`
+}
+
+// TestOutbound dispatches to the chosen probe mode:
+//   - mode="tcp": dial the outbound's host:port directly. No xray spin-up,
+//     parallel-safe, ~100ms per endpoint. Doesn't validate the proxy
+//     protocol — only that the remote is reachable on TCP.
+//   - mode="" or "http": spin a temp xray instance, route a real HTTP
+//     request through it, return delay + a DNS/Connect/TLS/TTFB breakdown.
+//     Authoritative but expensive and serialised by httpTestSemaphore.
+//
+// allOutboundsJSON is only consulted in HTTP mode (it backs
+// sockopt.dialerProxy chains during test).
+func (s *OutboundService) TestOutbound(outboundJSON string, testURL string, allOutboundsJSON string, mode string) (*TestOutboundResult, error) {
+	if mode == "tcp" {
+		return s.testOutboundTCP(outboundJSON)
+	}
+	return s.testOutboundHTTP(outboundJSON, testURL, allOutboundsJSON)
+}
+
+func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundResult, error) {
+	var ob map[string]any
+	if err := json.Unmarshal([]byte(outboundJSON), &ob); err != nil {
+		return &TestOutboundResult{Mode: "tcp", Success: false, Error: fmt.Sprintf("Invalid outbound JSON: %v", err)}, nil
+	}
+	tag, _ := ob["tag"].(string)
+	protocol, _ := ob["protocol"].(string)
+	if protocol == "blackhole" || protocol == "freedom" || tag == "blocked" {
+		return &TestOutboundResult{Mode: "tcp", Success: false, Error: "Outbound has no testable endpoint"}, nil
+	}
+
+	endpoints := extractOutboundEndpoints(ob)
+	if len(endpoints) == 0 {
+		return &TestOutboundResult{Mode: "tcp", Success: false, Error: "No testable endpoint"}, nil
+	}
+
+	results := make([]TestEndpointResult, len(endpoints))
+	var wg sync.WaitGroup
+	for i := range endpoints {
+		wg.Add(1)
+		go func(i int) {
+			defer wg.Done()
+			results[i] = probeTCPEndpoint(endpoints[i], 5*time.Second)
+		}(i)
+	}
+	wg.Wait()
+
+	var bestDelay int64 = -1
+	var firstErr string
+	for _, r := range results {
+		if r.Success {
+			if bestDelay < 0 || r.Delay < bestDelay {
+				bestDelay = r.Delay
+			}
+		} else if firstErr == "" {
+			firstErr = r.Error
+		}
+	}
+
+	out := &TestOutboundResult{Mode: "tcp", Endpoints: results}
+	if bestDelay >= 0 {
+		out.Success = true
+		out.Delay = bestDelay
+	} else {
+		out.Error = firstErr
+		if out.Error == "" {
+			out.Error = "All endpoints unreachable"
+		}
+	}
+	return out, nil
+}
+
+func probeTCPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult {
+	r := TestEndpointResult{Address: endpoint}
+	start := time.Now()
+	conn, err := net.DialTimeout("tcp", endpoint, timeout)
+	r.Delay = time.Since(start).Milliseconds()
+	if err != nil {
+		r.Error = err.Error()
+		return r
+	}
+	conn.Close()
+	r.Success = true
+	return r
+}
+
+func extractOutboundEndpoints(ob map[string]any) []string {
+	protocol, _ := ob["protocol"].(string)
+	settings, _ := ob["settings"].(map[string]any)
+	if settings == nil {
+		return nil
+	}
+	var out []string
+	addServer := func(addr any, port any) {
+		host, _ := addr.(string)
+		p := numAsInt(port)
+		if host != "" && p > 0 {
+			out = append(out, fmt.Sprintf("%s:%d", host, p))
+		}
+	}
+	switch protocol {
+	case "vmess":
+		if vnext, ok := settings["vnext"].([]any); ok {
+			for _, v := range vnext {
+				if vm, ok := v.(map[string]any); ok {
+					addServer(vm["address"], vm["port"])
+				}
+			}
+		}
+	case "vless":
+		addServer(settings["address"], settings["port"])
+	case "trojan", "shadowsocks", "http", "socks":
+		if servers, ok := settings["servers"].([]any); ok {
+			for _, sv := range servers {
+				if sm, ok := sv.(map[string]any); ok {
+					addServer(sm["address"], sm["port"])
+				}
+			}
+		}
+	case "wireguard":
+		if peers, ok := settings["peers"].([]any); ok {
+			for _, p := range peers {
+				if pm, ok := p.(map[string]any); ok {
+					if ep, _ := pm["endpoint"].(string); ep != "" {
+						out = append(out, ep)
+					}
+				}
+			}
+		}
+	}
+	return out
+}
+
+func numAsInt(v any) int {
+	switch n := v.(type) {
+	case float64:
+		return int(n)
+	case int:
+		return n
+	case int64:
+		return int(n)
+	case string:
+		if i, err := strconv.Atoi(n); err == nil {
+			return i
+		}
+	}
+	return 0
 }
 
-// TestOutbound tests an outbound by creating a temporary xray instance and measuring response time.
-// allOutboundsJSON must be a JSON array of all outbounds; they are copied into the test config unchanged.
-// Only the test inbound and a route rule (to the tested outbound tag) are added.
-func (s *OutboundService) TestOutbound(outboundJSON string, testURL string, allOutboundsJSON string) (*TestOutboundResult, error) {
+func (s *OutboundService) testOutboundHTTP(outboundJSON string, testURL string, allOutboundsJSON string) (*TestOutboundResult, error) {
 	if testURL == "" {
 		testURL = "https://www.google.com/generate_204"
 	}
 
-	// Limit to one concurrent test at a time
-	if !testSemaphore.TryLock() {
+	if !httpTestSemaphore.TryLock() {
 		return &TestOutboundResult{
+			Mode:    "http",
 			Success: false,
 			Error:   "Another outbound test is already running, please wait",
 		}, nil
 	}
-	defer testSemaphore.Unlock()
+	defer httpTestSemaphore.Unlock()
 
-	// Parse the outbound being tested to get its tag
 	var testOutbound map[string]any
 	if err := json.Unmarshal([]byte(outboundJSON), &testOutbound); err != nil {
-		return &TestOutboundResult{
-			Success: false,
-			Error:   fmt.Sprintf("Invalid outbound JSON: %v", err),
-		}, nil
+		return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Invalid outbound JSON: %v", err)}, nil
 	}
 	outboundTag, _ := testOutbound["tag"].(string)
 	if outboundTag == "" {
-		return &TestOutboundResult{
-			Success: false,
-			Error:   "Outbound has no tag",
-		}, nil
+		return &TestOutboundResult{Mode: "http", Success: false, Error: "Outbound has no tag"}, nil
 	}
 	if protocol, _ := testOutbound["protocol"].(string); protocol == "blackhole" || outboundTag == "blocked" {
-		return &TestOutboundResult{
-			Success: false,
-			Error:   "Blocked/blackhole outbound cannot be tested",
-		}, nil
+		return &TestOutboundResult{Mode: "http", Success: false, Error: "Blocked/blackhole outbound cannot be tested"}, nil
 	}
 
-	// Use all outbounds when provided; otherwise fall back to single outbound
 	var allOutbounds []any
 	if allOutboundsJSON != "" {
 		if err := json.Unmarshal([]byte(allOutboundsJSON), &allOutbounds); err != nil {
-			return &TestOutboundResult{
-				Success: false,
-				Error:   fmt.Sprintf("Invalid allOutbounds JSON: %v", err),
-			}, nil
+			return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Invalid allOutbounds JSON: %v", err)}, nil
 		}
 	}
 	if len(allOutbounds) == 0 {
 		allOutbounds = []any{testOutbound}
 	}
 
-	// Find an available port for test inbound
 	testPort, err := findAvailablePort()
 	if err != nil {
-		return &TestOutboundResult{
-			Success: false,
-			Error:   fmt.Sprintf("Failed to find available port: %v", err),
-		}, nil
+		return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Failed to find available port: %v", err)}, nil
 	}
 
-	// Copy all outbounds as-is, add only test inbound and route rule
 	testConfig := s.createTestConfig(outboundTag, allOutbounds, testPort)
 
-	// Use a temporary config file so the main config.json is never overwritten
 	testConfigPath, err := createTestConfigPath()
 	if err != nil {
-		return &TestOutboundResult{
-			Success: false,
-			Error:   fmt.Sprintf("Failed to create test config path: %v", err),
-		}, nil
+		return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Failed to create test config path: %v", err)}, nil
 	}
-	defer os.Remove(testConfigPath) // ensure temp file is removed even if process is not stopped
+	defer os.Remove(testConfigPath)
 
-	// Create temporary xray process with its own config file
 	testProcess := xray.NewTestProcess(testConfig, testConfigPath)
 	defer func() {
 		if testProcess.IsRunning() {
@@ -208,52 +353,24 @@ func (s *OutboundService) TestOutbound(outboundJSON string, testURL string, allO
 		}
 	}()
 
-	// Start the test process
 	if err := testProcess.Start(); err != nil {
-		return &TestOutboundResult{
-			Success: false,
-			Error:   fmt.Sprintf("Failed to start test xray instance: %v", err),
-		}, nil
+		return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Failed to start test xray instance: %v", err)}, nil
 	}
 
-	// Wait for xray to start listening on the test port
 	if err := waitForPort(testPort, 3*time.Second); err != nil {
 		if !testProcess.IsRunning() {
 			result := testProcess.GetResult()
-			return &TestOutboundResult{
-				Success: false,
-				Error:   fmt.Sprintf("Xray process exited: %s", result),
-			}, nil
+			return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Xray process exited: %s", result)}, nil
 		}
-		return &TestOutboundResult{
-			Success: false,
-			Error:   fmt.Sprintf("Xray failed to start listening: %v", err),
-		}, nil
+		return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Xray failed to start listening: %v", err)}, nil
 	}
 
-	// Check if process is still running
 	if !testProcess.IsRunning() {
 		result := testProcess.GetResult()
-		return &TestOutboundResult{
-			Success: false,
-			Error:   fmt.Sprintf("Xray process exited: %s", result),
-		}, nil
+		return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Xray process exited: %s", result)}, nil
 	}
 
-	// Test the connection through proxy
-	delay, statusCode, err := s.testConnection(testPort, testURL)
-	if err != nil {
-		return &TestOutboundResult{
-			Success: false,
-			Error:   err.Error(),
-		}, nil
-	}
-
-	return &TestOutboundResult{
-		Success:    true,
-		Delay:      delay,
-		StatusCode: statusCode,
-	}, nil
+	return s.testConnection(testPort, testURL)
 }
 
 // createTestConfig creates a test config by copying all outbounds unchanged and adding
@@ -329,55 +446,92 @@ func (s *OutboundService) createTestConfig(outboundTag string, allOutbounds []an
 	return cfg
 }
 
-// testConnection tests the connection through the proxy and measures delay.
-// It performs a warmup request first to establish the SOCKS connection and populate DNS caches,
-// then measures the second request for a more accurate latency reading.
-func (s *OutboundService) testConnection(proxyPort int, testURL string) (int64, int, error) {
-	// Create SOCKS5 proxy URL
-	proxyURL := fmt.Sprintf("socks5://127.0.0.1:%d", proxyPort)
-
-	// Parse proxy URL
-	proxyURLParsed, err := url.Parse(proxyURL)
+// testConnection runs the actual HTTP probe through the local SOCKS proxy.
+// A warmup request seeds xray's DNS cache / handshake; then a fresh
+// transport runs the measured request so httptrace sees a real cold
+// connection and reports DNS/Connect/TLS/TTFB. Note that DNS and Connect
+// reflect *client → SOCKS-on-loopback*, not the remote target — those
+// happen inside xray and aren't visible to net/http. TLS and TTFB are
+// the meaningful breakdown values for a SOCKS-proxied HTTPS probe.
+func (s *OutboundService) testConnection(proxyPort int, testURL string) (*TestOutboundResult, error) {
+	proxyURLStr := fmt.Sprintf("socks5://127.0.0.1:%d", proxyPort)
+	proxyURLParsed, err := url.Parse(proxyURLStr)
 	if err != nil {
-		return 0, 0, common.NewErrorf("Invalid proxy URL: %v", err)
-	}
-
-	// Create HTTP client with proxy and keep-alive for connection reuse
-	client := &http.Client{
-		Timeout: 10 * time.Second,
-		Transport: &http.Transport{
-			Proxy: http.ProxyURL(proxyURLParsed),
-			DialContext: (&net.Dialer{
-				Timeout:   5 * time.Second,
-				KeepAlive: 30 * time.Second,
-			}).DialContext,
-			MaxIdleConns:       1,
-			IdleConnTimeout:    10 * time.Second,
-			DisableCompression: true,
-		},
+		return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Invalid proxy URL: %v", err)}, nil
+	}
+
+	mkClient := func() *http.Client {
+		return &http.Client{
+			Timeout: 10 * time.Second,
+			Transport: &http.Transport{
+				Proxy: http.ProxyURL(proxyURLParsed),
+				DialContext: (&net.Dialer{
+					Timeout:   5 * time.Second,
+					KeepAlive: 30 * time.Second,
+				}).DialContext,
+				MaxIdleConns:       1,
+				IdleConnTimeout:    1 * time.Second,
+				DisableCompression: true,
+			},
+		}
 	}
 
-	// Warmup request: establishes SOCKS/TLS connection, DNS, and TCP to the target.
-	// This mirrors real-world usage where connections are reused.
-	warmupResp, err := client.Get(testURL)
+	warmup := mkClient()
+	warmupResp, err := warmup.Get(testURL)
 	if err != nil {
-		return 0, 0, common.NewErrorf("Request failed: %v", err)
+		return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Request failed: %v", err)}, nil
 	}
 	io.Copy(io.Discard, warmupResp.Body)
 	warmupResp.Body.Close()
+	warmup.CloseIdleConnections()
+
+	var dnsStart, dnsDone, connectStart, connectDone, tlsStart, tlsDone, firstByte time.Time
+	trace := &httptrace.ClientTrace{
+		DNSStart:             func(_ httptrace.DNSStartInfo) { dnsStart = time.Now() },
+		DNSDone:              func(_ httptrace.DNSDoneInfo) { dnsDone = time.Now() },
+		ConnectStart:         func(_, _ string) { connectStart = time.Now() },
+		ConnectDone:          func(_, _ string, _ error) { connectDone = time.Now() },
+		TLSHandshakeStart:    func() { tlsStart = time.Now() },
+		TLSHandshakeDone:     func(_ tls.ConnectionState, _ error) { tlsDone = time.Now() },
+		GotFirstResponseByte: func() { firstByte = time.Now() },
+	}
+
+	client := mkClient()
+	defer client.CloseIdleConnections()
+	ctx := httptrace.WithClientTrace(context.Background(), trace)
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, testURL, nil)
+	if err != nil {
+		return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Request build failed: %v", err)}, nil
+	}
 
-	// Measure the actual request on the warm connection
 	startTime := time.Now()
-	resp, err := client.Get(testURL)
+	resp, err := client.Do(req)
 	delay := time.Since(startTime).Milliseconds()
-
 	if err != nil {
-		return 0, 0, common.NewErrorf("Request failed: %v", err)
+		return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Request failed: %v", err)}, nil
 	}
 	io.Copy(io.Discard, resp.Body)
 	resp.Body.Close()
 
-	return delay, resp.StatusCode, nil
+	out := &TestOutboundResult{
+		Mode:       "http",
+		Success:    true,
+		Delay:      delay,
+		StatusCode: resp.StatusCode,
+	}
+	if !dnsStart.IsZero() && !dnsDone.IsZero() {
+		out.DNSMs = dnsDone.Sub(dnsStart).Milliseconds()
+	}
+	if !connectStart.IsZero() && !connectDone.IsZero() {
+		out.ConnectMs = connectDone.Sub(connectStart).Milliseconds()
+	}
+	if !tlsStart.IsZero() && !tlsDone.IsZero() {
+		out.TLSMs = tlsDone.Sub(tlsStart).Milliseconds()
+	}
+	if !firstByte.IsZero() {
+		out.TTFBMs = firstByte.Sub(startTime).Milliseconds()
+	}
+	return out, nil
 }
 
 // waitForPort polls until the given TCP port is accepting connections or the timeout expires.