1
0
Эх сурвалжийг харах

fix(outbounds): test subscriptions in Test All, skip direct/dns

Test All only iterated the editable template outbounds, so subscription
outbounds (the read-only "from subscriptions" table) were never probed in
bulk. They are now queued too, keyed by tag in subscriptionTestStates so
their rows light up live; the template and subscription HTTP lanes run
serially to respect the backend's single-batch lock (TCP runs alongside).

Also stop testing freedom ("direct") and dns outbounds: they aren't
proxies, so an HTTP probe through them only measures the host's own
reachability, not a tunnel. They are now untestable in every mode -- the
per-row button is disabled and Test All skips them -- with a matching
backend guard so a direct API caller can't HTTP-test them either.
MHSanaei 1 өдөр өмнө
parent
commit
1c0fdb4527

+ 67 - 18
frontend/src/hooks/useXraySetting.ts

@@ -142,10 +142,12 @@ export function useXraySetting(): UseXraySettingResult {
   const xraySettingRef = useRef('');
   const outboundTestUrlRef = useRef(outboundTestUrl);
   const templateSettingsRef = useRef<XraySettingsValue | null>(null);
+  const subscriptionOutboundsRef = useRef<unknown[]>([]);
 
   xraySettingRef.current = xraySetting;
   outboundTestUrlRef.current = outboundTestUrl;
   templateSettingsRef.current = templateSettings;
+  subscriptionOutboundsRef.current = subscriptionOutbounds;
 
   // Seed local editor state from the config query. Runs on first fetch and
   // every time the query refetches (e.g. after a successful save).
@@ -316,41 +318,61 @@ export function useXraySetting(): UseXraySettingResult {
   );
 
   const testAllOutbounds = useCallback(async (mode = 'tcp') => {
-    const list = templateSettingsRef.current?.outbounds || [];
-    if (list.length === 0 || testingAll) return;
+    // Template outbounds key their results by index (outboundTestStates);
+    // subscription outbounds aren't in the template, so they key by tag
+    // (subscriptionTestStates). Both go through the same probe endpoint.
+    const templateList = templateSettingsRef.current?.outbounds || [];
+    const subList = (subscriptionOutboundsRef.current || []) as Array<{ tag?: string; protocol?: string }>;
+    if ((templateList.length === 0 && subList.length === 0) || testingAll) return;
     setTestingAll(true);
     try {
-      const tcpQueue: { index: number; outbound: unknown }[] = [];
-      const httpQueue: { index: number; outbound: unknown }[] = [];
-      list.forEach((ob, i) => {
-        const tag = ob?.tag;
+      type TcpEntry =
+        | { kind: 'tpl'; index: number; outbound: unknown }
+        | { kind: 'sub'; tag: string; outbound: unknown };
+      const tcpQueue: TcpEntry[] = [];
+      // 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 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) => {
         const proto = ob?.protocol;
-        if (proto === 'blackhole' || proto === 'loopback' || tag === 'blocked') return;
-        if (mode === 'tcp' && (proto === 'freedom' || proto === 'dns')) return;
-        if (mode === 'http' || isUdpOutbound(ob)) {
-          httpQueue.push({ index: i, outbound: ob });
+        if (proto === 'blackhole' || proto === 'loopback' || ob?.tag === 'blocked') return;
+        // 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);
+        if (kind === 'tpl') {
+          if (toHttp) httpTplQueue.push({ index, outbound: ob });
+          else tcpQueue.push({ kind: 'tpl', index, outbound: ob });
+        } else if (toHttp) {
+          httpSubQueue.push({ tag, outbound: ob });
         } else {
-          tcpQueue.push({ index: i, outbound: ob });
+          tcpQueue.push({ kind: 'sub', tag, outbound: ob });
         }
-      });
+      };
+      templateList.forEach((ob, i) => enqueue(ob, 'tpl', i, ''));
+      subList.forEach((ob) => enqueue(ob, 'sub', -1, typeof ob?.tag === 'string' ? ob.tag : ''));
+
       // TCP probes are dial-only and cheap server-side; per-item requests
-      // keep results landing one by one.
+      // keep results landing one by one, each routed to its own state map.
       const runTcpLane = async () => {
         const queue = [...tcpQueue];
         const worker = async () => {
           while (queue.length > 0) {
             const item = queue.shift();
             if (!item) break;
-            await testOutbound(item.index, item.outbound, mode);
+            if (item.kind === 'sub') await testSubscriptionOutbound(item.tag, item.outbound, mode);
+            else await testOutbound(item.index, item.outbound, mode);
           }
         };
         await Promise.all(Array.from({ length: Math.min(8, queue.length) }, () => worker()));
       };
       // 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);
+      const runTplHttpLane = async () => {
+        for (let at = 0; at < httpTplQueue.length; at += HTTP_BATCH_CHUNK) {
+          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' };
@@ -366,11 +388,38 @@ export function useXraySetting(): UseXraySettingResult {
           });
         }
       };
+      const runSubHttpLane = async () => {
+        for (let at = 0; at < httpSubQueue.length; at += HTTP_BATCH_CHUNK) {
+          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' };
+            return next;
+          });
+          const results = await postOutboundTestBatch(chunk.map((c) => c.outbound), 'http');
+          setSubscriptionTestStates((prev) => {
+            const next = { ...prev };
+            chunk.forEach((item, i) => {
+              next[item.tag] = { testing: false, result: results[i] };
+            });
+            return next;
+          });
+        }
+      };
+      // HTTP batches must not overlap: the backend serialises them with a
+      // non-blocking lock and rejects a second concurrent batch ("Another
+      // outbound test is already running"). Run the template and subscription
+      // HTTP lanes one after the other; TCP probes don't take that lock, so
+      // they still run alongside.
+      const runHttpLane = async () => {
+        await runTplHttpLane();
+        await runSubHttpLane();
+      };
       await Promise.all([runTcpLane(), runHttpLane()]);
     } finally {
       setTestingAll(false);
     }
-  }, [testingAll, testOutbound, postOutboundTestBatch]);
+  }, [testingAll, testOutbound, testSubscriptionOutbound, postOutboundTestBatch]);
 
   useEffect(() => {
     const timer = window.setInterval(() => {

+ 1 - 1
frontend/src/pages/xray/outbounds/OutboundCardList.tsx

@@ -110,7 +110,7 @@ export default function OutboundCardList({
                 shape="circle"
                 size="small"
                 loading={isTesting(outboundTestStates, index)}
-                disabled={isUntestable(record, testMode) || isTesting(outboundTestStates, index)}
+                disabled={isUntestable(record) || isTesting(outboundTestStates, index)}
                 icon={<ThunderboltOutlined />}
                 onClick={() => onTest(index, testMode)}
               />

+ 1 - 1
frontend/src/pages/xray/outbounds/SubscriptionOutbounds.tsx

@@ -110,7 +110,7 @@ export default function SubscriptionOutbounds({
           shape="circle"
           size={isMobile ? 'small' : undefined}
           loading={isTesting(subscriptionTestStates, key)}
-          disabled={!record.tag || isUntestable(record, testMode) || isTesting(subscriptionTestStates, key)}
+          disabled={!record.tag || isUntestable(record) || isTesting(subscriptionTestStates, key)}
           icon={<ThunderboltOutlined />}
           onClick={() => onTestSubscription(record as unknown as Record<string, unknown>, testMode)}
         />

+ 5 - 2
frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts

@@ -31,10 +31,13 @@ export function outboundAddresses(o: OutboundRow): string[] {
   }
 }
 
-export function isUntestable(o: OutboundRow, mode: string): boolean {
+export function isUntestable(o: OutboundRow): boolean {
   if (!o) return true;
   if (o.protocol === Protocols.Blackhole || o.protocol === Protocols.Loopback || o.tag === 'blocked') return true;
-  if (mode === 'tcp' && (o.protocol === Protocols.Freedom || o.protocol === Protocols.DNS)) return true;
+  // freedom ("direct") and dns aren't proxies — a TCP dial has no endpoint and
+  // an HTTP probe would only measure the host's own direct reachability, so
+  // they're untestable in every mode.
+  if (o.protocol === Protocols.Freedom || o.protocol === Protocols.DNS) return true;
   return false;
 }
 

+ 1 - 1
frontend/src/pages/xray/outbounds/useOutboundColumns.tsx

@@ -172,7 +172,7 @@ export function useOutboundColumns({
               type="primary"
               shape="circle"
               loading={isTesting(outboundTestStates, index)}
-              disabled={isUntestable(record, testMode) || isTesting(outboundTestStates, index)}
+              disabled={isUntestable(record) || isTesting(outboundTestStates, index)}
               icon={<ThunderboltOutlined />}
               onClick={() => onTest(index, testMode)}
             />

+ 4 - 0
internal/web/service/outbound/probe_http.go

@@ -160,6 +160,10 @@ func (s *OutboundService) testOutboundsParsed(items []map[string]any, testURL st
 			r.Error = "Blocked/blackhole outbound cannot be tested"
 		case protocol == "loopback":
 			r.Error = "Loopback outbound cannot be tested"
+		case protocol == "freedom" || protocol == "dns":
+			// Direct/DNS outbounds aren't proxies — an HTTP probe through them
+			// would only measure the host's own reachability, not a tunnel.
+			r.Error = "Direct/DNS outbound cannot be tested"
 		case seenTags[tag]:
 			r.Error = fmt.Sprintf("Duplicate outbound tag in batch: %s", tag)
 		default: