8 Комити 6d732d8d32 ... b5479f3f30

Аутор SHA1 Порука Датум
  MHSanaei b5479f3f30 feat(sidebar): pin Logout above trigger, inline 3-state theme cycle пре 14 часа
  MHSanaei d8aedcdde4 fix(inbounds): bulk-delete keeps last client to satisfy backend constraint пре 15 часа
  MHSanaei 5f3e9ed0ea feat(xray/nord): searchable server list + colored load tag, surface API errors пре 15 часа
  MHSanaei 3e8a0eb93e fix(inbounds): paginate expanded client list, restore ID column, hide empty Remark пре 16 часа
  MHSanaei 4c2915586c fix(alpine): restart_xray uses rc-service; OpenRC reload reads pidfile contents пре 16 часа
  Harry NG 9f06bffbea chore: fix remarks shadowrocket subscription (#4247) пре 17 часа
  Amirmohammad Sadat Shokouhi e20d73ba7e add loopback and dns servers tag to inbound lists in RuleFormModal (#4244) пре 17 часа
  MHSanaei 8834e5fbbe feat(xray/outbounds): TCP probe mode + Test All + timing breakdown пре 21 часа

+ 77 - 77
frontend/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "3x-ui-frontend",
-  "version": "0.0.1",
+  "version": "0.0.2",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "3x-ui-frontend",
-      "version": "0.0.1",
+      "version": "0.0.2",
       "dependencies": {
         "@ant-design/icons-vue": "^7.0.1",
         "ant-design-vue": "^4.2.6",
@@ -424,18 +424,18 @@
       }
     },
     "node_modules/@oxc-project/types": {
-      "version": "0.128.0",
-      "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.128.0.tgz",
-      "integrity": "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==",
+      "version": "0.129.0",
+      "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz",
+      "integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==",
       "dev": true,
       "funding": {
         "url": "https://github.com/sponsors/Boshen"
       }
     },
     "node_modules/@rolldown/binding-android-arm64": {
-      "version": "1.0.0-rc.18",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz",
-      "integrity": "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz",
+      "integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==",
       "cpu": [
         "arm64"
       ],
@@ -449,9 +449,9 @@
       }
     },
     "node_modules/@rolldown/binding-darwin-arm64": {
-      "version": "1.0.0-rc.18",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz",
-      "integrity": "sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz",
+      "integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==",
       "cpu": [
         "arm64"
       ],
@@ -465,9 +465,9 @@
       }
     },
     "node_modules/@rolldown/binding-darwin-x64": {
-      "version": "1.0.0-rc.18",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz",
-      "integrity": "sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz",
+      "integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==",
       "cpu": [
         "x64"
       ],
@@ -481,9 +481,9 @@
       }
     },
     "node_modules/@rolldown/binding-freebsd-x64": {
-      "version": "1.0.0-rc.18",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz",
-      "integrity": "sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz",
+      "integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==",
       "cpu": [
         "x64"
       ],
@@ -497,9 +497,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
-      "version": "1.0.0-rc.18",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz",
-      "integrity": "sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz",
+      "integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==",
       "cpu": [
         "arm"
       ],
@@ -513,9 +513,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-arm64-gnu": {
-      "version": "1.0.0-rc.18",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz",
-      "integrity": "sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz",
+      "integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==",
       "cpu": [
         "arm64"
       ],
@@ -529,9 +529,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-arm64-musl": {
-      "version": "1.0.0-rc.18",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz",
-      "integrity": "sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz",
+      "integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==",
       "cpu": [
         "arm64"
       ],
@@ -545,9 +545,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-ppc64-gnu": {
-      "version": "1.0.0-rc.18",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz",
-      "integrity": "sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz",
+      "integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==",
       "cpu": [
         "ppc64"
       ],
@@ -561,9 +561,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-s390x-gnu": {
-      "version": "1.0.0-rc.18",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz",
-      "integrity": "sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz",
+      "integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==",
       "cpu": [
         "s390x"
       ],
@@ -577,9 +577,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-x64-gnu": {
-      "version": "1.0.0-rc.18",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz",
-      "integrity": "sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz",
+      "integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==",
       "cpu": [
         "x64"
       ],
@@ -593,9 +593,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-x64-musl": {
-      "version": "1.0.0-rc.18",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz",
-      "integrity": "sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz",
+      "integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==",
       "cpu": [
         "x64"
       ],
@@ -609,9 +609,9 @@
       }
     },
     "node_modules/@rolldown/binding-openharmony-arm64": {
-      "version": "1.0.0-rc.18",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz",
-      "integrity": "sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz",
+      "integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==",
       "cpu": [
         "arm64"
       ],
@@ -625,9 +625,9 @@
       }
     },
     "node_modules/@rolldown/binding-wasm32-wasi": {
-      "version": "1.0.0-rc.18",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz",
-      "integrity": "sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz",
+      "integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==",
       "cpu": [
         "wasm32"
       ],
@@ -643,9 +643,9 @@
       }
     },
     "node_modules/@rolldown/binding-win32-arm64-msvc": {
-      "version": "1.0.0-rc.18",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz",
-      "integrity": "sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz",
+      "integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==",
       "cpu": [
         "arm64"
       ],
@@ -659,9 +659,9 @@
       }
     },
     "node_modules/@rolldown/binding-win32-x64-msvc": {
-      "version": "1.0.0-rc.18",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz",
-      "integrity": "sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz",
+      "integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==",
       "cpu": [
         "x64"
       ],
@@ -2300,13 +2300,13 @@
       "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
     },
     "node_modules/rolldown": {
-      "version": "1.0.0-rc.18",
-      "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz",
-      "integrity": "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz",
+      "integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==",
       "dev": true,
       "dependencies": {
-        "@oxc-project/types": "=0.128.0",
-        "@rolldown/pluginutils": "1.0.0-rc.18"
+        "@oxc-project/types": "=0.129.0",
+        "@rolldown/pluginutils": "1.0.0"
       },
       "bin": {
         "rolldown": "bin/cli.mjs"
@@ -2315,27 +2315,27 @@
         "node": "^20.19.0 || >=22.12.0"
       },
       "optionalDependencies": {
-        "@rolldown/binding-android-arm64": "1.0.0-rc.18",
-        "@rolldown/binding-darwin-arm64": "1.0.0-rc.18",
-        "@rolldown/binding-darwin-x64": "1.0.0-rc.18",
-        "@rolldown/binding-freebsd-x64": "1.0.0-rc.18",
-        "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18",
-        "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18",
-        "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18",
-        "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18",
-        "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18",
-        "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18",
-        "@rolldown/binding-linux-x64-musl": "1.0.0-rc.18",
-        "@rolldown/binding-openharmony-arm64": "1.0.0-rc.18",
-        "@rolldown/binding-wasm32-wasi": "1.0.0-rc.18",
-        "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18",
-        "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18"
+        "@rolldown/binding-android-arm64": "1.0.0",
+        "@rolldown/binding-darwin-arm64": "1.0.0",
+        "@rolldown/binding-darwin-x64": "1.0.0",
+        "@rolldown/binding-freebsd-x64": "1.0.0",
+        "@rolldown/binding-linux-arm-gnueabihf": "1.0.0",
+        "@rolldown/binding-linux-arm64-gnu": "1.0.0",
+        "@rolldown/binding-linux-arm64-musl": "1.0.0",
+        "@rolldown/binding-linux-ppc64-gnu": "1.0.0",
+        "@rolldown/binding-linux-s390x-gnu": "1.0.0",
+        "@rolldown/binding-linux-x64-gnu": "1.0.0",
+        "@rolldown/binding-linux-x64-musl": "1.0.0",
+        "@rolldown/binding-openharmony-arm64": "1.0.0",
+        "@rolldown/binding-wasm32-wasi": "1.0.0",
+        "@rolldown/binding-win32-arm64-msvc": "1.0.0",
+        "@rolldown/binding-win32-x64-msvc": "1.0.0"
       }
     },
     "node_modules/rolldown/node_modules/@rolldown/pluginutils": {
-      "version": "1.0.0-rc.18",
-      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz",
-      "integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz",
+      "integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==",
       "dev": true
     },
     "node_modules/scroll-into-view-if-needed": {
@@ -2524,15 +2524,15 @@
       "dev": true
     },
     "node_modules/vite": {
-      "version": "8.0.11",
-      "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz",
-      "integrity": "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==",
+      "version": "8.0.12",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz",
+      "integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==",
       "dev": true,
       "dependencies": {
         "lightningcss": "^1.32.0",
         "picomatch": "^4.0.4",
         "postcss": "^8.5.14",
-        "rolldown": "1.0.0-rc.18",
+        "rolldown": "1.0.0",
         "tinyglobby": "^0.2.16"
       },
       "bin": {

+ 1 - 1
frontend/package.json

@@ -1,7 +1,7 @@
 {
   "name": "3x-ui-frontend",
   "private": true,
-  "version": "0.0.1",
+  "version": "0.0.2",
   "type": "module",
   "description": "3x-ui panel frontend (Vue 3 + Ant Design Vue 4 + Vite 8).",
   "scripts": {

+ 191 - 14
frontend/src/components/AppSidebar.vue

@@ -12,8 +12,7 @@ import {
   MenuOutlined,
 } from '@ant-design/icons-vue';
 
-import { currentTheme } from '@/composables/useTheme.js';
-import ThemeSwitch from '@/components/ThemeSwitch.vue';
+import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
 
 const { t } = useI18n();
 
@@ -98,17 +97,60 @@ function toggleDrawer() {
 function closeDrawer() {
   drawerOpen.value = false;
 }
+
+/* 3-state theme cycle driven by the brand-row icon button.
+ *   Light  → Dark   (turn dark on, ensure ultra off)
+ *   Dark   → Ultra  (turn ultra on)
+ *   Ultra  → Light  (turn ultra off, turn dark off)
+ * Using a single button keeps the sider header clean — the old
+ * ThemeSwitch a-sub-menu plus its expandable items lived here. */
+function cycleTheme() {
+  pauseAnimationsUntilLeave('theme-cycle');
+  if (!theme.isDark) {
+    toggleTheme();
+    if (theme.isUltra) toggleUltra();
+  } else if (!theme.isUltra) {
+    toggleUltra();
+  } else {
+    toggleUltra();
+    toggleTheme();
+  }
+}
 </script>
 
 <template>
   <div class="ant-sidebar">
     <a-layout-sider :theme="currentTheme" collapsible :collapsed="collapsed" breakpoint="md" @collapse="onCollapse">
       <div class="sider-brand" :class="{ 'sider-brand-collapsed': collapsed }">
-        {{ collapsed ? '3X' : '3X-UI' }}
+        <span class="brand-text">{{ collapsed ? '3X' : '3X-UI' }}</span>
+        <button v-if="!collapsed" id="theme-cycle" type="button" class="theme-cycle" :aria-label="t('menu.theme')"
+          :title="t('menu.theme')" @click="cycleTheme">
+          <svg v-if="!theme.isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
+            stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+            <circle cx="12" cy="12" r="4" />
+            <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
+          </svg>
+          <svg v-else-if="!theme.isUltra" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
+            stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+            <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
+          </svg>
+          <svg v-else viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5"
+            stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+            <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
+            <path fill="none" d="M19 3l0.7 1.4 1.4 0.7-1.4 0.7L19 7.2l-0.7-1.4-1.4-0.7 1.4-0.7z" />
+          </svg>
+        </button>
       </div>
-      <ThemeSwitch />
-      <a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" @click="({ key }) => openLink(key)">
-        <a-menu-item v-for="tab in tabs" :key="tab.key">
+      <a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="sider-nav"
+        @click="({ key }) => openLink(key)">
+        <a-menu-item v-for="tab in navTabs" :key="tab.key">
+          <component :is="iconByName[tab.icon]" />
+          <span>{{ tab.title }}</span>
+        </a-menu-item>
+      </a-menu>
+      <a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="sider-utility"
+        @click="({ key }) => openLink(key)">
+        <a-menu-item v-for="tab in utilTabs" :key="tab.key">
           <component :is="iconByName[tab.icon]" />
           <span>{{ tab.title }}</span>
         </a-menu-item>
@@ -121,11 +163,29 @@ function closeDrawer() {
       :header-style="{ display: 'none' }" @close="closeDrawer">
       <div class="drawer-header">
         <span class="drawer-brand">3X-UI</span>
-        <button class="drawer-close" type="button" :aria-label="t('close')" @click="closeDrawer">
-          <CloseOutlined />
-        </button>
+        <div class="drawer-header-actions">
+          <button id="theme-cycle-drawer" type="button" class="theme-cycle" :aria-label="t('menu.theme')"
+            :title="t('menu.theme')" @click="cycleTheme">
+            <svg v-if="!theme.isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
+              stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+              <circle cx="12" cy="12" r="4" />
+              <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
+            </svg>
+            <svg v-else-if="!theme.isUltra" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
+              stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+              <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
+            </svg>
+            <svg v-else viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5"
+              stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+              <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
+              <path fill="none" d="M19 3l0.7 1.4 1.4 0.7-1.4 0.7L19 7.2l-0.7-1.4-1.4-0.7 1.4-0.7z" />
+            </svg>
+          </button>
+          <button class="drawer-close" type="button" :aria-label="t('close')" @click="closeDrawer">
+            <CloseOutlined />
+          </button>
+        </div>
       </div>
-      <ThemeSwitch />
       <a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="drawer-menu drawer-nav"
         @click="({ key }) => openLink(key)">
         <a-menu-item v-for="tab in navTabs" :key="tab.key">
@@ -150,8 +210,18 @@ function closeDrawer() {
 </template>
 
 <style scoped>
+/* Pin the desktop sider to the viewport. Without this, AD-Vue's
+ * `<a-layout-sider>` stretches to match the flex row's height — which
+ * equals the page height on tall dashboards (cards stack into one
+ * column below `lg` = 992px), so the bottom-anchored
+ * `.ant-layout-sider-trigger` (and Logout right above it) slide off
+ * the screen. Sticky + 100vh keeps the sider exactly viewport-tall;
+ * `align-self: flex-start` stops the flex row from re-stretching it. */
 .ant-sidebar>.ant-layout-sider {
-  height: 100%;
+  position: sticky;
+  top: 0;
+  height: 100vh;
+  align-self: flex-start;
 }
 
 /* `.sider-brand` and `.drawer-brand` share the same light-theme colour
@@ -169,18 +239,65 @@ function closeDrawer() {
 }
 
 .sider-brand {
-  text-align: center;
-  padding: 16px 12px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  padding: 14px 14px;
   border-bottom: 1px solid rgba(128, 128, 128, 0.15);
   user-select: none;
 }
 
+/* Collapsed sider only has room for the '3X' brand — center it and
+ * hide the theme cycle button (which is `v-if`-ed out in template). */
 .sider-brand-collapsed {
+  justify-content: center;
   font-size: 16px;
-  padding: 16px 4px;
+  padding: 14px 4px;
   letter-spacing: 0;
 }
 
+.brand-text {
+  flex: 1 1 auto;
+}
+
+.sider-brand-collapsed .brand-text {
+  flex: 0 0 auto;
+}
+
+.theme-cycle {
+  background: transparent;
+  border: none;
+  width: 30px;
+  height: 30px;
+  border-radius: 50%;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  color: inherit;
+  padding: 0;
+  flex-shrink: 0;
+  transition: background-color 0.2s, transform 0.15s;
+}
+
+.theme-cycle:hover,
+.theme-cycle:focus-visible {
+  background: rgba(128, 128, 128, 0.18);
+  transform: scale(1.08);
+}
+
+.theme-cycle svg {
+  width: 16px;
+  height: 16px;
+}
+
+.drawer-header-actions {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+}
+
 .drawer-handle {
   position: fixed;
   top: 12px;
@@ -246,6 +363,41 @@ function closeDrawer() {
   border-top: 1px solid rgba(128, 128, 128, 0.15);
 }
 
+/* Pin Logout exactly above AD-Vue's `.ant-layout-sider-trigger` (the
+ * collapse bar at the bottom, position: absolute; height: 48px). The
+ * old `margin-top: auto` approach only pushed the utility down when the
+ * content was shorter than the container — on short viewports the
+ * Logout got hidden behind the trigger. Switching to a flex layout
+ * where `.sider-nav` consumes all spare space (flex: 1) and
+ * `.sider-utility` stays at content height pins it consistently. The
+ * padding-bottom: 48px on the parent reserves the trigger's strip so
+ * Logout sits directly above it.
+ *
+ * The mobile @media rule below still hides the whole sider on phones;
+ * this block only kicks in once that override no longer matches. */
+.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-children) {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  padding-bottom: 48px;
+}
+
+.sider-brand {
+  flex: 0 0 auto;
+}
+
+.sider-nav {
+  flex: 1 1 auto;
+  overflow-y: auto;
+  overflow-x: hidden;
+  min-height: 0;
+}
+
+.sider-utility {
+  flex: 0 0 auto;
+  border-top: 1px solid rgba(128, 128, 128, 0.15);
+}
+
 @media (max-width: 768px) {
   .drawer-handle {
     display: inline-flex;
@@ -311,4 +463,29 @@ html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-content,
 html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-body {
   background: #0a0a0a !important;
 }
+
+/* Force the same light-blue tint on selected + hover/active across
+ * all three themes. AD-Vue's defaults read too subtle on the dark
+ * sider, and the light-theme variant looked inconsistent vs. dark —
+ * applying the same RGBA tint over all backgrounds gives the active
+ * page the same visual weight everywhere. `!important` is required to
+ * beat AD-Vue's CSS-in-JS specificity; scoped to .sider-nav /
+ * .sider-utility / .drawer-menu so only the navigation menus pick up
+ * the override (other a-menu instances keep AD-Vue defaults). */
+.sider-nav .ant-menu-item-selected,
+.sider-utility .ant-menu-item-selected,
+.drawer-menu .ant-menu-item-selected {
+  background-color: rgba(64, 150, 255, 0.2) !important;
+  color: #4096ff !important;
+}
+
+.sider-nav .ant-menu-item-active:not(.ant-menu-item-selected),
+.sider-utility .ant-menu-item-active:not(.ant-menu-item-selected),
+.drawer-menu .ant-menu-item-active:not(.ant-menu-item-selected),
+.sider-nav .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
+.sider-utility .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
+.drawer-menu .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover {
+  background-color: rgba(64, 150, 255, 0.1) !important;
+  color: #4096ff !important;
+}
 </style>

+ 0 - 49
frontend/src/components/ThemeSwitch.vue

@@ -1,49 +0,0 @@
-<script setup>
-import { computed } from 'vue';
-import { useI18n } from 'vue-i18n';
-import { BulbFilled, BulbOutlined } from '@ant-design/icons-vue';
-import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
-
-const { t } = useI18n();
-
-const BulbIcon = computed(() => (theme.isDark ? BulbFilled : BulbOutlined));
-
-function onDarkChange() {
-  pauseAnimationsUntilLeave('change-theme');
-  toggleTheme();
-}
-
-function onUltraClick() {
-  pauseAnimationsUntilLeave('change-theme-ultra');
-  toggleUltra();
-}
-</script>
-
-<template>
-  <a-menu :theme="currentTheme" mode="inline" :selected-keys="[]">
-    <a-sub-menu>
-      <template #title>
-        <span>
-          <component :is="BulbIcon" />
-          <span class="theme-label">{{ t('menu.theme') }}</span>
-        </span>
-      </template>
-
-      <a-menu-item id="change-theme" class="ant-menu-theme-switch">
-        <span>{{ t('menu.dark') }}</span>
-        <a-switch :style="{ marginLeft: '2px' }" size="small" :checked="theme.isDark" @change="onDarkChange" />
-      </a-menu-item>
-
-      <a-menu-item v-if="theme.isDark" id="change-theme-ultra" class="ant-menu-theme-switch">
-        <span>{{ t('menu.ultraDark') }}</span>
-        <a-checkbox :style="{ marginLeft: '2px' }" :checked="theme.isUltra" @click="onUltraClick" />
-      </a-menu-item>
-    </a-sub-menu>
-  </a-menu>
-</template>
-
-<style scoped>
-.theme-label {
-  margin-left: 8px;
-}
-</style>

+ 0 - 25
frontend/src/components/ThemeSwitchLogin.vue

@@ -1,25 +0,0 @@
-<script setup>
-import { theme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
-
-function onDarkChange() {
-  pauseAnimationsUntilLeave('change-theme');
-  toggleTheme();
-}
-
-function onUltraClick() {
-  toggleUltra();
-}
-</script>
-
-<template>
-  <a-space id="change-theme" direction="vertical" :size="10" :style="{ width: '100%' }">
-    <a-space direction="horizontal" size="small">
-      <a-switch size="small" :checked="theme.isDark" @change="onDarkChange" />
-      <span>Dark</span>
-    </a-space>
-    <a-space v-if="theme.isDark" direction="horizontal" size="small">
-      <a-checkbox :checked="theme.isUltra" @click="onUltraClick" />
-      <span>Ultra dark</span>
-    </a-space>
-  </a-space>
-</template>

+ 46 - 5
frontend/src/pages/inbounds/ClientRowTable.vue

@@ -31,6 +31,7 @@ const props = defineProps({
   onlineClients: { type: Array, default: () => [] },
   lastOnlineMap: { type: Object, default: () => ({}) },
   isDarkTheme: { type: Boolean, default: false },
+  pageSize: { type: Number, default: 0 },
 });
 
 const emit = defineEmits([
@@ -46,6 +47,20 @@ const emit = defineEmits([
 const inbound = computed(() => props.dbInbound.toInbound());
 const clients = computed(() => inbound.value?.clients || []);
 
+const currentPage = ref(1);
+const paginatedClients = computed(() => {
+  if (!props.pageSize || props.pageSize <= 0) return clients.value;
+  const start = (currentPage.value - 1) * props.pageSize;
+  return clients.value.slice(start, start + props.pageSize);
+});
+
+watch([clients, () => props.pageSize], () => {
+  const total = clients.value.length;
+  const size = props.pageSize > 0 ? props.pageSize : (total || 1);
+  const maxPage = Math.max(1, Math.ceil(total / size));
+  if (currentPage.value > maxPage) currentPage.value = maxPage;
+});
+
 // === Per-client stats lookup =======================================
 const statsMap = computed(() => {
   const m = new Map();
@@ -204,14 +219,30 @@ watch(clients, (list) => {
 function confirmBulkDelete() {
   const picked = clients.value.filter((c) => selected.value.has(rowKey(c)));
   if (picked.length === 0) return;
+
+  const total = clients.value.length;
+  const keepLast = picked.length === total;
+  const toDelete = keepLast ? picked.slice(0, -1) : picked;
+
+  if (toDelete.length === 0) {
+    Modal.warning({
+      title: t('pages.inbounds.deleteClient'),
+      content: 'Inbound must keep at least one client — delete the inbound to remove all.',
+      okText: t('confirm'),
+    });
+    return;
+  }
+
   Modal.confirm({
-    title: t('pages.inbounds.deleteClient') + ` — ${picked.length}`,
-    content: t('pages.inbounds.deleteClientContent'),
+    title: `${t('pages.inbounds.deleteClient')} — ${toDelete.length}${keepLast ? ` / ${total}` : ''}`,
+    content: keepLast
+      ? 'Inbound must keep at least one client — the last selected will remain. Delete the inbound to remove all.'
+      : t('pages.inbounds.deleteClientContent'),
     okText: t('delete'),
     okType: 'danger',
     cancelText: t('cancel'),
     onOk: () => {
-      emit('delete-clients', { dbInbound: props.dbInbound, clients: picked });
+      emit('delete-clients', { dbInbound: props.dbInbound, clients: toDelete });
       clearSelection();
     },
   });
@@ -246,7 +277,7 @@ function confirmBulkDelete() {
         <div class="cell cell-expiry">{{ t('pages.inbounds.expireDate') }}</div>
       </div>
 
-      <div v-for="client in clients" :key="rowKey(client)" class="client-row"
+      <div v-for="client in paginatedClients" :key="rowKey(client)" class="client-row"
         :class="{ 'is-selected': isSelected(rowKey(client)) }">
         <div v-if="isRemovable" class="cell cell-select">
           <a-checkbox :checked="isSelected(rowKey(client))"
@@ -383,7 +414,7 @@ function confirmBulkDelete() {
 
     <!-- ====================== Mobile: card list ======================= -->
     <template v-else>
-      <div v-for="client in clients" :key="rowKey(client)" class="client-card"
+      <div v-for="client in paginatedClients" :key="rowKey(client)" class="client-card"
         :class="{ 'is-selected': isSelected(rowKey(client)) }">
         <div class="client-card-head">
           <a-checkbox v-if="isRemovable" :checked="isSelected(rowKey(client))"
@@ -474,6 +505,10 @@ function confirmBulkDelete() {
         </div>
       </div>
     </template>
+
+    <a-pagination v-if="pageSize > 0 && clients.length > pageSize" v-model:current="currentPage"
+      :page-size="pageSize" :total="clients.length" :show-size-changer="false" size="small"
+      class="client-list-pagination" />
   </div>
 </template>
 
@@ -687,6 +722,12 @@ function confirmBulkDelete() {
   padding: 0 !important;
 }
 
+.client-list-pagination {
+  display: flex;
+  justify-content: center;
+  padding: 10px 16px 4px;
+}
+
 /* ===== Mobile card list =========================================== */
 .client-list.is-mobile {
   display: flex;

+ 11 - 3
frontend/src/pages/inbounds/InboundList.vue

@@ -122,13 +122,19 @@ const visibleInbounds = computed(() => {
 // `key`-driven so we can render via the body-cell slot below. AD-Vue 4's
 // `responsive` array still works on column defs. Computed so column
 // labels react to live locale switches.
+const hasAnyRemark = computed(() =>
+  props.dbInbounds.some((i) => typeof i?.remark === 'string' && i.remark.trim() !== ''),
+);
+
 const desktopColumns = computed(() => {
   const cols = [
-    { title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 30, responsive: ['xs'] },
+    { title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 30 },
     { title: t('pages.inbounds.operate'), key: 'action', align: 'center', width: 30 },
     { title: t('pages.inbounds.enable'), key: 'enable', align: 'center', width: 35 },
-    { title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'center', width: 60 },
   ];
+  if (hasAnyRemark.value) {
+    cols.push({ title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'center', width: 60 });
+  }
   if (props.nodesById.size > 0) {
     cols.push({ title: t('pages.inbounds.node'), key: 'node', align: 'center', width: 60 });
   }
@@ -401,6 +407,7 @@ function showQrCodeMenu(dbInbound) {
           <div v-if="record.isMultiUser() && isExpanded(record.id)" class="card-clients">
             <ClientRowTable :db-inbound="record" :is-mobile="true" :traffic-diff="trafficDiff" :expire-diff="expireDiff"
               :online-clients="onlineClients" :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme"
+              :page-size="pageSize"
               @edit-client="(p) => emit('edit-client', p)" @qrcode-client="(p) => emit('qrcode-client', p)"
               @info-client="(p) => emit('info-client', p)"
               @reset-traffic-client="(p) => emit('reset-traffic-client', p)"
@@ -421,7 +428,8 @@ function showQrCodeMenu(dbInbound) {
         <template #expandedRowRender="{ record }">
           <ClientRowTable v-if="record.isMultiUser()" :db-inbound="record" :is-mobile="isMobile"
             :traffic-diff="trafficDiff" :expire-diff="expireDiff" :online-clients="onlineClients"
-            :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme" @edit-client="(p) => emit('edit-client', p)"
+            :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme" :page-size="pageSize"
+            @edit-client="(p) => emit('edit-client', p)"
             @qrcode-client="(p) => emit('qrcode-client', p)" @info-client="(p) => emit('info-client', p)"
             @reset-traffic-client="(p) => emit('reset-traffic-client', p)"
             @delete-client="(p) => emit('delete-client', p)"

+ 70 - 6
frontend/src/pages/login/LoginPage.vue

@@ -8,8 +8,10 @@ import {
   antdThemeConfig,
   currentTheme,
   theme as themeState,
+  toggleTheme,
+  toggleUltra,
+  pauseAnimationsUntilLeave,
 } from '@/composables/useTheme.js';
-import ThemeSwitchLogin from '@/components/ThemeSwitchLogin.vue';
 
 const { t } = useI18n();
 
@@ -61,21 +63,53 @@ const lang = ref(LanguageManager.getLanguage());
 function onLangChange(next) {
   LanguageManager.setLanguage(next);
 }
+
+/* Same Light -> Dark -> Ultra Dark -> Light cycle the sidebar's brand
+ * button uses, so the login chrome offers a one-click theme toggle
+ * without the popover ceremony. */
+function cycleTheme() {
+  pauseAnimationsUntilLeave('login-theme-cycle');
+  if (!themeState.isDark) {
+    toggleTheme();
+    if (themeState.isUltra) toggleUltra();
+  } else if (!themeState.isUltra) {
+    toggleUltra();
+  } else {
+    toggleUltra();
+    toggleTheme();
+  }
+}
 </script>
 
 <template>
   <a-config-provider :theme="antdThemeConfig">
     <a-layout class="login-app" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
       <a-layout-content class="login-content">
-        <!-- Floating settings (theme switcher + language picker) sits in
-             the viewport's top-right corner so the card stays uncluttered. -->
+        <!-- Floating chrome at top-right: theme cycle (Light/Dark/Ultra)
+             plus a language picker hidden behind the gear popover. -->
         <div class="login-toolbar">
-          <a-popover :overlay-class-name="currentTheme" :title="t('menu.settings')" placement="bottomRight"
+          <button type="button" class="theme-cycle" :aria-label="t('menu.theme')" :title="t('menu.theme')"
+            @click="cycleTheme">
+            <svg v-if="!themeState.isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
+              stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+              <circle cx="12" cy="12" r="4" />
+              <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
+            </svg>
+            <svg v-else-if="!themeState.isUltra" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
+              stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+              <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
+            </svg>
+            <svg v-else viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5"
+              stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+              <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
+              <path fill="none" d="M19 3l0.7 1.4 1.4 0.7-1.4 0.7L19 7.2l-0.7-1.4-1.4-0.7 1.4-0.7z" />
+            </svg>
+          </button>
+
+          <a-popover :overlay-class-name="currentTheme" :title="t('pages.settings.language')" placement="bottomRight"
             trigger="click">
             <template #content>
               <a-space direction="vertical" :size="10" class="settings-popover">
-                <ThemeSwitchLogin />
-                <span>{{ t('pages.settings.language') }}</span>
                 <a-select v-model:value="lang" class="lang-select" @change="onLangChange">
                   <a-select-option v-for="l in LanguageManager.supportedLanguages" :key="l.value" :value="l.value">
                     <span :aria-label="l.name">{{ l.icon }}</span>
@@ -286,6 +320,9 @@ function onLangChange(next) {
   top: 16px;
   right: 16px;
   z-index: 10;
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
 }
 
 .toolbar-btn {
@@ -293,6 +330,33 @@ function onLangChange(next) {
   height: 40px;
 }
 
+.theme-cycle {
+  width: 40px;
+  height: 40px;
+  border-radius: 50%;
+  border: 1px solid var(--color-border);
+  background: var(--bg-card);
+  color: var(--color-text);
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  padding: 0;
+  transition: background-color 0.2s, transform 0.15s, color 0.2s;
+}
+
+.theme-cycle:hover,
+.theme-cycle:focus-visible {
+  color: var(--color-accent);
+  transform: scale(1.05);
+  outline: none;
+}
+
+.theme-cycle svg {
+  width: 18px;
+  height: 18px;
+}
+
 .login-wrapper {
   min-height: 100vh;
   display: flex;

+ 103 - 21
frontend/src/pages/sub/SubPage.vue

@@ -14,8 +14,10 @@ import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
 import {
   theme as themeState,
   antdThemeConfig,
+  toggleTheme,
+  toggleUltra,
+  pauseAnimationsUntilLeave,
 } from '@/composables/useTheme.js';
-import ThemeSwitchLogin from '@/components/ThemeSwitchLogin.vue';
 
 const { t } = useI18n();
 
@@ -37,6 +39,8 @@ const lastOnlineMs = Number(subData.lastOnline || 0);
 const subUrl = subData.subUrl || '';
 const subJsonUrl = subData.subJsonUrl || '';
 const subClashUrl = subData.subClashUrl || '';
+const subTitle = subData.subTitle || '';
+const subSupportUrl = subData.subSupportUrl || '';
 const links = Array.isArray(subData.links) ? subData.links : [];
 // Panel's "Calendar Type" setting; controls whether expiry / lastOnline
 // render in Gregorian or Jalali on this standalone subscription page.
@@ -70,6 +74,22 @@ function onLangChange(next) {
   LanguageManager.setLanguage(next);
 }
 
+/* Same Light -> Dark -> Ultra Dark -> Light cycle the panel sidebar
+ * uses, so the standalone subscription page offers a one-click theme
+ * toggle without the popover ceremony. */
+function cycleTheme() {
+  pauseAnimationsUntilLeave('sub-theme-cycle');
+  if (!themeState.isDark) {
+    toggleTheme();
+    if (themeState.isUltra) toggleUltra();
+  } else if (!themeState.isUltra) {
+    toggleUltra();
+  } else {
+    toggleUltra();
+    toggleTheme();
+  }
+}
+
 const QR_SIZE = 240;
 
 // Actions =====================================================
@@ -102,7 +122,14 @@ function linkName(link, idx) {
 
 // iOS deep links — taken verbatim from the legacy subpage. Each
 // client expects the sub URL in a slightly different param name.
-const shadowrocketUrl = computed(() => `sub://${btoa(subUrl)}`);
+const shadowrocketUrl = computed(() => {
+  if (!subUrl) return '';
+  const separator = subUrl.includes('?') ? '&' : '?';
+  const rawUrl = subUrl + separator + 'flag=shadowrocket';
+  const base64Url = encodeURIComponent(btoa(rawUrl));
+  const remark = encodeURIComponent(subTitle || sId || 'Subscription');
+  return `shadowrocket://add/sub/${base64Url}?remark=${remark}`;
+});
 const v2boxUrl = computed(() => `v2box://install-sub?url=${encodeURIComponent(subUrl)}&name=${encodeURIComponent(sId)}`);
 const streisandUrl = computed(() => `streisand://import/${encodeURIComponent(subUrl)}`);
 const v2raytunUrl = computed(() => subUrl);
@@ -131,26 +158,44 @@ const themeClass = computed(() => ({
                 </a-space>
               </template>
               <template #extra>
-                <a-popover :title="t('menu.settings')" placement="bottomRight" trigger="click">
-                  <template #content>
-                    <a-space direction="vertical" :size="10" class="settings-popover">
-                      <ThemeSwitchLogin />
-                      <span>{{ t('pages.settings.language') }}</span>
-                      <a-select v-model:value="lang" class="lang-select" @change="onLangChange">
-                        <a-select-option v-for="l in LanguageManager.supportedLanguages" :key="l.value"
-                          :value="l.value">
-                          <span :aria-label="l.name">{{ l.icon }}</span>
-                          &nbsp;&nbsp;<span>{{ l.name }}</span>
-                        </a-select-option>
-                      </a-select>
-                    </a-space>
-                  </template>
-                  <a-button shape="circle">
-                    <template #icon>
-                      <SettingOutlined />
+                <a-space :size="8" align="center">
+                  <button type="button" class="theme-cycle" :aria-label="t('menu.theme')" :title="t('menu.theme')"
+                    @click="cycleTheme">
+                    <svg v-if="!themeState.isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+                      stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+                      <circle cx="12" cy="12" r="4" />
+                      <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
+                    </svg>
+                    <svg v-else-if="!themeState.isUltra" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+                      stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+                      <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
+                    </svg>
+                    <svg v-else viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5"
+                      stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+                      <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
+                      <path fill="none" d="M19 3l0.7 1.4 1.4 0.7-1.4 0.7L19 7.2l-0.7-1.4-1.4-0.7 1.4-0.7z" />
+                    </svg>
+                  </button>
+
+                  <a-popover :title="t('pages.settings.language')" placement="bottomRight" trigger="click">
+                    <template #content>
+                      <a-space direction="vertical" :size="10" class="settings-popover">
+                        <a-select v-model:value="lang" class="lang-select" @change="onLangChange">
+                          <a-select-option v-for="l in LanguageManager.supportedLanguages" :key="l.value"
+                            :value="l.value">
+                            <span :aria-label="l.name">{{ l.icon }}</span>
+                            &nbsp;&nbsp;<span>{{ l.name }}</span>
+                          </a-select-option>
+                        </a-select>
+                      </a-space>
                     </template>
-                  </a-button>
-                </a-popover>
+                    <a-button shape="circle">
+                      <template #icon>
+                        <SettingOutlined />
+                      </template>
+                    </a-button>
+                  </a-popover>
+                </a-space>
               </template>
 
               <!-- ============== QR codes ============== -->
@@ -441,6 +486,43 @@ const themeClass = computed(() => ({
   min-width: 220px;
 }
 
+.theme-cycle {
+  width: 32px;
+  height: 32px;
+  border-radius: 50%;
+  border: 1px solid rgba(0, 0, 0, 0.08);
+  background: var(--bg-card);
+  color: rgba(0, 0, 0, 0.65);
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  padding: 0;
+  transition: background-color 0.2s, transform 0.15s, color 0.2s;
+}
+
+.theme-cycle:hover,
+.theme-cycle:focus-visible {
+  color: #1677ff;
+  transform: scale(1.05);
+  outline: none;
+}
+
+.theme-cycle svg {
+  width: 16px;
+  height: 16px;
+}
+
+.is-dark .theme-cycle {
+  border-color: rgba(255, 255, 255, 0.08);
+  color: rgba(255, 255, 255, 0.85);
+}
+
+.is-dark .theme-cycle:hover,
+.is-dark .theme-cycle:focus-visible {
+  color: #4096ff;
+}
+
 .lang-select {
   width: 100%;
 }

+ 25 - 4
frontend/src/pages/xray/DnsTab.vue

@@ -22,6 +22,14 @@ const props = defineProps({
 
 const STRATEGIES = ['UseSystem', 'UseIP', 'UseIPv4', 'UseIPv6'];
 
+const dnsFieldOmit = Object.freeze(Object.create(null));
+
+function dnsValueEmptyForOmit(v) {
+  if (v === undefined || v === null) return true;
+  if (typeof v === 'string') return v.trim() === '';
+  return false;
+}
+
 const enableDNS = computed({
   get: () => !!props.templateSettings?.dns,
   set: (next) => {
@@ -29,7 +37,6 @@ const enableDNS = computed({
     if (next) {
       props.templateSettings.dns = {
         tag: 'dns_inbound',
-        clientIp: '',
         queryStrategy: 'UseIP',
         disableCache: false,
         disableFallback: false,
@@ -50,16 +57,30 @@ const enableDNS = computed({
 });
 
 function dnsField(field, fallback) {
+  const omitWhenUnset = fallback === dnsFieldOmit;
   return computed({
-    get: () => props.templateSettings?.dns?.[field] ?? fallback,
+    get: () => {
+      const raw = props.templateSettings?.dns?.[field];
+      if (fallback === dnsFieldOmit) return raw ?? '';
+      return raw ?? fallback;
+    },
     set: (v) => {
-      if (props.templateSettings?.dns) props.templateSettings.dns[field] = v;
+      if (!props.templateSettings?.dns) return;
+      if (omitWhenUnset) {
+        if (dnsValueEmptyForOmit(v)) {
+          if (field in props.templateSettings.dns) delete props.templateSettings.dns[field];
+        } else {
+          props.templateSettings.dns[field] = v;
+        }
+      } else {
+        props.templateSettings.dns[field] = v;
+      }
     },
   });
 }
 
 const dnsTag = dnsField('tag', 'dns_inbound');
-const dnsClientIp = dnsField('clientIp', '');
+const dnsClientIp = dnsField('clientIp', dnsFieldOmit);
 const dnsStrategy = dnsField('queryStrategy', 'UseIP');
 const dnsDisableCache = dnsField('disableCache', false);
 const dnsDisableFallback = dnsField('disableFallback', false);

+ 31 - 3
frontend/src/pages/xray/NordModal.vue

@@ -222,6 +222,12 @@ function resetOutbound() {
 }
 
 function close() { emit('update:open', false); }
+
+function loadColor(load) {
+  if (load < 30) return 'green';
+  if (load < 70) return 'orange';
+  return 'red';
+}
 </script>
 
 <template>
@@ -299,9 +305,13 @@ function close() { emit('update:open', false); }
         </a-form-item>
 
         <a-form-item v-if="filteredServers.length > 0" label="Server">
-          <a-select v-model:value="serverId">
-            <a-select-option v-for="s in filteredServers" :key="s.id" :value="s.id">
-              {{ s.cityName }} - {{ s.name }} (load: {{ s.load }}%)
+          <a-select v-model:value="serverId" show-search option-filter-prop="label">
+            <a-select-option v-for="s in filteredServers" :key="s.id" :value="s.id"
+              :label="`${s.cityName} ${s.name} ${s.hostname}`">
+              <span class="server-row">
+                <span class="server-name">{{ s.cityName }} - {{ s.name }}</span>
+                <a-tag :color="loadColor(s.load)" class="server-load-tag">{{ s.load }}%</a-tag>
+              </span>
             </a-select-option>
           </a-select>
         </a-form-item>
@@ -376,4 +386,22 @@ function close() { emit('update:open', false); }
 .ml-8 {
   margin-left: 8px;
 }
+
+.server-row {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  width: 100%;
+}
+
+.server-name {
+  flex: 1;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.server-load-tag {
+  margin-right: 0;
+  flex-shrink: 0;
+}
 </style>

+ 2 - 5
frontend/src/pages/xray/OutboundFormModal.vue

@@ -34,7 +34,6 @@ const props = defineProps({
   open: { type: Boolean, default: false },
   outbound: { type: Object, default: null },
   existingTags: { type: Array, default: () => [] },
-  inboundTags: { type: Array, default: () => [] },
 });
 
 const emit = defineEmits(['update:open', 'confirm']);
@@ -318,10 +317,8 @@ function regenerateWgKeys() {
           <!-- ============== Loopback ============== -->
           <template v-if="isLoopback">
             <a-form-item label="Inbound tag">
-              <a-auto-complete v-model:value="outbound.settings.inboundTag"
-                :options="inboundTags.map((tag) => ({ value: tag }))"
-                :filter-option="(input, option) => option.value.toLowerCase().includes(input.toLowerCase())"
-                placeholder="tag of an existing inbound to re-route into" />
+              <a-input v-model:value="outbound.settings.inboundTag"
+                placeholder="inbound tag using in routing rules" />
             </a-form-item>
           </template>
 

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

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

+ 22 - 8
frontend/src/pages/xray/RoutingTab.vue

@@ -65,18 +65,32 @@ const editingRule = ref(null);
 const editingIndex = ref(null);
 
 const inboundTagOptions = computed(() => {
-  const out = new Set();
+  const seen = new Set();
+  const out = [];
+
+  function pushUnique(tag) {
+    if (!tag) return;
+    if (seen.has(tag)) return;
+    seen.add(tag);
+    out.push(tag);
+  }
+
   for (const ib of props.templateSettings?.inbounds || []) {
-    if (ib.tag) out.add(ib.tag);
+    pushUnique(ib.tag);
+  }
+  for (const t of props.inboundTags || []) {
+    pushUnique(t);
   }
-  for (const t of props.inboundTags || []) out.add(t);
   for (const ob of props.templateSettings?.outbounds || []) {
-    const rt = ob?.reverse?.tag || ob?.settings?.reverse?.tag;
-    if (rt) out.add(rt);
+    const rt = ob?.reverse?.tag || ob?.settings?.reverse?.tag || ob?.settings?.inboundTag;
+    pushUnique(rt);
   }
-  // dnsTag if DNS is configured.
-  const dt = props.templateSettings?.dns?.tag;
-  if (dt) out.add(dt);
+  pushUnique(props.templateSettings?.dns?.tag);
+
+  for (const s of props.templateSettings?.dns?.servers || []) {
+    if (typeof s === 'object' && s?.tag) pushUnique(s.tag);
+  }
+
   return [...out];
 });
 

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

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

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

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

+ 1 - 1
sub/subController.go

@@ -128,7 +128,7 @@ func (a *SUBController) subs(c *gin.Context) {
 				basePath = "/"
 			}
 			basePathStr := basePath.(string)
-			page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL, subClashURL, basePathStr)
+			page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL, subClashURL, basePathStr, a.subTitle, a.subSupportUrl)
 			a.serveSubPage(c, basePathStr, page)
 			return
 		}

+ 43 - 39
sub/subService.go

@@ -1413,25 +1413,27 @@ func searchHost(headers any) string {
 // PageData is a view model for subpage.html
 // PageData contains data for rendering the subscription information page.
 type PageData struct {
-	Host         string
-	BasePath     string
-	SId          string
-	Enabled      bool
-	Download     string
-	Upload       string
-	Total        string
-	Used         string
-	Remained     string
-	Expire       int64
-	LastOnline   int64
-	Datepicker   string
-	DownloadByte int64
-	UploadByte   int64
-	TotalByte    int64
-	SubUrl       string
-	SubJsonUrl   string
-	SubClashUrl  string
-	Result       []string
+	Host          string
+	BasePath      string
+	SId           string
+	Enabled       bool
+	Download      string
+	Upload        string
+	Total         string
+	Used          string
+	Remained      string
+	Expire        int64
+	LastOnline    int64
+	Datepicker    string
+	DownloadByte  int64
+	UploadByte    int64
+	TotalByte     int64
+	SubUrl        string
+	SubJsonUrl    string
+	SubClashUrl   string
+	SubTitle      string
+	SubSupportUrl string
+	Result        []string
 }
 
 // ResolveRequest extracts scheme and host info from request/headers consistently.
@@ -1545,7 +1547,7 @@ func (s *SubService) joinPathWithID(basePath, subId string) string {
 
 // BuildPageData parses header and prepares the template view model.
 // BuildPageData constructs page data for rendering the subscription information page.
-func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, subURL, subJsonURL, subClashURL string, basePath string) PageData {
+func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, subURL, subJsonURL, subClashURL string, basePath string, subTitle string, subSupportUrl string) PageData {
 	download := common.FormatTraffic(traffic.Down)
 	upload := common.FormatTraffic(traffic.Up)
 	total := "∞"
@@ -1563,25 +1565,27 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray
 	}
 
 	return PageData{
-		Host:         hostHeader,
-		BasePath:     basePath,
-		SId:          subId,
-		Enabled:      traffic.Enable,
-		Download:     download,
-		Upload:       upload,
-		Total:        total,
-		Used:         used,
-		Remained:     remained,
-		Expire:       traffic.ExpiryTime / 1000,
-		LastOnline:   lastOnline,
-		Datepicker:   datepicker,
-		DownloadByte: traffic.Down,
-		UploadByte:   traffic.Up,
-		TotalByte:    traffic.Total,
-		SubUrl:       subURL,
-		SubJsonUrl:   subJsonURL,
-		SubClashUrl:  subClashURL,
-		Result:       subs,
+		Host:          hostHeader,
+		BasePath:      basePath,
+		SId:           subId,
+		Enabled:       traffic.Enable,
+		Download:      download,
+		Upload:        upload,
+		Total:         total,
+		Used:          used,
+		Remained:      remained,
+		Expire:        traffic.ExpiryTime / 1000,
+		LastOnline:    lastOnline,
+		Datepicker:    datepicker,
+		DownloadByte:  traffic.Down,
+		UploadByte:    traffic.Up,
+		TotalByte:     traffic.Total,
+		SubUrl:        subURL,
+		SubJsonUrl:    subJsonURL,
+		SubClashUrl:   subClashURL,
+		SubTitle:      subTitle,
+		SubSupportUrl: subSupportUrl,
+		Result:        subs,
 	}
 }
 

+ 4 - 1
web/controller/xray_setting.go

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

+ 7 - 2
web/service/nord.go

@@ -25,6 +25,9 @@ func (s *NordService) GetCountries() (string, error) {
 		return "", err
 	}
 	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		return "", common.NewErrorf("NordVPN API error: %s", resp.Status)
+	}
 	body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
 	if err != nil {
 		return "", err
@@ -45,6 +48,9 @@ func (s *NordService) GetServers(countryId string) (string, error) {
 		return "", err
 	}
 	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		return "", common.NewErrorf("NordVPN API error: %s", resp.Status)
+	}
 	body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
 	if err != nil {
 		return "", err
@@ -97,8 +103,7 @@ func (s *NordService) GetCredentials(token string) (string, error) {
 	}
 	req.SetBasicAuth("token", token)
 
-	client := &http.Client{Timeout: 10 * time.Second}
-	resp, err := client.Do(req)
+	resp, err := nordHTTPClient.Do(req)
 	if err != nil {
 		return "", err
 	}

+ 264 - 110
web/service/outbound.go

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

+ 1 - 1
x-ui.rc

@@ -13,6 +13,6 @@ start_pre(){
 }
 reload() {
   ebegin "Reloading ${RC_SVCNAME}"
-  kill -USR1  $pidfile
+  kill -USR1 $(cat $pidfile)
   eend $?
 }

+ 5 - 1
x-ui.sh

@@ -436,7 +436,11 @@ restart() {
 }
 
 restart_xray() {
-    systemctl reload x-ui
+    if [[ $release == "alpine" ]]; then
+        rc-service x-ui reload
+    else
+        systemctl reload x-ui
+    fi
     LOGI "xray-core Restart signal sent successfully, Please check the log information to confirm whether xray restarted successfully"
     sleep 2
     show_xray_status