1
0

18 Коммиты 7ae3ea66d1 ... 08bc481ae3

Автор SHA1 Сообщение Дата
  MHSanaei 08bc481ae3 refactor(settings): reorganize subscription settings into clearer tabs 2 дней назад
  MHSanaei 0f7da02a07 style(inbounds): show total up/down with directional arrows 2 дней назад
  MHSanaei 0c73862bbe fix(clients): invalidate Xray config cache after client mutations 2 дней назад
  MHSanaei c7a0188772 feat(settings): schedule picker, toggle placement, sub-theme docs link 2 дней назад
  iYuan 90e6217749 fix(inbound): preserve custom share strategy on edit (#5225) 2 дней назад
  MHSanaei 6e20588236 style(ui): enlarge row action icons and rebalance clients table widths 2 дней назад
  MHSanaei 5eec178483 feat(mtproto): route Telegram egress through Xray routing rules 2 дней назад
  MHSanaei 5716ae5987 feat(outbound): batched connection tester with direct timed HTTP probes 2 дней назад
  MHSanaei 85983eec1a refactor(groups): restyle traffic summary into upload/download + usage cards 2 дней назад
  MHSanaei 5af02265ec fix(inbound): remove stale mkcp-legacy finalmask when switching away from mKCP 2 дней назад
  MHSanaei 1c5cb84492 feat(groups): show upload/download breakdown in group traffic 2 дней назад
  MHSanaei 7c698c4bcf feat(inbound): support abstract unix sockets (@ prefix) in Address field 2 дней назад
  MHSanaei 80e168787e fix(xray): confine log.access/error to the panel log folder 2 дней назад
  MHSanaei 3af1afc53b fix(inbound): avoid UNIQUE email constraint when importing inbounds that share clients 2 дней назад
  MHSanaei 0cefadd166 feat(ui): use CodeMirror editor for Import Inbound and Inbound JSON 2 дней назад
  Rouzbeh† 0766e16684 feat: implement inbound XMUX form fields (#5211) 2 дней назад
  ssrlive 63a6d40457 Update ExecReload command in x-ui.service.debian (#5219) 2 дней назад
  MHSanaei f1a4286e2f feat(sub): per-inbound sort order for subscription links 2 дней назад
81 измененных файлов с 3266 добавлено и 590 удалено
  1. 48 0
      frontend/public/openapi.json
  2. 33 0
      frontend/src/api/queries/useOutboundTags.ts
  3. 7 1
      frontend/src/components/feedback/PromptModal.tsx
  4. 17 11
      frontend/src/components/feedback/TextModal.tsx
  5. 1 0
      frontend/src/generated/examples.ts
  6. 7 0
      frontend/src/generated/schemas.ts
  7. 1 0
      frontend/src/generated/types.ts
  8. 1 0
      frontend/src/generated/zod.ts
  9. 1 6
      frontend/src/hooks/useClients.ts
  10. 52 21
      frontend/src/hooks/useXraySetting.ts
  11. 17 0
      frontend/src/lib/xray/inbound-form-adapter.ts
  12. 36 2
      frontend/src/lib/xray/stream-wire-normalize.ts
  13. 3 0
      frontend/src/models/dbinbound.ts
  14. 11 0
      frontend/src/pages/api-docs/endpoints.ts
  15. 7 6
      frontend/src/pages/clients/ClientsPage.tsx
  16. 44 6
      frontend/src/pages/groups/GroupsPage.tsx
  17. 20 5
      frontend/src/pages/inbounds/InboundsPage.tsx
  18. 24 9
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  19. 25 0
      frontend/src/pages/inbounds/form/protocols/mtproto.tsx
  20. 71 0
      frontend/src/pages/inbounds/form/transport/xhttp.tsx
  21. 6 0
      frontend/src/pages/inbounds/list/InboundList.tsx
  22. 2 2
      frontend/src/pages/inbounds/list/RowActions.tsx
  23. 1 0
      frontend/src/pages/inbounds/list/types.ts
  24. 18 2
      frontend/src/pages/inbounds/list/useInboundColumns.tsx
  25. 4 4
      frontend/src/pages/nodes/NodeList.tsx
  26. 5 4
      frontend/src/pages/settings/GeneralTab.tsx
  27. 58 36
      frontend/src/pages/settings/SubscriptionGeneralTab.tsx
  28. 131 3
      frontend/src/pages/settings/TelegramTab.tsx
  29. 2 6
      frontend/src/pages/xray/outbounds/OutboundCardList.tsx
  30. 19 0
      frontend/src/pages/xray/outbounds/OutboundsTab.css
  31. 4 38
      frontend/src/pages/xray/outbounds/SubscriptionOutbounds.tsx
  32. 68 0
      frontend/src/pages/xray/outbounds/TestResultPopover.tsx
  33. 0 6
      frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts
  34. 3 34
      frontend/src/pages/xray/outbounds/useOutboundColumns.tsx
  35. 2 0
      frontend/src/schemas/client.ts
  36. 1 0
      frontend/src/schemas/forms/inbound-form.ts
  37. 7 0
      frontend/src/schemas/protocols/inbound/mtproto.ts
  38. 11 0
      frontend/src/schemas/xray.ts
  39. 1 0
      frontend/src/test/__snapshots__/inbound-form-blocks.test.tsx.snap
  40. 10 0
      frontend/src/test/__snapshots__/inbound-form-modal.test.tsx.snap
  41. 33 1
      frontend/src/test/inbound-form-adapter.test.ts
  42. 37 0
      frontend/src/test/inbound-form-modal.test.tsx
  43. 6 2
      frontend/src/test/outbound-form-adapter.test.ts
  44. 63 0
      frontend/src/test/stream-wire-normalize.test.ts
  45. 18 0
      internal/database/db.go
  46. 1 0
      internal/database/model/model.go
  47. 23 2
      internal/mtproto/manager.go
  48. 33 1
      internal/mtproto/manager_test.go
  49. 1 1
      internal/sub/service.go
  50. 79 0
      internal/sub/service_sort_test.go
  51. 34 0
      internal/web/controller/xray_setting.go
  52. 13 0
      internal/web/job/mtproto_job.go
  53. 1 0
      internal/web/runtime/remote.go
  54. 7 3
      internal/web/service/client_groups.go
  55. 165 8
      internal/web/service/inbound.go
  56. 127 0
      internal/web/service/inbound_import_shared_clients_test.go
  57. 94 0
      internal/web/service/inbound_mtproto_test.go
  58. 2 0
      internal/web/service/inbound_node.go
  59. 111 0
      internal/web/service/inbound_sub_sort_test.go
  60. 10 0
      internal/web/service/inbound_util.go
  61. 16 312
      internal/web/service/outbound/outbound.go
  62. 552 0
      internal/web/service/outbound/probe_http.go
  63. 470 0
      internal/web/service/outbound/probe_http_test.go
  64. 88 16
      internal/web/service/xray.go
  65. 97 0
      internal/web/service/xray_config_inject_test.go
  66. 31 3
      internal/web/translation/ar-EG.json
  67. 31 3
      internal/web/translation/en-US.json
  68. 31 3
      internal/web/translation/es-ES.json
  69. 31 3
      internal/web/translation/fa-IR.json
  70. 31 3
      internal/web/translation/id-ID.json
  71. 31 3
      internal/web/translation/ja-JP.json
  72. 31 3
      internal/web/translation/pt-BR.json
  73. 31 3
      internal/web/translation/ru-RU.json
  74. 31 3
      internal/web/translation/tr-TR.json
  75. 31 3
      internal/web/translation/uk-UA.json
  76. 31 3
      internal/web/translation/vi-VN.json
  77. 31 3
      internal/web/translation/zh-CN.json
  78. 31 3
      internal/web/translation/zh-TW.json
  79. 1 1
      x-ui.service.arch
  80. 1 1
      x-ui.service.debian
  81. 1 1
      x-ui.service.rhel

+ 48 - 0
frontend/public/openapi.json

@@ -1345,6 +1345,12 @@
           },
           "sniffing": {},
           "streamSettings": {},
+          "subSortIndex": {
+            "description": "1-based sort order of this inbound's links in subscription output only (lower first; ties by id)",
+            "example": 1,
+            "minimum": 1,
+            "type": "integer"
+          },
           "tag": {
             "example": "in-443-tcp",
             "type": "string"
@@ -1385,6 +1391,7 @@
           "shareAddrStrategy",
           "sniffing",
           "streamSettings",
+          "subSortIndex",
           "tag",
           "total",
           "trafficReset",
@@ -2153,6 +2160,7 @@
                       "shareAddrStrategy": "node",
                       "sniffing": null,
                       "streamSettings": null,
+                      "subSortIndex": 1,
                       "tag": "in-443-tcp",
                       "total": 0,
                       "trafficReset": "never",
@@ -7516,6 +7524,46 @@
         }
       }
     },
+    "/panel/api/xray/testOutbounds": {
+      "post": {
+        "tags": [
+          "Xray Settings"
+        ],
+        "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.",
+        "operationId": "post_panel_api_xray_testOutbounds",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/xray/balancerStatus": {
       "post": {
         "tags": [

+ 33 - 0
frontend/src/api/queries/useOutboundTags.ts

@@ -0,0 +1,33 @@
+import { useQuery } from '@tanstack/react-query';
+
+import { keys } from '@/api/queryKeys';
+import { fetchXrayConfig } from '@/hooks/useXraySetting';
+
+// Available outbound (and balancer-eligible) tags the user can route an mtproto
+// inbound's Telegram traffic to. Shares the cached xray config query so opening
+// the inbound form costs no extra request when the Xray page was already
+// visited; `select` derives just the tag list without disturbing other readers.
+export function useOutboundTags() {
+  return useQuery({
+    queryKey: keys.xray.config(),
+    queryFn: fetchXrayConfig,
+    staleTime: Infinity,
+    select: (data): string[] => {
+      const tags = new Set<string>();
+      for (const o of data?.xraySetting?.outbounds ?? []) {
+        const tag = (o as { tag?: string } | null)?.tag;
+        if (tag) tags.add(tag);
+      }
+      for (const t of data?.subscriptionOutboundTags ?? []) {
+        if (t) tags.add(t);
+      }
+      // Balancers are valid routing targets too — injectMtprotoEgress emits a
+      // balancerTag rule when the chosen tag names a balancer.
+      const balancers = (data?.xraySetting?.routing as { balancers?: Array<{ tag?: string }> } | undefined)?.balancers;
+      for (const b of balancers ?? []) {
+        if (b?.tag) tags.add(b.tag);
+      }
+      return [...tags];
+    },
+  });
+}

+ 7 - 1
frontend/src/components/feedback/PromptModal.tsx

@@ -3,6 +3,8 @@ import { Input, Modal } from 'antd';
 import type { InputRef } from 'antd';
 import { useTranslation } from 'react-i18next';
 
+import JsonEditor from '@/components/form/JsonEditor';
+
 interface PromptModalProps {
   open: boolean;
   onClose: () => void;
@@ -11,6 +13,7 @@ interface PromptModalProps {
   type?: 'input' | 'textarea';
   initialValue?: string;
   loading?: boolean;
+  json?: boolean;
   onConfirm: (value: string) => void;
 }
 
@@ -22,6 +25,7 @@ export default function PromptModal({
   type = 'input',
   initialValue = '',
   loading = false,
+  json = false,
   onConfirm,
 }: PromptModalProps) {
   const { t } = useTranslation();
@@ -63,7 +67,9 @@ export default function PromptModal({
       onCancel={onClose}
       destroyOnHidden
     >
-      {type === 'textarea' ? (
+      {json ? (
+        <JsonEditor value={value} onChange={setValue} minHeight="240px" maxHeight="60vh" />
+      ) : type === 'textarea' ? (
         <Input.TextArea
           ref={(el) => { textareaRef.current = (el as unknown as { resizableTextArea?: { textArea: HTMLTextAreaElement } })?.resizableTextArea?.textArea ?? null; }}
           value={value}

+ 17 - 11
frontend/src/components/feedback/TextModal.tsx

@@ -2,6 +2,7 @@ import { Button, Input, Modal, message } from 'antd';
 import { CopyOutlined, DownloadOutlined } from '@ant-design/icons';
 import { useTranslation } from 'react-i18next';
 
+import JsonEditor from '@/components/form/JsonEditor';
 import { ClipboardManager, FileManager } from '@/utils';
 
 interface TextModalProps {
@@ -10,9 +11,10 @@ interface TextModalProps {
   title: string;
   content: string;
   fileName?: string;
+  json?: boolean;
 }
 
-export default function TextModal({ open, onClose, title, content, fileName = '' }: TextModalProps) {
+export default function TextModal({ open, onClose, title, content, fileName = '', json = false }: TextModalProps) {
   const { t } = useTranslation();
   const [messageApi, messageContextHolder] = message.useMessage();
   async function copy() {
@@ -45,16 +47,20 @@ export default function TextModal({ open, onClose, title, content, fileName = ''
         </>
       )}
     >
-      <Input.TextArea
-        value={content}
-        readOnly
-        autoSize={{ minRows: 10, maxRows: 20 }}
-        style={{
-          fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
-          fontSize: 12,
-          overflowY: 'auto',
-        }}
-      />
+      {json ? (
+        <JsonEditor value={content} readOnly minHeight="240px" maxHeight="60vh" />
+      ) : (
+        <Input.TextArea
+          value={content}
+          readOnly
+          autoSize={{ minRows: 10, maxRows: 20 }}
+          style={{
+            fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
+            fontSize: 12,
+            overflowY: 'auto',
+          }}
+        />
+      )}
       </Modal>
     </>
   );

+ 1 - 0
frontend/src/generated/examples.ts

@@ -292,6 +292,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "shareAddrStrategy": "node",
     "sniffing": null,
     "streamSettings": null,
+    "subSortIndex": 1,
     "tag": "in-443-tcp",
     "total": 0,
     "trafficReset": "never",

+ 7 - 0
frontend/src/generated/schemas.ts

@@ -1319,6 +1319,12 @@ export const SCHEMAS: Record<string, unknown> = {
       },
       "sniffing": {},
       "streamSettings": {},
+      "subSortIndex": {
+        "description": "1-based sort order of this inbound's links in subscription output only (lower first; ties by id)",
+        "example": 1,
+        "minimum": 1,
+        "type": "integer"
+      },
       "tag": {
         "example": "in-443-tcp",
         "type": "string"
@@ -1359,6 +1365,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "shareAddrStrategy",
       "sniffing",
       "streamSettings",
+      "subSortIndex",
       "tag",
       "total",
       "trafficReset",

+ 1 - 0
frontend/src/generated/types.ts

@@ -293,6 +293,7 @@ export interface Inbound {
   shareAddrStrategy: string;
   sniffing: unknown;
   streamSettings: unknown;
+  subSortIndex: number;
   tag: string;
   total: number;
   trafficReset: string;

+ 1 - 0
frontend/src/generated/zod.ts

@@ -314,6 +314,7 @@ export const InboundSchema = z.object({
   shareAddrStrategy: z.enum(['node', 'listen', 'custom']),
   sniffing: z.unknown(),
   streamSettings: z.unknown(),
+  subSortIndex: z.number().int().min(1),
   tag: z.string(),
   total: z.number().int(),
   trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']),

+ 1 - 6
frontend/src/hooks/useClients.ts

@@ -255,12 +255,6 @@ export function useClients() {
     return { ...live, total: serverSummary.total || live.total };
   }, [allClientStats, onlines, expireDiff, trafficDiff, listQuery.data?.summary]);
 
-  // Client mutations (add/update/remove/attach/detach/resetTraffic/…) all
-  // mutate inbound rows server-side too — adding a client appends to
-  // settings.clients on each attached inbound, the slim list's per-inbound
-  // client count is derived from that. Invalidate both buckets so the
-  // Inbounds page and any open edit modal pick up the new shape without
-  // a manual reload.
   const invalidateAll = useCallback(
     () => {
       markLocalInvalidate();
@@ -268,6 +262,7 @@ export function useClients() {
       return Promise.all([
         queryClient.invalidateQueries({ queryKey: keys.clients.root() }),
         queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
+        queryClient.invalidateQueries({ queryKey: keys.xray.config() }),
       ]);
     },
     [queryClient],

+ 52 - 21
frontend/src/hooks/useXraySetting.ts

@@ -7,7 +7,7 @@ import { parseMsg } from '@/utils/zodValidate';
 import { keys } from '@/api/queryKeys';
 import {
   OutboundTrafficListSchema,
-  OutboundTestResultSchema,
+  OutboundTestResultListSchema,
   XrayConfigPayloadSchema,
   XraySettingsValueSchema,
   type OutboundTestResult,
@@ -16,6 +16,10 @@ import {
 
 const DIRTY_POLL_MS = 1000;
 const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
+// One HTTP-mode batch request tests this many outbounds through a single
+// shared temp xray instance; chunking keeps responses bounded (~15s worst
+// case) and lands Test All results progressively.
+const HTTP_BATCH_CHUNK = 16;
 
 export function isUdpOutbound(outbound: unknown): boolean {
   const o = outbound as { protocol?: string; streamSettings?: { network?: string } } | null | undefined;
@@ -77,7 +81,7 @@ export interface UseXraySettingResult {
 
 type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
 
-async function fetchXrayConfig(): Promise<XrayConfigPayload> {
+export async function fetchXrayConfig(): Promise<XrayConfigPayload> {
   const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to load xray config');
   if (typeof msg.obj !== 'string') throw new Error('Malformed xray config response: expected string');
@@ -254,21 +258,26 @@ export function useXraySetting(): UseXraySettingResult {
 
   const spinning = saveMut.isPending || resetDefaultMut.isPending;
 
-  // Shared POST + parse for a single outbound test. Returns an OutboundTestResult
-  // (success or a failure-shaped result); callers store it under their own key.
-  const postOutboundTest = useCallback(
-    async (outbound: unknown, effMode: string): Promise<OutboundTestResult> => {
+  // Shared POST + parse for a batch of outbound tests. The backend probes the
+  // whole batch through one shared temp xray instance and returns results in
+  // request order; this aligns them by index and shapes failures so every
+  // input gets an OutboundTestResult.
+  const postOutboundTestBatch = useCallback(
+    async (outbounds: unknown[], effMode: string): Promise<OutboundTestResult[]> => {
+      const failAll = (error: string): OutboundTestResult[] =>
+        outbounds.map(() => ({ success: false, error, mode: effMode }));
       try {
-        const raw = await HttpUtil.post('/panel/api/xray/testOutbound', {
-          outbound: JSON.stringify(outbound),
+        const raw = await HttpUtil.post('/panel/api/xray/testOutbounds', {
+          outbounds: JSON.stringify(outbounds),
           allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
           mode: effMode,
         });
-        const msg = parseMsg(raw, OutboundTestResultSchema, 'xray/testOutbound');
-        if (msg?.success && msg.obj) return msg.obj;
-        return { success: false, error: msg?.msg || 'Unknown error', mode: effMode };
+        const msg = parseMsg(raw, OutboundTestResultListSchema, 'xray/testOutbounds');
+        if (!msg?.success || !Array.isArray(msg.obj)) return failAll(msg?.msg || 'Unknown error');
+        const list = msg.obj;
+        return outbounds.map((_ob, i) => list[i] ?? { success: false, error: 'Missing result', mode: effMode });
       } catch (e) {
-        return { success: false, error: String(e), mode: effMode };
+        return failAll(String(e));
       }
     },
     [],
@@ -282,11 +291,11 @@ export function useXraySetting(): UseXraySettingResult {
         ...prev,
         [index]: { testing: true, result: null, mode: effMode },
       }));
-      const result = await postOutboundTest(outbound, effMode);
+      const [result] = await postOutboundTestBatch([outbound], effMode);
       setOutboundTestStates((prev) => ({ ...prev, [index]: { testing: false, result } }));
       return result.success ? result : null;
     },
-    [postOutboundTest],
+    [postOutboundTestBatch],
   );
 
   // Test a subscription outbound (not present in templateSettings.outbounds);
@@ -299,11 +308,11 @@ export function useXraySetting(): UseXraySettingResult {
         ...prev,
         [tag]: { testing: true, result: null, mode: effMode },
       }));
-      const result = await postOutboundTest(outbound, effMode);
+      const [result] = await postOutboundTestBatch([outbound], effMode);
       setSubscriptionTestStates((prev) => ({ ...prev, [tag]: { testing: false, result } }));
       return result.success ? result : null;
     },
-    [postOutboundTest],
+    [postOutboundTestBatch],
   );
 
   const testAllOutbounds = useCallback(async (mode = 'tcp') => {
@@ -324,7 +333,10 @@ export function useXraySetting(): UseXraySettingResult {
           tcpQueue.push({ index: i, outbound: ob });
         }
       });
-      const runLane = async (queue: { index: number; outbound: unknown }[], concurrency: number) => {
+      // TCP probes are dial-only and cheap server-side; per-item requests
+      // keep results landing one by one.
+      const runTcpLane = async () => {
+        const queue = [...tcpQueue];
         const worker = async () => {
           while (queue.length > 0) {
             const item = queue.shift();
@@ -332,14 +344,33 @@ export function useXraySetting(): UseXraySettingResult {
             await testOutbound(item.index, item.outbound, mode);
           }
         };
-        const workers = Array.from({ length: Math.min(concurrency, queue.length) }, () => worker());
-        await Promise.all(workers);
+        await Promise.all(Array.from({ length: Math.min(8, queue.length) }, () => worker()));
       };
-      await Promise.all([runLane(tcpQueue, 8), runLane(httpQueue, 1)]);
+      // 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);
+          setOutboundTestStates((prev) => {
+            const next = { ...prev };
+            for (const item of chunk) next[item.index] = { testing: true, result: null, mode: 'http' };
+            return next;
+          });
+          const results = await postOutboundTestBatch(chunk.map((c) => c.outbound), 'http');
+          setOutboundTestStates((prev) => {
+            const next = { ...prev };
+            chunk.forEach((item, i) => {
+              next[item.index] = { testing: false, result: results[i] };
+            });
+            return next;
+          });
+        }
+      };
+      await Promise.all([runTcpLane(), runHttpLane()]);
     } finally {
       setTestingAll(false);
     }
-  }, [testingAll, testOutbound]);
+  }, [testingAll, testOutbound, postOutboundTestBatch]);
 
   useEffect(() => {
     const timer = window.setInterval(() => {

+ 17 - 0
frontend/src/lib/xray/inbound-form-adapter.ts

@@ -12,6 +12,9 @@ import type { Sniffing } from '@/schemas/primitives';
 import type { z } from 'zod';
 import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
 import { canEnableSniffing } from '@/lib/xray/protocol-capabilities';
+import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
+
+const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
 
 // Plain-data adapter between the panel's stored inbound row shape and
 // the typed InboundFormValues that Form.useForm<T> carries inside
@@ -39,6 +42,7 @@ export interface RawInboundRow {
   nodeId?: number | null;
   shareAddrStrategy?: string;
   shareAddr?: string;
+  subSortIndex?: number;
   clientStats?: unknown;
 }
 
@@ -65,6 +69,7 @@ export interface WireInboundPayload {
   nodeId?: number;
   shareAddrStrategy: ShareAddrStrategy;
   shareAddr: string;
+  subSortIndex: number;
 }
 
 function coerceJsonObject(value: unknown): Record<string, unknown> {
@@ -155,6 +160,16 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
   if (streamSettings) {
     healStreamNetworkKey(streamSettings as unknown as Record<string, unknown>);
     synthesizeTlsCertUseFile(streamSettings as unknown as Record<string, unknown>);
+    const streamRecord = streamSettings as unknown as Record<string, unknown>;
+    const xh = streamRecord.xhttpSettings;
+    if (xh && typeof xh === 'object' && !Array.isArray(xh)) {
+      const xhttp = xh as Record<string, unknown>;
+      const xmux = xhttp.xmux;
+      if (xmux && typeof xmux === 'object' && !Array.isArray(xmux)) {
+        xhttp.enableXmux = true;
+        xhttp.xmux = { ...XMUX_DEFAULTS, ...(xmux as Record<string, unknown>) };
+      }
+    }
   }
   const sniffing = coerceJsonObject(row.sniffing) as unknown as Sniffing;
 
@@ -175,6 +190,7 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
     nodeId: row.nodeId ?? null,
     shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy),
     shareAddr: row.shareAddr ?? '',
+    subSortIndex: Math.max(1, row.subSortIndex ?? 1),
     protocol,
     settings,
   } as InboundFormValues;
@@ -322,6 +338,7 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
     tag: values.tag,
     shareAddrStrategy: values.shareAddrStrategy,
     shareAddr: values.shareAddr,
+    subSortIndex: values.subSortIndex,
   };
   if (values.nodeId != null) payload.nodeId = values.nodeId;
   return payload;

+ 36 - 2
frontend/src/lib/xray/stream-wire-normalize.ts

@@ -41,6 +41,36 @@ function hasMeaningfulHeaders(headers: unknown): boolean {
   return isRecord(headers) && Object.keys(headers).length > 0;
 }
 
+// Upper bound of an xray-core Int32Range value: "16-32" -> 32, "4" -> 4,
+// 4 -> 4, "" / null -> 0. xmux fields are ranges, and xray-core keys its
+// mutual-exclusivity check on the `.To` (upper) side.
+function int32RangeUpper(v: unknown): number {
+  if (typeof v === 'number') return Number.isFinite(v) ? v : 0;
+  if (typeof v !== 'string') return 0;
+  const trimmed = v.trim();
+  if (trimmed === '') return 0;
+  const parts = trimmed.split('-');
+  const n = Number(parts[parts.length - 1]);
+  return Number.isFinite(n) ? n : 0;
+}
+
+// xray-core's XmuxConfig rejects a config that sets BOTH maxConnections
+// and maxConcurrency ("maxConnections cannot be specified together with
+// maxConcurrency"). The panel pre-fills maxConcurrency ("16-32") whenever
+// XMUX is enabled, so any explicit maxConnections would otherwise always
+// collide and make xray refuse the config. maxConnections defaults to 0
+// (off), so a positive value is an explicit opt-in to connection-pool
+// mode — honor it and drop the leftover default maxConcurrency, matching
+// core's "one strategy at a time" semantics.
+function resolveXmuxExclusivity(xmux: Record<string, unknown>): Record<string, unknown> {
+  if (int32RangeUpper(xmux.maxConnections) > 0 && int32RangeUpper(xmux.maxConcurrency) > 0) {
+    const out = { ...xmux };
+    delete out.maxConcurrency;
+    return out;
+  }
+  return xmux;
+}
+
 /** Validates REALITY inbound `target` / `dest` (must include a port). */
 export function validateRealityTarget(target: string): string | undefined {
   const trimmed = target.trim();
@@ -115,15 +145,19 @@ export function normalizeXhttpForWire(
 ): Record<string, unknown> {
   const out: Record<string, unknown> = { ...raw };
   const mode = typeof out.mode === 'string' && out.mode !== '' ? out.mode : 'auto';
-
+  const enableXmux = out.enableXmux === true;
   delete out.enableXmux;
 
   if (side === 'inbound') {
-    delete out.xmux;
+    if (!enableXmux) delete out.xmux;
     delete out.scMinPostsIntervalMs;
     delete out.uplinkChunkSize;
   }
 
+  if (isRecord(out.xmux)) {
+    out.xmux = resolveXmuxExclusivity(out.xmux);
+  }
+
   dropEmptyStrings(out, PLACEMENT_STRING_FIELDS);
   // Empty tuning fields mean "use xray-core's default" — never emit them.
   dropEmptyStrings(out, ['scMaxEachPostBytes', 'scMinPostsIntervalMs', 'scStreamUpServerSecs']);

+ 3 - 0
frontend/src/models/dbinbound.ts

@@ -42,6 +42,7 @@ export type DBInboundInit = Partial<{
     nodeId: number | null;
     shareAddrStrategy: string;
     shareAddr: string;
+    subSortIndex: number;
     originNodeGuid: string;
     fallbackParent: FallbackParentRef | null;
 }>;
@@ -88,6 +89,7 @@ export class DBInbound {
     nodeId: number | null;
     shareAddrStrategy: string;
     shareAddr: string;
+    subSortIndex: number;
     originNodeGuid: string;
     fallbackParent: FallbackParentRef | null;
 
@@ -116,6 +118,7 @@ export class DBInbound {
         this.nodeId = null;
         this.shareAddrStrategy = "node";
         this.shareAddr = "";
+        this.subSortIndex = 1;
         this.originNodeGuid = "";
         this.fallbackParent = null;
         if (data == null) {

+ 11 - 0
frontend/src/pages/api-docs/endpoints.ts

@@ -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 - 6
frontend/src/pages/clients/ClientsPage.tsx

@@ -605,19 +605,19 @@ export default function ClientsPage() {
       render: (_v, record) => (
         <Space size={4}>
           <Tooltip title={t('pages.clients.qrCode')}>
-            <Button size="small" type="text" icon={<QrcodeOutlined />} onClick={() => onShowQr(record)} />
+            <Button size="small" type="text" style={{ fontSize: 18 }} icon={<QrcodeOutlined />} onClick={() => onShowQr(record)} />
           </Tooltip>
           <Tooltip title={t('pages.clients.clientInfo')}>
-            <Button size="small" type="text" icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} />
+            <Button size="small" type="text" style={{ fontSize: 18 }} icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} />
           </Tooltip>
           <Tooltip title={t('pages.inbounds.resetTraffic')}>
-            <Button size="small" type="text" icon={<RetweetOutlined />} onClick={() => onResetTraffic(record)} />
+            <Button size="small" type="text" style={{ fontSize: 18 }} icon={<RetweetOutlined />} onClick={() => onResetTraffic(record)} />
           </Tooltip>
           <Tooltip title={t('edit')}>
-            <Button size="small" type="text" icon={<EditOutlined />} onClick={() => onEdit(record)} />
+            <Button size="small" type="text" style={{ fontSize: 18 }} icon={<EditOutlined />} onClick={() => onEdit(record)} />
           </Tooltip>
           <Tooltip title={t('delete')}>
-            <Button size="small" type="text" danger icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
+            <Button size="small" type="text" danger style={{ fontSize: 18 }} icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
           </Tooltip>
         </Space>
       ),
@@ -663,6 +663,7 @@ export default function ClientsPage() {
     {
       title: t('pages.clients.client'),
       key: 'email',
+      width: 220,
       render: (_v, record) => (
         <div className="email-cell">
           <span className="email">{record.email}</span>
@@ -742,7 +743,6 @@ export default function ClientsPage() {
     {
       title: t('pages.clients.traffic'),
       key: 'traffic',
-      width: 240,
       render: (_v, record) => (
         <ClientTrafficCell
           up={record.traffic?.up}
@@ -762,6 +762,7 @@ export default function ClientsPage() {
     {
       title: t('pages.clients.duration'),
       key: 'expiryTime',
+      width: 130,
       render: (_v, record) => (
         <Tooltip title={expiryLabel(record)}>
           <Tag color={expiryColor(record)}>{record.expiryTime ? expiryRelative(record) : '∞'}</Tag>

+ 44 - 6
frontend/src/pages/groups/GroupsPage.tsx

@@ -22,11 +22,14 @@ import {
 } from 'antd';
 import type { MenuProps, TableColumnsType } from 'antd';
 import {
+  ArrowDownOutlined,
+  ArrowUpOutlined,
   ClockCircleOutlined,
   DeleteOutlined,
   EditOutlined,
   LinkOutlined,
   MoreOutlined,
+  PieChartOutlined,
   PlusOutlined,
   RetweetOutlined,
   TagsOutlined,
@@ -165,6 +168,14 @@ export default function GroupsPage() {
     () => groups.reduce((acc, g) => acc + (g.trafficUsed || 0), 0),
     [groups],
   );
+  const totalUpload = useMemo(
+    () => groups.reduce((acc, g) => acc + (g.up || 0), 0),
+    [groups],
+  );
+  const totalDownload = useMemo(
+    () => groups.reduce((acc, g) => acc + (g.down || 0), 0),
+    [groups],
+  );
 
   function openCreate() {
     setCreateName('');
@@ -396,10 +407,10 @@ export default function GroupsPage() {
       render: (_v, row) => (
         <Space size={4}>
           <Dropdown trigger={['click']} menu={{ items: rowActions(row) }}>
-            <Button size="small" type="text" icon={<MoreOutlined />} />
+            <Button size="small" type="text" style={{ fontSize: 18 }} icon={<MoreOutlined />} />
           </Dropdown>
           <Tooltip title={t('pages.groups.rename')}>
-            <Button size="small" type="text" icon={<EditOutlined />} onClick={() => openRename(row)} />
+            <Button size="small" type="text" style={{ fontSize: 18 }} icon={<EditOutlined />} onClick={() => openRename(row)} />
           </Tooltip>
         </Space>
       ),
@@ -417,6 +428,20 @@ export default function GroupsPage() {
       width: 180,
       render: (count: number) => <span>{count || 0}</span>,
     },
+    {
+      title: t('pages.groups.upload'),
+      dataIndex: 'up',
+      key: 'up',
+      width: 140,
+      render: (bytes: number) => <span>{SizeFormatter.sizeFormat(bytes || 0)}</span>,
+    },
+    {
+      title: t('pages.groups.download'),
+      dataIndex: 'down',
+      key: 'down',
+      width: 140,
+      render: (bytes: number) => <span>{SizeFormatter.sizeFormat(bytes || 0)}</span>,
+    },
     {
       title: t('pages.groups.trafficUsed'),
       dataIndex: 'trafficUsed',
@@ -456,25 +481,38 @@ export default function GroupsPage() {
                   <Col span={24}>
                     <Card size="small" hoverable className="summary-card">
                       <Row gutter={[16, isMobile ? 16 : 12]}>
-                        <Col xs={12} sm={8} md={6}>
+                        <Col xs={12} sm={12} md={6}>
                           <Statistic
                             title={t('pages.groups.totalGroups')}
                             value={String(totalGroups)}
                             prefix={<TagsOutlined />}
                           />
                         </Col>
-                        <Col xs={12} sm={8} md={6}>
+                        <Col xs={12} sm={12} md={6}>
                           <Statistic
                             title={t('pages.groups.totalGroupedClients')}
                             value={String(totalClients)}
                             prefix={<TeamOutlined />}
                           />
                         </Col>
-                        <Col xs={12} sm={8} md={6}>
+                        <Col xs={12} sm={12} md={6}>
+                          <Statistic
+                            title={t('pages.groups.totalUpDown')}
+                            value={0}
+                            formatter={() => (
+                              <span>
+                                <ArrowUpOutlined /> {SizeFormatter.sizeFormat(totalUpload)}
+                                {' / '}
+                                <ArrowDownOutlined /> {SizeFormatter.sizeFormat(totalDownload)}
+                              </span>
+                            )}
+                          />
+                        </Col>
+                        <Col xs={12} sm={12} md={6}>
                           <Statistic
                             title={t('pages.groups.totalTraffic')}
                             value={SizeFormatter.sizeFormat(totalTraffic)}
-                            prefix={<RetweetOutlined />}
+                            prefix={<PieChartOutlined />}
                           />
                         </Col>
                       </Row>

+ 20 - 5
frontend/src/pages/inbounds/InboundsPage.tsx

@@ -16,7 +16,8 @@ import {
 
 import { setMessageInstance } from '@/utils/messageBus';
 import {
-  SwapOutlined,
+  ArrowUpOutlined,
+  ArrowDownOutlined,
   PieChartOutlined,
   BarsOutlined,
 } from '@ant-design/icons';
@@ -146,12 +147,14 @@ export default function InboundsPage() {
   const [textTitle, setTextTitle] = useState('');
   const [textContent, setTextContent] = useState('');
   const [textFileName, setTextFileName] = useState('');
+  const [textJson, setTextJson] = useState(false);
 
   const [promptOpen, setPromptOpen] = useState(false);
   const [promptTitle, setPromptTitle] = useState('');
   const [promptOkText, setPromptOkText] = useState('OK');
   const [promptType, setPromptType] = useState<'textarea' | 'input'>('textarea');
   const [promptInitial, setPromptInitial] = useState('');
+  const [promptJson, setPromptJson] = useState(false);
   const [promptLoading, setPromptLoading] = useState(false);
   const [promptHandler, setPromptHandler] = useState<((value: string) => Promise<boolean | void> | boolean | void) | null>(null);
 
@@ -163,10 +166,11 @@ export default function InboundsPage() {
   const infoNodeAddress = useMemo(() => hostOverrideFor(infoDbInbound), [infoDbInbound, hostOverrideFor]);
   const qrNodeAddress = useMemo(() => hostOverrideFor(qrDbInbound), [qrDbInbound, hostOverrideFor]);
 
-  const openText = useCallback((opts: { title: string; content: string; fileName?: string }) => {
+  const openText = useCallback((opts: { title: string; content: string; fileName?: string; json?: boolean }) => {
     setTextTitle(opts.title);
     setTextContent(opts.content);
     setTextFileName(opts.fileName || '');
+    setTextJson(opts.json || false);
     setTextOpen(true);
   }, []);
 
@@ -175,12 +179,14 @@ export default function InboundsPage() {
     okText?: string;
     type?: 'textarea' | 'input';
     value?: string;
+    json?: boolean;
     confirm: (value: string) => Promise<boolean | void> | boolean | void;
   }) => {
     setPromptTitle(opts.title);
     setPromptOkText(opts.okText || t('confirm'));
     setPromptType(opts.type || 'textarea');
     setPromptInitial(opts.value || '');
+    setPromptJson(opts.json || false);
     setPromptHandler(() => opts.confirm);
     setPromptOpen(true);
   }, [t]);
@@ -267,7 +273,7 @@ export default function InboundsPage() {
   }, [checkFallback, remarkModel, hostOverrideFor, subSettings.publicHost, openText, t]);
 
   const exportInboundClipboard = useCallback((dbInbound: DBInbound) => {
-    openText({ title: t('pages.inbounds.inboundJsonTitle'), content: JSON.stringify(dbInbound, null, 2) });
+    openText({ title: t('pages.inbounds.inboundJsonTitle'), content: JSON.stringify(dbInbound, null, 2), json: true });
   }, [openText, t]);
 
   const exportInboundSubs = useCallback((dbInbound: DBInbound) => {
@@ -327,6 +333,7 @@ export default function InboundsPage() {
       okText: t('pages.inbounds.import'),
       type: 'textarea',
       value: '',
+      json: true,
       confirm: async (value) => {
         const msg = await HttpUtil.post('/panel/api/inbounds/import', { data: value });
         if (msg?.success) {
@@ -579,8 +586,14 @@ export default function InboundsPage() {
                         <Col xs={12} sm={12} md={8}>
                           <Statistic
                             title={t('pages.inbounds.totalDownUp')}
-                            value={`${SizeFormatter.sizeFormat(totals.up)} / ${SizeFormatter.sizeFormat(totals.down)}`}
-                            prefix={<SwapOutlined />}
+                            value={0}
+                            formatter={() => (
+                              <span>
+                                <ArrowUpOutlined /> {SizeFormatter.sizeFormat(totals.up)}
+                                {' / '}
+                                <ArrowDownOutlined /> {SizeFormatter.sizeFormat(totals.down)}
+                              </span>
+                            )}
                           />
                         </Col>
                         <Col xs={12} sm={12} md={8}>
@@ -705,6 +718,7 @@ export default function InboundsPage() {
             title={textTitle}
             content={textContent}
             fileName={textFileName}
+            json={textJson}
           />
         </LazyMount>
         <LazyMount when={promptOpen}>
@@ -716,6 +730,7 @@ export default function InboundsPage() {
             type={promptType}
             initialValue={promptInitial}
             loading={promptLoading}
+            json={promptJson}
             onConfirm={onPromptConfirm}
           />
         </LazyMount>

+ 24 - 9
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -205,7 +205,7 @@ export default function InboundFormModal({
 
   const wPort = Form.useWatch('port', form);
   const wListen = (Form.useWatch('listen', form) ?? '') as string;
-  const isUdsListen = wListen.startsWith('/');
+  const isUdsListen = wListen.startsWith('/') || wListen.startsWith('@');
   const wNodeId = Form.useWatch('nodeId', form) ?? null;
   const shareAddrStrategy = Form.useWatch('shareAddrStrategy', form) ?? 'node';
   const wTag = Form.useWatch('tag', form) ?? '';
@@ -381,7 +381,8 @@ export default function InboundFormModal({
   // protocol reset drops a nodeId that no longer applies.
   useEffect(() => {
     if (!open) return;
-    if (!nodeShareOptionAvailable && shareAddrStrategy === 'node') {
+    const current = form.getFieldValue('shareAddrStrategy') as InboundFormValues['shareAddrStrategy'] | undefined;
+    if (!nodeShareOptionAvailable && (current ?? 'node') === 'node') {
       form.setFieldValue('shareAddrStrategy', 'listen');
     }
     // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -575,6 +576,14 @@ export default function InboundFormModal({
         </Form.Item>
       )}
 
+      <Form.Item
+        name="subSortIndex"
+        label={t('pages.inbounds.form.subSortIndex')}
+        extra={t('pages.inbounds.form.subSortIndexHelp')}
+      >
+        <InputNumber min={1} />
+      </Form.Item>
+
       <Form.Item
         name="port"
         label={t('pages.inbounds.port')}
@@ -683,13 +692,13 @@ export default function InboundFormModal({
       {isFallbackHost && fallbacksCard}
       {(protocol === Protocols.VLESS || protocol === Protocols.TROJAN)
         && network === 'tcp' && !isFallbackHost && (
-        <Alert
-          className="mt-12"
-          type="info"
-          showIcon
-          message={t('pages.inbounds.fallbacks.needsTls')}
-        />
-      )}
+          <Alert
+            className="mt-12"
+            type="info"
+            showIcon
+            message={t('pages.inbounds.fallbacks.needsTls')}
+          />
+        )}
     </>
   );
 
@@ -741,6 +750,12 @@ export default function InboundFormModal({
           udp: [...udp, { type: 'mkcp-legacy', settings: { header: '', value: '' } }],
         };
       }
+    } else {
+      const fm = cleaned.finalmask as Record<string, unknown> | undefined;
+      if (fm && Array.isArray(fm.udp)) {
+        const udp = (fm.udp as unknown[]).filter((m) => (m as { type?: string })?.type !== 'mkcp-legacy');
+        cleaned.finalmask = { ...fm, udp };
+      }
     }
     form.setFieldValue('streamSettings', cleaned);
   };

+ 25 - 0
frontend/src/pages/inbounds/form/protocols/mtproto.tsx

@@ -3,10 +3,13 @@ import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
 import { ReloadOutlined } from '@ant-design/icons';
 
 import { generateMtprotoSecret, mtprotoSecretForDomain } from '@/lib/xray/inbound-defaults';
+import { useOutboundTags } from '@/api/queries/useOutboundTags';
 
 export default function MtprotoFields() {
   const { t } = useTranslation();
   const form = Form.useFormInstance();
+  const routeThroughXray = Form.useWatch(['settings', 'routeThroughXray'], form) as boolean | undefined;
+  const { data: outboundTags } = useOutboundTags();
   return (
     <>
       <Form.Item name={['settings', 'fakeTlsDomain']} label={t('pages.inbounds.form.fakeTlsDomain')}>
@@ -71,6 +74,28 @@ export default function MtprotoFields() {
       <Form.Item name={['settings', 'debug']} label={t('pages.inbounds.form.mtgDebug')} valuePropName="checked">
         <Switch />
       </Form.Item>
+      <Form.Item
+        name={['settings', 'routeThroughXray']}
+        label={t('pages.inbounds.form.mtgRouteThroughXray')}
+        tooltip={t('pages.inbounds.form.mtgRouteThroughXrayHint')}
+        valuePropName="checked"
+      >
+        <Switch />
+      </Form.Item>
+      {routeThroughXray && (
+        <Form.Item
+          name={['settings', 'outboundTag']}
+          label={t('pages.inbounds.form.mtgRouteOutbound')}
+          tooltip={t('pages.inbounds.form.mtgRouteOutboundHint')}
+        >
+          <Select
+            allowClear
+            showSearch
+            placeholder={t('pages.inbounds.form.mtgRouteOutboundPlaceholder')}
+            options={(outboundTags ?? []).map((tag) => ({ value: tag, label: tag }))}
+          />
+        </Form.Item>
+      )}
     </>
   );
 }

+ 71 - 0
frontend/src/pages/inbounds/form/transport/xhttp.tsx

@@ -3,6 +3,9 @@ import { Form, Input, InputNumber, Select, Switch, type FormInstance } from 'ant
 
 import { HeaderMapEditor } from '@/components/form';
 import type { InboundFormValues } from '@/schemas/forms/inbound-form';
+import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
+
+const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
 
 export default function XhttpForm({ form }: { form: FormInstance<InboundFormValues> }) {
   const { t } = useTranslation();
@@ -11,6 +14,15 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
   const xhttpSessionPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'sessionPlacement'], form);
   const xhttpSeqPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'seqPlacement'], form);
   const xhttpUplinkPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'uplinkDataPlacement'], form);
+
+  function onXmuxToggle(checked: boolean) {
+    if (!checked) return;
+    const existing = form.getFieldValue(['streamSettings', 'xhttpSettings', 'xmux']);
+    const hasValues = existing && typeof existing === 'object' && Object.keys(existing).length > 0;
+    if (hasValues) return;
+    form.setFieldValue(['streamSettings', 'xhttpSettings', 'xmux'], { ...XMUX_DEFAULTS });
+  }
+
   return (
     <>
       <Form.Item name={['streamSettings', 'xhttpSettings', 'host']} label={t('host')}>
@@ -213,6 +225,65 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
       >
         <Switch />
       </Form.Item>
+      {/* XMUX is the connection-multiplexing layer
+          xHTTP uses to fan out parallel requests over
+          a small pool of upstream connections. UI-only
+          toggle (enableXmux) hides the 6 nested knobs
+          when off. */}
+      <Form.Item
+        label="XMUX"
+        name={['streamSettings', 'xhttpSettings', 'enableXmux']}
+        valuePropName="checked"
+      >
+        <Switch onChange={onXmuxToggle} />
+      </Form.Item>
+      <Form.Item shouldUpdate noStyle>
+        {() => {
+          if (!form.getFieldValue([
+            'streamSettings', 'xhttpSettings', 'enableXmux',
+          ])) return null;
+          return (
+            <>
+              <Form.Item
+                label={t('pages.xray.outboundForm.maxConcurrency')}
+                name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConcurrency']}
+              >
+                <Input placeholder="16-32" />
+              </Form.Item>
+              <Form.Item
+                label={t('pages.xray.outboundForm.maxConnections')}
+                name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConnections']}
+              >
+                <Input placeholder="0" />
+              </Form.Item>
+              <Form.Item
+                label={t('pages.xray.outboundForm.maxReuseTimes')}
+                name={['streamSettings', 'xhttpSettings', 'xmux', 'cMaxReuseTimes']}
+              >
+                <Input />
+              </Form.Item>
+              <Form.Item
+                label={t('pages.xray.outboundForm.maxRequestTimes')}
+                name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxRequestTimes']}
+              >
+                <Input placeholder="600-900" />
+              </Form.Item>
+              <Form.Item
+                label={t('pages.xray.outboundForm.maxReusableSecs')}
+                name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxReusableSecs']}
+              >
+                <Input placeholder="1800-3000" />
+              </Form.Item>
+              <Form.Item
+                label={t('pages.xray.outboundForm.keepAlivePeriod')}
+                name={['streamSettings', 'xhttpSettings', 'xmux', 'hKeepAlivePeriod']}
+              >
+                <InputNumber min={0} style={{ width: '100%' }} />
+              </Form.Item>
+            </>
+          );
+        }}
+      </Form.Item>
     </>
   );
 }

+ 6 - 0
frontend/src/pages/inbounds/list/InboundList.tsx

@@ -93,6 +93,11 @@ export default function InboundList({
     [dbInbounds],
   );
 
+  const hasAnySubSortIndex = useMemo(
+    () => dbInbounds.some((i) => (i.subSortIndex ?? 1) > 1),
+    [dbInbounds],
+  );
+
   const toggleSelect = useCallback((id: number, checked: boolean) => {
     setSelectedRowKeys((prev) => {
       const next = new Set(prev);
@@ -115,6 +120,7 @@ export default function InboundList({
 
   const columns = useInboundColumns({
     hasAnyRemark,
+    hasAnySubSortIndex,
     hasActiveNode,
     nodesById,
     clientCount,

+ 2 - 2
frontend/src/pages/inbounds/list/RowActions.tsx

@@ -69,7 +69,7 @@ export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowAc
   const { t } = useTranslation();
   return (
     <div className="action-buttons">
-      <Button type="text" size="small" icon={<EditOutlined />} onClick={() => onClick('edit')} />
+      <Button type="text" size="small" style={{ fontSize: 18 }} icon={<EditOutlined />} onClick={() => onClick('edit')} />
       <Dropdown
         trigger={['click']}
         menu={{
@@ -77,7 +77,7 @@ export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowAc
           onClick: ({ key }) => onClick(key as RowAction),
         }}
       >
-        <Button type="text" size="small" icon={<MoreOutlined />} />
+        <Button type="text" size="small" style={{ fontSize: 18 }} icon={<MoreOutlined />} />
       </Dropdown>
     </div>
   );

+ 1 - 0
frontend/src/pages/inbounds/list/types.ts

@@ -22,6 +22,7 @@ export interface DBInboundRecord extends ProtocolFlags {
   id: number;
   enable: boolean;
   remark: string;
+  subSortIndex: number;
   port: number;
   protocol: string;
   up: number;

+ 18 - 2
frontend/src/pages/inbounds/list/useInboundColumns.tsx

@@ -1,6 +1,6 @@
 import { useMemo, type ReactElement } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Popover, Switch, Tag, type TableColumnType } from 'antd';
+import { Popover, Switch, Tag, Tooltip, type TableColumnType } from 'antd';
 import { TeamOutlined } from '@ant-design/icons';
 
 import { SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
@@ -21,6 +21,7 @@ import type { ClientCountEntry, DBInboundRecord, RowAction } from './types';
 
 interface UseInboundColumnsParams {
   hasAnyRemark: boolean;
+  hasAnySubSortIndex: boolean;
   hasActiveNode: boolean;
   nodesById: Map<number, NodeRecord>;
   clientCount: Record<number, ClientCountEntry>;
@@ -33,6 +34,7 @@ interface UseInboundColumnsParams {
 
 export function useInboundColumns({
   hasAnyRemark,
+  hasAnySubSortIndex,
   hasActiveNode,
   nodesById,
   clientCount,
@@ -113,6 +115,20 @@ export function useInboundColumns({
       });
     }
 
+    if (hasAnySubSortIndex) {
+      cols.push({
+        title: (
+          <Tooltip title={t('pages.inbounds.form.subSortIndex')}>
+            {t('pages.inbounds.subSortIndex')}
+          </Tooltip>
+        ),
+        dataIndex: 'subSortIndex',
+        key: 'subSortIndex',
+        align: 'right',
+        width: 70,
+      });
+    }
+
     cols.push(
       {
         title: t('pages.inbounds.port'),
@@ -267,5 +283,5 @@ export function useInboundColumns({
     );
 
     return cols;
-  }, [t, hasAnyRemark, hasActiveNode, nodesById, clientCount, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable]);
+  }, [t, hasAnyRemark, hasAnySubSortIndex, hasActiveNode, nodesById, clientCount, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable]);
 }

+ 4 - 4
frontend/src/pages/nodes/NodeList.tsx

@@ -241,18 +241,18 @@ export default function NodeList({
       ) : (
         <Space>
           <Tooltip title={t('pages.nodes.probe')}>
-            <Button type="text" size="small" icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
+            <Button type="text" size="small" style={{ fontSize: 18 }} icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
           </Tooltip>
           {isUpdateEligible(record) && (
             <Tooltip title={t('pages.nodes.updatePanel')}>
-              <Button type="text" size="small" icon={<CloudDownloadOutlined />} onClick={() => onUpdateNode(record)} />
+              <Button type="text" size="small" style={{ fontSize: 18 }} icon={<CloudDownloadOutlined />} onClick={() => onUpdateNode(record)} />
             </Tooltip>
           )}
           <Tooltip title={t('edit')}>
-            <Button type="text" size="small" icon={<EditOutlined />} onClick={() => onEdit(record)} />
+            <Button type="text" size="small" style={{ fontSize: 18 }} icon={<EditOutlined />} onClick={() => onEdit(record)} />
           </Tooltip>
           <Tooltip title={t('delete')}>
-            <Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
+            <Button type="text" size="small" danger style={{ fontSize: 18 }} icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
           </Tooltip>
         </Space>
       ),

+ 5 - 4
frontend/src/pages/settings/GeneralTab.tsx

@@ -210,6 +210,11 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
                 onChange={(v) => updateSetting({ pageSize: Number(v) || 0 })} />
             </SettingListItem>
 
+            <SettingListItem paddings="small" title={t('pages.settings.restartXrayOnClientDisable')} description={t('pages.settings.restartXrayOnClientDisableDesc')}>
+              <Switch checked={allSetting.restartXrayOnClientDisable}
+                onChange={(v) => updateSetting({ restartXrayOnClientDisable: v })} />
+            </SettingListItem>
+
             <SettingListItem paddings="small" title={t('pages.settings.language')}>
               <Select
                 value={lang}
@@ -267,10 +272,6 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
                 onChange={(e) => updateSetting({ externalTrafficInformURI: e.target.value })}
               />
             </SettingListItem>
-            <SettingListItem paddings="small" title={t('pages.settings.restartXrayOnClientDisable')} description={t('pages.settings.restartXrayOnClientDisableDesc')}>
-              <Switch checked={allSetting.restartXrayOnClientDisable}
-                onChange={(v) => updateSetting({ restartXrayOnClientDisable: v })} />
-            </SettingListItem>
           </>
         ),
       },

+ 58 - 36
frontend/src/pages/settings/SubscriptionGeneralTab.tsx

@@ -1,6 +1,6 @@
 import { useMemo } from 'react';
-import { Divider, Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
-import { ClockCircleOutlined, InfoCircleOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons';
+import { Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
+import { BranchesOutlined, IdcardOutlined, InfoCircleOutlined, NodeIndexOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons';
 import { useTranslation } from 'react-i18next';
 import type { AllSetting } from '@/models/setting';
 import { SettingListItem } from '@/components/ui';
@@ -139,8 +139,18 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
               </Space.Compact>
             </SettingListItem>
 
-            <Divider>{t('pages.settings.subTitle')}</Divider>
-
+            <SettingListItem paddings="small" title={t('pages.settings.subUpdates')} description={t('pages.settings.subUpdatesDesc')}>
+              <InputNumber value={allSetting.subUpdates} min={1} style={{ width: '100%' }}
+                onChange={(v) => updateSetting({ subUpdates: Number(v) || 0 })} />
+            </SettingListItem>
+          </>
+        ),
+      },
+      {
+        key: '3',
+        label: catTabLabel(<IdcardOutlined />, t('pages.settings.profile'), isMobile),
+        children: (
+          <>
             <SettingListItem paddings="small" title={t('pages.settings.subTitle')} description={t('pages.settings.subTitleDesc')}>
               <Input value={allSetting.subTitle} onChange={(e) => updateSetting({ subTitle: e.target.value })} />
             </SettingListItem>
@@ -156,40 +166,30 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
               <Input.TextArea value={allSetting.subAnnounce}
                 onChange={(e) => updateSetting({ subAnnounce: e.target.value })} />
             </SettingListItem>
-
-            <SettingListItem paddings="small" title={t('pages.settings.subThemeDir')} description={t('pages.settings.subThemeDirDesc')}>
+            <SettingListItem
+              paddings="small"
+              title={t('pages.settings.subThemeDir')}
+              description={(
+                <>
+                  {t('pages.settings.subThemeDirDesc')}{' '}
+                  <a
+                    href="https://github.com/MHSanaei/3x-ui/blob/main/docs/custom-subscription-templates.md"
+                    target="_blank"
+                    rel="noopener noreferrer"
+                  >
+                    {t('pages.settings.subThemeDirDocs')}
+                  </a>
+                </>
+              )}
+            >
               <Input value={allSetting.subThemeDir} placeholder="/etc/3x-ui/sub_templates/my-theme/"
                 onChange={(e) => updateSetting({ subThemeDir: e.target.value })} />
             </SettingListItem>
-
-            <Divider>Happ</Divider>
-
-            <SettingListItem paddings="small" title={t('pages.settings.subEnableRouting')} description={t('pages.settings.subEnableRoutingDesc')}>
-              <Switch checked={allSetting.subEnableRouting} onChange={(v) => updateSetting({ subEnableRouting: v })} />
-            </SettingListItem>
-            <SettingListItem paddings="small" title={t('pages.settings.subRoutingRules')} description={t('pages.settings.subRoutingRulesDesc')}>
-              <Input.TextArea value={allSetting.subRoutingRules} placeholder="happ://routing/add/..."
-                onChange={(e) => updateSetting({ subRoutingRules: e.target.value })} />
-            </SettingListItem>
-
-            <Divider>Clash / Mihomo</Divider>
-
-            <SettingListItem paddings="small" title={t('pages.settings.subClashEnableRouting')} description={t('pages.settings.subClashEnableRoutingDesc')}>
-              <Switch checked={allSetting.subClashEnableRouting} onChange={(v) => updateSetting({ subClashEnableRouting: v })} />
-            </SettingListItem>
-            <SettingListItem paddings="small" title={t('pages.settings.subClashRoutingRules')} description={t('pages.settings.subClashRoutingRulesDesc')}>
-              <Input.TextArea
-                value={allSetting.subClashRules}
-                rows={8}
-                placeholder={'GEOSITE,category-ir,DIRECT\nGEOIP,private,DIRECT'}
-                onChange={(e) => updateSetting({ subClashRules: e.target.value })}
-              />
-            </SettingListItem>
           </>
         ),
       },
       {
-        key: '3',
+        key: '4',
         label: catTabLabel(<SafetyCertificateOutlined />, t('pages.settings.certs'), isMobile),
         children: (
           <>
@@ -203,13 +203,35 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
         ),
       },
       {
-        key: '4',
-        label: catTabLabel(<ClockCircleOutlined />, t('pages.settings.intervals'), isMobile),
+        key: '5',
+        label: catTabLabel(<BranchesOutlined />, 'Happ', isMobile),
         children: (
           <>
-            <SettingListItem paddings="small" title={t('pages.settings.subUpdates')} description={t('pages.settings.subUpdatesDesc')}>
-              <InputNumber value={allSetting.subUpdates} min={1} style={{ width: '100%' }}
-                onChange={(v) => updateSetting({ subUpdates: Number(v) || 0 })} />
+            <SettingListItem paddings="small" title={t('pages.settings.subEnableRouting')} description={t('pages.settings.subEnableRoutingDesc')}>
+              <Switch checked={allSetting.subEnableRouting} onChange={(v) => updateSetting({ subEnableRouting: v })} />
+            </SettingListItem>
+            <SettingListItem paddings="small" title={t('pages.settings.subRoutingRules')} description={t('pages.settings.subRoutingRulesDesc')}>
+              <Input.TextArea value={allSetting.subRoutingRules} placeholder="happ://routing/add/..."
+                onChange={(e) => updateSetting({ subRoutingRules: e.target.value })} />
+            </SettingListItem>
+          </>
+        ),
+      },
+      {
+        key: '6',
+        label: catTabLabel(<NodeIndexOutlined />, 'Clash / Mihomo', isMobile),
+        children: (
+          <>
+            <SettingListItem paddings="small" title={t('pages.settings.subClashEnableRouting')} description={t('pages.settings.subClashEnableRoutingDesc')}>
+              <Switch checked={allSetting.subClashEnableRouting} onChange={(v) => updateSetting({ subClashEnableRouting: v })} />
+            </SettingListItem>
+            <SettingListItem paddings="small" title={t('pages.settings.subClashRoutingRules')} description={t('pages.settings.subClashRoutingRulesDesc')}>
+              <Input.TextArea
+                value={allSetting.subClashRules}
+                rows={8}
+                placeholder={'GEOSITE,category-ir,DIRECT\nGEOIP,private,DIRECT'}
+                onChange={(e) => updateSetting({ subClashRules: e.target.value })}
+              />
             </SettingListItem>
           </>
         ),

+ 131 - 3
frontend/src/pages/settings/TelegramTab.tsx

@@ -1,6 +1,6 @@
-import { useMemo } from 'react';
+import { useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Input, InputNumber, Select, Switch, Tabs } from 'antd';
+import { Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
 import { BellOutlined, SettingOutlined } from '@ant-design/icons';
 import { LanguageManager } from '@/utils';
 import type { AllSetting } from '@/models/setting';
@@ -13,6 +13,134 @@ interface TelegramTabProps {
   updateSetting: (patch: Partial<AllSetting>) => void;
 }
 
+// The notification schedule is fed straight to robfig/cron's AddJob (see
+// web.go startTask), which accepts @every <duration>, the @hourly/@daily/...
+// macros, and full crontab expressions. This builder covers the common cases
+// with dropdowns so users don't have to memorise the syntax, while "Custom"
+// preserves the raw crontab escape hatch.
+type Unit = 's' | 'm' | 'h';
+type Macro = '@hourly' | '@daily' | '@weekly' | '@monthly';
+type Mode = 'every' | Macro | 'custom';
+const MACROS: Macro[] = ['@hourly', '@daily', '@weekly', '@monthly'];
+const EVERY_RE = /^@every\s+(\d+)\s*([smh])$/i;
+
+interface RunTime {
+  mode: Mode;
+  num: number;
+  unit: Unit;
+  custom: string;
+}
+
+function parseRunTime(raw: string): RunTime {
+  const v = (raw ?? '').trim();
+  const m = v.match(EVERY_RE);
+  if (m) {
+    return { mode: 'every', num: Math.max(1, Number(m[1]) || 1), unit: m[2].toLowerCase() as Unit, custom: '' };
+  }
+  if ((MACROS as string[]).includes(v)) {
+    return { mode: v as Macro, num: 1, unit: 'h', custom: '' };
+  }
+  return { mode: 'custom', num: 1, unit: 'h', custom: v };
+}
+
+function composeRunTime(s: RunTime): string {
+  if (s.mode === 'every') return `@every ${Math.max(1, s.num || 1)}${s.unit}`;
+  if (s.mode === 'custom') return s.custom;
+  return s.mode;
+}
+
+// The panel's cron runs with seconds enabled (cron.WithSeconds() in web.go), so
+// crontab expressions are 6-field: "second minute hour day month weekday". When
+// the user drops into Custom we seed the box with the crontab equivalent of the
+// current selection rather than a bare @macro, so they get a real expression to
+// edit (and one that the 6-field parser accepts).
+function toCrontab(s: RunTime): string {
+  switch (s.mode) {
+    case '@hourly': return '0 0 * * * *';
+    case '@daily': return '0 0 0 * * *';
+    case '@weekly': return '0 0 0 * * 0';
+    case '@monthly': return '0 0 0 1 * *';
+    case 'every': {
+      const n = Math.max(1, s.num || 1);
+      if (s.unit === 's') return `*/${n} * * * * *`;
+      if (s.unit === 'm') return `0 */${n} * * * *`;
+      return `0 0 */${n} * * *`;
+    }
+    default: return s.custom;
+  }
+}
+
+function NotifyTimeField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
+  const { t } = useTranslation();
+  // Init once: the Settings tabs only mount after settings are fetched, so the
+  // incoming value is already the persisted one.
+  const [state, setState] = useState<RunTime>(() => parseRunTime(value));
+
+  function update(patch: Partial<RunTime>) {
+    const next = { ...state, ...patch };
+    setState(next);
+    onChange(composeRunTime(next));
+  }
+
+  function onModeChange(mode: Mode) {
+    // Seed Custom with the crontab equivalent of the current selection so the
+    // box starts from a real expression (e.g. "0 0 0 * * *", not "@daily").
+    if (mode === 'custom' && !state.custom.trim()) {
+      update({ mode, custom: toCrontab(state) });
+    } else {
+      update({ mode });
+    }
+  }
+
+  const modeOptions = [
+    { value: 'every', label: t('pages.settings.notifyTime.every') },
+    { value: '@hourly', label: t('pages.settings.notifyTime.hourly') },
+    { value: '@daily', label: t('pages.settings.notifyTime.daily') },
+    { value: '@weekly', label: t('pages.settings.notifyTime.weekly') },
+    { value: '@monthly', label: t('pages.settings.notifyTime.monthly') },
+    { value: 'custom', label: t('pages.settings.notifyTime.custom') },
+  ];
+  const unitOptions = [
+    { value: 's', label: t('pages.settings.notifyTime.seconds') },
+    { value: 'm', label: t('pages.settings.notifyTime.minutes') },
+    { value: 'h', label: t('pages.settings.notifyTime.hours') },
+  ];
+
+  return (
+    <Space direction="vertical" size="small" style={{ width: '100%' }}>
+      <Select<Mode>
+        style={{ width: '100%' }}
+        value={state.mode}
+        options={modeOptions}
+        onChange={onModeChange}
+      />
+      {state.mode === 'every' && (
+        <Space.Compact style={{ width: '100%' }}>
+          <InputNumber
+            min={1}
+            style={{ width: '50%' }}
+            value={state.num}
+            onChange={(v) => update({ num: Math.max(1, Number(v) || 1) })}
+          />
+          <Select<Unit>
+            style={{ width: '50%' }}
+            value={state.unit}
+            options={unitOptions}
+            onChange={(unit) => update({ unit })}
+          />
+        </Space.Compact>
+      )}
+      {state.mode === 'custom' && (
+        <Input
+          value={state.custom}
+          placeholder="0 30 8 * * *"
+          onChange={(e) => update({ custom: e.target.value })}
+        />
+      )}
+    </Space>
+  );
+}
+
 export default function TelegramTab({ allSetting, updateSetting }: TelegramTabProps) {
   const { t } = useTranslation();
   const { isMobile } = useMediaQuery();
@@ -79,7 +207,7 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
         children: (
           <>
             <SettingListItem paddings="small" title={t('pages.settings.telegramNotifyTime')} description={t('pages.settings.telegramNotifyTimeDesc')}>
-              <Input value={allSetting.tgRunTime} onChange={(e) => updateSetting({ tgRunTime: e.target.value })} />
+              <NotifyTimeField value={allSetting.tgRunTime} onChange={(v) => updateSetting({ tgRunTime: v })} />
             </SettingListItem>
             <SettingListItem paddings="small" title={t('pages.settings.tgNotifyBackup')} description={t('pages.settings.tgNotifyBackupDesc')}>
               <Switch checked={allSetting.tgBotBackup} onChange={(v) => updateSetting({ tgBotBackup: v })} />

+ 2 - 6
frontend/src/pages/xray/outbounds/OutboundCardList.tsx

@@ -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}&nbsp;ms</span> : <span>failed</span>}
-                </span>
+                <TestResultPopover result={testResult(outboundTestStates, index)!} />
               ) : isTesting(outboundTestStates, index) ? (
                 <LoadingOutlined />
               ) : null}

+ 19 - 0
frontend/src/pages/xray/outbounds/OutboundsTab.css

@@ -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;
 }

+ 4 - 38
frontend/src/pages/xray/outbounds/SubscriptionOutbounds.tsx

@@ -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}&nbsp;ms</span> : <span>failed</span>}
-        </span>
-      </Popover>
-    );
+    return <TestResultPopover result={r} />;
   };
 
   const testButton = (record: OutboundRow) => {

+ 68 - 0
frontend/src/pages/xray/outbounds/TestResultPopover.tsx

@@ -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}&nbsp;ms</span> : <span>failed</span>}
+        </span>
+      )}
+    </Popover>
+  );
+}

+ 0 - 6
frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts

@@ -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 };

+ 3 - 34
frontend/src/pages/xray/outbounds/useOutboundColumns.tsx

@@ -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}&nbsp;ms</span> : <span>failed</span>}
-              </span>
-            </Popover>
-          );
+          return <TestResultPopover result={r} />;
         },
       },
       {

+ 2 - 0
frontend/src/schemas/client.ts

@@ -129,6 +129,8 @@ export const GroupSummarySchema = z.object({
   name: z.string(),
   clientCount: z.number(),
   trafficUsed: z.number().nullable().transform((v) => v ?? 0),
+  up: z.number().nullable().transform((v) => v ?? 0),
+  down: z.number().nullable().transform((v) => v ?? 0),
 });
 
 export const GroupSummaryListSchema = z.array(GroupSummarySchema).nullable().transform((v) => v ?? []);

+ 1 - 0
frontend/src/schemas/forms/inbound-form.ts

@@ -39,6 +39,7 @@ export const InboundDbFieldsSchema = z.object({
   nodeId: z.number().int().nullable().optional(),
   shareAddrStrategy: ShareAddrStrategySchema.default('node'),
   shareAddr: z.string().default(''),
+  subSortIndex: z.number().int().min(1).default(1),
 });
 export type InboundDbFields = z.infer<typeof InboundDbFieldsSchema>;
 

+ 7 - 0
frontend/src/schemas/protocols/inbound/mtproto.ts

@@ -22,5 +22,12 @@ export const MtprotoInboundSettingsSchema = z.object({
   preferIp: z.enum(['prefer-ipv6', 'prefer-ipv4', 'only-ipv6', 'only-ipv4']).optional(),
   debug: z.boolean().optional(),
   domainFronting: MtprotoDomainFrontingSchema.optional(),
+  // When set, the mtg sidecar dials Telegram through a loopback SOCKS bridge in
+  // the Xray config so the egress obeys routing rules. `outboundTag` optionally
+  // forces that traffic out a specific outbound/balancer. `routeXrayPort` is the
+  // bridge port; it is allocated and owned by the backend (never edited here).
+  routeThroughXray: z.boolean().optional(),
+  outboundTag: z.string().optional(),
+  routeXrayPort: z.number().int().min(0).max(65535).optional(),
 });
 export type MtprotoInboundSettings = z.infer<typeof MtprotoInboundSettingsSchema>;

+ 11 - 0
frontend/src/schemas/xray.ts

@@ -56,10 +56,18 @@ export const OutboundTrafficRowSchema = z.object({
 export const OutboundTrafficListSchema = z.array(OutboundTrafficRowSchema);
 
 export const OutboundTestResultSchema = z.object({
+  tag: z.string().optional(),
   success: z.boolean(),
   delay: z.number().optional(),
   error: z.string().optional(),
   mode: z.string().optional(),
+  // HTTP-mode extras: status answered by the test URL plus the httptrace
+  // timing breakdown (dial to local inbound / target TLS via the outbound /
+  // time to first byte).
+  httpStatus: z.number().optional(),
+  connectMs: z.number().optional(),
+  tlsMs: z.number().optional(),
+  ttfbMs: z.number().optional(),
   endpoints: z
     .array(
       z.object({
@@ -72,6 +80,9 @@ export const OutboundTestResultSchema = z.object({
     .optional(),
 }).loose();
 
+// Batch results from /xray/testOutbounds, aligned with the request order.
+export const OutboundTestResultListSchema = z.array(OutboundTestResultSchema);
+
 export const RuleFormSchema = z.object({
   domain: z.string(),
   ip: z.string(),

+ 1 - 0
frontend/src/test/__snapshots__/inbound-form-blocks.test.tsx.snap

@@ -130,5 +130,6 @@ exports[`inbound transport forms > XhttpForm field structure is stable 1`] = `
   "Session Placement",
   "Sequence Placement",
   "No SSE Header",
+  "XMUX",
 ]
 `;

+ 10 - 0
frontend/src/test/__snapshots__/inbound-form-modal.test.tsx.snap

@@ -7,6 +7,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > http
   "Protocol",
   "Address",
   "Share address strategy",
+  "Subscription sort order",
   "Port",
   "Total Flow",
   "Traffic Reset",
@@ -22,6 +23,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > hyste
   "Protocol",
   "Address",
   "Share address strategy",
+  "Subscription sort order",
   "Port",
   "Total Flow",
   "Traffic Reset",
@@ -37,6 +39,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > mixed
   "Protocol",
   "Address",
   "Share address strategy",
+  "Subscription sort order",
   "Port",
   "Total Flow",
   "Traffic Reset",
@@ -52,6 +55,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > shado
   "Protocol",
   "Address",
   "Share address strategy",
+  "Subscription sort order",
   "Port",
   "Total Flow",
   "Traffic Reset",
@@ -67,6 +71,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > troja
   "Protocol",
   "Address",
   "Share address strategy",
+  "Subscription sort order",
   "Port",
   "Total Flow",
   "Traffic Reset",
@@ -82,6 +87,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > tun 1
   "Protocol",
   "Address",
   "Share address strategy",
+  "Subscription sort order",
   "Port",
   "Total Flow",
   "Traffic Reset",
@@ -97,6 +103,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > tunne
   "Protocol",
   "Address",
   "Share address strategy",
+  "Subscription sort order",
   "Port",
   "Total Flow",
   "Traffic Reset",
@@ -112,6 +119,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > vless
   "Protocol",
   "Address",
   "Share address strategy",
+  "Subscription sort order",
   "Port",
   "Total Flow",
   "Traffic Reset",
@@ -127,6 +135,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > vmess
   "Protocol",
   "Address",
   "Share address strategy",
+  "Subscription sort order",
   "Port",
   "Total Flow",
   "Traffic Reset",
@@ -142,6 +151,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > wireg
   "Protocol",
   "Address",
   "Share address strategy",
+  "Subscription sort order",
   "Port",
   "Total Flow",
   "Traffic Reset",

+ 33 - 1
frontend/src/test/inbound-form-adapter.test.ts

@@ -6,7 +6,7 @@ import {
   formValuesToWirePayload,
   type RawInboundRow,
 } from '@/lib/xray/inbound-form-adapter';
-import { InboundFormSchema } from '@/schemas/forms/inbound-form';
+import { InboundDbFieldsSchema, InboundFormSchema } from '@/schemas/forms/inbound-form';
 import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
 
 // Round-trip: raw DB row → InboundFormValues → wire payload, asserting
@@ -262,3 +262,35 @@ describe('formValuesToWirePayload', () => {
     expect(replay.streamSettings).toEqual(original.streamSettings);
   });
 });
+
+describe('subSortIndex', () => {
+  it('rawInboundToFormValues defaults to 1 when field is absent', () => {
+    const values = rawInboundToFormValues({ ...vlessRow, subSortIndex: undefined });
+    expect(values.subSortIndex).toBe(1);
+  });
+
+  it('rawInboundToFormValues preserves valid values and clamps below-minimum ones to 1', () => {
+    expect(rawInboundToFormValues({ ...vlessRow, subSortIndex: 5 }).subSortIndex).toBe(5);
+    expect(rawInboundToFormValues({ ...vlessRow, subSortIndex: 0 }).subSortIndex).toBe(1);
+    expect(rawInboundToFormValues({ ...vlessRow, subSortIndex: -10 }).subSortIndex).toBe(1);
+  });
+
+  it('formValuesToWirePayload includes subSortIndex in the payload', () => {
+    const values = rawInboundToFormValues({ ...vlessRow, subSortIndex: 3 });
+    const payload = formValuesToWirePayload(values);
+    expect(payload.subSortIndex).toBe(3);
+  });
+
+  it('subSortIndex round-trips through raw → values → payload', () => {
+    const values = rawInboundToFormValues({ ...vlessRow, subSortIndex: 42 });
+    const payload = formValuesToWirePayload(values);
+    const replay = rawInboundToFormValues({ ...vlessRow, subSortIndex: payload.subSortIndex });
+    expect(replay.subSortIndex).toBe(42);
+  });
+
+  it('InboundDbFieldsSchema enforces an integer minimum of 1 and defaults to 1', () => {
+    expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 1.5 }).success).toBe(false);
+    expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 0 }).success).toBe(false);
+    expect(InboundDbFieldsSchema.parse({}).subSortIndex).toBe(1);
+  });
+});

+ 37 - 0
frontend/src/test/inbound-form-modal.test.tsx

@@ -1,6 +1,8 @@
 import { describe, it, expect } from 'vitest';
+import { screen } from '@testing-library/react';
 
 import InboundFormModal from '@/pages/inbounds/form/InboundFormModal';
+import { DBInbound } from '@/models/dbinbound';
 import {
   renderWithProviders,
   fieldLabels,
@@ -38,4 +40,39 @@ describe('InboundFormModal', () => {
       expect(fieldLabels()).toMatchSnapshot(proto);
     }
   });
+
+  it('preserves custom share address strategy when editing a local inbound', async () => {
+    renderWithProviders(
+      <InboundFormModal
+        open
+        mode="edit"
+        dbInbound={new DBInbound({
+          id: 1,
+          port: 12345,
+          listen: '',
+          protocol: 'shadowsocks',
+          remark: 'edge',
+          enable: true,
+          settings: {
+            method: '2022-blake3-aes-128-gcm',
+            password: 'server-password',
+            network: 'tcp,udp',
+            clients: [],
+          },
+          streamSettings: { network: 'tcp', security: 'none', tcpSettings: {} },
+          sniffing: { enabled: false },
+          nodeId: null,
+          shareAddrStrategy: 'custom',
+          shareAddr: 'edge.example.test',
+        })}
+        dbInbounds={[]}
+        availableNodes={[]}
+        onClose={() => {}}
+        onSaved={() => {}}
+      />,
+    );
+
+    const shareAddrInput = await screen.findByDisplayValue('edge.example.test');
+    expect((shareAddrInput as HTMLInputElement).value).toBe('edge.example.test');
+  });
 });

+ 6 - 2
frontend/src/test/outbound-form-adapter.test.ts

@@ -399,11 +399,15 @@ describe('outbound-form-adapter: xhttp xmux toggle', () => {
     });
   });
 
-  it('round-trips xmux on save and strips the UI-only enableXmux flag', () => {
+  it('round-trips xmux on save, strips enableXmux, and enforces xmux exclusivity', () => {
     const back = formValuesToWirePayload(rawOutboundToFormValues(xmuxWire));
     const xhttp = (back.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
     expect(xhttp).not.toHaveProperty('enableXmux');
-    expect(xhttp.xmux).toMatchObject({ maxConcurrency: '11', maxConnections: '1' });
+    const xmux = xhttp.xmux as Record<string, unknown>;
+    // xray-core rejects maxConnections + maxConcurrency together; the
+    // explicit maxConnections wins and maxConcurrency is dropped.
+    expect(xmux).not.toHaveProperty('maxConcurrency');
+    expect(xmux).toMatchObject({ maxConnections: '1', hMaxRequestTimes: '1', hMaxReusableSecs: '1' });
   });
 
   it('drops xmux on save when the toggle is off', () => {

+ 63 - 0
frontend/src/test/stream-wire-normalize.test.ts

@@ -65,6 +65,69 @@ describe('normalizeXhttpForWire stream-one', () => {
     expect(out.xmux).toEqual({ maxConcurrency: '16-32' });
     expect(out).not.toHaveProperty('scMaxEachPostBytes');
   });
+
+  it('keeps inbound xmux when enableXmux is on (for the share-link extra)', () => {
+    const out = normalizeXhttpForWire({
+      path: '/app',
+      mode: 'auto',
+      enableXmux: true,
+      xmux: { maxConcurrency: '16-32' },
+    }, 'inbound');
+
+    expect(out).not.toHaveProperty('enableXmux');
+    expect(out.xmux).toEqual({ maxConcurrency: '16-32' });
+  });
+
+  it('drops inbound xmux when enableXmux is off', () => {
+    const out = normalizeXhttpForWire({
+      path: '/app',
+      mode: 'auto',
+      enableXmux: false,
+      xmux: { maxConcurrency: '16-32' },
+    }, 'inbound');
+
+    expect(out).not.toHaveProperty('enableXmux');
+    expect(out).not.toHaveProperty('xmux');
+  });
+
+  // xray-core rejects a config with both maxConnections and maxConcurrency.
+  it('drops maxConcurrency when maxConnections is set (xray-core exclusivity)', () => {
+    const out = normalizeXhttpForWire({
+      path: '/app',
+      mode: 'auto',
+      enableXmux: true,
+      xmux: { maxConcurrency: '16-32', maxConnections: 4, hKeepAlivePeriod: 30 },
+    }, 'inbound');
+
+    const xmux = out.xmux as Record<string, unknown>;
+    expect(xmux).not.toHaveProperty('maxConcurrency');
+    expect(xmux.maxConnections).toBe(4);
+    expect(xmux.hKeepAlivePeriod).toBe(30);
+  });
+
+  it('keeps maxConcurrency when maxConnections is 0/unset', () => {
+    const out = normalizeXhttpForWire({
+      path: '/app',
+      mode: 'stream-one',
+      xmux: { maxConcurrency: '16-32', maxConnections: 0 },
+    }, 'outbound');
+
+    const xmux = out.xmux as Record<string, unknown>;
+    expect(xmux.maxConcurrency).toBe('16-32');
+    expect(xmux.maxConnections).toBe(0);
+  });
+
+  it('applies xmux exclusivity on the outbound side too', () => {
+    const out = normalizeXhttpForWire({
+      path: '/app',
+      mode: 'stream-one',
+      xmux: { maxConcurrency: '16-32', maxConnections: '8' },
+    }, 'outbound');
+
+    const xmux = out.xmux as Record<string, unknown>;
+    expect(xmux).not.toHaveProperty('maxConcurrency');
+    expect(xmux.maxConnections).toBe('8');
+  });
 });
 
 describe('normalizeSockoptForWire', () => {

+ 18 - 0
internal/database/db.go

@@ -91,6 +91,9 @@ func initModels() error {
 	if err := pruneOrphanedClientInbounds(); err != nil {
 		return err
 	}
+	if err := normalizeInboundSubSortIndex(); err != nil {
+		return err
+	}
 	if IsPostgres() {
 		if err := resyncPostgresSequences(db, models); err != nil {
 			log.Printf("Error resyncing postgres sequences: %v", err)
@@ -123,6 +126,21 @@ func pruneOrphanedClientInbounds() error {
 	return nil
 }
 
+// normalizeInboundSubSortIndex lifts sub_sort_index values below the 1-based
+// minimum (rows written by builds that defaulted the column to 0, or by nodes
+// predating the field) so they cannot sort ahead of explicitly ranked inbounds.
+func normalizeInboundSubSortIndex() error {
+	res := db.Exec("UPDATE inbounds SET sub_sort_index = 1 WHERE sub_sort_index < 1")
+	if res.Error != nil {
+		log.Printf("Error normalizing inbound sub_sort_index: %v", res.Error)
+		return res.Error
+	}
+	if res.RowsAffected > 0 {
+		log.Printf("Normalized sub_sort_index on %d inbound(s)", res.RowsAffected)
+	}
+	return nil
+}
+
 func isIgnorableDuplicateColumnErr(err error, mdl any) bool {
 	if err == nil {
 		return false

+ 1 - 0
internal/database/model/model.go

@@ -50,6 +50,7 @@ type Inbound struct {
 	Down                 int64                `json:"down" form:"down"`                                                                                                                                             // Download traffic in bytes
 	Total                int64                `json:"total" form:"total"`                                                                                                                                           // Total traffic limit in bytes
 	Remark               string               `json:"remark" form:"remark" example:"VLESS-443"`                                                                                                                     // Human-readable remark
+	SubSortIndex         int                  `json:"subSortIndex" form:"subSortIndex" gorm:"default:1" validate:"omitempty,gte=1" example:"1"`                                                                     // 1-based sort order of this inbound's links in subscription output only (lower first; ties by id)
 	Enable               bool                 `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1" example:"true"`                                                                         // Whether the inbound is enabled
 	ExpiryTime           int64                `json:"expiryTime" form:"expiryTime"`                                                                                                                                 // Expiration timestamp
 	TrafficReset         string               `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule

+ 23 - 2
internal/mtproto/manager.go

@@ -32,6 +32,12 @@ type Instance struct {
 	FrontingIP            string
 	FrontingPort          int
 	FrontingProxyProtocol bool
+
+	// When RouteThroughXray is set, mtg dials Telegram through the loopback
+	// SOCKS bridge the panel injects into the Xray config at XrayRoutePort, so
+	// the egress obeys the core's routing rules instead of going out directly.
+	RouteThroughXray bool
+	XrayRoutePort    int
 }
 
 func (inst Instance) bindTo() string {
@@ -54,6 +60,8 @@ func (inst Instance) fingerprint() string {
 		inst.FrontingIP,
 		strconv.Itoa(inst.FrontingPort),
 		strconv.FormatBool(inst.FrontingProxyProtocol),
+		strconv.FormatBool(inst.RouteThroughXray),
+		strconv.Itoa(inst.XrayRoutePort),
 	}, "|")
 }
 
@@ -117,6 +125,8 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
 			Port          int    `json:"port"`
 			ProxyProtocol bool   `json:"proxyProtocol"`
 		} `json:"domainFronting"`
+		RouteThroughXray bool `json:"routeThroughXray"`
+		RouteXrayPort    int  `json:"routeXrayPort"`
 	}
 	if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
 		return Instance{}, false
@@ -136,6 +146,8 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
 		FrontingIP:            parsed.DomainFronting.IP,
 		FrontingPort:          parsed.DomainFronting.Port,
 		FrontingProxyProtocol: parsed.DomainFronting.ProxyProtocol,
+		RouteThroughXray:      parsed.RouteThroughXray,
+		XrayRoutePort:         parsed.RouteXrayPort,
 	}, true
 }
 
@@ -172,7 +184,7 @@ func (m *Manager) ensureLocked(inst Instance) error {
 		cur.proc.Stop()
 		delete(m.procs, inst.Id)
 	}
-	metricsPort, err := freeLocalPort()
+	metricsPort, err := FreeLocalPort()
 	if err != nil {
 		return err
 	}
@@ -307,7 +319,10 @@ func (m *Manager) CollectTraffic() []Traffic {
 	return out
 }
 
-func freeLocalPort() (int, error) {
+// FreeLocalPort asks the OS for an unused loopback TCP port. It is used both
+// for mtg's metrics endpoint and to allocate the per-inbound SOCKS egress
+// bridge port persisted into mtproto inbound settings.
+func FreeLocalPort() (int, error) {
 	l, err := net.Listen("tcp", "127.0.0.1:0")
 	if err != nil {
 		return 0, err
@@ -345,6 +360,12 @@ func renderConfig(inst Instance, metricsPort int) string {
 			b.WriteString("proxy-protocol = true\n")
 		}
 	}
+	// When the inbound opts into Xray routing, mtg reaches Telegram through the
+	// loopback SOCKS bridge the panel injects into the running Xray config. mtg
+	// only supports SOCKS5 upstreams, which is exactly what the bridge exposes.
+	if inst.RouteThroughXray && inst.XrayRoutePort > 0 {
+		fmt.Fprintf(&b, "\n[network]\nproxies = [\"socks5://127.0.0.1:%d\"]\n", inst.XrayRoutePort)
+	}
 	fmt.Fprintf(&b, "\n[stats.prometheus]\nenabled = true\nbind-to = \"127.0.0.1:%d\"\nhttp-path = \"/metrics\"\nmetric-prefix = \"mtg\"\n", metricsPort)
 	return b.String()
 }

+ 33 - 1
internal/mtproto/manager_test.go

@@ -40,7 +40,8 @@ func TestInstanceFromInbound(t *testing.T) {
 		Protocol: model.MTProto,
 		Settings: `{"fakeTlsDomain":"example.com","secret":"",` +
 			`"debug":true,"proxyProtocolListener":true,"preferIp":"prefer-ipv4",` +
-			`"domainFronting":{"ip":"127.0.0.1","port":9443,"proxyProtocol":true}}`,
+			`"domainFronting":{"ip":"127.0.0.1","port":9443,"proxyProtocol":true},` +
+			`"routeThroughXray":true,"routeXrayPort":50000}`,
 	}
 	inst, ok := InstanceFromInbound(ib)
 	if !ok {
@@ -58,6 +59,9 @@ func TestInstanceFromInbound(t *testing.T) {
 	if inst.FrontingIP != "127.0.0.1" || inst.FrontingPort != 9443 || !inst.FrontingProxyProtocol {
 		t.Fatalf("domain-fronting not parsed: %+v", inst)
 	}
+	if !inst.RouteThroughXray || inst.XrayRoutePort != 50000 {
+		t.Fatalf("xray routing not parsed: %+v", inst)
+	}
 
 	if _, ok := InstanceFromInbound(&model.Inbound{Protocol: model.VLESS}); ok {
 		t.Fatal("non-mtproto inbound should not produce an instance")
@@ -108,6 +112,32 @@ func TestRenderConfig(t *testing.T) {
 	}
 }
 
+func TestRenderConfigXrayEgress(t *testing.T) {
+	// Routing through Xray emits a [network] proxies upstream pointing at the
+	// loopback SOCKS bridge, before the prometheus block.
+	routed := renderConfig(Instance{
+		Secret: "ee22", Listen: "0.0.0.0", Port: 443,
+		RouteThroughXray: true, XrayRoutePort: 50000,
+	}, 7000)
+	if !strings.Contains(routed, "[network]") ||
+		!strings.Contains(routed, `proxies = ["socks5://127.0.0.1:50000"]`) {
+		t.Fatalf("routed config must emit the SOCKS upstream:\n%s", routed)
+	}
+	if strings.Index(routed, "[network]") > strings.Index(routed, "[stats.prometheus]") {
+		t.Fatalf("[network] must precede [stats.prometheus]:\n%s", routed)
+	}
+
+	// Without the flag (or without a port) the section is omitted.
+	for _, inst := range []Instance{
+		{Secret: "ee", Listen: "0.0.0.0", Port: 443},
+		{Secret: "ee", Listen: "0.0.0.0", Port: 443, RouteThroughXray: true},
+	} {
+		if got := renderConfig(inst, 7000); strings.Contains(got, "[network]") {
+			t.Fatalf("unrouted config must omit [network]:\n%s", got)
+		}
+	}
+}
+
 func TestFingerprintReactsToOptions(t *testing.T) {
 	base := Instance{Secret: "ee", Listen: "0.0.0.0", Port: 443}
 	for name, mutate := range map[string]func(*Instance){
@@ -117,6 +147,8 @@ func TestFingerprintReactsToOptions(t *testing.T) {
 		"frontingIP":    func(i *Instance) { i.FrontingIP = "127.0.0.1" },
 		"frontingPort":  func(i *Instance) { i.FrontingPort = 9443 },
 		"frontingProxy": func(i *Instance) { i.FrontingProxyProtocol = true },
+		"routeXray":     func(i *Instance) { i.RouteThroughXray = true },
+		"routeXrayPort": func(i *Instance) { i.XrayRoutePort = 50000 },
 	} {
 		changed := base
 		mutate(&changed)

+ 1 - 1
internal/sub/service.go

@@ -284,7 +284,7 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error)
 		WHERE
 			inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria')
 			AND clients.sub_id = ? AND inbounds.enable = ?
-	)`, subId, true).Find(&inbounds).Error
+	)`, subId, true).Order("sub_sort_index ASC").Order("id ASC").Find(&inbounds).Error
 	if err != nil {
 		return nil, err
 	}

+ 79 - 0
internal/sub/service_sort_test.go

@@ -0,0 +1,79 @@
+package sub
+
+import (
+	"fmt"
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// TestGetSubs_OrdersBySubSortIndexThenId verifies that subscription output
+// lists inbound links ordered by sub_sort_index ASC, breaking ties by id ASC.
+// The same query feeds the raw body, the HTML sub page, and the JSON/Clash
+// formats, so asserting on GetSubs covers all of them.
+func TestGetSubs_OrdersBySubSortIndexThenId(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	const subId = "sub-sort"
+	db := database.GetDB()
+
+	seed := []struct {
+		tag          string
+		port         int
+		subSortIndex int
+		email        string
+		uuid         string
+	}{
+		// Created in this order on purpose: without the ORDER BY the links
+		// would come out s3, s1, s2a, s2b (creation order).
+		{"sort-3", 42101, 3, "[email protected]", "0d68a695-4be1-4d92-a9c3-8c0f1c2cf001"},
+		{"sort-1", 42102, 1, "[email protected]", "0d68a695-4be1-4d92-a9c3-8c0f1c2cf002"},
+		{"sort-2a", 42103, 2, "[email protected]", "0d68a695-4be1-4d92-a9c3-8c0f1c2cf003"},
+		{"sort-2b", 42104, 2, "[email protected]", "0d68a695-4be1-4d92-a9c3-8c0f1c2cf004"},
+	}
+	for _, s := range seed {
+		settings := fmt.Sprintf(`{"clients": [{"id": %q, "email": %q, "subId": %q, "enable": true}]}`, s.uuid, s.email, subId)
+		ib := &model.Inbound{
+			UserId:         1,
+			Tag:            s.tag,
+			Enable:         true,
+			Port:           s.port,
+			Protocol:       model.VLESS,
+			Settings:       settings,
+			StreamSettings: `{"network": "tcp", "security": "none"}`,
+			SubSortIndex:   s.subSortIndex,
+		}
+		if err := db.Create(ib).Error; err != nil {
+			t.Fatalf("seed inbound %s: %v", s.tag, err)
+		}
+		client := &model.ClientRecord{Email: s.email, SubID: subId, UUID: s.uuid, Enable: true}
+		if err := db.Create(client).Error; err != nil {
+			t.Fatalf("seed client %s: %v", s.email, err)
+		}
+		if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
+			t.Fatalf("seed client_inbound %s: %v", s.email, err)
+		}
+	}
+
+	s := NewSubService(false, "-ieo")
+	links, emails, _, _, err := s.GetSubs(subId, "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs: %v", err)
+	}
+	if len(links) != len(seed) {
+		t.Fatalf("links = %d, want %d", len(links), len(seed))
+	}
+	want := []string{"[email protected]", "[email protected]", "[email protected]", "[email protected]"}
+	for i, email := range want {
+		if emails[i] != email {
+			t.Fatalf("emails order = %v, want %v (sub_sort_index ASC, id ASC)", emails, want)
+		}
+	}
+}

+ 34 - 0
internal/web/controller/xray_setting.go

@@ -48,6 +48,7 @@ func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
 	g.POST("/update", a.updateSetting)
 	g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
 	g.POST("/testOutbound", a.testOutbound)
+	g.POST("/testOutbounds", a.testOutbounds)
 	g.POST("/balancerStatus", a.balancerStatus)
 	g.POST("/balancerOverride", a.balancerOverride)
 	g.POST("/routeTest", a.routeTest)
@@ -286,6 +287,39 @@ func (a *XraySettingController) testOutbound(c *gin.Context) {
 	jsonObj(c, result, nil)
 }
 
+// testOutbounds tests a batch of outbound configurations through one shared
+// temp xray instance and returns an array of results in input order.
+// Form "outbounds": JSON array of outbound configs (required).
+// Optional form "allOutbounds": JSON array of all outbounds; used to resolve sockopt.dialerProxy dependencies.
+// Optional form "mode": "tcp" for fast dial-only probes, anything else
+// (default) for real HTTP requests routed through each outbound.
+func (a *XraySettingController) testOutbounds(c *gin.Context) {
+	outboundsJSON := c.PostForm("outbounds")
+	allOutboundsJSON := c.PostForm("allOutbounds")
+	mode := c.PostForm("mode")
+
+	if outboundsJSON == "" {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("outbounds parameter is required"))
+		return
+	}
+
+	// Load the test URL from server settings to prevent SSRF via user-controlled URLs
+	testURL, _ := a.SettingService.GetXrayOutboundTestUrl()
+	testURL, err := service.SanitizePublicHTTPURL(testURL, false)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+
+	results, err := a.OutboundService.TestOutbounds(outboundsJSON, testURL, allOutboundsJSON, mode)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+
+	jsonObj(c, results, nil)
+}
+
 // balancerStatus reports the live state (override + strategy picks) of the
 // balancer tags given as a comma-separated "tags" form field.
 func (a *XraySettingController) balancerStatus(c *gin.Context) {

+ 13 - 0
internal/web/job/mtproto_job.go

@@ -31,12 +31,16 @@ func (j *MtprotoJob) Run() {
 	}
 
 	var desired []mtproto.Instance
+	routedTags := make(map[string]bool)
 	for _, ib := range inbounds {
 		if ib.Protocol != model.MTProto || !ib.Enable || ib.NodeID != nil {
 			continue
 		}
 		if inst, ok := mtproto.InstanceFromInbound(ib); ok {
 			desired = append(desired, inst)
+			if inst.RouteThroughXray {
+				routedTags[inst.Tag] = true
+			}
 		}
 	}
 
@@ -49,6 +53,12 @@ func (j *MtprotoJob) Run() {
 	}
 	traffics := make([]*xray.Traffic, 0, len(deltas))
 	for _, d := range deltas {
+		// Routed inbounds egress through the Xray SOCKS bridge, which carries the
+		// inbound's tag and is metered by xray_traffic_job. Folding mtg's own
+		// metrics in too would double-count, so skip them here.
+		if routedTags[d.Tag] {
+			continue
+		}
 		traffics = append(traffics, &xray.Traffic{
 			IsInbound: true,
 			Tag:       d.Tag,
@@ -56,6 +66,9 @@ func (j *MtprotoJob) Run() {
 			Down:      d.Down,
 		})
 	}
+	if len(traffics) == 0 {
+		return
+	}
 	if _, _, err := j.inboundService.AddTraffic(traffics, nil); err != nil {
 		logger.Warning("mtproto job: add traffic failed:", err)
 	}

+ 1 - 0
internal/web/runtime/remote.go

@@ -490,6 +490,7 @@ func wireInbound(ib *model.Inbound) url.Values {
 	v := url.Values{}
 	v.Set("total", strconv.FormatInt(ib.Total, 10))
 	v.Set("remark", ib.Remark)
+	v.Set("subSortIndex", strconv.Itoa(ib.SubSortIndex))
 	v.Set("enable", strconv.FormatBool(ib.Enable))
 	v.Set("expiryTime", strconv.FormatInt(ib.ExpiryTime, 10))
 	v.Set("listen", ib.Listen)

+ 7 - 3
internal/web/service/client_groups.go

@@ -14,6 +14,8 @@ type GroupSummary struct {
 	Name        string `json:"name"`
 	ClientCount int    `json:"clientCount"`
 	TrafficUsed int64  `json:"trafficUsed"`
+	Up          int64  `json:"up"`
+	Down        int64  `json:"down"`
 }
 
 func (s *ClientService) ListGroups() ([]GroupSummary, error) {
@@ -22,7 +24,7 @@ func (s *ClientService) ListGroups() ([]GroupSummary, error) {
 	// never double-counts a client's traffic.
 	var derived []GroupSummary
 	if err := db.Table("clients AS c").
-		Select("c.group_name AS name, COUNT(*) AS client_count, COALESCE(SUM(ct.up + ct.down), 0) AS traffic_used").
+		Select("c.group_name AS name, COUNT(*) AS client_count, COALESCE(SUM(ct.up + ct.down), 0) AS traffic_used, COALESCE(SUM(ct.up), 0) AS up, COALESCE(SUM(ct.down), 0) AS down").
 		Joins("LEFT JOIN client_traffics ct ON ct.email = c.email").
 		Where("c.group_name <> ''").
 		Group("c.group_name").
@@ -36,17 +38,19 @@ func (s *ClientService) ListGroups() ([]GroupSummary, error) {
 	type groupAgg struct {
 		count   int
 		traffic int64
+		up      int64
+		down    int64
 	}
 	merged := make(map[string]groupAgg, len(derived)+len(stored))
 	for _, g := range stored {
 		merged[g.Name] = groupAgg{}
 	}
 	for _, g := range derived {
-		merged[g.Name] = groupAgg{count: g.ClientCount, traffic: g.TrafficUsed}
+		merged[g.Name] = groupAgg{count: g.ClientCount, traffic: g.TrafficUsed, up: g.Up, down: g.Down}
 	}
 	out := make([]GroupSummary, 0, len(merged))
 	for name, agg := range merged {
-		out = append(out, GroupSummary{Name: name, ClientCount: agg.count, TrafficUsed: agg.traffic})
+		out = append(out, GroupSummary{Name: name, ClientCount: agg.count, TrafficUsed: agg.traffic, Up: agg.up, Down: agg.down})
 	}
 	sort.Slice(out, func(i, j int) bool {
 		return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)

+ 165 - 8
internal/web/service/inbound.go

@@ -14,11 +14,13 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 
 	"gorm.io/gorm"
+	"gorm.io/gorm/clause"
 )
 
 type InboundService struct {
@@ -450,6 +452,108 @@ func (s *InboundService) normalizeMtprotoSecret(inbound *model.Inbound) {
 	}
 }
 
+// mtprotoRoutesThroughXray reports whether an mtproto inbound is configured to
+// egress through the core's router (the loopback SOCKS bridge in §xray.go).
+func mtprotoRoutesThroughXray(inbound *model.Inbound) bool {
+	if inbound == nil || inbound.Protocol != model.MTProto {
+		return false
+	}
+	var parsed struct {
+		RouteThroughXray bool `json:"routeThroughXray"`
+	}
+	if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil {
+		return false
+	}
+	return parsed.RouteThroughXray
+}
+
+func settingsRouteXrayPort(parsed map[string]any) int {
+	switch v := parsed["routeXrayPort"].(type) {
+	case float64:
+		return int(v)
+	case int:
+		return v
+	case json.Number:
+		if n, err := v.Int64(); err == nil {
+			return int(n)
+		}
+	}
+	return 0
+}
+
+func parseRouteXrayPort(settings string) int {
+	if settings == "" {
+		return 0
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
+		return 0
+	}
+	return settingsRouteXrayPort(parsed)
+}
+
+// normalizeMtprotoXrayPort guarantees a routed mtproto inbound carries a stable
+// loopback egress port in its settings, so the generated Xray SOCKS bridge and
+// the mtg sidecar agree on where mtg dials out. The port is backend-owned: it is
+// allocated once when routing is first enabled and preserved across edits
+// (carried over from oldSettings, which wins over any value the client echoed
+// back). When routing is off it — together with the now-inert outbound
+// selection — is stripped so a disabled bridge leaves nothing stale behind.
+//
+// It returns an error when an egress port cannot be allocated or persisted, so
+// the caller refuses the save rather than storing a routed-but-portless inbound,
+// which would otherwise route no traffic and have its mtg metrics skipped (see
+// mtproto_job) — silently losing its accounting.
+func (s *InboundService) normalizeMtprotoXrayPort(inbound *model.Inbound, oldSettings string) error {
+	if inbound.Protocol != model.MTProto {
+		return nil
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil || parsed == nil {
+		return nil
+	}
+	routed, _ := parsed["routeThroughXray"].(bool)
+	if !routed {
+		_, hadPort := parsed["routeXrayPort"]
+		_, hadTag := parsed["outboundTag"]
+		if !hadPort && !hadTag {
+			return nil
+		}
+		delete(parsed, "routeXrayPort")
+		delete(parsed, "outboundTag")
+		if bs, err := json.MarshalIndent(parsed, "", "  "); err == nil {
+			inbound.Settings = string(bs)
+		} else {
+			logger.Warning("mtproto: failed to marshal settings after disabling routing:", err)
+		}
+		return nil
+	}
+
+	// Prefer the already-stored port (carried across edits), then any value the
+	// client sent, then allocate a fresh one.
+	port := parseRouteXrayPort(oldSettings)
+	if port <= 0 {
+		port = settingsRouteXrayPort(parsed)
+	}
+	if port <= 0 {
+		allocated, err := mtproto.FreeLocalPort()
+		if err != nil {
+			return common.NewError("mtproto: could not allocate an Xray egress port:", err)
+		}
+		port = allocated
+	}
+	if settingsRouteXrayPort(parsed) == port {
+		return nil
+	}
+	parsed["routeXrayPort"] = port
+	bs, err := json.MarshalIndent(parsed, "", "  ")
+	if err != nil {
+		return common.NewError("mtproto: could not persist the Xray egress port:", err)
+	}
+	inbound.Settings = string(bs)
+	return nil
+}
+
 // AddInbound creates a new inbound configuration.
 // It validates port uniqueness, client email uniqueness, and required fields,
 // then saves the inbound to the database and optionally adds it to the running Xray instance.
@@ -458,6 +562,10 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 	// Normalize streamSettings based on protocol
 	s.normalizeStreamSettings(inbound)
 	s.normalizeMtprotoSecret(inbound)
+	if err := s.normalizeMtprotoXrayPort(inbound, ""); err != nil {
+		return inbound, false, err
+	}
+	inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex)
 	if err := normalizeInboundShareAddressStrict(inbound); err != nil {
 		return inbound, false, err
 	}
@@ -549,16 +657,40 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 		}
 	}()
 
-	err = tx.Save(inbound).Error
-	if err == nil {
-		if len(inbound.ClientStats) == 0 {
-			for _, client := range clients {
-				s.AddClientStat(tx, inbound.Id, &client)
-			}
-		}
-	} else {
+	// Omit the ClientStats has-many association: GORM's cascade would INSERT
+	// those rows with an ON CONFLICT target on the primary key only, which
+	// collides with the globally-unique client_traffics.email when an imported
+	// inbound carries clients that another inbound already created (e.g.
+	// importing two inbounds that share the same clients). We insert the stats
+	// ourselves below with the same email-conflict guard AddClientStat uses.
+	err = tx.Omit("ClientStats").Save(inbound).Error
+	if err != nil {
 		return inbound, false, err
 	}
+	// Imported stats first, so their traffic counters survive; emails that
+	// already own a (shared) row are skipped instead of tripping the unique
+	// constraint.
+	for i := range inbound.ClientStats {
+		if inbound.ClientStats[i].Email == "" {
+			continue
+		}
+		inbound.ClientStats[i].Id = 0
+		inbound.ClientStats[i].InboundId = inbound.Id
+		if err = tx.Clauses(clause.OnConflict{
+			Columns:   []clause.Column{{Name: "email"}},
+			DoNothing: true,
+		}).Create(&inbound.ClientStats[i]).Error; err != nil {
+			return inbound, false, err
+		}
+	}
+	// Then make sure every client has a stats row. AddClientStat is a no-op
+	// where one exists (including the rows just inserted), and fills the gap
+	// for clients an import payload didn't carry stats for.
+	for _, client := range clients {
+		if err = s.AddClientStat(tx, inbound.Id, &client); err != nil {
+			return inbound, false, err
+		}
+	}
 
 	if err = s.clientService.SyncInbound(tx, inbound.Id, clients); err != nil {
 		return inbound, false, err
@@ -596,6 +728,13 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 		}
 	}
 
+	// A routed mtproto inbound is not an Xray inbound itself, so the runtime
+	// push above only (re)starts the mtg sidecar. The egress SOCKS bridge lives
+	// in the generated config, so force a regen to wire it in.
+	if mtprotoRoutesThroughXray(inbound) {
+		needRestart = true
+	}
+
 	return inbound, needRestart, err
 }
 
@@ -659,6 +798,10 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
 			}
 		}
 	}
+	// Drop the egress SOCKS bridge a routed mtproto inbound left in the config.
+	if mtprotoRoutesThroughXray(&ib) {
+		needRestart = true
+	}
 	return needRestart, nil
 }
 
@@ -786,6 +929,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 	// Normalize streamSettings based on protocol
 	s.normalizeStreamSettings(inbound)
 	s.normalizeMtprotoSecret(inbound)
+	inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex)
 
 	conflict, err := s.checkPortConflict(inbound, inbound.Id)
 	if err != nil {
@@ -800,6 +944,13 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 		return inbound, false, err
 	}
 	inbound.NodeID = oldInbound.NodeID
+	// Capture the pre-edit routing state before oldInbound.Settings is replaced
+	// with the new settings further down, then ensure a routed inbound keeps a
+	// stable egress port (reusing the one already stored).
+	oldRoutedMtproto := mtprotoRoutesThroughXray(oldInbound)
+	if err := s.normalizeMtprotoXrayPort(inbound, oldInbound.Settings); err != nil {
+		return inbound, false, err
+	}
 
 	tag := oldInbound.Tag
 	oldBits := inboundTransports(oldInbound.Protocol, oldInbound.StreamSettings, oldInbound.Settings)
@@ -888,6 +1039,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 
 	oldInbound.Total = inbound.Total
 	oldInbound.Remark = inbound.Remark
+	oldInbound.SubSortIndex = inbound.SubSortIndex
 	oldInbound.Enable = inbound.Enable
 	oldInbound.ExpiryTime = inbound.ExpiryTime
 	oldInbound.TrafficReset = inbound.TrafficReset
@@ -981,6 +1133,11 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 	if err = s.clientService.SyncInbound(tx, oldInbound.Id, newClients); err != nil {
 		return inbound, false, err
 	}
+	// (Re)generate the Xray config whenever routing was or is now enabled, so the
+	// egress SOCKS bridge is added, moved, or dropped to match the new settings.
+	if mtprotoRoutesThroughXray(inbound) || oldRoutedMtproto {
+		needRestart = true
+	}
 	return inbound, needRestart, nil
 }
 

+ 127 - 0
internal/web/service/inbound_import_shared_clients_test.go

@@ -0,0 +1,127 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// makeImportInbound builds an inbound shaped like the import payload: a clients
+// JSON blob plus carried-over ClientStats (the exported traffic counters). The
+// stats mirror what controller.importInbound feeds AddInbound after zeroing ids.
+func makeImportInbound(tag string, port int, settings string, stats []xray.ClientTraffic) *model.Inbound {
+	for i := range stats {
+		stats[i].Id = 0
+		stats[i].Enable = true
+	}
+	return &model.Inbound{
+		UserId:         1,
+		Tag:            tag,
+		Enable:         true,
+		Listen:         "0.0.0.0",
+		Port:           port,
+		Protocol:       model.VLESS,
+		StreamSettings: `{"network":"tcp"}`,
+		Settings:       settings,
+		ClientStats:    stats,
+	}
+}
+
+// TestAddInbound_ImportTwoInboundsSharingClients reproduces the panel report:
+// importing inbound #1 then inbound #2 when both carry the same clients (same
+// email + subId) used to fail with "UNIQUE constraint failed: client_traffics.email".
+// The shared email already owns a row from the first import, and the second
+// inbound's ClientStats association tried to plain-INSERT it again.
+func TestAddInbound_ImportTwoInboundsSharingClients(t *testing.T) {
+	setupConflictDB(t)
+	svc := &InboundService{}
+
+	// Inbound #1: clients alice (shared) and bob (unique to #1).
+	settings1 := `{"clients":[` +
+		`{"id":"11111111-1111-1111-1111-111111111111","email":"alice","subId":"s-alice","enable":true},` +
+		`{"id":"22222222-2222-2222-2222-222222222222","email":"bob","subId":"s-bob","enable":true}` +
+		`],"decryption":"none","encryption":"none"}`
+	in1 := makeImportInbound("in-9101-tcp", 9101, settings1, []xray.ClientTraffic{
+		{Email: "alice", Up: 100, Down: 200, Total: 1000},
+		{Email: "bob", Up: 1, Down: 2, Total: 1000},
+	})
+	if _, _, err := svc.AddInbound(in1); err != nil {
+		t.Fatalf("import inbound #1: %v", err)
+	}
+
+	// Inbound #2: clients alice (same email+subId as #1) and carol (unique to #2).
+	settings2 := `{"clients":[` +
+		`{"id":"11111111-1111-1111-1111-111111111111","email":"alice","subId":"s-alice","enable":true},` +
+		`{"id":"33333333-3333-3333-3333-333333333333","email":"carol","subId":"s-carol","enable":true}` +
+		`],"decryption":"none","encryption":"none"}`
+	in2 := makeImportInbound("in-9102-tcp", 9102, settings2, []xray.ClientTraffic{
+		{Email: "alice", Up: 999, Down: 999, Total: 9999}, // would clobber the shared row if inserted
+		{Email: "carol", Up: 3, Down: 4, Total: 1000},
+	})
+	if _, _, err := svc.AddInbound(in2); err != nil {
+		t.Fatalf("import inbound #2 (the reported failure): %v", err)
+	}
+
+	// One traffic row per distinct email — no duplicate "alice".
+	for _, tc := range []struct {
+		email string
+		want  int64
+	}{
+		{"alice", 100}, // preserved from import #1, not clobbered by #2's 999
+		{"bob", 1},
+		{"carol", 3},
+	} {
+		var rows []xray.ClientTraffic
+		if err := database.GetDB().Where("email = ?", tc.email).Find(&rows).Error; err != nil {
+			t.Fatalf("query %s: %v", tc.email, err)
+		}
+		if len(rows) != 1 {
+			t.Fatalf("email %q: got %d traffic rows, want exactly 1", tc.email, len(rows))
+		}
+		if rows[0].Up != tc.want {
+			t.Fatalf("email %q: Up = %d, want %d (shared row should keep the first import's counters)", tc.email, rows[0].Up, tc.want)
+		}
+	}
+}
+
+// TestAddInbound_ImportStatsMissingClientStillGetsTrafficRow covers an import
+// payload whose clientStats doesn't cover every client in settings (older
+// exports / hand-edited JSON): the uncovered client must still end up with a
+// traffic row, or it would escape quota and expiry accounting.
+func TestAddInbound_ImportStatsMissingClientStillGetsTrafficRow(t *testing.T) {
+	setupConflictDB(t)
+	svc := &InboundService{}
+
+	settings := `{"clients":[` +
+		`{"id":"44444444-4444-4444-4444-444444444444","email":"dave","subId":"s-dave","enable":true,"totalGB":1000},` +
+		`{"id":"55555555-5555-5555-5555-555555555555","email":"erin","subId":"s-erin","enable":true,"totalGB":2000}` +
+		`],"decryption":"none","encryption":"none"}`
+	// Stats cover dave only; erin is missing.
+	in := makeImportInbound("in-9103-tcp", 9103, settings, []xray.ClientTraffic{
+		{Email: "dave", Up: 7, Down: 8, Total: 1000},
+	})
+	if _, _, err := svc.AddInbound(in); err != nil {
+		t.Fatalf("import inbound: %v", err)
+	}
+
+	var dave xray.ClientTraffic
+	if err := database.GetDB().Where("email = ?", "dave").First(&dave).Error; err != nil {
+		t.Fatalf("dave row: %v", err)
+	}
+	if dave.Up != 7 {
+		t.Fatalf("dave Up = %d, want 7 (imported counters preserved)", dave.Up)
+	}
+
+	var erin xray.ClientTraffic
+	if err := database.GetDB().Where("email = ?", "erin").First(&erin).Error; err != nil {
+		t.Fatalf("erin must still get a traffic row despite missing from clientStats: %v", err)
+	}
+	if erin.Up != 0 || erin.Down != 0 {
+		t.Fatalf("erin counters = %d/%d, want zeroed", erin.Up, erin.Down)
+	}
+	if erin.Total != 2000 {
+		t.Fatalf("erin Total = %d, want 2000 (quota taken from client settings)", erin.Total)
+	}
+}

+ 94 - 0
internal/web/service/inbound_mtproto_test.go

@@ -0,0 +1,94 @@
+package service
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestMtprotoRoutesThroughXray(t *testing.T) {
+	cases := map[string]struct {
+		ib   *model.Inbound
+		want bool
+	}{
+		"routed":      {&model.Inbound{Protocol: model.MTProto, Settings: `{"routeThroughXray":true}`}, true},
+		"off":         {&model.Inbound{Protocol: model.MTProto, Settings: `{"routeThroughXray":false}`}, false},
+		"absent":      {&model.Inbound{Protocol: model.MTProto, Settings: `{}`}, false},
+		"non-mtproto": {&model.Inbound{Protocol: model.VLESS, Settings: `{"routeThroughXray":true}`}, false},
+		"bad json":    {&model.Inbound{Protocol: model.MTProto, Settings: `{nope`}, false},
+		"nil":         {nil, false},
+	}
+	for name, c := range cases {
+		if got := mtprotoRoutesThroughXray(c.ib); got != c.want {
+			t.Fatalf("%s: got %v want %v", name, got, c.want)
+		}
+	}
+}
+
+func routeXrayPortOf(t *testing.T, settings string) int {
+	t.Helper()
+	var parsed map[string]any
+	if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
+		t.Fatalf("settings not valid JSON: %v\n%s", err, settings)
+	}
+	return settingsRouteXrayPort(parsed)
+}
+
+func TestNormalizeMtprotoXrayPort(t *testing.T) {
+	s := &InboundService{}
+
+	// Non-mtproto inbounds are left alone.
+	ib := &model.Inbound{Protocol: model.VLESS, Settings: `{"x":1}`}
+	if err := s.normalizeMtprotoXrayPort(ib, ""); err != nil {
+		t.Fatal(err)
+	}
+	if ib.Settings != `{"x":1}` {
+		t.Fatalf("non-mtproto settings must be untouched, got %s", ib.Settings)
+	}
+
+	// Routing on with no existing port allocates a fresh one.
+	ib = &model.Inbound{Protocol: model.MTProto, Settings: `{"routeThroughXray":true}`}
+	if err := s.normalizeMtprotoXrayPort(ib, ""); err != nil {
+		t.Fatal(err)
+	}
+	if p := routeXrayPortOf(t, ib.Settings); p <= 0 {
+		t.Fatalf("a routed inbound must get a port, got %d", p)
+	}
+
+	// On update, the stored port wins over both a missing and a client-echoed
+	// value — the backend owns it, so no churn and no client override.
+	ib = &model.Inbound{Protocol: model.MTProto, Settings: `{"routeThroughXray":true,"routeXrayPort":99999}`}
+	if err := s.normalizeMtprotoXrayPort(ib, `{"routeThroughXray":true,"routeXrayPort":51000}`); err != nil {
+		t.Fatal(err)
+	}
+	if p := routeXrayPortOf(t, ib.Settings); p != 51000 {
+		t.Fatalf("stored port must win, got %d", p)
+	}
+
+	// An already-present port (no old settings) is stable and not re-marshaled.
+	const stable = `{"routeThroughXray":true,"routeXrayPort":52000}`
+	ib = &model.Inbound{Protocol: model.MTProto, Settings: stable}
+	if err := s.normalizeMtprotoXrayPort(ib, ""); err != nil {
+		t.Fatal(err)
+	}
+	if ib.Settings != stable {
+		t.Fatalf("stable settings must pass through untouched, got %s", ib.Settings)
+	}
+
+	// Turning routing off strips both the bridge port and the inert outbound.
+	ib = &model.Inbound{Protocol: model.MTProto, Settings: `{"routeThroughXray":false,"routeXrayPort":53000,"outboundTag":"warp"}`}
+	if err := s.normalizeMtprotoXrayPort(ib, ""); err != nil {
+		t.Fatal(err)
+	}
+	if p := routeXrayPortOf(t, ib.Settings); p != 0 {
+		t.Fatalf("disabling routing must drop the port, got %d", p)
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal([]byte(ib.Settings), &parsed); err != nil {
+		t.Fatal(err)
+	}
+	if _, ok := parsed["outboundTag"]; ok {
+		t.Fatalf("disabling routing must drop the inert outbound tag, got %s", ib.Settings)
+	}
+}

+ 2 - 0
internal/web/service/inbound_node.go

@@ -358,6 +358,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 				LastTrafficResetTime: snapIb.LastTrafficResetTime,
 				Enable:               snapIb.Enable,
 				Remark:               snapIb.Remark,
+				SubSortIndex:         normalizeSubSortIndex(snapIb.SubSortIndex),
 				Total:                snapIb.Total,
 				ExpiryTime:           snapIb.ExpiryTime,
 				Up:                   snapIb.Up,
@@ -382,6 +383,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 		if !dirty {
 			updates["enable"] = snapIb.Enable
 			updates["remark"] = snapIb.Remark
+			updates["sub_sort_index"] = normalizeSubSortIndex(snapIb.SubSortIndex)
 			updates["listen"] = snapIb.Listen
 			updates["port"] = snapIb.Port
 			updates["protocol"] = snapIb.Protocol

+ 111 - 0
internal/web/service/inbound_sub_sort_test.go

@@ -0,0 +1,111 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func makeInboundWithSubSortIndex(tag string, port int, subSortIndex int) *model.Inbound {
+	return &model.Inbound{
+		UserId:         1,
+		Tag:            tag,
+		Enable:         true,
+		Listen:         "0.0.0.0",
+		Port:           port,
+		Protocol:       model.VLESS,
+		StreamSettings: `{"network":"tcp"}`,
+		Settings:       `{"clients":[]}`,
+		SubSortIndex:   subSortIndex,
+	}
+}
+
+// TestUpdateInbound_PersistsSubSortIndex verifies that UpdateInbound copies
+// SubSortIndex from the incoming update payload to the persisted row.
+func TestUpdateInbound_PersistsSubSortIndex(t *testing.T) {
+	setupConflictDB(t)
+
+	ib := makeInboundWithSubSortIndex("in-7001-tcp", 7001, 1)
+	if err := database.GetDB().Create(ib).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+
+	update := *ib
+	update.SubSortIndex = 7
+
+	svc := &InboundService{}
+	got, _, err := svc.UpdateInbound(&update)
+	if err != nil {
+		t.Fatalf("UpdateInbound: %v", err)
+	}
+	if got.SubSortIndex != 7 {
+		t.Fatalf("returned SubSortIndex = %d, want 7", got.SubSortIndex)
+	}
+
+	var reloaded model.Inbound
+	if err := database.GetDB().First(&reloaded, ib.Id).Error; err != nil {
+		t.Fatalf("reload: %v", err)
+	}
+	if reloaded.SubSortIndex != 7 {
+		t.Fatalf("persisted SubSortIndex = %d, want 7", reloaded.SubSortIndex)
+	}
+}
+
+// TestUpdateInbound_SubSortIndexClampedToMinimum verifies that values below
+// the 1-based minimum (0 from clients that predate the field, or negatives)
+// are clamped to 1 instead of being stored.
+func TestUpdateInbound_SubSortIndexClampedToMinimum(t *testing.T) {
+	setupConflictDB(t)
+
+	ib := makeInboundWithSubSortIndex("in-7002-tcp", 7002, 5)
+	if err := database.GetDB().Create(ib).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+
+	svc := &InboundService{}
+	for _, below := range []int{0, -3} {
+		update := *ib
+		update.SubSortIndex = below
+
+		got, _, err := svc.UpdateInbound(&update)
+		if err != nil {
+			t.Fatalf("UpdateInbound(%d): %v", below, err)
+		}
+		if got.SubSortIndex != 1 {
+			t.Fatalf("returned SubSortIndex = %d for input %d, want 1", got.SubSortIndex, below)
+		}
+
+		var reloaded model.Inbound
+		if err := database.GetDB().First(&reloaded, ib.Id).Error; err != nil {
+			t.Fatalf("reload: %v", err)
+		}
+		if reloaded.SubSortIndex != 1 {
+			t.Fatalf("persisted SubSortIndex = %d for input %d, want 1", reloaded.SubSortIndex, below)
+		}
+	}
+}
+
+// TestAddInbound_SubSortIndexClampedToMinimum verifies the same clamping on
+// the create path (an omitted form field binds to 0).
+func TestAddInbound_SubSortIndexClampedToMinimum(t *testing.T) {
+	setupConflictDB(t)
+
+	svc := &InboundService{}
+	ib := makeInboundWithSubSortIndex("in-7003-tcp", 7003, 0)
+	got, _, err := svc.AddInbound(ib)
+	if err != nil {
+		t.Fatalf("AddInbound: %v", err)
+	}
+	if got.SubSortIndex != 1 {
+		t.Fatalf("returned SubSortIndex = %d, want 1", got.SubSortIndex)
+	}
+
+	var reloaded model.Inbound
+	if err := database.GetDB().First(&reloaded, got.Id).Error; err != nil {
+		t.Fatalf("reload: %v", err)
+	}
+	if reloaded.SubSortIndex != 1 {
+		t.Fatalf("persisted SubSortIndex = %d, want 1", reloaded.SubSortIndex)
+	}
+}

+ 10 - 0
internal/web/service/inbound_util.go

@@ -7,6 +7,16 @@ package service
 // installs (>32k clients) where even modern SQLite would refuse a single IN.
 const sqliteMaxVars = 900
 
+// normalizeSubSortIndex clamps the 1-based subscription sort order. Values
+// below 1 arrive from clients that predate the field (omitted form key binds
+// to 0) and must not sort ahead of explicitly ranked inbounds.
+func normalizeSubSortIndex(v int) int {
+	if v < 1 {
+		return 1
+	}
+	return v
+}
+
 // uniqueNonEmptyStrings returns a deduplicated copy of in with empty strings
 // removed, preserving the order of first occurrence.
 func uniqueNonEmptyStrings(in []string) []string {

+ 16 - 312
internal/web/service/outbound/outbound.go

@@ -4,17 +4,13 @@ import (
 	"encoding/json"
 	"fmt"
 	"net"
-	"net/http"
-	"os"
 	"strconv"
 	"sync"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v3/internal/config"
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
-	"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 
 	"gorm.io/gorm"
@@ -24,11 +20,6 @@ import (
 // It handles outbound traffic monitoring and statistics.
 type OutboundService struct{}
 
-// 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
 	db := database.GetDB()
@@ -119,14 +110,25 @@ func (s *OutboundService) ResetOutboundTraffic(tag string) error {
 
 // TestOutboundResult represents the result of testing an outbound.
 // Delay is in milliseconds. Endpoints is only populated for TCP-mode
-// probes; HTTP mode reports the round-trip delay measured by xray's
-// burstObservatory probe.
+// probes; HTTP mode reports the time of a real HTTP request routed
+// through the outbound, with an optional timing breakdown.
 type TestOutboundResult struct {
+	Tag     string `json:"tag,omitempty"`
 	Success bool   `json:"success"`
 	Delay   int64  `json:"delay"`
 	Error   string `json:"error,omitempty"`
 	Mode    string `json:"mode,omitempty"`
 
+	// HTTP-mode extras. Any HTTP response counts as reachable; HTTPStatus
+	// records what the test URL answered. ConnectMs is the dial to the local
+	// test inbound; TLSMs covers outbound-chain establishment + target TLS
+	// (https URLs only, since xray ACKs the SOCKS CONNECT before dialing
+	// upstream); TTFBMs is request start → first response byte.
+	HTTPStatus int   `json:"httpStatus,omitempty"`
+	ConnectMs  int64 `json:"connectMs,omitempty"`
+	TLSMs      int64 `json:"tlsMs,omitempty"`
+	TTFBMs     int64 `json:"ttfbMs,omitempty"`
+
 	Endpoints []TestEndpointResult `json:"endpoints,omitempty"`
 }
 
@@ -139,31 +141,6 @@ type TestEndpointResult struct {
 	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" {
-		// A bare TCP dial only proves reachability for TCP-based proxies.
-		// UDP protocols (wireguard, hysteria, kcp/quic transports) ignore
-		// unauthenticated packets, so a raw dial can't tell "reachable" from
-		// "dead" — route them through the authoritative xray handshake probe.
-		var ob map[string]any
-		if json.Unmarshal([]byte(outboundJSON), &ob) == nil && outboundTransportIsUDP(ob) {
-			return s.testOutboundHTTP(outboundJSON, testURL, allOutboundsJSON)
-		}
-		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 {
@@ -172,12 +149,12 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes
 	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
+		return &TestOutboundResult{Tag: tag, 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
+		return &TestOutboundResult{Tag: tag, Mode: "tcp", Success: false, Error: "No testable endpoint"}, nil
 	}
 
 	results := make([]TestEndpointResult, len(endpoints))
@@ -203,7 +180,7 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes
 		}
 	}
 
-	out := &TestOutboundResult{Mode: "tcp", Endpoints: results}
+	out := &TestOutboundResult{Tag: tag, Mode: "tcp", Endpoints: results}
 	if bestDelay >= 0 {
 		out.Success = true
 		out.Delay = bestDelay
@@ -312,276 +289,3 @@ func numAsInt(v any) int {
 	}
 	return 0
 }
-
-// testOutboundHTTP spins up a temporary xray instance whose only job is
-// to run a burstObservatory probe against the target outbound, then polls
-// xray's metrics /debug/vars endpoint until that outbound is reported
-// alive (success) or the deadline expires (failure). The probe lives
-// inside xray, so the measured delay and any failure reason reflect what
-// xray itself sees over the real proxy chain — no SOCKS round-trip on
-// the client side.
-func (s *OutboundService) testOutboundHTTP(outboundJSON string, testURL string, allOutboundsJSON string) (*TestOutboundResult, error) {
-	if testURL == "" {
-		testURL = "https://www.google.com/generate_204"
-	}
-
-	if !httpTestSemaphore.TryLock() {
-		return &TestOutboundResult{
-			Mode:    "http",
-			Success: false,
-			Error:   "Another outbound test is already running, please wait",
-		}, nil
-	}
-	defer httpTestSemaphore.Unlock()
-
-	var testOutbound map[string]any
-	if err := json.Unmarshal([]byte(outboundJSON), &testOutbound); 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{Mode: "http", Success: false, Error: "Outbound has no tag"}, nil
-	}
-	if protocol, _ := testOutbound["protocol"].(string); protocol == "blackhole" || outboundTag == "blocked" {
-		return &TestOutboundResult{Mode: "http", Success: false, Error: "Blocked/blackhole outbound cannot be tested"}, nil
-	}
-
-	var allOutbounds []any
-	if allOutboundsJSON != "" {
-		if err := json.Unmarshal([]byte(allOutboundsJSON), &allOutbounds); err != nil {
-			return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Invalid allOutbounds JSON: %v", err)}, nil
-		}
-	}
-	// The outbound under test must be present in the config so burstObservatory
-	// has something with outboundTag to probe. allOutbounds is the template's
-	// outbounds (for dialerProxy chains); subscription outbounds are injected at
-	// runtime and aren't part of it, so without this the probe targets a tag that
-	// doesn't exist in the config and every test times out. Append (don't replace)
-	// so manual outbounds' dialerProxy chains keep resolving.
-	if !outboundsContainTag(allOutbounds, outboundTag) {
-		allOutbounds = append(allOutbounds, testOutbound)
-	}
-
-	metricsPort, err := findAvailablePort()
-	if err != nil {
-		return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Failed to find available port: %v", err)}, nil
-	}
-
-	testConfig := s.createTestConfig(outboundTag, allOutbounds, metricsPort, testURL)
-
-	testConfigPath, err := createTestConfigPath()
-	if err != nil {
-		return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Failed to create test config path: %v", err)}, nil
-	}
-	defer os.Remove(testConfigPath)
-
-	testProcess := xray.NewTestProcess(testConfig, testConfigPath)
-	defer func() {
-		if testProcess.IsRunning() {
-			testProcess.Stop()
-		}
-	}()
-
-	if err := testProcess.Start(); err != nil {
-		return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Failed to start test xray instance: %v", err)}, nil
-	}
-
-	if err := waitForPort(metricsPort, 5*time.Second); err != nil {
-		if !testProcess.IsRunning() {
-			result := testProcess.GetResult()
-			return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Xray process exited: %s", result)}, nil
-		}
-		return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Xray failed to start metrics listener: %v", err)}, nil
-	}
-
-	if !testProcess.IsRunning() {
-		result := testProcess.GetResult()
-		return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Xray process exited: %s", result)}, nil
-	}
-
-	return pollObservatoryResult(testProcess, metricsPort, outboundTag, 15*time.Second), nil
-}
-
-// outboundsContainTag reports whether any outbound in the slice has the given tag.
-func outboundsContainTag(outbounds []any, tag string) bool {
-	for _, ob := range outbounds {
-		if m, ok := ob.(map[string]any); ok {
-			if t, _ := m["tag"].(string); t == tag {
-				return true
-			}
-		}
-	}
-	return false
-}
-
-func (s *OutboundService) createTestConfig(outboundTag string, allOutbounds []any, metricsPort int, probeURL string) *xray.Config {
-	processedOutbounds := make([]any, len(allOutbounds))
-	for i, ob := range allOutbounds {
-		outbound, ok := ob.(map[string]any)
-		if !ok {
-			processedOutbounds[i] = ob
-			continue
-		}
-		if protocol, ok := outbound["protocol"].(string); ok && protocol == "wireguard" {
-			if settings, ok := outbound["settings"].(map[string]any); ok {
-				settings["noKernelTun"] = true
-			} else {
-				outbound["settings"] = map[string]any{"noKernelTun": true}
-			}
-		}
-		processedOutbounds[i] = outbound
-	}
-	outboundsJSON, _ := json.Marshal(processedOutbounds)
-
-	routingJSON, _ := json.Marshal(map[string]any{
-		"domainStrategy": "AsIs",
-		"rules":          []any{},
-	})
-
-	burstObservatoryJSON, _ := json.Marshal(map[string]any{
-		"subjectSelector": []string{outboundTag},
-		"pingConfig": map[string]any{
-			"destination":   probeURL,
-			"interval":      "1s",
-			"connectivity":  "",
-			"timeout":       "10s",
-			"samplingCount": 1,
-		},
-	})
-
-	metricsJSON, _ := json.Marshal(map[string]any{
-		"tag":    "test-metrics",
-		"listen": fmt.Sprintf("127.0.0.1:%d", metricsPort),
-	})
-
-	logConfig := map[string]any{
-		"loglevel": "warning",
-		"access":   "none",
-		"error":    "",
-		"dnsLog":   false,
-	}
-	logJSON, _ := json.Marshal(logConfig)
-
-	cfg := &xray.Config{
-		LogConfig:        json_util.RawMessage(logJSON),
-		InboundConfigs:   []xray.InboundConfig{},
-		OutboundConfigs:  json_util.RawMessage(string(outboundsJSON)),
-		RouterConfig:     json_util.RawMessage(string(routingJSON)),
-		Policy:           json_util.RawMessage(`{}`),
-		Stats:            json_util.RawMessage(`{}`),
-		BurstObservatory: json_util.RawMessage(string(burstObservatoryJSON)),
-		Metrics:          json_util.RawMessage(string(metricsJSON)),
-	}
-
-	return cfg
-}
-
-// observatoryEntry mirrors the per-outbound shape published by xray's
-// observatory under /debug/vars.
-type observatoryEntry struct {
-	Alive        bool   `json:"alive"`
-	Delay        int64  `json:"delay"`
-	LastSeenTime int64  `json:"last_seen_time"`
-	LastTryTime  int64  `json:"last_try_time"`
-	OutboundTag  string `json:"outbound_tag"`
-}
-
-func pollObservatoryResult(testProcess *xray.Process, metricsPort int, tag string, timeout time.Duration) *TestOutboundResult {
-	url := fmt.Sprintf("http://127.0.0.1:%d/debug/vars", metricsPort)
-	client := &http.Client{Timeout: 2 * time.Second}
-	deadline := time.Now().Add(timeout)
-	var lastEntry observatoryEntry
-	var sawEntry bool
-	for time.Now().Before(deadline) {
-		if !testProcess.IsRunning() {
-			result := testProcess.GetResult()
-			return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Xray process exited: %s", result)}
-		}
-		entry, ok := fetchObservatoryEntry(client, url, tag)
-		if ok {
-			if entry.Alive {
-				delay := entry.Delay
-				if delay <= 0 {
-					delay = 1
-				}
-				return &TestOutboundResult{Mode: "http", Success: true, Delay: delay}
-			}
-			lastEntry = entry
-			sawEntry = true
-		}
-		time.Sleep(400 * time.Millisecond)
-	}
-
-	msg := "Probe timed out — outbound did not become reachable (see Xray log for details)"
-	if sawEntry && lastEntry.LastTryTime > 0 {
-		msg = fmt.Sprintf("All probes failed (last attempt %ds ago; see Xray log for details)", time.Now().Unix()-lastEntry.LastTryTime)
-	}
-	return &TestOutboundResult{Mode: "http", Success: false, Error: msg}
-}
-
-func fetchObservatoryEntry(client *http.Client, url, tag string) (observatoryEntry, bool) {
-	resp, err := client.Get(url)
-	if err != nil {
-		return observatoryEntry{}, false
-	}
-	defer resp.Body.Close()
-	if resp.StatusCode != http.StatusOK {
-		return observatoryEntry{}, false
-	}
-	var payload struct {
-		Observatory map[string]observatoryEntry `json:"observatory"`
-	}
-	if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
-		return observatoryEntry{}, false
-	}
-	if entry, ok := payload.Observatory[tag]; ok {
-		return entry, true
-	}
-	for _, entry := range payload.Observatory {
-		if entry.OutboundTag == tag {
-			return entry, true
-		}
-	}
-	return observatoryEntry{}, false
-}
-
-// waitForPort polls until the given TCP port is accepting connections or the timeout expires.
-func waitForPort(port int, timeout time.Duration) error {
-	deadline := time.Now().Add(timeout)
-	for time.Now().Before(deadline) {
-		conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 100*time.Millisecond)
-		if err == nil {
-			conn.Close()
-			return nil
-		}
-		time.Sleep(50 * time.Millisecond)
-	}
-	return fmt.Errorf("port %d not ready after %v", port, timeout)
-}
-
-// findAvailablePort finds an available port for testing
-func findAvailablePort() (int, error) {
-	listener, err := net.Listen("tcp", ":0")
-	if err != nil {
-		return 0, err
-	}
-	defer listener.Close()
-
-	addr := listener.Addr().(*net.TCPAddr)
-	return addr.Port, nil
-}
-
-// createTestConfigPath returns a unique path for a temporary xray config file in the bin folder.
-// The temp file is created and closed so the path is reserved; Start() will overwrite it.
-func createTestConfigPath() (string, error) {
-	tmpFile, err := os.CreateTemp(config.GetBinFolderPath(), "xray_test_*.json")
-	if err != nil {
-		return "", err
-	}
-	path := tmpFile.Name()
-	if err := tmpFile.Close(); err != nil {
-		os.Remove(path)
-		return "", err
-	}
-	return path, nil
-}

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

@@ -0,0 +1,552 @@
+package outbound
+
+import (
+	"context"
+	"crypto/tls"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io/fs"
+	"net"
+	"net/http"
+	"net/http/httptrace"
+	"net/url"
+	"os"
+	"strconv"
+	"sync"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/config"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// HTTP-mode probing works by spinning up ONE temporary xray instance per
+// batch: every outbound under test gets its own loopback SOCKS inbound plus
+// an inboundTag→outboundTag routing rule, and the panel then issues a real,
+// individually-timed HTTP request through each inbound. Measuring the request
+// client-side (instead of polling xray's observatory) returns the moment the
+// response lands, yields the actual HTTP status, and allows an httptrace
+// timing breakdown — while the shared process keeps "Test All" at one xray
+// spawn per batch instead of one per outbound.
+
+const (
+	// httpProbeTimeout bounds one probe request end-to-end.
+	httpProbeTimeout = 10 * time.Second
+	// httpProbeConcurrency caps parallel probe requests within a batch —
+	// enough to keep a batch fast, low enough not to spike CPU with TLS
+	// handshakes on small VPSes.
+	httpProbeConcurrency = 16
+	// batchPortsReadyTimeout bounds the wait for the temp instance to open
+	// its test inbounds.
+	batchPortsReadyTimeout = 10 * time.Second
+	// maxBatchItems caps one batch request; the frontend chunks below this.
+	maxBatchItems = 50
+	// tcpBatchConcurrency caps parallel TCP-mode items in a batch (each item
+	// already dials its endpoints concurrently).
+	tcpBatchConcurrency = 8
+
+	defaultTestURL = "https://www.google.com/generate_204"
+)
+
+// httpTestSemaphore serialises HTTP-mode batches (each 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
+
+// batchProcess is the slice of xray.Process the batch engine needs; a seam
+// so unit tests can stub the process without an xray binary.
+type batchProcess interface {
+	Start() error
+	Stop() error
+	IsRunning() bool
+	GetResult() string
+}
+
+var newBatchProcess = func(cfg *xray.Config, configPath string) batchProcess {
+	return xray.NewTestProcess(cfg, configPath)
+}
+
+// httpBatchItem is one outbound inside an HTTP-mode batch. result is the
+// pre-allocated entry in the caller's result slice, filled in place.
+type httpBatchItem struct {
+	index    int
+	tag      string
+	outbound map[string]any
+	result   *TestOutboundResult
+}
+
+// TestOutbound probes a single outbound; legacy single-test API kept for the
+// /testOutbound endpoint. Dispatch matches TestOutbounds: mode "tcp" dials
+// the outbound's endpoints directly, anything else routes a real HTTP request
+// through a temp xray instance (UDP-transport outbounds are always forced to
+// the HTTP probe — a raw dial can't measure them).
+func (s *OutboundService) TestOutbound(outboundJSON string, testURL string, allOutboundsJSON string, mode string) (*TestOutboundResult, error) {
+	var ob map[string]any
+	if err := json.Unmarshal([]byte(outboundJSON), &ob); err != nil {
+		m := "http"
+		if mode == "tcp" {
+			m = "tcp"
+		}
+		return &TestOutboundResult{Mode: m, Success: false, Error: fmt.Sprintf("Invalid outbound JSON: %v", err)}, nil
+	}
+	results := s.testOutboundsParsed([]map[string]any{ob}, testURL, allOutboundsJSON, mode)
+	return results[0], nil
+}
+
+// TestOutbounds probes a JSON array of outbounds and returns one result per
+// input, in input order, each carrying the outbound's tag. allOutboundsJSON
+// supplies the config context (sockopt.dialerProxy chains); testURL falls
+// back to the default probe URL when empty.
+func (s *OutboundService) TestOutbounds(outboundsJSON string, testURL string, allOutboundsJSON string, mode string) ([]*TestOutboundResult, error) {
+	var raw []json.RawMessage
+	if err := json.Unmarshal([]byte(outboundsJSON), &raw); err != nil {
+		return nil, fmt.Errorf("invalid outbounds JSON: %v", err)
+	}
+	if len(raw) > maxBatchItems {
+		return nil, fmt.Errorf("too many outbounds in one request (max %d)", maxBatchItems)
+	}
+	items := make([]map[string]any, len(raw))
+	for i, r := range raw {
+		var ob map[string]any
+		if err := json.Unmarshal(r, &ob); err == nil {
+			items[i] = ob
+		}
+	}
+	return s.testOutboundsParsed(items, testURL, allOutboundsJSON, mode), nil
+}
+
+// testOutboundsParsed splits items into the TCP lane (direct dials, bounded
+// worker pool) and the HTTP lane (one shared temp xray instance), runs both,
+// and returns results aligned with items. A nil item marks unparseable input.
+func (s *OutboundService) testOutboundsParsed(items []map[string]any, testURL string, allOutboundsJSON string, mode string) []*TestOutboundResult {
+	results := make([]*TestOutboundResult, len(items))
+
+	modeLabel := "http"
+	if mode == "tcp" {
+		modeLabel = "tcp"
+	}
+
+	type tcpEntry struct {
+		idx int
+		ob  map[string]any
+	}
+	var tcpLane []tcpEntry
+	var httpItems []*httpBatchItem
+	seenTags := make(map[string]bool)
+
+	for i, ob := range items {
+		if ob == nil {
+			results[i] = &TestOutboundResult{Mode: modeLabel, Success: false, Error: "Invalid outbound JSON"}
+			continue
+		}
+		// A bare TCP dial only proves reachability for TCP-based proxies.
+		// UDP protocols (wireguard, hysteria, kcp/quic transports) ignore
+		// unauthenticated packets, so a raw dial can't tell "reachable" from
+		// "dead" — route them through the real xray probe.
+		if mode == "tcp" && !outboundTransportIsUDP(ob) {
+			tcpLane = append(tcpLane, tcpEntry{idx: i, ob: ob})
+			continue
+		}
+
+		tag, _ := ob["tag"].(string)
+		r := &TestOutboundResult{Tag: tag, Mode: "http"}
+		results[i] = r
+		protocol, _ := ob["protocol"].(string)
+		switch {
+		case tag == "":
+			r.Error = "Outbound has no tag"
+		case protocol == "blackhole" || tag == "blocked":
+			r.Error = "Blocked/blackhole outbound cannot be tested"
+		case protocol == "loopback":
+			r.Error = "Loopback outbound cannot be tested"
+		case seenTags[tag]:
+			r.Error = fmt.Sprintf("Duplicate outbound tag in batch: %s", tag)
+		default:
+			seenTags[tag] = true
+			httpItems = append(httpItems, &httpBatchItem{index: i, tag: tag, outbound: ob, result: r})
+		}
+	}
+
+	if len(tcpLane) > 0 {
+		var wg sync.WaitGroup
+		sem := make(chan struct{}, tcpBatchConcurrency)
+		for _, e := range tcpLane {
+			wg.Add(1)
+			go func(e tcpEntry) {
+				defer wg.Done()
+				sem <- struct{}{}
+				defer func() { <-sem }()
+				obJSON, err := json.Marshal(e.ob)
+				if err != nil {
+					tag, _ := e.ob["tag"].(string)
+					results[e.idx] = &TestOutboundResult{Tag: tag, Mode: "tcp", Success: false, Error: fmt.Sprintf("Invalid outbound JSON: %v", err)}
+					return
+				}
+				r, _ := s.testOutboundTCP(string(obJSON))
+				results[e.idx] = r
+			}(e)
+		}
+		wg.Wait()
+	}
+
+	if len(httpItems) == 0 {
+		return results
+	}
+
+	failAll := func(msg string) {
+		for _, it := range httpItems {
+			it.result.Success = false
+			it.result.Error = msg
+		}
+	}
+
+	var allOutbounds []any
+	if allOutboundsJSON != "" {
+		if err := json.Unmarshal([]byte(allOutboundsJSON), &allOutbounds); err != nil {
+			failAll(fmt.Sprintf("Invalid allOutbounds JSON: %v", err))
+			return results
+		}
+	}
+
+	if testURL == "" {
+		testURL = defaultTestURL
+	}
+
+	if !httpTestSemaphore.TryLock() {
+		failAll("Another outbound test is already running, please wait")
+		return results
+	}
+	defer httpTestSemaphore.Unlock()
+
+	retryPerItem, err := runHTTPProbeBatch(httpItems, allOutbounds, testURL)
+	if err == nil {
+		return results
+	}
+	if !retryPerItem || len(httpItems) == 1 {
+		failAll(err.Error())
+		return results
+	}
+	// The shared process never came up — one structurally-bad outbound can
+	// poison the whole batch config. Retry each item in its own isolated
+	// instance so the broken outbound reports xray's real error and the
+	// rest still get tested. Serial: the poisoned case fails fast (~1s).
+	for _, it := range httpItems {
+		if _, ferr := runHTTPProbeBatch([]*httpBatchItem{it}, allOutbounds, testURL); ferr != nil {
+			it.result.Success = false
+			it.result.Error = ferr.Error()
+		}
+	}
+	return results
+}
+
+// runHTTPProbeBatch makes one shared-process attempt for the given items,
+// writing per-request outcomes into the items' results. It returns a non-nil
+// error only when the process never became usable; retryPerItem reports
+// whether splitting the batch into per-item instances could help (true for
+// start failures / early exits that a poisoned config would explain, false
+// for environmental failures like a missing binary or no free ports).
+func runHTTPProbeBatch(items []*httpBatchItem, allOutbounds []any, testURL string) (retryPerItem bool, err error) {
+	ports, release, err := reserveLoopbackPorts(len(items))
+	if err != nil {
+		return false, fmt.Errorf("Failed to reserve test ports: %v", err)
+	}
+	defer release()
+
+	cfg := buildBatchTestConfig(items, allOutbounds, ports)
+
+	configPath, err := createTestConfigPath()
+	if err != nil {
+		return false, fmt.Errorf("Failed to create test config path: %v", err)
+	}
+	defer os.Remove(configPath)
+
+	proc := newBatchProcess(cfg, configPath)
+	defer func() {
+		if proc.IsRunning() {
+			proc.Stop()
+		}
+	}()
+
+	// Free the reserved ports just before xray binds them; the window is
+	// milliseconds, and a lost race makes xray exit fast, which surfaces
+	// below and triggers the per-item retry with fresh ports.
+	release()
+	if err := proc.Start(); err != nil {
+		if errors.Is(err, fs.ErrNotExist) {
+			// Binary missing — per-item retries would all fail the same way.
+			return false, fmt.Errorf("Failed to start test xray instance: %v", err)
+		}
+		return true, fmt.Errorf("Failed to start test xray instance: %v", err)
+	}
+
+	if err := waitForPortsReady(proc, ports, batchPortsReadyTimeout); err != nil {
+		return err.exited, err
+	}
+
+	sem := make(chan struct{}, httpProbeConcurrency)
+	var wg sync.WaitGroup
+	for i := range items {
+		wg.Add(1)
+		go func(it *httpBatchItem, port int) {
+			defer wg.Done()
+			sem <- struct{}{}
+			defer func() { <-sem }()
+			probeThroughSocks(port, testURL, httpProbeTimeout, it.result)
+		}(items[i], ports[i])
+	}
+	wg.Wait()
+
+	if !proc.IsRunning() {
+		detail := proc.GetResult()
+		for _, it := range items {
+			if !it.result.Success {
+				it.result.Error = "Xray process exited: " + detail
+			}
+		}
+	}
+	return false, nil
+}
+
+// portsReadyError distinguishes "process died" (a poisoned config — worth a
+// per-item retry) from "ports never opened while alive" (environmental).
+type portsReadyError struct {
+	msg    string
+	exited bool
+}
+
+func (e *portsReadyError) Error() string { return e.msg }
+
+// waitForPortsReady polls until every test inbound accepts connections,
+// aborting as soon as the process exits.
+func waitForPortsReady(proc batchProcess, ports []int, timeout time.Duration) *portsReadyError {
+	deadline := time.Now().Add(timeout)
+	for _, port := range ports {
+		for {
+			if !proc.IsRunning() {
+				return &portsReadyError{msg: "Xray process exited: " + proc.GetResult(), exited: true}
+			}
+			conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 100*time.Millisecond)
+			if err == nil {
+				conn.Close()
+				break
+			}
+			if time.Now().After(deadline) {
+				return &portsReadyError{msg: fmt.Sprintf("Xray failed to open test inbounds: port %d not ready after %v", port, timeout)}
+			}
+			time.Sleep(50 * time.Millisecond)
+		}
+	}
+	return nil
+}
+
+// buildBatchTestConfig assembles the temp instance config: one loopback SOCKS
+// inbound per tested outbound, a routing rule binding each inbound to its
+// outbound tag, and the full outbound context so dialerProxy chains resolve.
+func buildBatchTestConfig(items []*httpBatchItem, allOutbounds []any, ports []int) *xray.Config {
+	// allOutbounds is the template's outbound list; subscription outbounds
+	// are injected at runtime and aren't part of it, so append any tested
+	// outbound whose tag is missing. When a tested outbound's tag collides
+	// with a template outbound, the template version wins — same semantics
+	// as the pre-batch tester.
+	outbounds := make([]any, 0, len(allOutbounds)+len(items))
+	outbounds = append(outbounds, allOutbounds...)
+	for _, it := range items {
+		if !outboundsContainTag(outbounds, it.tag) {
+			outbounds = append(outbounds, it.outbound)
+		}
+	}
+	for _, ob := range outbounds {
+		outbound, ok := ob.(map[string]any)
+		if !ok {
+			continue
+		}
+		// The temp instance must not touch kernel WireGuard devices.
+		if protocol, ok := outbound["protocol"].(string); ok && protocol == "wireguard" {
+			if settings, ok := outbound["settings"].(map[string]any); ok {
+				settings["noKernelTun"] = true
+			} else {
+				outbound["settings"] = map[string]any{"noKernelTun": true}
+			}
+		}
+	}
+	outboundsJSON, _ := json.Marshal(outbounds)
+
+	inbounds := make([]xray.InboundConfig, len(items))
+	rules := make([]any, len(items))
+	for i, it := range items {
+		inTag := fmt.Sprintf("test-in-%d", i)
+		inbounds[i] = xray.InboundConfig{
+			Listen:   json_util.RawMessage(`"127.0.0.1"`),
+			Port:     ports[i],
+			Protocol: "socks",
+			Settings: json_util.RawMessage(`{"auth":"noauth","udp":false}`),
+			Tag:      inTag,
+		}
+		rules[i] = map[string]any{
+			"type":        "field",
+			"inboundTag":  []string{inTag},
+			"outboundTag": it.tag,
+		}
+	}
+	routingJSON, _ := json.Marshal(map[string]any{
+		"domainStrategy": "AsIs",
+		"rules":          rules,
+	})
+
+	logJSON, _ := json.Marshal(map[string]any{
+		"loglevel": "warning",
+		"access":   "none",
+		"error":    "",
+		"dnsLog":   false,
+	})
+
+	return &xray.Config{
+		LogConfig:       json_util.RawMessage(logJSON),
+		InboundConfigs:  inbounds,
+		OutboundConfigs: json_util.RawMessage(outboundsJSON),
+		RouterConfig:    json_util.RawMessage(routingJSON),
+		Policy:          json_util.RawMessage(`{}`),
+		Stats:           json_util.RawMessage(`{}`),
+	}
+}
+
+// outboundsContainTag reports whether any outbound in the slice has the given tag.
+func outboundsContainTag(outbounds []any, tag string) bool {
+	for _, ob := range outbounds {
+		if m, ok := ob.(map[string]any); ok {
+			if t, _ := m["tag"].(string); t == tag {
+				return true
+			}
+		}
+	}
+	return false
+}
+
+// probeThroughSocks issues one timed GET through the local SOCKS inbound at
+// the given port and fills result. Any HTTP response — including 4xx/5xx and
+// unfollowed redirects — counts as reachable; only transport-level failures
+// (refused, reset, timeout, proxy errors) are failures. Delay is request
+// start → response headers; the test URL's hostname is resolved by xray
+// (Go's SOCKS5 client sends the domain to the proxy), so DNS goes through
+// the outbound too.
+func probeThroughSocks(port int, testURL string, timeout time.Duration, result *TestOutboundResult) {
+	proxyURL := &url.URL{Scheme: "socks5", Host: net.JoinHostPort("127.0.0.1", strconv.Itoa(port))}
+	tr := &http.Transport{
+		Proxy:             http.ProxyURL(proxyURL),
+		DisableKeepAlives: true,
+	}
+	defer tr.CloseIdleConnections()
+	client := &http.Client{
+		Transport: tr,
+		Timeout:   timeout,
+		// A redirect would re-dial through the proxy and skew the timing;
+		// the 3xx itself already proves the outbound works.
+		CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
+	}
+
+	// Timing breakdown. ConnectStart/Done wrap the TCP dial to the local
+	// inbound (the SOCKS handshake isn't traced, and xray ACKs CONNECT
+	// before dialing upstream — so the real outbound establishment lands in
+	// the TLS phase for https URLs, or inside TTFB for plain http).
+	var (
+		connStart, tlsStart           time.Time
+		connDur, tlsDur, ttfbDur      time.Duration
+		connDone, tlsDone, gotFirstRB bool
+	)
+	start := time.Now()
+	trace := &httptrace.ClientTrace{
+		ConnectStart: func(network, addr string) {
+			if connStart.IsZero() {
+				connStart = time.Now()
+			}
+		},
+		ConnectDone: func(network, addr string, err error) {
+			if err == nil && !connDone && !connStart.IsZero() {
+				connDone = true
+				connDur = time.Since(connStart)
+			}
+		},
+		TLSHandshakeStart: func() {
+			if tlsStart.IsZero() {
+				tlsStart = time.Now()
+			}
+		},
+		TLSHandshakeDone: func(_ tls.ConnectionState, err error) {
+			if err == nil && !tlsDone && !tlsStart.IsZero() {
+				tlsDone = true
+				tlsDur = time.Since(tlsStart)
+			}
+		},
+		GotFirstResponseByte: func() {
+			if !gotFirstRB {
+				gotFirstRB = true
+				ttfbDur = time.Since(start)
+			}
+		},
+	}
+
+	req, err := http.NewRequestWithContext(httptrace.WithClientTrace(context.Background(), trace), http.MethodGet, testURL, nil)
+	if err != nil {
+		result.Error = err.Error()
+		return
+	}
+	resp, err := client.Do(req)
+	delay := time.Since(start).Milliseconds()
+	if err != nil {
+		result.Error = err.Error()
+		return
+	}
+	resp.Body.Close()
+
+	result.Success = true
+	result.Delay = max(delay, 1)
+	result.HTTPStatus = resp.StatusCode
+	if connDone {
+		result.ConnectMs = max(connDur.Milliseconds(), 1)
+	}
+	if tlsDone {
+		result.TLSMs = max(tlsDur.Milliseconds(), 1)
+	}
+	if gotFirstRB {
+		result.TTFBMs = max(ttfbDur.Milliseconds(), 1)
+	}
+}
+
+// reserveLoopbackPorts grabs n free loopback ports and keeps the listeners
+// open so nothing else claims them; release() frees them (idempotent — the
+// caller releases right before starting xray and again via defer).
+func reserveLoopbackPorts(n int) ([]int, func(), error) {
+	listeners := make([]net.Listener, 0, n)
+	release := func() {
+		for _, l := range listeners {
+			l.Close()
+		}
+	}
+	ports := make([]int, 0, n)
+	for range n {
+		l, err := net.Listen("tcp", "127.0.0.1:0")
+		if err != nil {
+			release()
+			return nil, nil, err
+		}
+		listeners = append(listeners, l)
+		ports = append(ports, l.Addr().(*net.TCPAddr).Port)
+	}
+	return ports, release, nil
+}
+
+// createTestConfigPath returns a unique path for a temporary xray config file in the bin folder.
+// The temp file is created and closed so the path is reserved; Start() will overwrite it.
+func createTestConfigPath() (string, error) {
+	tmpFile, err := os.CreateTemp(config.GetBinFolderPath(), "xray_test_*.json")
+	if err != nil {
+		return "", err
+	}
+	path := tmpFile.Name()
+	if err := tmpFile.Close(); err != nil {
+		os.Remove(path)
+		return "", err
+	}
+	return path, nil
+}

+ 470 - 0
internal/web/service/outbound/probe_http_test.go

@@ -0,0 +1,470 @@
+package outbound
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"io/fs"
+	"net"
+	"net/http"
+	"net/http/httptest"
+	"strconv"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// stubProcess implements batchProcess without an xray binary. When serveSocks
+// is set, Start opens a minimal SOCKS5 server on every inbound port from the
+// config, so probes run against a real tunnel.
+type stubProcess struct {
+	cfg        *xray.Config
+	startErr   error
+	result     string
+	serveSocks bool
+
+	running   bool
+	listeners []net.Listener
+}
+
+func (p *stubProcess) Start() error {
+	if p.startErr != nil {
+		return p.startErr
+	}
+	for _, in := range p.cfg.InboundConfigs {
+		l, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", in.Port))
+		if err != nil {
+			return err
+		}
+		p.listeners = append(p.listeners, l)
+		if p.serveSocks {
+			go serveStubSocks(l)
+		}
+	}
+	p.running = true
+	return nil
+}
+
+func (p *stubProcess) Stop() error {
+	for _, l := range p.listeners {
+		l.Close()
+	}
+	p.running = false
+	return nil
+}
+
+func (p *stubProcess) IsRunning() bool { return p.running }
+func (p *stubProcess) GetResult() string {
+	if p.result != "" {
+		return p.result
+	}
+	return "stub exited"
+}
+
+// serveStubSocks answers SOCKS5 no-auth CONNECTs and pipes to the requested
+// target — just enough protocol for net/http's socks5 client.
+func serveStubSocks(l net.Listener) {
+	for {
+		conn, err := l.Accept()
+		if err != nil {
+			return
+		}
+		go func(c net.Conn) {
+			defer c.Close()
+			hello := make([]byte, 2)
+			if _, err := io.ReadFull(c, hello); err != nil {
+				return
+			}
+			methods := make([]byte, hello[1])
+			if _, err := io.ReadFull(c, methods); err != nil {
+				return
+			}
+			c.Write([]byte{0x05, 0x00})
+			hdr := make([]byte, 4)
+			if _, err := io.ReadFull(c, hdr); err != nil {
+				return
+			}
+			var host string
+			switch hdr[3] {
+			case 0x01:
+				b := make([]byte, 4)
+				io.ReadFull(c, b)
+				host = net.IP(b).String()
+			case 0x03:
+				lb := make([]byte, 1)
+				io.ReadFull(c, lb)
+				b := make([]byte, lb[0])
+				io.ReadFull(c, b)
+				host = string(b)
+			case 0x04:
+				b := make([]byte, 16)
+				io.ReadFull(c, b)
+				host = net.IP(b).String()
+			default:
+				return
+			}
+			pb := make([]byte, 2)
+			if _, err := io.ReadFull(c, pb); err != nil {
+				return
+			}
+			port := int(pb[0])<<8 | int(pb[1])
+			upstream, err := net.Dial("tcp", net.JoinHostPort(host, strconv.Itoa(port)))
+			if err != nil {
+				c.Write([]byte{0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
+				return
+			}
+			defer upstream.Close()
+			c.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
+			go io.Copy(upstream, c)
+			io.Copy(c, upstream)
+		}(conn)
+	}
+}
+
+func withStubProcess(t *testing.T, factory func(cfg *xray.Config, configPath string) batchProcess) {
+	t.Helper()
+	// createTestConfigPath writes into the bin folder, which doesn't exist
+	// when running tests from the package directory.
+	t.Setenv("XUI_BIN_FOLDER", t.TempDir())
+	orig := newBatchProcess
+	newBatchProcess = factory
+	t.Cleanup(func() { newBatchProcess = orig })
+}
+
+func mustJSON(t *testing.T, v any) string {
+	t.Helper()
+	b, err := json.Marshal(v)
+	if err != nil {
+		t.Fatalf("marshal: %v", err)
+	}
+	return string(b)
+}
+
+func TestBuildBatchTestConfig(t *testing.T) {
+	items := []*httpBatchItem{
+		{tag: "wg-sub", outbound: map[string]any{"tag": "wg-sub", "protocol": "wireguard"}},
+		{tag: "proxy-a", outbound: map[string]any{"tag": "proxy-a", "protocol": "vless"}},
+	}
+	allOutbounds := []any{
+		map[string]any{"tag": "direct", "protocol": "freedom", "settings": map[string]any{}},
+		map[string]any{"tag": "proxy-a", "protocol": "vless", "settings": map[string]any{"address": "a.example.com"}},
+	}
+	ports := []int{61001, 61002}
+
+	cfg := buildBatchTestConfig(items, allOutbounds, ports)
+	raw, err := json.Marshal(cfg)
+	if err != nil {
+		t.Fatalf("marshal config: %v", err)
+	}
+	var m map[string]any
+	if err := json.Unmarshal(raw, &m); err != nil {
+		t.Fatalf("unmarshal config: %v", err)
+	}
+
+	inbounds, _ := m["inbounds"].([]any)
+	if len(inbounds) != 2 {
+		t.Fatalf("expected 2 inbounds, got %d", len(inbounds))
+	}
+	for i, raw := range inbounds {
+		in := raw.(map[string]any)
+		if got := in["tag"]; got != fmt.Sprintf("test-in-%d", i) {
+			t.Errorf("inbound %d tag = %v", i, got)
+		}
+		if got := int(in["port"].(float64)); got != ports[i] {
+			t.Errorf("inbound %d port = %d, want %d", i, got, ports[i])
+		}
+		if got := in["protocol"]; got != "socks" {
+			t.Errorf("inbound %d protocol = %v", i, got)
+		}
+		if got := in["listen"]; got != "127.0.0.1" {
+			t.Errorf("inbound %d listen = %v", i, got)
+		}
+		settings := in["settings"].(map[string]any)
+		if settings["auth"] != "noauth" || settings["udp"] != false {
+			t.Errorf("inbound %d settings = %v", i, settings)
+		}
+	}
+
+	routing := m["routing"].(map[string]any)
+	rules, _ := routing["rules"].([]any)
+	if len(rules) != 2 {
+		t.Fatalf("expected 2 routing rules, got %d", len(rules))
+	}
+	wantTags := []string{"wg-sub", "proxy-a"}
+	for i, raw := range rules {
+		rule := raw.(map[string]any)
+		inTags := rule["inboundTag"].([]any)
+		if len(inTags) != 1 || inTags[0] != fmt.Sprintf("test-in-%d", i) {
+			t.Errorf("rule %d inboundTag = %v", i, inTags)
+		}
+		if rule["outboundTag"] != wantTags[i] {
+			t.Errorf("rule %d outboundTag = %v, want %s", i, rule["outboundTag"], wantTags[i])
+		}
+	}
+
+	outbounds, _ := m["outbounds"].([]any)
+	if len(outbounds) != 3 {
+		t.Fatalf("expected 3 outbounds (wg-sub appended once, proxy-a deduped), got %d", len(outbounds))
+	}
+	var wg map[string]any
+	for _, raw := range outbounds {
+		ob := raw.(map[string]any)
+		if ob["tag"] == "wg-sub" {
+			wg = ob
+		}
+	}
+	if wg == nil {
+		t.Fatal("wg-sub not appended to outbounds")
+	}
+	if settings, _ := wg["settings"].(map[string]any); settings == nil || settings["noKernelTun"] != true {
+		t.Errorf("wireguard settings missing noKernelTun: %v", wg["settings"])
+	}
+
+	if m["burstObservatory"] != nil {
+		t.Errorf("burstObservatory should not be set, got %v", m["burstObservatory"])
+	}
+	if m["metrics"] != nil {
+		t.Errorf("metrics should not be set, got %v", m["metrics"])
+	}
+}
+
+func TestTestOutboundsPrevalidationAndOrdering(t *testing.T) {
+	calls := 0
+	withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
+		calls++
+		return &stubProcess{cfg: cfg, startErr: errors.New("boom")}
+	})
+
+	batch := mustJSON(t, []any{
+		map[string]any{"protocol": "vless"},                   // no tag
+		map[string]any{"tag": "bh", "protocol": "blackhole"},  // blackhole
+		map[string]any{"tag": "loop", "protocol": "loopback"}, // loopback
+		map[string]any{"tag": "a", "protocol": "socks"},       // valid
+		map[string]any{"tag": "a", "protocol": "vless"},       // duplicate
+	})
+	results, err := (&OutboundService{}).TestOutbounds(batch, "http://example.invalid/gen", "", "http")
+	if err != nil {
+		t.Fatalf("TestOutbounds: %v", err)
+	}
+	if len(results) != 5 {
+		t.Fatalf("expected 5 results, got %d", len(results))
+	}
+	wantErrs := []string{
+		"Outbound has no tag",
+		"Blocked/blackhole outbound cannot be tested",
+		"Loopback outbound cannot be tested",
+		"Failed to start test xray instance: boom",
+		"Duplicate outbound tag in batch: a",
+	}
+	for i, want := range wantErrs {
+		if results[i].Success {
+			t.Errorf("result %d unexpectedly succeeded", i)
+		}
+		if results[i].Error != want {
+			t.Errorf("result %d error = %q, want %q", i, results[i].Error, want)
+		}
+	}
+	if results[3].Tag != "a" || results[4].Tag != "a" || results[1].Tag != "bh" {
+		t.Errorf("tags not propagated: %+v", results)
+	}
+	// Single valid item → no per-item fallback round.
+	if calls != 1 {
+		t.Errorf("process spawned %d times, want 1", calls)
+	}
+}
+
+func TestTestOutboundsFallbackOnStartFailure(t *testing.T) {
+	calls := 0
+	withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
+		calls++
+		return &stubProcess{cfg: cfg, startErr: errors.New("boom")}
+	})
+
+	batch := mustJSON(t, []any{
+		map[string]any{"tag": "a", "protocol": "socks"},
+		map[string]any{"tag": "b", "protocol": "vless"},
+	})
+	results, err := (&OutboundService{}).TestOutbounds(batch, "http://example.invalid/gen", "", "http")
+	if err != nil {
+		t.Fatalf("TestOutbounds: %v", err)
+	}
+	for i, r := range results {
+		if r.Success || r.Error != "Failed to start test xray instance: boom" {
+			t.Errorf("result %d = %+v, want start failure", i, r)
+		}
+	}
+	// 1 shared attempt + 2 isolated fallback attempts.
+	if calls != 3 {
+		t.Errorf("process spawned %d times, want 3", calls)
+	}
+}
+
+func TestTestOutboundsNoFallbackWhenBinaryMissing(t *testing.T) {
+	calls := 0
+	withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
+		calls++
+		return &stubProcess{cfg: cfg, startErr: &fs.PathError{Op: "exec", Path: "xray", Err: fs.ErrNotExist}}
+	})
+
+	batch := mustJSON(t, []any{
+		map[string]any{"tag": "a", "protocol": "socks"},
+		map[string]any{"tag": "b", "protocol": "vless"},
+	})
+	results, err := (&OutboundService{}).TestOutbounds(batch, "http://example.invalid/gen", "", "http")
+	if err != nil {
+		t.Fatalf("TestOutbounds: %v", err)
+	}
+	for i, r := range results {
+		if r.Success || !strings.HasPrefix(r.Error, "Failed to start test xray instance:") {
+			t.Errorf("result %d = %+v, want start failure", i, r)
+		}
+	}
+	if calls != 1 {
+		t.Errorf("process spawned %d times, want 1 (no fallback for missing binary)", calls)
+	}
+}
+
+func TestTestOutboundsSemaphoreBusy(t *testing.T) {
+	withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
+		t.Fatal("process must not be spawned while semaphore is held")
+		return nil
+	})
+
+	httpTestSemaphore.Lock()
+	defer httpTestSemaphore.Unlock()
+
+	batch := mustJSON(t, []any{map[string]any{"tag": "a", "protocol": "socks"}})
+	results, err := (&OutboundService{}).TestOutbounds(batch, "", "", "http")
+	if err != nil {
+		t.Fatalf("TestOutbounds: %v", err)
+	}
+	if results[0].Success || results[0].Error != "Another outbound test is already running, please wait" {
+		t.Errorf("result = %+v, want busy error", results[0])
+	}
+}
+
+func TestTestOutboundsInputValidation(t *testing.T) {
+	s := &OutboundService{}
+	if _, err := s.TestOutbounds("not json", "", "", "tcp"); err == nil {
+		t.Error("expected error for invalid JSON")
+	}
+
+	big := make([]any, maxBatchItems+1)
+	for i := range big {
+		big[i] = map[string]any{"tag": fmt.Sprintf("t%d", i), "protocol": "socks"}
+	}
+	if _, err := s.TestOutbounds(mustJSON(t, big), "", "", "tcp"); err == nil {
+		t.Error("expected error for oversized batch")
+	}
+
+	results, err := s.TestOutbounds("[]", "", "", "tcp")
+	if err != nil || len(results) != 0 {
+		t.Errorf("empty batch: results=%v err=%v", results, err)
+	}
+}
+
+func TestTestOutboundsTCPLane(t *testing.T) {
+	l, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatalf("listen: %v", err)
+	}
+	defer l.Close()
+	go func() {
+		for {
+			conn, err := l.Accept()
+			if err != nil {
+				return
+			}
+			conn.Close()
+		}
+	}()
+	port := l.Addr().(*net.TCPAddr).Port
+
+	batch := mustJSON(t, []any{map[string]any{
+		"tag":      "t1",
+		"protocol": "socks",
+		"settings": map[string]any{"servers": []any{map[string]any{"address": "127.0.0.1", "port": port}}},
+	}})
+	results, err := (&OutboundService{}).TestOutbounds(batch, "", "", "tcp")
+	if err != nil {
+		t.Fatalf("TestOutbounds: %v", err)
+	}
+	r := results[0]
+	if !r.Success || r.Mode != "tcp" || r.Tag != "t1" || len(r.Endpoints) != 1 {
+		t.Errorf("unexpected tcp result: %+v", r)
+	}
+}
+
+func TestTestOutboundsHTTPBatchThroughStubSocks(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusNoContent)
+	}))
+	defer srv.Close()
+
+	var proc *stubProcess
+	calls := 0
+	withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
+		calls++
+		proc = &stubProcess{cfg: cfg, serveSocks: true}
+		return proc
+	})
+
+	batch := mustJSON(t, []any{
+		map[string]any{"tag": "a", "protocol": "vless"},
+		map[string]any{"tag": "b", "protocol": "trojan"},
+	})
+	results, err := (&OutboundService{}).TestOutbounds(batch, srv.URL, "", "http")
+	if err != nil {
+		t.Fatalf("TestOutbounds: %v", err)
+	}
+	if calls != 1 {
+		t.Fatalf("process spawned %d times, want 1", calls)
+	}
+	for i, r := range results {
+		if !r.Success {
+			t.Fatalf("result %d failed: %+v", i, r)
+		}
+		if r.HTTPStatus != http.StatusNoContent {
+			t.Errorf("result %d status = %d, want 204", i, r.HTTPStatus)
+		}
+		if r.Delay < 1 || r.ConnectMs < 1 || r.TTFBMs < 1 {
+			t.Errorf("result %d timing not populated: %+v", i, r)
+		}
+		if r.TLSMs != 0 {
+			t.Errorf("result %d TLSMs = %d, want 0 for plain http", i, r.TLSMs)
+		}
+		if r.Mode != "http" {
+			t.Errorf("result %d mode = %q", i, r.Mode)
+		}
+	}
+	if proc.IsRunning() {
+		t.Error("temp process not stopped after batch")
+	}
+}
+
+func TestProbeThroughSocksTransportFailure(t *testing.T) {
+	// A listener that accepts and immediately closes — SOCKS handshake dies.
+	l, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatalf("listen: %v", err)
+	}
+	defer l.Close()
+	go func() {
+		for {
+			conn, err := l.Accept()
+			if err != nil {
+				return
+			}
+			conn.Close()
+		}
+	}()
+
+	var result TestOutboundResult
+	probeThroughSocks(l.Addr().(*net.TCPAddr).Port, "http://127.0.0.1:9/", 2*time.Second, &result)
+	if result.Success || result.Error == "" {
+		t.Errorf("expected transport failure, got %+v", result)
+	}
+}

+ 88 - 16
internal/web/service/xray.go

@@ -3,6 +3,7 @@ package service
 import (
 	"encoding/json"
 	"errors"
+	"path"
 	"path/filepath"
 	"runtime"
 	"strings"
@@ -274,6 +275,19 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
 		mergeSubscriptionOutbounds(xrayConfig, prepend, appendList)
 	}
 
+	// Route opted-in local mtproto inbounds through the core's router. Each one
+	// gets a loopback SOCKS bridge — tagged with the inbound's own tag so it is
+	// matchable in routing rules — that its mtg sidecar dials Telegram through.
+	// Done after the subscription merge so a selected subscription outbound (or
+	// balancer) is a valid rule target.
+	for i := range inbounds {
+		inbound := inbounds[i]
+		if inbound.Protocol != model.MTProto || !inbound.Enable || inbound.NodeID != nil {
+			continue
+		}
+		injectMtprotoEgress(xrayConfig, inbound)
+	}
+
 	// Wire the panel's own HTTP traffic through the configured outbound, after
 	// the subscription merge so subscription outbound tags are valid targets.
 	if egressTag, err := s.settingService.GetPanelOutbound(); err != nil {
@@ -381,6 +395,75 @@ func routingTagIsBalancer(routing map[string]any, tag string) bool {
 	return false
 }
 
+// mtprotoEgressSocksSettings is the loopback SOCKS server a routed mtproto
+// inbound exposes for its mtg sidecar to dial Telegram through. mtg makes plain
+// TCP connections, so UDP is left off (matching the panel egress bridge).
+const mtprotoEgressSocksSettings = `{"auth":"noauth","udp":false}`
+
+// injectMtprotoEgress wires one routed mtproto inbound into the generated
+// config: it appends a loopback SOCKS inbound (tagged with the inbound's own tag,
+// on the egress port persisted in settings) and, when an outbound is selected,
+// prepends a routing rule sending that tag to it. Both live only in the generated
+// config — the stored template is untouched — and both are hot-appliable, so
+// toggling routing never forces a full Xray restart. Mirrors injectPanelEgress.
+func injectMtprotoEgress(cfg *xray.Config, inbound *model.Inbound) {
+	var parsed struct {
+		RouteThroughXray bool   `json:"routeThroughXray"`
+		RouteXrayPort    int    `json:"routeXrayPort"`
+		OutboundTag      string `json:"outboundTag"`
+	}
+	if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil {
+		return
+	}
+	if !parsed.RouteThroughXray || parsed.RouteXrayPort <= 0 || inbound.Tag == "" {
+		return
+	}
+	tag := inbound.Tag
+	for i := range cfg.InboundConfigs {
+		if cfg.InboundConfigs[i].Tag == tag {
+			logger.Warning("mtproto egress: inbound tag [", tag, "] already present in generated config, skipping bridge")
+			return
+		}
+	}
+
+	if parsed.OutboundTag != "" {
+		routing := map[string]any{}
+		parseOK := true
+		if len(cfg.RouterConfig) > 0 {
+			if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
+				logger.Warning("mtproto egress: routing section is unparsable, skipping rule:", err)
+				parseOK = false
+			}
+		}
+		if parseOK {
+			rules, _ := routing["rules"].([]any)
+			rule := map[string]any{
+				"type":       "field",
+				"inboundTag": []any{tag},
+			}
+			if routingTagIsBalancer(routing, parsed.OutboundTag) {
+				rule["balancerTag"] = parsed.OutboundTag
+			} else {
+				rule["outboundTag"] = parsed.OutboundTag
+			}
+			routing["rules"] = append([]any{rule}, rules...)
+			if newRouting, err := json.Marshal(routing); err == nil {
+				cfg.RouterConfig = json_util.RawMessage(newRouting)
+			} else {
+				logger.Warning("mtproto egress: failed to rebuild routing section, skipping rule:", err)
+			}
+		}
+	}
+
+	cfg.InboundConfigs = append(cfg.InboundConfigs, xray.InboundConfig{
+		Listen:   json_util.RawMessage(`"127.0.0.1"`),
+		Port:     parsed.RouteXrayPort,
+		Protocol: "socks",
+		Settings: json_util.RawMessage(mtprotoEgressSocksSettings),
+		Tag:      tag,
+	})
+}
+
 // mergeSubscriptionOutbounds appends the subscription outbounds to the
 // OutboundConfigs array of the xray config. It works on the already-unmarshaled
 // template so that manually configured outbounds are never overwritten.
@@ -498,11 +581,6 @@ func ensureStatsPolicy(policy json_util.RawMessage) json_util.RawMessage {
 	return out
 }
 
-// resolveXrayLogPaths rewrites relative `log.access` / `log.error` values to
-// absolute paths under config.GetLogFolder(), so Xray writes those files
-// alongside the panel's other logs regardless of the working directory the
-// panel was launched from. Values that are empty, "none", or already absolute
-// are left untouched, as are unparseable log blocks.
 func resolveXrayLogPaths(logCfg json_util.RawMessage) json_util.RawMessage {
 	if len(logCfg) == 0 {
 		return logCfg
@@ -521,21 +599,15 @@ func resolveXrayLogPaths(logCfg json_util.RawMessage) json_util.RawMessage {
 		if trimmed == "" || strings.EqualFold(trimmed, "none") {
 			continue
 		}
-		if filepath.IsAbs(trimmed) {
-			continue
-		}
-		cleaned := filepath.ToSlash(filepath.Clean(trimmed))
-		base := filepath.Base(cleaned)
-		if base == "" || base == "." || base == string(filepath.Separator) {
+		base := path.Base(filepath.ToSlash(trimmed))
+		if base == "" || base == "." || base == ".." || base == "/" {
 			continue
 		}
-		// Only rewrite bare names ("./access.log", "access.log").
-		// A nested relative path like "./logs/foo.log" is treated as
-		// a deliberate user choice and left alone.
-		if cleaned != base {
+		confined := filepath.Join(config.GetLogFolder(), base)
+		if confined == trimmed {
 			continue
 		}
-		parsed[key] = filepath.Join(config.GetLogFolder(), base)
+		parsed[key] = confined
 		changed = true
 	}
 	if !changed {

+ 97 - 0
internal/web/service/xray_config_inject_test.go

@@ -5,6 +5,7 @@ import (
 	"os"
 	"testing"
 
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
@@ -271,3 +272,99 @@ func TestInjectPanelEgress_BadRoutingSkips(t *testing.T) {
 		t.Fatal("unparsable routing must be left untouched")
 	}
 }
+
+func mtprotoInbound(tag string, settings string) *model.Inbound {
+	return &model.Inbound{Tag: tag, Protocol: model.MTProto, Enable: true, Settings: settings}
+}
+
+func TestInjectMtprotoEgress_WithOutbound(t *testing.T) {
+	cfg := egressTestConfig()
+	injectMtprotoEgress(cfg, mtprotoInbound("inbound-443",
+		`{"routeThroughXray":true,"routeXrayPort":50000,"outboundTag":"warp"}`))
+
+	if len(cfg.InboundConfigs) != 2 {
+		t.Fatalf("expected the bridge inbound to be appended, got %d", len(cfg.InboundConfigs))
+	}
+	ib := cfg.InboundConfigs[1]
+	if ib.Tag != "inbound-443" || ib.Protocol != "socks" || ib.Port != 50000 {
+		t.Fatalf("unexpected bridge inbound: %+v", ib)
+	}
+	if string(ib.Listen) != `"127.0.0.1"` {
+		t.Fatalf("bridge must listen on loopback, got %s", ib.Listen)
+	}
+
+	var routing egressRouting
+	if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
+		t.Fatal(err)
+	}
+	if len(routing.Rules) != 2 {
+		t.Fatalf("expected the egress rule prepended to the existing rule, got %+v", routing.Rules)
+	}
+	first := routing.Rules[0]
+	if first.Type != "field" || first.OutboundTag != "warp" ||
+		len(first.InboundTag) != 1 || first.InboundTag[0] != "inbound-443" {
+		t.Fatalf("egress rule must bind the inbound tag to the outbound, got %+v", first)
+	}
+}
+
+func TestInjectMtprotoEgress_NoOutboundLeavesRouting(t *testing.T) {
+	cfg := egressTestConfig()
+	before := string(cfg.RouterConfig)
+	injectMtprotoEgress(cfg, mtprotoInbound("inbound-443",
+		`{"routeThroughXray":true,"routeXrayPort":50001}`))
+
+	if len(cfg.InboundConfigs) != 2 || cfg.InboundConfigs[1].Port != 50001 {
+		t.Fatalf("bridge must still be appended without an outbound, got %+v", cfg.InboundConfigs)
+	}
+	if string(cfg.RouterConfig) != before {
+		t.Fatalf("no outbound means no rule change, got %s", cfg.RouterConfig)
+	}
+}
+
+func TestInjectMtprotoEgress_BalancerTag(t *testing.T) {
+	cfg := egressTestConfig()
+	cfg.RouterConfig = json_util.RawMessage(`{"rules":[],"balancers":[{"tag":"lb","selector":["warp"]}]}`)
+	injectMtprotoEgress(cfg, mtprotoInbound("inbound-443",
+		`{"routeThroughXray":true,"routeXrayPort":50002,"outboundTag":"lb"}`))
+
+	var routing struct {
+		Rules []struct {
+			OutboundTag string `json:"outboundTag"`
+			BalancerTag string `json:"balancerTag"`
+		} `json:"rules"`
+	}
+	if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
+		t.Fatal(err)
+	}
+	if len(routing.Rules) != 1 || routing.Rules[0].BalancerTag != "lb" || routing.Rules[0].OutboundTag != "" {
+		t.Fatalf("a balancer tag must target balancerTag, got %+v", routing.Rules)
+	}
+}
+
+func TestInjectMtprotoEgress_Disabled(t *testing.T) {
+	// Not routed, and routed-but-portless, are both no-ops.
+	for _, settings := range []string{
+		`{"routeThroughXray":false,"routeXrayPort":50000}`,
+		`{"routeThroughXray":true}`,
+		`{"routeThroughXray":true,"routeXrayPort":0}`,
+	} {
+		cfg := egressTestConfig()
+		before := string(cfg.RouterConfig)
+		injectMtprotoEgress(cfg, mtprotoInbound("inbound-443", settings))
+		if len(cfg.InboundConfigs) != 1 || string(cfg.RouterConfig) != before {
+			t.Fatalf("settings %s must be a no-op, got %d inbounds", settings, len(cfg.InboundConfigs))
+		}
+	}
+}
+
+func TestInjectMtprotoEgress_TagCollisionSkips(t *testing.T) {
+	cfg := egressTestConfig()
+	cfg.InboundConfigs = append(cfg.InboundConfigs,
+		xray.InboundConfig{Port: 443, Protocol: "vless", Tag: "inbound-443"})
+	before := string(cfg.RouterConfig)
+	injectMtprotoEgress(cfg, mtprotoInbound("inbound-443",
+		`{"routeThroughXray":true,"routeXrayPort":50003,"outboundTag":"warp"}`))
+	if len(cfg.InboundConfigs) != 2 || string(cfg.RouterConfig) != before {
+		t.Fatal("a real inbound already owning the tag must make the bridge a no-op")
+	}
+}

+ 31 - 3
internal/web/translation/ar-EG.json

@@ -416,6 +416,7 @@
       },
       "telegramDesc": "ادخل ID شات Telegram. (استخدم '/id' في البوت) أو ({'@'}userinfobot)",
       "subscriptionDesc": "عشان تلاقي رابط الاشتراك، ادخل على 'التفاصيل'. وكمان ممكن تستخدم نفس الاسم لعدة عملاء.",
+      "subSortIndex": "ترتيب الاشتراك",
       "same": "نفسه",
       "inboundInfo": "معلومات الإدخال",
       "exportInbound": "تصدير الإدخال",
@@ -484,6 +485,11 @@
         "mtgProxyProtocolListener": "قبول بروتوكول PROXY (المستمع)",
         "mtgPreferIp": "تفضيل IP",
         "mtgDebug": "سجل التصحيح",
+        "mtgRouteThroughXray": "التوجيه عبر Xray",
+        "mtgRouteThroughXrayHint": "أرسل حركة Telegram لهذا البروكسي عبر Xray ليتبع قواعد التوجيه لديك. يتصل وسيط mtg عبر جسر SOCKS محلي يحمل وسم هذا الاتصال الوارد؛ استخدم ذلك الوسم في تبويب التوجيه للقواعد المتقدمة.",
+        "mtgRouteOutbound": "الصادر",
+        "mtgRouteOutboundHint": "اختياري. إجبار حركة Telegram على الخروج عبر هذا الصادر (أو الموازِن). اتركه فارغًا لتقرر قواعد التوجيه.",
+        "mtgRouteOutboundPlaceholder": "استخدام قواعد التوجيه",
         "visionTestseed": "Vision testseed",
         "version": "الإصدار",
         "udpIdleTimeout": "UDP idle timeout (ثانية)",
@@ -590,11 +596,13 @@
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
         "getNewSeed": "احصل على Seed جديد",
-        "listenHelp": "يمكنك أيضًا إدخال مسار Unix socket (مثل /run/xray/in.sock) للاستماع على socket بدلاً من منفذ TCP — في هذه الحالة اضبط المنفذ على 0.",
+        "listenHelp": "يمكنك أيضًا إدخال مسار Unix socket (مثل /run/xray/in.sock)، أو اسم socket مجرد مسبوقًا بـ @ (مثل @xray/in.sock)، للاستماع على socket بدلاً من منفذ TCP — في هذه الحالة اضبط المنفذ على 0.",
         "shareAddrStrategy": "استراتيجية عنوان المشاركة",
         "shareAddrStrategyHelp": "تحدد العنوان الذي يُكتب في روابط المشاركة المصدّرة ورموز QR ومخرجات الاشتراك.",
         "shareAddr": "عنوان مشاركة مخصص",
         "shareAddrHelp": "يُستخدم فقط عندما تكون استراتيجية عنوان المشاركة مخصصة. أدخل اسم مضيف أو عنوان IP بدون بروتوكول أو منفذ.",
+        "subSortIndex": "ترتيب الروابط في الاشتراك",
+        "subSortIndexHelp": "موضع روابط هذا الوارد في مخرجات الاشتراك (صفحة الاشتراك وتطبيقات العملاء). القيم الأقل تظهر أولاً، والقيم المتساوية تحافظ على ترتيب الإنشاء. لا يؤثر على قائمة الواردات في اللوحة.",
         "shareAddrStrategyOptions": {
           "node": "عنوان العقدة",
           "listen": "عنوان استماع الوارد",
@@ -809,11 +817,14 @@
     "groups": {
       "title": "المجموعات",
       "name": "الاسم",
-      "clientCount": "عملاء في المجموعة",
+      "clientCount": "العملاء",
       "totalGroups": "إجمالي المجموعات",
       "totalGroupedClients": "العملاء بمجموعة",
       "trafficUsed": "حركة المرور المستخدمة",
+      "upload": "رفع",
+      "download": "تنزيل",
       "totalTraffic": "إجمالي حركة المرور",
+      "totalUpDown": "إجمالي الرفع / التنزيل",
       "addGroup": "إضافة مجموعة",
       "createSuccess": "تم إنشاء المجموعة «{name}».",
       "rename": "إعادة تسمية",
@@ -994,7 +1005,18 @@
       "telegramChatId": "ID شات الأدمن",
       "telegramChatIdDesc": "ID شات الأدمن في Telegram. (مفصول بفواصل)(تقدر تجيبه من {'@'}userinfobot) أو (استخدم '/id' في البوت)",
       "telegramNotifyTime": "وقت الإشعار",
-      "telegramNotifyTimeDesc": "وقت إشعار البوت للتقارير الدورية. (استخدم صيغة وقت crontab)",
+      "telegramNotifyTimeDesc": "عدد مرات إرسال البوت للتقارير الدورية. اختر فترة جاهزة، أو اختر «مخصص» لإدخال تعبير crontab.",
+      "notifyTime": {
+        "every": "@every — التكرار ضمن فترة",
+        "hourly": "@hourly — كل ساعة",
+        "daily": "@daily — كل يوم الساعة 00:00",
+        "weekly": "@weekly — كل أسبوع",
+        "monthly": "@monthly — كل شهر",
+        "custom": "مخصص (crontab)",
+        "seconds": "ثوانٍ",
+        "minutes": "دقائق",
+        "hours": "ساعات"
+      },
       "tgNotifyBackup": "نسخة احتياطية لقاعدة البيانات",
       "tgNotifyBackupDesc": "ابعت ملف النسخة الاحتياطية لقاعدة البيانات مع التقرير.",
       "tgNotifyLogin": "إشعار بتسجيل الدخول",
@@ -1025,6 +1047,7 @@
       "subAnnounceDesc": "نص الإعلان المعروض في عميل VPN",
       "subThemeDir": "مجلد قالب الاشتراك",
       "subThemeDirDesc": "المسار المطلق لمجلد يحتوي على قالب مخصص (index.html/sub.html) لصفحة الاشتراك (مثل /etc/3x-ui/sub_templates/my-theme/). اتركه فارغًا لاستخدام الصفحة الافتراضية.",
+      "subThemeDirDocs": "دليل القالب ↗",
       "subEnableRouting": "تفعيل التوجيه",
       "subEnableRoutingDesc": "إعداد عام لتمكين التوجيه (Routing) في عميل VPN. (فقط لـ Happ)",
       "subRoutingRules": "قواعد التوجيه",
@@ -1128,6 +1151,7 @@
       "proxyAndServer": "البروكسي والسيرفر",
       "intervals": "الفترات",
       "information": "المعلومات",
+      "profile": "الملف الشخصي",
       "language": "اللغة",
       "telegramBotLanguage": "لغة بوت Telegram",
       "security": {
@@ -1378,6 +1402,10 @@
         "testError": "فشل اختبار المخرج",
         "testModeTooltip": "TCP: فحص dial سريع. HTTP: طلب كامل عبر xray.",
         "testAll": "اختبار الكل",
+        "httpStatus": "حالة HTTP",
+        "breakdownConnect": "اتصال البروكسي",
+        "breakdownTls": "TLS عبر الصادر",
+        "breakdownTtfb": "أول بايت",
         "nordvpn": "NordVPN",
         "accessToken": "رمز الوصول",
         "country": "الدولة",

+ 31 - 3
internal/web/translation/en-US.json

@@ -416,6 +416,7 @@
       },
       "telegramDesc": "Please provide Telegram Chat ID. (use '/id' command in the bot) or ({'@'}userinfobot)",
       "subscriptionDesc": "To find your subscription URL, navigate to the 'Details'. Additionally, you can use the same name for several clients.",
+      "subSortIndex": "Sub order",
       "same": "Same",
       "inboundInfo": "Inbound Information",
       "exportInbound": "Export Inbound",
@@ -485,6 +486,11 @@
         "mtgProxyProtocolListener": "Accept PROXY protocol (listener)",
         "mtgPreferIp": "IP preference",
         "mtgDebug": "Debug logging",
+        "mtgRouteThroughXray": "Route through Xray",
+        "mtgRouteThroughXrayHint": "Send this proxy's Telegram traffic through Xray so it follows your routing rules. The mtg sidecar dials out via a loopback SOCKS bridge tagged with this inbound's tag; reference that tag in the Routing tab for advanced rules.",
+        "mtgRouteOutbound": "Outbound",
+        "mtgRouteOutboundHint": "Optional. Force Telegram traffic out through this outbound (or balancer). Leave empty to let your routing rules decide.",
+        "mtgRouteOutboundPlaceholder": "Use routing rules",
         "visionTestseed": "Vision testseed",
         "version": "Version",
         "udpIdleTimeout": "UDP idle timeout (s)",
@@ -591,11 +597,13 @@
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
         "getNewSeed": "Get New Seed",
-        "listenHelp": "You can also enter a Unix socket path (e.g. /run/xray/in.sock) to listen on a socket instead of a TCP port — set Port to 0 in that case.",
+        "listenHelp": "You can also enter a Unix socket path (e.g. /run/xray/in.sock), or an abstract socket name prefixed with @ (e.g. @xray/in.sock), to listen on a socket instead of a TCP port — set Port to 0 in that case.",
         "shareAddrStrategy": "Share address strategy",
         "shareAddrStrategyHelp": "Controls which address is written into exported share links, QR codes, and subscription output.",
         "shareAddr": "Custom share address",
         "shareAddrHelp": "Used only when the share address strategy is Custom. Enter a host or IP without a scheme or port.",
+        "subSortIndex": "Subscription sort order",
+        "subSortIndexHelp": "Position of this inbound's links in subscription output (sub page and client apps). Lower values come first; equal values keep creation order. Does not affect the panel inbound list.",
         "shareAddrStrategyOptions": {
           "node": "Node address",
           "listen": "Inbound listen",
@@ -810,11 +818,14 @@
     "groups": {
       "title": "Groups",
       "name": "Name",
-      "clientCount": "Clients in group",
+      "clientCount": "Clients",
       "totalGroups": "Total groups",
       "totalGroupedClients": "Clients with a group",
       "trafficUsed": "Traffic used",
+      "upload": "Upload",
+      "download": "Download",
       "totalTraffic": "Total traffic",
+      "totalUpDown": "Total upload / download",
       "addGroup": "Add Group",
       "createSuccess": "Group \"{name}\" created.",
       "rename": "Rename",
@@ -995,7 +1006,18 @@
       "telegramChatId": "Admin Chat ID",
       "telegramChatIdDesc": "The Telegram Admin Chat ID(s). (comma-separated)(get it here {'@'}userinfobot) or (use '/id' command in the bot)",
       "telegramNotifyTime": "Notification Time",
-      "telegramNotifyTimeDesc": "The Telegram bot notification time set for periodic reports. (use the crontab time format)",
+      "telegramNotifyTimeDesc": "How often the Telegram bot sends periodic reports. Pick a preset interval, or choose Custom to enter a raw crontab expression.",
+      "notifyTime": {
+        "every": "@every — repeat at an interval",
+        "hourly": "@hourly — every hour",
+        "daily": "@daily — every day at 00:00",
+        "weekly": "@weekly — every week",
+        "monthly": "@monthly — every month",
+        "custom": "Custom (crontab)",
+        "seconds": "Seconds",
+        "minutes": "Minutes",
+        "hours": "Hours"
+      },
       "tgNotifyBackup": "Database Backup",
       "tgNotifyBackupDesc": "Send a database backup file with a report.",
       "tgNotifyLogin": "Login Notification",
@@ -1026,6 +1048,7 @@
       "subAnnounceDesc": "The announcement text displayed in the VPN client",
       "subThemeDir": "Sub Theme Directory",
       "subThemeDirDesc": "Absolute path to a folder containing a custom index.html/sub.html subscription page template (e.g. /etc/3x-ui/sub_templates/my-theme/). Leave empty to use the default page.",
+      "subThemeDirDocs": "Template guide ↗",
       "subEnableRouting": "Enable routing",
       "subEnableRoutingDesc": "Global setting to enable routing in the VPN client. (Only for Happ)",
       "subRoutingRules": "Routing rules",
@@ -1129,6 +1152,7 @@
       "proxyAndServer": "Proxy and Server",
       "intervals": "Intervals",
       "information": "Information",
+      "profile": "Profile",
       "language": "Language",
       "telegramBotLanguage": "Telegram Bot Language",
       "security": {
@@ -1381,6 +1405,10 @@
         "testError": "Failed to test outbound",
         "testModeTooltip": "TCP: fast dial-only probe. HTTP: full request through xray.",
         "testAll": "Test all",
+        "httpStatus": "HTTP status",
+        "breakdownConnect": "Proxy connect",
+        "breakdownTls": "TLS via outbound",
+        "breakdownTtfb": "First byte",
         "nordvpn": "NordVPN",
         "accessToken": "Access Token",
         "country": "Country",

+ 31 - 3
internal/web/translation/es-ES.json

@@ -416,6 +416,7 @@
       },
       "telegramDesc": "Por favor, proporciona el ID de Chat de Telegram. (usa el comando '/id' en el bot) o ({'@'}userinfobot)",
       "subscriptionDesc": "Puedes encontrar tu enlace de suscripción en Detalles, también puedes usar el mismo nombre para varias configuraciones.",
+      "subSortIndex": "Orden sub",
       "same": "misma",
       "inboundInfo": "Información de entrada",
       "exportInbound": "Exportación entrante",
@@ -484,6 +485,11 @@
         "mtgProxyProtocolListener": "Aceptar protocolo PROXY (escucha)",
         "mtgPreferIp": "Preferencia de IP",
         "mtgDebug": "Registro de depuración",
+        "mtgRouteThroughXray": "Enrutar a través de Xray",
+        "mtgRouteThroughXrayHint": "Envía el tráfico de Telegram de este proxy a través de Xray para que siga tus reglas de enrutamiento. El sidecar mtg sale por un puente SOCKS local con la etiqueta de esta entrada; usa esa etiqueta en la pestaña Enrutamiento para reglas avanzadas.",
+        "mtgRouteOutbound": "Salida",
+        "mtgRouteOutboundHint": "Opcional. Fuerza el tráfico de Telegram a salir por esta salida (o balanceador). Déjalo vacío para que decidan tus reglas de enrutamiento.",
+        "mtgRouteOutboundPlaceholder": "Usar reglas de enrutamiento",
         "visionTestseed": "Vision testseed",
         "version": "Versión",
         "udpIdleTimeout": "UDP idle timeout (s)",
@@ -590,11 +596,13 @@
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
         "getNewSeed": "Obtener nuevo Seed",
-        "listenHelp": "También puedes introducir una ruta de socket Unix (p. ej. /run/xray/in.sock) para escuchar en un socket en lugar de un puerto TCP; en ese caso, establece el Puerto en 0.",
+        "listenHelp": "También puedes introducir una ruta de socket Unix (p. ej. /run/xray/in.sock), o un nombre de socket abstracto con el prefijo @ (p. ej. @xray/in.sock), para escuchar en un socket en lugar de un puerto TCP; en ese caso, establece el Puerto en 0.",
         "shareAddrStrategy": "Estrategia de dirección para compartir",
         "shareAddrStrategyHelp": "Controla qué dirección se escribe en los enlaces compartidos exportados, códigos QR y la salida de suscripción.",
         "shareAddr": "Dirección compartida personalizada",
         "shareAddrHelp": "Solo se usa cuando la estrategia de dirección para compartir es Personalizada. Introduce un host o IP sin esquema ni puerto.",
+        "subSortIndex": "Orden en la suscripción",
+        "subSortIndexHelp": "Posición de los enlaces de esta entrada en la salida de la suscripción (página de suscripción y apps cliente). Los valores más bajos van primero; con valores iguales se mantiene el orden de creación. No afecta a la lista de entradas del panel.",
         "shareAddrStrategyOptions": {
           "node": "Dirección del nodo",
           "listen": "Dirección de escucha del inbound",
@@ -809,11 +817,14 @@
     "groups": {
       "title": "Grupos",
       "name": "Nombre",
-      "clientCount": "Clientes en el grupo",
+      "clientCount": "Clientes",
       "totalGroups": "Total de grupos",
       "totalGroupedClients": "Clientes con grupo",
       "trafficUsed": "Tráfico usado",
+      "upload": "Subida",
+      "download": "Bajada",
       "totalTraffic": "Tráfico total",
+      "totalUpDown": "Subida / bajada total",
       "addGroup": "Añadir grupo",
       "createSuccess": "Grupo «{name}» creado.",
       "rename": "Renombrar",
@@ -994,7 +1005,18 @@
       "telegramChatId": "IDs de Chat de Telegram para Administradores",
       "telegramChatIdDesc": "IDs de Chat múltiples separados por comas. Use {'@'}userinfobot o use el comando '/id' en el bot para obtener sus IDs de Chat.",
       "telegramNotifyTime": "Hora de Notificación del Bot de Telegram",
-      "telegramNotifyTimeDesc": "Usar el formato de tiempo de Crontab.",
+      "telegramNotifyTimeDesc": "Con qué frecuencia el bot de Telegram envía informes periódicos. Elige un intervalo predefinido o selecciona Personalizado para introducir una expresión crontab.",
+      "notifyTime": {
+        "every": "@every — repetir en un intervalo",
+        "hourly": "@hourly — cada hora",
+        "daily": "@daily — cada día a las 00:00",
+        "weekly": "@weekly — cada semana",
+        "monthly": "@monthly — cada mes",
+        "custom": "Personalizado (crontab)",
+        "seconds": "Segundos",
+        "minutes": "Minutos",
+        "hours": "Horas"
+      },
       "tgNotifyBackup": "Respaldo de Base de Datos",
       "tgNotifyBackupDesc": "Incluir archivo de respaldo de base de datos con notificación de informe.",
       "tgNotifyLogin": "Notificación de Inicio de Sesión",
@@ -1025,6 +1047,7 @@
       "subAnnounceDesc": "El texto del anuncio mostrado en el cliente VPN",
       "subThemeDir": "Directorio del tema de suscripción",
       "subThemeDirDesc": "Ruta absoluta a una carpeta que contiene una plantilla personalizada (index.html/sub.html) para la página de suscripción (p. ej. /etc/3x-ui/sub_templates/my-theme/). Déjalo vacío para usar la página predeterminada.",
+      "subThemeDirDocs": "Guía de plantillas ↗",
       "subEnableRouting": "Habilitar enrutamiento",
       "subEnableRoutingDesc": "Configuración global para habilitar el enrutamiento en el cliente VPN. (Solo para Happ)",
       "subRoutingRules": "Reglas de enrutamiento",
@@ -1128,6 +1151,7 @@
       "proxyAndServer": "Proxy y Servidor",
       "intervals": "Intervalos",
       "information": "Información",
+      "profile": "Perfil",
       "language": "Idioma",
       "telegramBotLanguage": "Idioma del Bot de Telegram",
       "security": {
@@ -1378,6 +1402,10 @@
         "testError": "Error al probar la salida",
         "testModeTooltip": "TCP: sonda rápida solo de dial. HTTP: petición completa a través de xray.",
         "testAll": "Probar todo",
+        "httpStatus": "Estado HTTP",
+        "breakdownConnect": "Conexión al proxy",
+        "breakdownTls": "TLS vía salida",
+        "breakdownTtfb": "Primer byte",
         "nordvpn": "NordVPN",
         "accessToken": "Token de acceso",
         "country": "País",

+ 31 - 3
internal/web/translation/fa-IR.json

@@ -416,6 +416,7 @@
       },
       "telegramDesc": "لطفا شناسه گفتگوی تلگرام را وارد کنید. (از دستور '/id' در ربات استفاده کنید) یا ({'@'}userinfobot)",
       "subscriptionDesc": "شما می‌توانید لینک سابسکربپشن خودرا در 'جزئیات' پیدا کنید، همچنین می‌توانید از همین نام برای چندین کاربر استفاده‌کنید",
+      "subSortIndex": "ترتیب اشتراک",
       "same": "همسان",
       "inboundInfo": "اطلاعات ورودی",
       "exportInbound": "استخراج ورودی",
@@ -484,6 +485,11 @@
         "mtgProxyProtocolListener": "پذیرش پروتکل PROXY (شنونده)",
         "mtgPreferIp": "ترجیح IP",
         "mtgDebug": "گزارش اشکال‌زدایی",
+        "mtgRouteThroughXray": "مسیریابی از طریق Xray",
+        "mtgRouteThroughXrayHint": "ترافیک تلگرام این پراکسی را از طریق Xray بفرستید تا از قوانین مسیریابی شما پیروی کند. سرویس جانبی mtg از طریق یک پل SOCKS محلی که با تگ همین ورودی نشانه‌گذاری شده خارج می‌شود؛ برای قوانین پیشرفته در تب مسیریابی به همان تگ ارجاع دهید.",
+        "mtgRouteOutbound": "خروجی",
+        "mtgRouteOutboundHint": "اختیاری. ترافیک تلگرام را وادار کنید از این خروجی (یا متعادل‌کننده) خارج شود. برای اینکه قوانین مسیریابی تصمیم بگیرند، خالی بگذارید.",
+        "mtgRouteOutboundPlaceholder": "استفاده از قوانین مسیریابی",
         "visionTestseed": "Vision testseed",
         "version": "نسخه",
         "udpIdleTimeout": "UDP idle timeout (s)",
@@ -590,11 +596,13 @@
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
         "getNewSeed": "دریافت Seed جدید",
-        "listenHelp": "می‌توانید به‌جای پورت TCP یک مسیر سوکت یونیکس وارد کنید (مثلاً /run/xray/in.sock) تا روی سوکت گوش داده شود — در این حالت پورت را روی ۰ بگذارید.",
+        "listenHelp": "می‌توانید به‌جای پورت TCP یک مسیر سوکت یونیکس وارد کنید (مثلاً /run/xray/in.sock)، یا یک نام سوکت انتزاعی با پیشوند @ (مثلاً @xray/in.sock)، تا روی سوکت گوش داده شود — در این حالت پورت را روی ۰ بگذارید.",
         "shareAddrStrategy": "راهبرد آدرس اشتراک‌گذاری",
         "shareAddrStrategyHelp": "مشخص می‌کند کدام آدرس در لینک‌های اشتراک‌گذاری خروجی، کدهای QR و خروجی اشتراک نوشته شود.",
         "shareAddr": "آدرس اشتراک‌گذاری سفارشی",
         "shareAddrHelp": "فقط زمانی استفاده می‌شود که راهبرد آدرس اشتراک‌گذاری روی سفارشی باشد. میزبان یا IP را بدون طرح و پورت وارد کنید.",
+        "subSortIndex": "ترتیب در اشتراک",
+        "subSortIndexHelp": "جایگاه لینک‌های این ورودی در خروجی اشتراک (صفحه اشتراک و برنامه‌های کلاینت). مقدار کمتر اول می‌آید و مقدارهای برابر ترتیب ایجاد را حفظ می‌کنند. روی فهرست ورودی‌های پنل تأثیری ندارد.",
         "shareAddrStrategyOptions": {
           "node": "آدرس نود",
           "listen": "آدرس شنود ورودی",
@@ -809,11 +817,14 @@
     "groups": {
       "title": "گروه‌ها",
       "name": "نام",
-      "clientCount": "کاربران در گروه",
+      "clientCount": "کاربران",
       "totalGroups": "تعداد گروه‌ها",
       "totalGroupedClients": "کاربران دارای گروه",
       "trafficUsed": "ترافیک مصرف‌شده",
+      "upload": "آپلود",
+      "download": "دانلود",
       "totalTraffic": "مجموع ترافیک",
+      "totalUpDown": "مجموع آپلود / دانلود",
       "addGroup": "افزودن گروه",
       "createSuccess": "گروه «{name}» ایجاد شد.",
       "rename": "تغییر نام",
@@ -994,7 +1005,18 @@
       "telegramChatId": "آی‌دی چت مدیر",
       "telegramChatIdDesc": "دریافت ‌کنید ('/id'یا (دستور ({'@'}userinfobot) آی‌دی(های) چت تلگرام مدیر، از",
       "telegramNotifyTime": "زمان نوتیفیکیشن",
-      "telegramNotifyTimeDesc": "زمان‌اطلاع‌رسانی ربات تلگرام برای گزارش های دوره‌ای. از فرمت زمانبندی لینوکس استفاده‌کنید‌",
+      "telegramNotifyTimeDesc": "هر چند وقت یک‌بار ربات تلگرام گزارش دوره‌ای بفرستد. یک بازهٔ آماده انتخاب کنید یا گزینهٔ سفارشی را بزنید تا عبارت crontab وارد کنید.",
+      "notifyTime": {
+        "every": "@every — تکرار در یک بازه",
+        "hourly": "@hourly — هر ساعت",
+        "daily": "@daily — هر روز ساعت ۰۰:۰۰",
+        "weekly": "@weekly — هر هفته",
+        "monthly": "@monthly — هر ماه",
+        "custom": "سفارشی (crontab)",
+        "seconds": "ثانیه",
+        "minutes": "دقیقه",
+        "hours": "ساعت"
+      },
       "tgNotifyBackup": "پشتیبان‌گیری از دیتابیس",
       "tgNotifyBackupDesc": "فایل پشتیبان‌دیتابیس را به‌همراه گزارش ارسال می‌کند",
       "tgNotifyLogin": "اعلان ورود",
@@ -1025,6 +1047,7 @@
       "subAnnounceDesc": "متن اعلانی که در کلاینت VPN نمایش داده می‌شود",
       "subThemeDir": "پوشه قالب صفحه اشتراک",
       "subThemeDirDesc": "مسیر مطلق پوشه‌ای که شامل یک قالب سفارشی (index.html/sub.html) برای صفحه اشتراک است (مثلاً /etc/3x-ui/sub_templates/my-theme/). برای استفاده از صفحه پیش‌فرض خالی بگذارید.",
+      "subThemeDirDocs": "راهنمای قالب ↗",
       "subEnableRouting": "فعال‌سازی مسیریابی",
       "subEnableRoutingDesc": "تنظیمات سراسری برای فعال‌سازی مسیریابی در کلاینت VPN. (فقط برای Happ)",
       "subRoutingRules": "قوانین مسیریابی",
@@ -1128,6 +1151,7 @@
       "proxyAndServer": "پراکسی و سرور",
       "intervals": "فواصل",
       "information": "اطلاعات",
+      "profile": "پروفایل",
       "language": "زبان",
       "telegramBotLanguage": "زبان ربات تلگرام",
       "security": {
@@ -1378,6 +1402,10 @@
         "testError": "خطا در تست خروجی",
         "testModeTooltip": "TCP: فقط dial سریع. HTTP: درخواست کامل از طریق xray.",
         "testAll": "تست همه",
+        "httpStatus": "وضعیت HTTP",
+        "breakdownConnect": "اتصال پروکسی",
+        "breakdownTls": "TLS از طریق خروجی",
+        "breakdownTtfb": "اولین بایت",
         "nordvpn": "NordVPN",
         "accessToken": "توکن دسترسی",
         "country": "کشور",

+ 31 - 3
internal/web/translation/id-ID.json

@@ -416,6 +416,7 @@
       },
       "telegramDesc": "Harap berikan ID Obrolan Telegram. (gunakan perintah '/id' di bot) atau ({'@'}userinfobot)",
       "subscriptionDesc": "Untuk menemukan URL langganan Anda, buka 'Rincian'. Selain itu, Anda dapat menggunakan nama yang sama untuk beberapa klien.",
+      "subSortIndex": "Urutan sub",
       "same": "Sama",
       "inboundInfo": "Informasi Inbound",
       "exportInbound": "Ekspor Masuk",
@@ -484,6 +485,11 @@
         "mtgProxyProtocolListener": "Terima protokol PROXY (listener)",
         "mtgPreferIp": "Preferensi IP",
         "mtgDebug": "Log debug",
+        "mtgRouteThroughXray": "Rutekan melalui Xray",
+        "mtgRouteThroughXrayHint": "Kirim lalu lintas Telegram proxy ini melalui Xray agar mengikuti aturan routing Anda. Sidecar mtg keluar lewat bridge SOCKS loopback yang diberi tag sama dengan inbound ini; rujuk tag tersebut di tab Routing untuk aturan lanjutan.",
+        "mtgRouteOutbound": "Outbound",
+        "mtgRouteOutboundHint": "Opsional. Paksa lalu lintas Telegram keluar melalui outbound (atau balancer) ini. Biarkan kosong agar aturan routing yang menentukan.",
+        "mtgRouteOutboundPlaceholder": "Gunakan aturan routing",
         "visionTestseed": "Vision testseed",
         "version": "Versi",
         "udpIdleTimeout": "UDP idle timeout (d)",
@@ -590,11 +596,13 @@
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
         "getNewSeed": "Dapatkan Seed baru",
-        "listenHelp": "Anda juga dapat memasukkan path Unix socket (mis. /run/xray/in.sock) untuk listen pada socket alih-alih port TCP — dalam hal ini setel Port ke 0.",
+        "listenHelp": "Anda juga dapat memasukkan path Unix socket (mis. /run/xray/in.sock), atau nama abstract socket dengan awalan @ (mis. @xray/in.sock), untuk listen pada socket alih-alih port TCP — dalam hal ini setel Port ke 0.",
         "shareAddrStrategy": "Strategi alamat berbagi",
         "shareAddrStrategyHelp": "Menentukan alamat yang ditulis ke tautan berbagi yang diekspor, kode QR, dan keluaran langganan.",
         "shareAddr": "Alamat berbagi kustom",
         "shareAddrHelp": "Hanya digunakan saat strategi alamat berbagi adalah Kustom. Masukkan host atau IP tanpa skema atau port.",
+        "subSortIndex": "Urutan dalam langganan",
+        "subSortIndexHelp": "Posisi tautan inbound ini dalam keluaran langganan (halaman langganan dan aplikasi klien). Nilai lebih kecil tampil lebih dulu; nilai sama mempertahankan urutan pembuatan. Tidak memengaruhi daftar inbound di panel.",
         "shareAddrStrategyOptions": {
           "node": "Alamat node",
           "listen": "Alamat listen inbound",
@@ -809,11 +817,14 @@
     "groups": {
       "title": "Grup",
       "name": "Nama",
-      "clientCount": "Klien di grup",
+      "clientCount": "Klien",
       "totalGroups": "Total grup",
       "totalGroupedClients": "Klien dengan grup",
       "trafficUsed": "Trafik terpakai",
+      "upload": "Unggah",
+      "download": "Unduh",
       "totalTraffic": "Total trafik",
+      "totalUpDown": "Total unggah / unduh",
       "addGroup": "Tambah grup",
       "createSuccess": "Grup «{name}» dibuat.",
       "rename": "Ubah nama",
@@ -994,7 +1005,18 @@
       "telegramChatId": "ID Obrolan Admin",
       "telegramChatIdDesc": "ID Obrolan Admin Telegram. (dipisahkan koma)(dapatkan di sini {'@'}userinfobot) atau (gunakan perintah '/id' di bot)",
       "telegramNotifyTime": "Waktu Notifikasi",
-      "telegramNotifyTimeDesc": "Waktu notifikasi bot Telegram yang diatur untuk laporan berkala. (gunakan format waktu crontab)",
+      "telegramNotifyTimeDesc": "Seberapa sering bot Telegram mengirim laporan berkala. Pilih interval siap pakai, atau pilih Kustom untuk memasukkan ekspresi crontab.",
+      "notifyTime": {
+        "every": "@every — ulangi dalam interval",
+        "hourly": "@hourly — setiap jam",
+        "daily": "@daily — setiap hari pukul 00:00",
+        "weekly": "@weekly — setiap minggu",
+        "monthly": "@monthly — setiap bulan",
+        "custom": "Kustom (crontab)",
+        "seconds": "Detik",
+        "minutes": "Menit",
+        "hours": "Jam"
+      },
       "tgNotifyBackup": "Cadangan Database",
       "tgNotifyBackupDesc": "Kirim berkas cadangan database dengan laporan.",
       "tgNotifyLogin": "Notifikasi Login",
@@ -1025,6 +1047,7 @@
       "subAnnounceDesc": "Teks pengumuman yang ditampilkan di klien VPN",
       "subThemeDir": "Direktori Tema Langganan",
       "subThemeDirDesc": "Path absolut ke folder yang berisi template kustom (index.html/sub.html) untuk halaman langganan (mis. /etc/3x-ui/sub_templates/my-theme/). Biarkan kosong untuk menggunakan halaman default.",
+      "subThemeDirDocs": "Panduan templat ↗",
       "subEnableRouting": "Aktifkan perutean",
       "subEnableRoutingDesc": "Pengaturan global untuk mengaktifkan perutean (routing) di klien VPN. (Hanya untuk Happ)",
       "subRoutingRules": "Aturan routing",
@@ -1128,6 +1151,7 @@
       "proxyAndServer": "Proxy dan Server",
       "intervals": "Interval",
       "information": "Informasi",
+      "profile": "Profil",
       "language": "Bahasa",
       "telegramBotLanguage": "Bahasa Bot Telegram",
       "security": {
@@ -1378,6 +1402,10 @@
         "testError": "Gagal menguji outbound",
         "testModeTooltip": "TCP: probe dial-only cepat. HTTP: permintaan penuh via xray.",
         "testAll": "Tes semua",
+        "httpStatus": "Status HTTP",
+        "breakdownConnect": "Koneksi proxy",
+        "breakdownTls": "TLS melalui outbound",
+        "breakdownTtfb": "Byte pertama",
         "nordvpn": "NordVPN",
         "accessToken": "Token Akses",
         "country": "Negara",

+ 31 - 3
internal/web/translation/ja-JP.json

@@ -416,6 +416,7 @@
       },
       "telegramDesc": "TelegramチャットIDを提供してください。(ボットで'/id'コマンドを使用)または({'@'}userinfobot)",
       "subscriptionDesc": "サブスクリプションURLを見つけるには、“詳細情報”に移動してください。また、複数のクライアントに同じ名前を使用することができます。",
+      "subSortIndex": "サブ並び順",
       "same": "同じ",
       "inboundInfo": "インバウンド情報",
       "exportInbound": "インバウンドルールをエクスポート",
@@ -484,6 +485,11 @@
         "mtgProxyProtocolListener": "PROXY プロトコルを受け入れる(リスナー)",
         "mtgPreferIp": "IP の優先設定",
         "mtgDebug": "デバッグログ",
+        "mtgRouteThroughXray": "Xray 経由でルーティング",
+        "mtgRouteThroughXrayHint": "このプロキシの Telegram トラフィックを Xray 経由にして、ルーティングルールに従わせます。mtg サイドカーは、この受信のタグを付けたループバック SOCKS ブリッジ経由で接続します。高度なルールでは、ルーティングタブでそのタグを参照してください。",
+        "mtgRouteOutbound": "アウトバウンド",
+        "mtgRouteOutboundHint": "任意。Telegram トラフィックをこのアウトバウンド(またはバランサー)から強制的に送出します。空欄にするとルーティングルールに従います。",
+        "mtgRouteOutboundPlaceholder": "ルーティングルールを使用",
         "visionTestseed": "Vision testseed",
         "version": "バージョン",
         "udpIdleTimeout": "UDP idle timeout (秒)",
@@ -590,11 +596,13 @@
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
         "getNewSeed": "新しい Seed を取得",
-        "listenHelp": "TCP ポートの代わりに Unix ソケットのパス(例: /run/xray/in.sock)を入力してソケットでリッスンすることもできます。その場合はポートを 0 に設定してください。",
+        "listenHelp": "TCP ポートの代わりに Unix ソケットのパス(例: /run/xray/in.sock)、または @ を先頭に付けた抽象ソケット名(例: @xray/in.sock)を入力してソケットでリッスンすることもできます。その場合はポートを 0 に設定してください。",
         "shareAddrStrategy": "共有アドレス戦略",
         "shareAddrStrategyHelp": "エクスポートされる共有リンク、QRコード、サブスクリプション出力に書き込むアドレスを制御します。",
         "shareAddr": "カスタム共有アドレス",
         "shareAddrHelp": "共有アドレス戦略がカスタムの場合のみ使用されます。スキームやポートを含めずにホスト名またはIPを入力してください。",
+        "subSortIndex": "サブスクリプションでの並び順",
+        "subSortIndexHelp": "サブスクリプション出力(サブスクリプションページおよびクライアントアプリ)におけるこのインバウンドのリンクの位置。値が小さいほど先頭に表示され、同じ値の場合は作成順が維持されます。パネルのインバウンド一覧には影響しません。",
         "shareAddrStrategyOptions": {
           "node": "ノードアドレス",
           "listen": "インバウンドのリッスンアドレス",
@@ -809,11 +817,14 @@
     "groups": {
       "title": "グループ",
       "name": "名前",
-      "clientCount": "グループ内のクライアント",
+      "clientCount": "クライアント",
       "totalGroups": "グループ合計",
       "totalGroupedClients": "グループのあるクライアント",
       "trafficUsed": "使用済みトラフィック",
+      "upload": "アップロード",
+      "download": "ダウンロード",
       "totalTraffic": "合計トラフィック",
+      "totalUpDown": "合計アップロード / ダウンロード",
       "addGroup": "グループ追加",
       "createSuccess": "グループ「{name}」を作成しました。",
       "rename": "名前変更",
@@ -994,7 +1005,18 @@
       "telegramChatId": "管理者チャットID",
       "telegramChatIdDesc": "Telegram管理者チャットID(複数の場合はカンマで区切る){'@'}userinfobotで取得するか、ボットで'/id'コマンドを使用して取得する",
       "telegramNotifyTime": "通知時間",
-      "telegramNotifyTimeDesc": "定期的なTelegramボット通知時間を設定する(crontab時間形式を使用)",
+      "telegramNotifyTimeDesc": "Telegram ボットが定期レポートを送信する頻度です。プリセットの間隔を選ぶか、「カスタム」を選んで crontab 式を入力します。",
+      "notifyTime": {
+        "every": "@every — 一定間隔で繰り返す",
+        "hourly": "@hourly — 1時間ごと",
+        "daily": "@daily — 毎日 00:00",
+        "weekly": "@weekly — 毎週",
+        "monthly": "@monthly — 毎月",
+        "custom": "カスタム (crontab)",
+        "seconds": "秒",
+        "minutes": "分",
+        "hours": "時間"
+      },
       "tgNotifyBackup": "データベースバックアップ",
       "tgNotifyBackupDesc": "レポート付きのデータベースバックアップファイルを送信",
       "tgNotifyLogin": "ログイン通知",
@@ -1025,6 +1047,7 @@
       "subAnnounceDesc": "VPNクライアントに表示されるお知らせのテキスト",
       "subThemeDir": "サブスクリプションテーマディレクトリ",
       "subThemeDirDesc": "サブスクリプションページのカスタムテンプレート (index.html/sub.html) を含むフォルダーの絶対パス(例: /etc/3x-ui/sub_templates/my-theme/)。空欄の場合はデフォルトのページを使用します。",
+      "subThemeDirDocs": "テンプレートガイド ↗",
       "subEnableRouting": "ルーティングを有効化",
       "subEnableRoutingDesc": "VPNクライアントでルーティングを有効にするためのグローバル設定。(Happのみ)",
       "subRoutingRules": "ルーティングルール",
@@ -1128,6 +1151,7 @@
       "proxyAndServer": "プロキシとサーバー",
       "intervals": "間隔",
       "information": "情報",
+      "profile": "プロフィール",
       "language": "言語",
       "telegramBotLanguage": "Telegram Botの言語",
       "security": {
@@ -1378,6 +1402,10 @@
         "testError": "アウトバウンドのテストに失敗しました",
         "testModeTooltip": "TCP: 高速 dial-only プローブ。HTTP: xray を経由した完全リクエスト。",
         "testAll": "すべてテスト",
+        "httpStatus": "HTTPステータス",
+        "breakdownConnect": "プロキシ接続",
+        "breakdownTls": "アウトバウンド経由のTLS",
+        "breakdownTtfb": "最初のバイト",
         "nordvpn": "NordVPN",
         "accessToken": "アクセストークン",
         "country": "国",

+ 31 - 3
internal/web/translation/pt-BR.json

@@ -416,6 +416,7 @@
       },
       "telegramDesc": "Por favor, forneça o ID do Chat do Telegram. (use o comando '/id' no bot) ou ({'@'}userinfobot)",
       "subscriptionDesc": "Para encontrar seu URL de assinatura, navegue até 'Detalhes'. Além disso, você pode usar o mesmo nome para vários clientes.",
+      "subSortIndex": "Ordem sub",
       "same": "Igual",
       "inboundInfo": "Informações do Inbound",
       "exportInbound": "Exportar Inbound",
@@ -484,6 +485,11 @@
         "mtgProxyProtocolListener": "Aceitar protocolo PROXY (listener)",
         "mtgPreferIp": "Preferência de IP",
         "mtgDebug": "Log de depuração",
+        "mtgRouteThroughXray": "Rotear pelo Xray",
+        "mtgRouteThroughXrayHint": "Envie o tráfego do Telegram deste proxy pelo Xray para que ele siga suas regras de roteamento. O sidecar mtg sai por uma ponte SOCKS loopback com a tag deste inbound; use essa tag na aba Roteamento para regras avançadas.",
+        "mtgRouteOutbound": "Saída",
+        "mtgRouteOutboundHint": "Opcional. Force o tráfego do Telegram a sair por esta saída (ou balanceador). Deixe vazio para que suas regras de roteamento decidam.",
+        "mtgRouteOutboundPlaceholder": "Usar regras de roteamento",
         "visionTestseed": "Vision testseed",
         "version": "Versão",
         "udpIdleTimeout": "UDP idle timeout (s)",
@@ -590,11 +596,13 @@
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
         "getNewSeed": "Obter novo Seed",
-        "listenHelp": "Você também pode informar um caminho de socket Unix (ex.: /run/xray/in.sock) para escutar em um socket em vez de uma porta TCP — nesse caso, defina a Porta como 0.",
+        "listenHelp": "Você também pode informar um caminho de socket Unix (ex.: /run/xray/in.sock), ou um nome de socket abstrato com o prefixo @ (ex.: @xray/in.sock), para escutar em um socket em vez de uma porta TCP — nesse caso, defina a Porta como 0.",
         "shareAddrStrategy": "Estratégia de endereço de compartilhamento",
         "shareAddrStrategyHelp": "Controla qual endereço é gravado nos links de compartilhamento exportados, códigos QR e na saída de assinatura.",
         "shareAddr": "Endereço de compartilhamento personalizado",
         "shareAddrHelp": "Usado apenas quando a estratégia de endereço de compartilhamento é Personalizada. Informe um host ou IP sem esquema nem porta.",
+        "subSortIndex": "Ordem na assinatura",
+        "subSortIndexHelp": "Posição dos links desta entrada na saída da assinatura (página de assinatura e aplicativos cliente). Valores menores vêm primeiro; valores iguais mantêm a ordem de criação. Não afeta a lista de entradas do painel.",
         "shareAddrStrategyOptions": {
           "node": "Endereço do nó",
           "listen": "Endereço de escuta do inbound",
@@ -809,11 +817,14 @@
     "groups": {
       "title": "Grupos",
       "name": "Nome",
-      "clientCount": "Clientes no grupo",
+      "clientCount": "Clientes",
       "totalGroups": "Total de grupos",
       "totalGroupedClients": "Clientes com grupo",
       "trafficUsed": "Tráfego usado",
+      "upload": "Envio",
+      "download": "Recebimento",
       "totalTraffic": "Tráfego total",
+      "totalUpDown": "Total de envio / recebimento",
       "addGroup": "Adicionar grupo",
       "createSuccess": "Grupo «{name}» criado.",
       "rename": "Renomear",
@@ -994,7 +1005,18 @@
       "telegramChatId": "ID de Chat do Administrador",
       "telegramChatIdDesc": "O(s) ID(s) de Chat do Administrador no Telegram. (separado por vírgulas)(obtenha aqui {'@'}userinfobot) ou (use o comando '/id' no bot)",
       "telegramNotifyTime": "Hora da Notificação",
-      "telegramNotifyTimeDesc": "O horário de notificação do bot do Telegram configurado para relatórios periódicos. (use o formato de tempo do crontab)",
+      "telegramNotifyTimeDesc": "Com que frequência o bot do Telegram envia relatórios periódicos. Escolha um intervalo predefinido ou selecione Personalizado para inserir uma expressão crontab.",
+      "notifyTime": {
+        "every": "@every — repetir em um intervalo",
+        "hourly": "@hourly — a cada hora",
+        "daily": "@daily — todos os dias às 00:00",
+        "weekly": "@weekly — toda semana",
+        "monthly": "@monthly — todo mês",
+        "custom": "Personalizado (crontab)",
+        "seconds": "Segundos",
+        "minutes": "Minutos",
+        "hours": "Horas"
+      },
       "tgNotifyBackup": "Backup do Banco de Dados",
       "tgNotifyBackupDesc": "Enviar arquivo de backup do banco de dados junto com o relatório.",
       "tgNotifyLogin": "Notificação de Login",
@@ -1025,6 +1047,7 @@
       "subAnnounceDesc": "O texto do anúncio exibido no cliente VPN",
       "subThemeDir": "Diretório do tema de assinatura",
       "subThemeDirDesc": "Caminho absoluto para uma pasta contendo um modelo personalizado (index.html/sub.html) para a página de assinatura (ex.: /etc/3x-ui/sub_templates/my-theme/). Deixe vazio para usar a página padrão.",
+      "subThemeDirDocs": "Guia de modelos ↗",
       "subEnableRouting": "Ativar roteamento",
       "subEnableRoutingDesc": "Configuração global para habilitar o roteamento no cliente VPN. (Apenas para Happ)",
       "subRoutingRules": "Regras de roteamento",
@@ -1128,6 +1151,7 @@
       "proxyAndServer": "Proxy e Servidor",
       "intervals": "Intervalos",
       "information": "Informação",
+      "profile": "Perfil",
       "language": "Idioma",
       "telegramBotLanguage": "Idioma do Bot do Telegram",
       "security": {
@@ -1378,6 +1402,10 @@
         "testError": "Falha ao testar saída",
         "testModeTooltip": "TCP: sondagem rápida apenas de dial. HTTP: requisição completa pelo xray.",
         "testAll": "Testar todos",
+        "httpStatus": "Status HTTP",
+        "breakdownConnect": "Conexão do proxy",
+        "breakdownTls": "TLS via saída",
+        "breakdownTtfb": "Primeiro byte",
         "nordvpn": "NordVPN",
         "accessToken": "Token de Acesso",
         "country": "País",

+ 31 - 3
internal/web/translation/ru-RU.json

@@ -416,6 +416,7 @@
       },
       "telegramDesc": "Пожалуйста, укажите Chat ID Telegram. (используйте команду '/id' в боте) или ({'@'}userinfobot)",
       "subscriptionDesc": "Вы можете найти свою ссылку подписки в разделе 'Подробнее'",
+      "subSortIndex": "Порядок",
       "same": "Тот же",
       "inboundInfo": "Информация о подключении",
       "exportInbound": "Экспорт подключений",
@@ -484,6 +485,11 @@
         "mtgProxyProtocolListener": "Принимать PROXY-протокол (слушатель)",
         "mtgPreferIp": "Предпочтение IP",
         "mtgDebug": "Журнал отладки",
+        "mtgRouteThroughXray": "Маршрутизация через Xray",
+        "mtgRouteThroughXrayHint": "Направляйте трафик Telegram этого прокси через Xray, чтобы он подчинялся вашим правилам маршрутизации. Сайдкар mtg выходит через локальный SOCKS-мост с тегом этого входящего подключения; используйте этот тег на вкладке «Маршрутизация» для расширенных правил.",
+        "mtgRouteOutbound": "Исходящее",
+        "mtgRouteOutboundHint": "Необязательно. Принудительно направить трафик Telegram через это исходящее соединение (или балансировщик). Оставьте пустым, чтобы решали ваши правила маршрутизации.",
+        "mtgRouteOutboundPlaceholder": "Использовать правила маршрутизации",
         "visionTestseed": "Vision testseed",
         "version": "Версия",
         "udpIdleTimeout": "UDP idle timeout (с)",
@@ -590,11 +596,13 @@
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
         "getNewSeed": "Получить новый Seed",
-        "listenHelp": "Можно также указать путь Unix-сокета (например, /run/xray/in.sock), чтобы слушать сокет вместо TCP-порта — в этом случае задайте порт 0.",
+        "listenHelp": "Можно также указать путь Unix-сокета (например, /run/xray/in.sock) или имя абстрактного сокета с префиксом @ (например, @xray/in.sock), чтобы слушать сокет вместо TCP-порта — в этом случае задайте порт 0.",
         "shareAddrStrategy": "Стратегия адреса для ссылок",
         "shareAddrStrategyHelp": "Определяет, какой адрес записывать в экспортируемые ссылки, QR-коды и выдачу подписки.",
         "shareAddr": "Пользовательский адрес для ссылок",
         "shareAddrHelp": "Используется только когда стратегия адреса для ссылок — пользовательская. Укажите хост или IP без схемы и порта.",
+        "subSortIndex": "Порядок в подписке",
+        "subSortIndexHelp": "Позиция ссылок этого входящего в выдаче подписки (страница подписки и клиентские приложения). Меньшие значения идут первыми; при равных значениях сохраняется порядок создания. Не влияет на список входящих в панели.",
         "shareAddrStrategyOptions": {
           "node": "Адрес узла",
           "listen": "Адрес прослушивания inbound",
@@ -809,11 +817,14 @@
     "groups": {
       "title": "Группы",
       "name": "Имя",
-      "clientCount": "Клиентов в группе",
+      "clientCount": "Клиенты",
       "totalGroups": "Всего групп",
       "totalGroupedClients": "Клиенты с группой",
       "trafficUsed": "Использованный трафик",
+      "upload": "Отправлено",
+      "download": "Получено",
       "totalTraffic": "Общий трафик",
+      "totalUpDown": "Всего отправлено / получено",
       "addGroup": "Добавить группу",
       "createSuccess": "Группа «{name}» создана.",
       "rename": "Переименовать",
@@ -994,7 +1005,18 @@
       "telegramChatId": "User ID администратора бота",
       "telegramChatIdDesc": "Один или несколько User ID администратора(-ов) Telegram-бота. Для получения User ID используйте {'@'}userinfobot или команду '/id' в боте.",
       "telegramNotifyTime": "Частота уведомлений для администраторов от бота",
-      "telegramNotifyTimeDesc": "Укажите интервал уведомлений в формате Crontab",
+      "telegramNotifyTimeDesc": "Как часто бот Telegram отправляет периодические отчёты. Выберите готовый интервал или «Произвольный», чтобы ввести выражение crontab.",
+      "notifyTime": {
+        "every": "@every — повторять с интервалом",
+        "hourly": "@hourly — каждый час",
+        "daily": "@daily — каждый день в 00:00",
+        "weekly": "@weekly — каждую неделю",
+        "monthly": "@monthly — каждый месяц",
+        "custom": "Произвольный (crontab)",
+        "seconds": "Секунды",
+        "minutes": "Минуты",
+        "hours": "Часы"
+      },
       "tgNotifyBackup": "Резервное копирование базы данных",
       "tgNotifyBackupDesc": "Отправлять уведомление с файлом резервной копии базы данных",
       "tgNotifyLogin": "Уведомление о входе",
@@ -1025,6 +1047,7 @@
       "subAnnounceDesc": "Текст объявления, отображаемый в VPN-клиенте",
       "subThemeDir": "Каталог темы подписки",
       "subThemeDirDesc": "Абсолютный путь к папке с пользовательским шаблоном (index.html/sub.html) для страницы подписки (например, /etc/3x-ui/sub_templates/my-theme/). Оставьте пустым, чтобы использовать страницу по умолчанию.",
+      "subThemeDirDocs": "Руководство по шаблонам ↗",
       "subEnableRouting": "Включить маршрутизацию",
       "subEnableRoutingDesc": "Глобальная настройка для включения маршрутизации в VPN-клиенте. (Только для Happ)",
       "subRoutingRules": "Правила маршрутизации",
@@ -1128,6 +1151,7 @@
       "proxyAndServer": "Прокси и сервер",
       "intervals": "Интервалы",
       "information": "Информация",
+      "profile": "Профиль",
       "language": "Язык интерфейса",
       "telegramBotLanguage": "Язык Telegram-бота",
       "security": {
@@ -1378,6 +1402,10 @@
         "testError": "Не удалось протестировать исходящее подключение",
         "testModeTooltip": "TCP: быстрый dial-only probe. HTTP: полный запрос через xray.",
         "testAll": "Тестировать все",
+        "httpStatus": "HTTP-статус",
+        "breakdownConnect": "Подключение к прокси",
+        "breakdownTls": "TLS через исходящий",
+        "breakdownTtfb": "Первый байт",
         "nordvpn": "NordVPN",
         "accessToken": "Токен доступа",
         "country": "Страна",

+ 31 - 3
internal/web/translation/tr-TR.json

@@ -416,6 +416,7 @@
       },
       "telegramDesc": "Lütfen Telegram Sohbet Kimliği (Chat ID) sağlayın. ({'@'}userinfobot'tan öğrenebilir veya botta '/id' komutunu kullanabilirsiniz.)",
       "subscriptionDesc": "Abonelik URL'nizi bulmak için 'Detaylar'a gidin. Aynı adı birden fazla kullanıcı için kullanabilirsiniz.",
+      "subSortIndex": "Sıralama",
       "same": "Aynı",
       "inboundInfo": "Gelen Bağlantı Bilgileri",
       "exportInbound": "Gelen Bağlantını Dışa Aktar",
@@ -485,6 +486,11 @@
         "mtgProxyProtocolListener": "PROXY protokolünü kabul et (dinleyici)",
         "mtgPreferIp": "IP tercihi",
         "mtgDebug": "Hata ayıklama günlüğü",
+        "mtgRouteThroughXray": "Xray üzerinden yönlendir",
+        "mtgRouteThroughXrayHint": "Bu proxy'nin Telegram trafiğini Xray üzerinden geçirerek yönlendirme kurallarınıza uymasını sağlayın. mtg yardımcı süreci, bu gelen bağlantının etiketini taşıyan bir loopback SOCKS köprüsü üzerinden çıkış yapar; gelişmiş kurallar için Yönlendirme sekmesinde bu etiketi kullanın.",
+        "mtgRouteOutbound": "Giden",
+        "mtgRouteOutboundHint": "İsteğe bağlı. Telegram trafiğini bu giden bağlantı (veya dengeleyici) üzerinden çıkmaya zorlar. Yönlendirme kurallarınızın karar vermesi için boş bırakın.",
+        "mtgRouteOutboundPlaceholder": "Yönlendirme kurallarını kullan",
         "visionTestseed": "Vision Testseed",
         "version": "Sürüm",
         "udpIdleTimeout": "UDP Idle Timeout (s)",
@@ -591,11 +597,13 @@
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
         "getNewSeed": "Yeni Seed Al",
-        "listenHelp": "TCP portu yerine bir Unix soket yolu da girebilirsiniz (örn. /run/xray/in.sock) — bu durumda Port'u 0 olarak ayarlayın.",
+        "listenHelp": "TCP portu yerine bir Unix soket yolu (örn. /run/xray/in.sock) ya da @ ön ekli bir soyut soket adı (örn. @xray/in.sock) da girebilirsiniz — bu durumda Port'u 0 olarak ayarlayın.",
         "shareAddrStrategy": "Paylaşım adresi stratejisi",
         "shareAddrStrategyHelp": "Dışa aktarılan paylaşım bağlantılarına, QR kodlarına ve abonelik çıktısına hangi adresin yazılacağını belirler.",
         "shareAddr": "Özel paylaşım adresi",
         "shareAddrHelp": "Yalnızca paylaşım adresi stratejisi Özel olduğunda kullanılır. Şema veya port olmadan bir ana makine ya da IP girin.",
+        "subSortIndex": "Abonelikte sıralama",
+        "subSortIndexHelp": "Bu gelen bağlantının linklerinin abonelik çıktısındaki (abonelik sayfası ve istemci uygulamaları) konumu. Küçük değerler önce gelir; eşit değerlerde oluşturulma sırası korunur. Paneldeki gelen bağlantı listesini etkilemez.",
         "shareAddrStrategyOptions": {
           "node": "Düğüm adresi",
           "listen": "Inbound dinleme adresi",
@@ -810,11 +818,14 @@
     "groups": {
       "title": "Gruplar",
       "name": "İsim",
-      "clientCount": "Gruptaki kullanıcılar",
+      "clientCount": "Kullanıcılar",
       "totalGroups": "Toplam grup",
       "totalGroupedClients": "Grubu olan kullanıcılar",
       "trafficUsed": "Kullanılan trafik",
+      "upload": "Yükleme",
+      "download": "İndirme",
       "totalTraffic": "Toplam trafik",
+      "totalUpDown": "Toplam yükleme / indirme",
       "addGroup": "Grup ekle",
       "createSuccess": "«{name}» grubu oluşturuldu.",
       "rename": "Yeniden adlandır",
@@ -993,7 +1004,18 @@
       "telegramChatId": "Yönetici Sohbet Kimliği",
       "telegramChatIdDesc": "Telegram Yönetici Sohbet Kimliği (Chat ID). Birden fazla ise virgülle ayırın. ({'@'}userinfobot'tan alabilirsiniz veya botta '/id' komutunu kullanabilirsiniz.)",
       "telegramNotifyTime": "Bildirim Zamanı",
-      "telegramNotifyTimeDesc": "Periyodik raporlar için ayarlanan Telegram bot bildirim zamanı. (crontab zaman formatını kullanın)",
+      "telegramNotifyTimeDesc": "Telegram botunun periyodik raporları gönderme sıklığı. Hazır bir aralık seçin veya bir crontab ifadesi girmek için Özel'i seçin.",
+      "notifyTime": {
+        "every": "@every — bir aralıkla tekrarla",
+        "hourly": "@hourly — her saat",
+        "daily": "@daily — her gün 00:00'da",
+        "weekly": "@weekly — her hafta",
+        "monthly": "@monthly — her ay",
+        "custom": "Özel (crontab)",
+        "seconds": "Saniye",
+        "minutes": "Dakika",
+        "hours": "Saat"
+      },
       "tgNotifyBackup": "Veritabanı Yedeği",
       "tgNotifyBackupDesc": "Bir rapor ile birlikte veritabanı yedek dosyasını gönderir.",
       "tgNotifyLogin": "Giriş Bildirimi",
@@ -1024,6 +1046,7 @@
       "subAnnounceDesc": "VPN istemcisinde görüntülenen duyuru metni",
       "subThemeDir": "Abonelik Tema Dizini",
       "subThemeDirDesc": "Abonelik sayfası için özel bir şablon (index.html/sub.html) içeren klasörün mutlak yolu (örn. /etc/3x-ui/sub_templates/my-theme/). Varsayılan sayfayı kullanmak için boş bırakın.",
+      "subThemeDirDocs": "Şablon kılavuzu ↗",
       "subEnableRouting": "Yönlendirmeyi etkinleştir",
       "subEnableRoutingDesc": "VPN istemcisinde yönlendirmeyi etkinleştirmek için genel ayar. (Yalnızca Happ için)",
       "subRoutingRules": "Yönlendirme kuralları",
@@ -1127,6 +1150,7 @@
       "proxyAndServer": "Proxy ve Sunucu",
       "intervals": "Aralıklar",
       "information": "Bilgi",
+      "profile": "Profil",
       "language": "Dil",
       "telegramBotLanguage": "Telegram Bot Dili",
       "security": {
@@ -1379,6 +1403,10 @@
         "testError": "Giden bağlantı test edilemedi",
         "testModeTooltip": "TCP: hızlı sadece arama (dial-only) testi. HTTP: Xray üzerinden tam istek.",
         "testAll": "Tümünü Test Et",
+        "httpStatus": "HTTP durumu",
+        "breakdownConnect": "Proxy bağlantısı",
+        "breakdownTls": "Giden üzerinden TLS",
+        "breakdownTtfb": "İlk bayt",
         "nordvpn": "NordVPN",
         "accessToken": "Erişim Jetonu",
         "country": "Ülke",

+ 31 - 3
internal/web/translation/uk-UA.json

@@ -416,6 +416,7 @@
       },
       "telegramDesc": "Будь ласка, вкажіть ID чату Telegram. (використовуйте команду '/id' у боті) або ({'@'}userinfobot)",
       "subscriptionDesc": "Щоб знайти URL-адресу вашої підписки, перейдіть до «Деталі». Крім того, ви можете використовувати одне ім'я для кількох клієнтів.",
+      "subSortIndex": "Порядок",
       "same": "Те саме",
       "inboundInfo": "Інформація про підключення",
       "exportInbound": "Експортувати вхідні",
@@ -484,6 +485,11 @@
         "mtgProxyProtocolListener": "Приймати PROXY-протокол (слухач)",
         "mtgPreferIp": "Перевага IP",
         "mtgDebug": "Журнал налагодження",
+        "mtgRouteThroughXray": "Маршрутизація через Xray",
+        "mtgRouteThroughXrayHint": "Спрямуйте трафік Telegram цього проксі через Xray, щоб він підкорявся вашим правилам маршрутизації. Сайдкар mtg виходить через локальний SOCKS-міст із тегом цього вхідного підключення; використовуйте цей тег на вкладці «Маршрутизація» для розширених правил.",
+        "mtgRouteOutbound": "Вихідне",
+        "mtgRouteOutboundHint": "Необов'язково. Примусово спрямувати трафік Telegram через це вихідне з'єднання (або балансувальник). Залиште порожнім, щоб вирішували ваші правила маршрутизації.",
+        "mtgRouteOutboundPlaceholder": "Використовувати правила маршрутизації",
         "visionTestseed": "Vision testseed",
         "version": "Версія",
         "udpIdleTimeout": "UDP idle timeout (с)",
@@ -590,11 +596,13 @@
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
         "getNewSeed": "Отримати новий Seed",
-        "listenHelp": "Можна також указати шлях Unix-сокета (наприклад, /run/xray/in.sock), щоб слухати сокет замість TCP-порту — у цьому разі встановіть порт 0.",
+        "listenHelp": "Можна також указати шлях Unix-сокета (наприклад, /run/xray/in.sock) або ім'я абстрактного сокета з префіксом @ (наприклад, @xray/in.sock), щоб слухати сокет замість TCP-порту — у цьому разі встановіть порт 0.",
         "shareAddrStrategy": "Стратегія адреси поширення",
         "shareAddrStrategyHelp": "Визначає, яку адресу записувати в експортовані посилання поширення, QR-коди та вивід підписки.",
         "shareAddr": "Користувацька адреса поширення",
         "shareAddrHelp": "Використовується лише коли стратегія адреси поширення — користувацька. Введіть хост або IP без схеми та порту.",
+        "subSortIndex": "Порядок у підписці",
+        "subSortIndexHelp": "Позиція посилань цього вхідного у виводі підписки (сторінка підписки та клієнтські застосунки). Менші значення йдуть першими; за однакових значень зберігається порядок створення. Не впливає на список вхідних у панелі.",
         "shareAddrStrategyOptions": {
           "node": "Адреса вузла",
           "listen": "Адреса прослуховування inbound",
@@ -809,11 +817,14 @@
     "groups": {
       "title": "Групи",
       "name": "Назва",
-      "clientCount": "Клієнтів у групі",
+      "clientCount": "Клієнти",
       "totalGroups": "Всього груп",
       "totalGroupedClients": "Клієнти з групою",
       "trafficUsed": "Використаний трафік",
+      "upload": "Вивантаження",
+      "download": "Завантаження",
       "totalTraffic": "Загальний трафік",
+      "totalUpDown": "Всього вивантажено / завантажено",
       "addGroup": "Додати групу",
       "createSuccess": "Групу «{name}» створено.",
       "rename": "Перейменувати",
@@ -994,7 +1005,18 @@
       "telegramChatId": "Ідентифікатор чату адміністратора",
       "telegramChatIdDesc": "Ідентифікатори чату адміністратора Telegram. (розділені комами) (отримайте тут {'@'}userinfobot) або (використовуйте команду '/id' у боті)",
       "telegramNotifyTime": "Час сповіщення",
-      "telegramNotifyTimeDesc": "Час повідомлення бота Telegram, встановлений для періодичних звітів. (використовуйте формат часу crontab)",
+      "telegramNotifyTimeDesc": "Як часто бот Telegram надсилає періодичні звіти. Виберіть готовий інтервал або «Власний», щоб ввести вираз crontab.",
+      "notifyTime": {
+        "every": "@every — повторювати з інтервалом",
+        "hourly": "@hourly — щогодини",
+        "daily": "@daily — щодня о 00:00",
+        "weekly": "@weekly — щотижня",
+        "monthly": "@monthly — щомісяця",
+        "custom": "Власний (crontab)",
+        "seconds": "Секунди",
+        "minutes": "Хвилини",
+        "hours": "Години"
+      },
       "tgNotifyBackup": "Резервне копіювання бази даних",
       "tgNotifyBackupDesc": "Надіслати файл резервної копії бази даних зі звітом.",
       "tgNotifyLogin": "Сповіщення про вхід",
@@ -1025,6 +1047,7 @@
       "subAnnounceDesc": "Текст оголошення, що відображається у VPN-клієнті",
       "subThemeDir": "Каталог теми підписки",
       "subThemeDirDesc": "Абсолютний шлях до теки з користувацьким шаблоном (index.html/sub.html) для сторінки підписки (наприклад, /etc/3x-ui/sub_templates/my-theme/). Залиште порожнім, щоб використовувати сторінку за замовчуванням.",
+      "subThemeDirDocs": "Посібник із шаблонів ↗",
       "subEnableRouting": "Увімкнути маршрутизацію",
       "subEnableRoutingDesc": "Глобальне налаштування для увімкнення маршрутизації у VPN-клієнті. (Тільки для Happ)",
       "subRoutingRules": "Правила маршрутизації",
@@ -1128,6 +1151,7 @@
       "proxyAndServer": "Проксі та сервер",
       "intervals": "Інтервали",
       "information": "Інформація",
+      "profile": "Профіль",
       "language": "Мова",
       "telegramBotLanguage": "Мова Telegram-бота",
       "security": {
@@ -1378,6 +1402,10 @@
         "testError": "Не вдалося протестувати вихідне з'єднання",
         "testModeTooltip": "TCP: швидкий dial-only probe. HTTP: повний запит через xray.",
         "testAll": "Тестувати всі",
+        "httpStatus": "HTTP-статус",
+        "breakdownConnect": "Підключення до проксі",
+        "breakdownTls": "TLS через вихідний",
+        "breakdownTtfb": "Перший байт",
         "nordvpn": "NordVPN",
         "accessToken": "Токен доступу",
         "country": "Країна",

+ 31 - 3
internal/web/translation/vi-VN.json

@@ -416,6 +416,7 @@
       },
       "telegramDesc": "Vui lòng cung cấp ID Trò chuyện Telegram. (sử dụng lệnh '/id' trong bot) hoặc ({'@'}userinfobot)",
       "subscriptionDesc": "Bạn có thể tìm liên kết gói đăng ký của mình trong Chi tiết, cũng như bạn có thể sử dụng cùng tên cho nhiều cấu hình khác nhau",
+      "subSortIndex": "Thứ tự sub",
       "same": "Giống nhau",
       "inboundInfo": "Thông tin Inbound",
       "exportInbound": "Xuất nhập khẩu",
@@ -484,6 +485,11 @@
         "mtgProxyProtocolListener": "Chấp nhận giao thức PROXY (trình lắng nghe)",
         "mtgPreferIp": "Ưu tiên IP",
         "mtgDebug": "Nhật ký gỡ lỗi",
+        "mtgRouteThroughXray": "Định tuyến qua Xray",
+        "mtgRouteThroughXrayHint": "Gửi lưu lượng Telegram của proxy này qua Xray để tuân theo các quy tắc định tuyến của bạn. Tiến trình phụ mtg đi ra qua một cầu SOCKS loopback mang thẻ của inbound này; tham chiếu thẻ đó trong tab Định tuyến cho các quy tắc nâng cao.",
+        "mtgRouteOutbound": "Outbound",
+        "mtgRouteOutboundHint": "Tùy chọn. Buộc lưu lượng Telegram đi ra qua outbound (hoặc bộ cân bằng) này. Để trống để các quy tắc định tuyến của bạn quyết định.",
+        "mtgRouteOutboundPlaceholder": "Dùng quy tắc định tuyến",
         "visionTestseed": "Vision testseed",
         "version": "Phiên bản",
         "udpIdleTimeout": "UDP idle timeout (s)",
@@ -590,11 +596,13 @@
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
         "getNewSeed": "Lấy Seed mới",
-        "listenHelp": "Bạn cũng có thể nhập đường dẫn Unix socket (ví dụ /run/xray/in.sock) để lắng nghe trên socket thay vì cổng TCP — khi đó hãy đặt Port là 0.",
+        "listenHelp": "Bạn cũng có thể nhập đường dẫn Unix socket (ví dụ /run/xray/in.sock), hoặc tên abstract socket có tiền tố @ (ví dụ @xray/in.sock), để lắng nghe trên socket thay vì cổng TCP — khi đó hãy đặt Port là 0.",
         "shareAddrStrategy": "Chiến lược địa chỉ chia sẻ",
         "shareAddrStrategyHelp": "Kiểm soát địa chỉ được ghi vào liên kết chia sẻ đã xuất, mã QR và nội dung đăng ký.",
         "shareAddr": "Địa chỉ chia sẻ tùy chỉnh",
         "shareAddrHelp": "Chỉ dùng khi chiến lược địa chỉ chia sẻ là Tùy chỉnh. Nhập host hoặc IP không kèm giao thức hoặc cổng.",
+        "subSortIndex": "Thứ tự trong gói đăng ký",
+        "subSortIndexHelp": "Vị trí liên kết của inbound này trong nội dung gói đăng ký (trang đăng ký và ứng dụng khách). Giá trị nhỏ hơn xếp trước; giá trị bằng nhau giữ thứ tự tạo. Không ảnh hưởng đến danh sách inbound trong bảng điều khiển.",
         "shareAddrStrategyOptions": {
           "node": "Địa chỉ node",
           "listen": "Địa chỉ listen inbound",
@@ -809,11 +817,14 @@
     "groups": {
       "title": "Nhóm",
       "name": "Tên",
-      "clientCount": "Client trong nhóm",
+      "clientCount": "Client",
       "totalGroups": "Tổng số nhóm",
       "totalGroupedClients": "Client có nhóm",
       "trafficUsed": "Lưu lượng đã dùng",
+      "upload": "Tải lên",
+      "download": "Tải xuống",
       "totalTraffic": "Tổng lưu lượng",
+      "totalUpDown": "Tổng tải lên / tải xuống",
       "addGroup": "Thêm nhóm",
       "createSuccess": "Đã tạo nhóm «{name}».",
       "rename": "Đổi tên",
@@ -994,7 +1005,18 @@
       "telegramChatId": "Chat ID Telegram của quản trị viên",
       "telegramChatIdDesc": "Nhiều Chat ID phân tách bằng dấu phẩy. Sử dụng {'@'}userinfobot hoặc sử dụng lệnh '/id' trong bot để lấy Chat ID của bạn.",
       "telegramNotifyTime": "Thời gian thông báo của bot Telegram",
-      "telegramNotifyTimeDesc": "Sử dụng định dạng thời gian Crontab.",
+      "telegramNotifyTimeDesc": "Tần suất bot Telegram gửi báo cáo định kỳ. Chọn một khoảng thời gian có sẵn, hoặc chọn Tùy chỉnh để nhập biểu thức crontab.",
+      "notifyTime": {
+        "every": "@every — lặp lại theo khoảng thời gian",
+        "hourly": "@hourly — mỗi giờ",
+        "daily": "@daily — mỗi ngày lúc 00:00",
+        "weekly": "@weekly — mỗi tuần",
+        "monthly": "@monthly — mỗi tháng",
+        "custom": "Tùy chỉnh (crontab)",
+        "seconds": "Giây",
+        "minutes": "Phút",
+        "hours": "Giờ"
+      },
       "tgNotifyBackup": "Sao lưu Cơ sở dữ liệu",
       "tgNotifyBackupDesc": "Bao gồm tệp sao lưu cơ sở dữ liệu với thông báo báo cáo.",
       "tgNotifyLogin": "Thông báo Đăng nhập",
@@ -1025,6 +1047,7 @@
       "subAnnounceDesc": "Văn bản thông báo hiển thị trong ứng dụng VPN",
       "subThemeDir": "Thư mục giao diện Đăng ký",
       "subThemeDirDesc": "Đường dẫn tuyệt đối đến thư mục chứa mẫu tùy chỉnh (index.html/sub.html) cho trang đăng ký (ví dụ: /etc/3x-ui/sub_templates/my-theme/). Để trống để dùng trang mặc định.",
+      "subThemeDirDocs": "Hướng dẫn mẫu ↗",
       "subEnableRouting": "Bật định tuyến",
       "subEnableRoutingDesc": "Cài đặt toàn cục để bật định tuyến trong ứng dụng khách VPN. (Chỉ dành cho Happ)",
       "subRoutingRules": "Quy tắc định tuyến",
@@ -1128,6 +1151,7 @@
       "proxyAndServer": "Proxy và máy chủ",
       "intervals": "Khoảng thời gian",
       "information": "Thông tin",
+      "profile": "Hồ sơ",
       "language": "Ngôn ngữ",
       "telegramBotLanguage": "Ngôn ngữ của Bot Telegram",
       "security": {
@@ -1378,6 +1402,10 @@
         "testError": "Không thể kiểm tra đầu ra",
         "testModeTooltip": "TCP: probe dial nhanh. HTTP: yêu cầu đầy đủ qua xray.",
         "testAll": "Kiểm tra tất cả",
+        "httpStatus": "Trạng thái HTTP",
+        "breakdownConnect": "Kết nối proxy",
+        "breakdownTls": "TLS qua outbound",
+        "breakdownTtfb": "Byte đầu tiên",
         "nordvpn": "NordVPN",
         "accessToken": "Mã truy cập",
         "country": "Quốc gia",

+ 31 - 3
internal/web/translation/zh-CN.json

@@ -416,6 +416,7 @@
       },
       "telegramDesc": "请提供Telegram聊天ID。(在机器人中使用'/id'命令)或({'@'}userinfobot",
       "subscriptionDesc": "要找到你的订阅 URL,请导航到“详细信息”。此外,你可以为多个客户端使用相同的名称。",
+      "subSortIndex": "订阅排序",
       "same": "相同",
       "inboundInfo": "入站信息",
       "exportInbound": "导出入站规则",
@@ -484,6 +485,11 @@
         "mtgProxyProtocolListener": "接受 PROXY 协议(监听器)",
         "mtgPreferIp": "IP 优先级",
         "mtgDebug": "调试日志",
+        "mtgRouteThroughXray": "通过 Xray 路由",
+        "mtgRouteThroughXrayHint": "让此代理的 Telegram 流量经过 Xray,以应用您的路由规则。mtg 附属进程会通过带有此入站标签的本地 SOCKS 桥接出站;在路由选项卡中引用该标签即可设置高级规则。",
+        "mtgRouteOutbound": "出站",
+        "mtgRouteOutboundHint": "可选。强制 Telegram 流量经由此出站(或负载均衡器)发出。留空则由您的路由规则决定。",
+        "mtgRouteOutboundPlaceholder": "使用路由规则",
         "visionTestseed": "Vision testseed",
         "version": "版本",
         "udpIdleTimeout": "UDP 空闲超时 (s)",
@@ -590,11 +596,13 @@
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
         "getNewSeed": "获取新 Seed",
-        "listenHelp": "也可以填写 Unix socket 路径(例如 /run/xray/in.sock),以使用套接字而非 TCP 端口监听——此时请将端口设为 0。",
+        "listenHelp": "也可以填写 Unix socket 路径(例如 /run/xray/in.sock),或以 @ 为前缀的抽象套接字名称(例如 @xray/in.sock),以使用套接字而非 TCP 端口监听——此时请将端口设为 0。",
         "shareAddrStrategy": "分享地址策略",
         "shareAddrStrategyHelp": "控制导出分享链接、二维码和订阅输出时写入哪个地址。",
         "shareAddr": "自定义分享地址",
         "shareAddrHelp": "仅在分享地址策略为自定义时使用。填写不带协议和端口的域名或 IP。",
+        "subSortIndex": "订阅排序",
+        "subSortIndexHelp": "此入站的链接在订阅输出(订阅页面和客户端应用)中的位置。数值越小越靠前;数值相同时保持创建顺序。不影响面板中的入站列表。",
         "shareAddrStrategyOptions": {
           "node": "节点地址",
           "listen": "入站监听地址",
@@ -809,11 +817,14 @@
     "groups": {
       "title": "分组",
       "name": "名称",
-      "clientCount": "分组中的客户端",
+      "clientCount": "客户端",
       "totalGroups": "分组总数",
       "totalGroupedClients": "有分组的客户端",
       "trafficUsed": "已用流量",
+      "upload": "上传",
+      "download": "下载",
       "totalTraffic": "总流量",
+      "totalUpDown": "总上传 / 下载",
       "addGroup": "添加分组",
       "createSuccess": "已创建分组 “{name}”。",
       "rename": "重命名",
@@ -994,7 +1005,18 @@
       "telegramChatId": "管理员聊天 ID",
       "telegramChatIdDesc": "Telegram 管理员聊天 ID (多个以逗号分隔)(可通过 {'@'}userinfobot 获取,或在机器人中使用 '/id' 命令获取)",
       "telegramNotifyTime": "通知时间",
-      "telegramNotifyTimeDesc": "设置周期性的 Telegram 机器人通知时间(使用 crontab 时间格式)",
+      "telegramNotifyTimeDesc": "Telegram 机器人发送周期性报告的频率。选择预设间隔,或选择“自定义”以输入 crontab 表达式。",
+      "notifyTime": {
+        "every": "@every — 按间隔重复",
+        "hourly": "@hourly — 每小时",
+        "daily": "@daily — 每天 00:00",
+        "weekly": "@weekly — 每周",
+        "monthly": "@monthly — 每月",
+        "custom": "自定义 (crontab)",
+        "seconds": "秒",
+        "minutes": "分钟",
+        "hours": "小时"
+      },
       "tgNotifyBackup": "数据库备份",
       "tgNotifyBackupDesc": "发送带有报告的数据库备份文件",
       "tgNotifyLogin": "登录通知",
@@ -1025,6 +1047,7 @@
       "subAnnounceDesc": "VPN 客户端中显示的公告文本",
       "subThemeDir": "订阅主题目录",
       "subThemeDirDesc": "包含自定义订阅页面模板 (index.html/sub.html) 的文件夹的绝对路径(例如 /etc/3x-ui/sub_templates/my-theme/)。留空则使用默认页面。",
+      "subThemeDirDocs": "模板指南 ↗",
       "subEnableRouting": "启用路由",
       "subEnableRoutingDesc": "在 VPN 客户端中启用路由的全局设置。(僅限 Happ)",
       "subRoutingRules": "路由規則",
@@ -1128,6 +1151,7 @@
       "proxyAndServer": "代理和服务器",
       "intervals": "间隔",
       "information": "信息",
+      "profile": "资料",
       "language": "语言",
       "telegramBotLanguage": "Telegram 机器人语言",
       "security": {
@@ -1378,6 +1402,10 @@
         "testError": "测试出站失败",
         "testModeTooltip": "TCP: 快速 dial-only 探测。HTTP: 通过 xray 的完整请求。",
         "testAll": "全部测试",
+        "httpStatus": "HTTP 状态",
+        "breakdownConnect": "代理连接",
+        "breakdownTls": "经由出站的 TLS",
+        "breakdownTtfb": "首字节",
         "nordvpn": "NordVPN",
         "accessToken": "访问令牌",
         "country": "国家",

+ 31 - 3
internal/web/translation/zh-TW.json

@@ -416,6 +416,7 @@
       },
       "telegramDesc": "請提供Telegram聊天ID。(在機器人中使用'/id'命令)或({'@'}userinfobot",
       "subscriptionDesc": "要找到你的訂閱 URL,請導航到“詳細資訊”。此外,你可以為多個客戶端使用相同的名稱。",
+      "subSortIndex": "訂閱排序",
       "same": "相同",
       "inboundInfo": "入站資訊",
       "exportInbound": "匯出入站規則",
@@ -484,6 +485,11 @@
         "mtgProxyProtocolListener": "接受 PROXY 協定(監聽器)",
         "mtgPreferIp": "IP 偏好",
         "mtgDebug": "除錯日誌",
+        "mtgRouteThroughXray": "透過 Xray 路由",
+        "mtgRouteThroughXrayHint": "讓此代理的 Telegram 流量經過 Xray,以套用您的路由規則。mtg 附屬程序會透過帶有此入站標籤的本機 SOCKS 橋接出站;在路由分頁中引用該標籤即可設定進階規則。",
+        "mtgRouteOutbound": "出站",
+        "mtgRouteOutboundHint": "選填。強制 Telegram 流量經由此出站(或負載平衡器)送出。留空則由您的路由規則決定。",
+        "mtgRouteOutboundPlaceholder": "使用路由規則",
         "visionTestseed": "Vision testseed",
         "version": "版本",
         "udpIdleTimeout": "UDP 閒置逾時 (s)",
@@ -590,11 +596,13 @@
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
         "getNewSeed": "取得新 Seed",
-        "listenHelp": "也可以填寫 Unix socket 路徑(例如 /run/xray/in.sock),以使用通訊端而非 TCP 連接埠監聽——此時請將連接埠設為 0。",
+        "listenHelp": "也可以填寫 Unix socket 路徑(例如 /run/xray/in.sock),或以 @ 為前綴的抽象通訊端名稱(例如 @xray/in.sock),以使用通訊端而非 TCP 連接埠監聽——此時請將連接埠設為 0。",
         "shareAddrStrategy": "分享地址策略",
         "shareAddrStrategyHelp": "控制匯出分享連結、QR Code 和訂閱輸出時寫入哪個地址。",
         "shareAddr": "自訂分享地址",
         "shareAddrHelp": "僅在分享地址策略為自訂時使用。填寫不帶協定和連接埠的網域或 IP。",
+        "subSortIndex": "訂閱排序",
+        "subSortIndexHelp": "此入站的連結在訂閱輸出(訂閱頁面和客戶端應用)中的位置。數值越小越靠前;數值相同時保持建立順序。不影響面板中的入站清單。",
         "shareAddrStrategyOptions": {
           "node": "節點地址",
           "listen": "入站監聽地址",
@@ -809,11 +817,14 @@
     "groups": {
       "title": "群組",
       "name": "名稱",
-      "clientCount": "群組中的客戶端",
+      "clientCount": "客戶端",
       "totalGroups": "群組總數",
       "totalGroupedClients": "有群組的客戶端",
       "trafficUsed": "已用流量",
+      "upload": "上傳",
+      "download": "下載",
       "totalTraffic": "總流量",
+      "totalUpDown": "總上傳 / 下載",
       "addGroup": "新增群組",
       "createSuccess": "已建立群組「{name}」。",
       "rename": "重新命名",
@@ -994,7 +1005,18 @@
       "telegramChatId": "管理員聊天 ID",
       "telegramChatIdDesc": "Telegram 管理員聊天 ID (多個以逗號分隔)(可通過 {'@'}userinfobot 獲取,或在機器人中使用 '/id' 命令獲取)",
       "telegramNotifyTime": "通知時間",
-      "telegramNotifyTimeDesc": "設定週期性的 Telegram 機器人通知時間(使用 crontab 時間格式)",
+      "telegramNotifyTimeDesc": "Telegram 機器人傳送週期性報告的頻率。選擇預設間隔,或選擇「自訂」以輸入 crontab 運算式。",
+      "notifyTime": {
+        "every": "@every — 依間隔重複",
+        "hourly": "@hourly — 每小時",
+        "daily": "@daily — 每天 00:00",
+        "weekly": "@weekly — 每週",
+        "monthly": "@monthly — 每月",
+        "custom": "自訂 (crontab)",
+        "seconds": "秒",
+        "minutes": "分鐘",
+        "hours": "小時"
+      },
       "tgNotifyBackup": "資料庫備份",
       "tgNotifyBackupDesc": "傳送帶有報告的資料庫備份檔案",
       "tgNotifyLogin": "登入通知",
@@ -1025,6 +1047,7 @@
       "subAnnounceDesc": "VPN 用戶端中顯示的公告文字",
       "subThemeDir": "訂閱主題目錄",
       "subThemeDirDesc": "包含自訂訂閱頁面範本 (index.html/sub.html) 的資料夾的絕對路徑(例如 /etc/3x-ui/sub_templates/my-theme/)。留空則使用預設頁面。",
+      "subThemeDirDocs": "範本指南 ↗",
       "subEnableRouting": "啟用路由",
       "subEnableRoutingDesc": "在 VPN 用戶端中啟用路由的全域設定。(僅限 Happ)",
       "subRoutingRules": "路由規則",
@@ -1128,6 +1151,7 @@
       "proxyAndServer": "代理和伺服器",
       "intervals": "間隔",
       "information": "資訊",
+      "profile": "資料",
       "language": "語言",
       "telegramBotLanguage": "Telegram 機器人語言",
       "security": {
@@ -1378,6 +1402,10 @@
         "testError": "測試出站失敗",
         "testModeTooltip": "TCP: 快速 dial-only 探測。HTTP: 透過 xray 的完整請求。",
         "testAll": "全部測試",
+        "httpStatus": "HTTP 狀態",
+        "breakdownConnect": "代理連線",
+        "breakdownTls": "經由出站的 TLS",
+        "breakdownTtfb": "首位元組",
         "nordvpn": "NordVPN",
         "accessToken": "訪問令牌",
         "country": "國家",

+ 1 - 1
x-ui.service.arch

@@ -9,7 +9,7 @@ Environment="XRAY_VMESS_AEAD_FORCED=false"
 Type=simple
 WorkingDirectory=/usr/local/x-ui/
 ExecStart=/usr/local/x-ui/x-ui
-ExecReload=kill -USR1 $MAINPID
+ExecReload=/bin/kill -USR1 $MAINPID
 Restart=on-failure
 RestartSec=5s
 

+ 1 - 1
x-ui.service.debian

@@ -9,7 +9,7 @@ Environment="XRAY_VMESS_AEAD_FORCED=false"
 Type=simple
 WorkingDirectory=/usr/local/x-ui/
 ExecStart=/usr/local/x-ui/x-ui
-ExecReload=kill -USR1 $MAINPID
+ExecReload=/bin/kill -USR1 $MAINPID
 Restart=on-failure
 RestartSec=5s
 

+ 1 - 1
x-ui.service.rhel

@@ -9,7 +9,7 @@ Environment="XRAY_VMESS_AEAD_FORCED=false"
 Type=simple
 WorkingDirectory=/usr/local/x-ui/
 ExecStart=/usr/local/x-ui/x-ui
-ExecReload=kill -USR1 $MAINPID
+ExecReload=/bin/kill -USR1 $MAINPID
 Restart=on-failure
 RestartSec=5s