18 Commits fb311afa6f ... 2a03844566

Author SHA1 Message Date
  MHSanaei 2a03844566 v3.2.5 7 hours ago
  MHSanaei 51d383b1c3 chore: bump bundled Xray-core to v26.6.1 7 hours ago
  MHSanaei 2bb9ed1cda feat(outbound): sync DNS outbound config with Xray core changes 7 hours ago
  MHSanaei 32f96298f8 feat(finalmask): sync transport with upstream Xray core changes 7 hours ago
  MHSanaei c5ff166056 fix(inbounds): refresh routing inbound-tag list after inbound changes 8 hours ago
  MHSanaei a3dca4b82d fix(inbounds): drop listen address from auto-generated inbound tag 8 hours ago
  MHSanaei 48f470c465 fix(test): drain macrotasks via setTimeout, not setImmediate 8 hours ago
  MHSanaei eee5e8f6b6 Update Go module dependency versions 8 hours ago
  MHSanaei ed21cf836d fix(test): drain React scheduler macrotask before jsdom teardown 8 hours ago
  MHSanaei cfd3b34362 feat(clients): show last-online tooltip on the depleted tag too 9 hours ago
  MHSanaei 88a3677318 feat(clients): enforce unique subId per client like email 9 hours ago
  MHSanaei d2058f07dd fix(inbounds): correct per-inbound client counts and align stat colors 9 hours ago
  MHSanaei 44a8c94108 fix(clients): refresh summary counts after a client mutation 9 hours ago
  MHSanaei b9cbc0c1e8 fix(ui): exit infinite spinner with a retry card on failed initial load 10 hours ago
  MHSanaei dd14e9b3b0 feat(inbounds): attach existing clients to an inbound in one click 10 hours ago
  MHSanaei 971843f669 feat(nodes): bulk panel self-update with live online indicator 10 hours ago
  MHSanaei c8df1b19ff feat(clients): live online dot + last-online tooltip on offline 11 hours ago
  MHSanaei b67c4c2f81 fix(clients): keep the summary card live without a page refresh 11 hours ago
66 changed files with 1397 additions and 303 deletions
  1. 2 2
      .github/workflows/release.yml
  2. 1 1
      DockerInit.sh
  3. 1 1
      config/version
  4. 68 68
      frontend/package-lock.json
  5. 1 1
      frontend/package.json
  6. 63 0
      frontend/public/openapi.json
  7. 16 0
      frontend/src/api/queries/useNodeMutations.ts
  8. 3 1
      frontend/src/api/queries/useNodesQuery.ts
  9. 2 1
      frontend/src/api/queries/useStatusQuery.ts
  10. 1 1
      frontend/src/generated/zod.ts
  11. 55 4
      frontend/src/hooks/useClients.ts
  12. 29 0
      frontend/src/lib/panel-version.ts
  13. 44 22
      frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx
  14. 3 8
      frontend/src/lib/xray/inbound-tag.ts
  15. 13 11
      frontend/src/lib/xray/outbound-form-adapter.ts
  16. 7 0
      frontend/src/pages/api-docs/endpoints.ts
  17. 26 5
      frontend/src/pages/clients/ClientsPage.tsx
  18. 10 1
      frontend/src/pages/groups/GroupsPage.tsx
  19. 26 0
      frontend/src/pages/inbounds/InboundsPage.tsx
  20. 233 0
      frontend/src/pages/inbounds/clients/AttachExistingClientsModal.tsx
  21. 1 0
      frontend/src/pages/inbounds/clients/index.ts
  22. 4 7
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  23. 3 0
      frontend/src/pages/inbounds/list/RowActions.tsx
  24. 16 15
      frontend/src/pages/inbounds/list/useInboundColumns.tsx
  25. 9 10
      frontend/src/pages/inbounds/useInbounds.ts
  26. 9 1
      frontend/src/pages/index/IndexPage.tsx
  27. 73 8
      frontend/src/pages/nodes/NodeList.tsx
  28. 74 3
      frontend/src/pages/nodes/NodesPage.tsx
  29. 5 2
      frontend/src/pages/xray/outbounds/protocols/dns.tsx
  30. 4 3
      frontend/src/schemas/forms/outbound-form.ts
  31. 1 1
      frontend/src/schemas/primitives/options.ts
  32. 4 3
      frontend/src/schemas/protocols/outbound/dns.ts
  33. 3 9
      frontend/src/schemas/protocols/stream/finalmask.ts
  34. 20 0
      frontend/src/styles/utils.css
  35. 28 8
      frontend/src/test/__snapshots__/finalmask.test.ts.snap
  36. 62 0
      frontend/src/test/clients-summary.test.ts
  37. 1 1
      frontend/src/test/golden/fixtures/finalmask/combined.json
  38. 11 4
      frontend/src/test/golden/fixtures/finalmask/udp-mask.json
  39. 4 5
      frontend/src/test/inbound-tag.test.ts
  40. 15 5
      frontend/src/test/outbound-form-adapter.test.ts
  41. 33 0
      frontend/src/test/panel-version.test.ts
  42. 13 1
      frontend/src/test/setup.components.ts
  43. 6 6
      go.mod
  44. 12 12
      go.sum
  45. 23 31
      sub/subService.go
  46. 40 0
      sub/subService_test.go
  47. 17 0
      web/controller/node.go
  48. 8 0
      web/runtime/remote.go
  49. 24 0
      web/service/client.go
  50. 1 1
      web/service/inbound.go
  51. 50 0
      web/service/node.go
  52. 7 10
      web/service/port_conflict.go
  53. 17 17
      web/service/port_conflict_test.go
  54. 15 1
      web/translation/ar-EG.json
  55. 15 1
      web/translation/en-US.json
  56. 15 1
      web/translation/es-ES.json
  57. 15 1
      web/translation/fa-IR.json
  58. 15 1
      web/translation/id-ID.json
  59. 15 1
      web/translation/ja-JP.json
  60. 15 1
      web/translation/pt-BR.json
  61. 15 1
      web/translation/ru-RU.json
  62. 15 1
      web/translation/tr-TR.json
  63. 15 1
      web/translation/uk-UA.json
  64. 15 1
      web/translation/vi-VN.json
  65. 15 1
      web/translation/zh-CN.json
  66. 15 1
      web/translation/zh-TW.json

+ 2 - 2
.github/workflows/release.yml

@@ -112,7 +112,7 @@ jobs:
           cd x-ui/bin
 
           # Download dependencies
-          Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.5.9/"
+          Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.6.1/"
           if [ "${{ matrix.platform }}" == "amd64" ]; then
             wget -q ${Xray_URL}Xray-linux-64.zip
             unzip Xray-linux-64.zip
@@ -246,7 +246,7 @@ jobs:
           cd x-ui\bin
 
           # Download Xray for Windows
-          $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.5.9/"
+          $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.6.1/"
           Invoke-WebRequest -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
           Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
           Remove-Item "Xray-windows-64.zip"

+ 1 - 1
DockerInit.sh

@@ -27,7 +27,7 @@ case $1 in
 esac
 mkdir -p build/bin
 cd build/bin
-curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.5.9/Xray-linux-${ARCH}.zip"
+curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.6.1/Xray-linux-${ARCH}.zip"
 unzip "Xray-linux-${ARCH}.zip"
 rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
 mv xray "xray-linux-${FNAME}"

+ 1 - 1
config/version

@@ -1 +1 @@
-3.2.0
+3.2.5

+ 68 - 68
frontend/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "3x-ui-frontend",
-  "version": "0.2.0",
+  "version": "0.2.5",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "3x-ui-frontend",
-      "version": "0.2.0",
+      "version": "0.2.5",
       "dependencies": {
         "@ant-design/icons": "^6.2.5",
         "@codemirror/lang-json": "^6.0.2",
@@ -873,9 +873,9 @@
       }
     },
     "node_modules/@eslint/plugin-kit": {
-      "version": "0.7.1",
-      "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz",
-      "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==",
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz",
+      "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==",
       "dev": true,
       "license": "Apache-2.0",
       "dependencies": {
@@ -1733,9 +1733,9 @@
       }
     },
     "node_modules/@rc-component/trigger": {
-      "version": "3.9.0",
-      "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-3.9.0.tgz",
-      "integrity": "sha512-X8btpwfrT27AgrZVOz4swclhEHTZcqaHeQMXXBgveagOiakTa36uObXbdwerXffgV8G9dH1fAAE0DHtVQs8EHg==",
+      "version": "3.9.1",
+      "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-3.9.1.tgz",
+      "integrity": "sha512-LNsYvz60mrLJ/kRvKcHE7boUvcQfVMCfRqZ71x3Fo9AOiZ1KKIEqkzMA8DNvz2V3Bcvir/vwQNn7JF1NPODQ7Q==",
       "license": "MIT",
       "dependencies": {
         "@rc-component/motion": "^1.1.4",
@@ -3365,16 +3365,16 @@
       }
     },
     "node_modules/@vitest/expect": {
-      "version": "4.1.7",
-      "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz",
-      "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==",
+      "version": "4.1.8",
+      "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz",
+      "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
         "@standard-schema/spec": "^1.1.0",
         "@types/chai": "^5.2.2",
-        "@vitest/spy": "4.1.7",
-        "@vitest/utils": "4.1.7",
+        "@vitest/spy": "4.1.8",
+        "@vitest/utils": "4.1.8",
         "chai": "^6.2.2",
         "tinyrainbow": "^3.1.0"
       },
@@ -3383,13 +3383,13 @@
       }
     },
     "node_modules/@vitest/mocker": {
-      "version": "4.1.7",
-      "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz",
-      "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==",
+      "version": "4.1.8",
+      "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz",
+      "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@vitest/spy": "4.1.7",
+        "@vitest/spy": "4.1.8",
         "estree-walker": "^3.0.3",
         "magic-string": "^0.30.21"
       },
@@ -3410,9 +3410,9 @@
       }
     },
     "node_modules/@vitest/pretty-format": {
-      "version": "4.1.7",
-      "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz",
-      "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==",
+      "version": "4.1.8",
+      "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz",
+      "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -3423,13 +3423,13 @@
       }
     },
     "node_modules/@vitest/runner": {
-      "version": "4.1.7",
-      "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz",
-      "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==",
+      "version": "4.1.8",
+      "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz",
+      "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@vitest/utils": "4.1.7",
+        "@vitest/utils": "4.1.8",
         "pathe": "^2.0.3"
       },
       "funding": {
@@ -3437,14 +3437,14 @@
       }
     },
     "node_modules/@vitest/snapshot": {
-      "version": "4.1.7",
-      "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz",
-      "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==",
+      "version": "4.1.8",
+      "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz",
+      "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@vitest/pretty-format": "4.1.7",
-        "@vitest/utils": "4.1.7",
+        "@vitest/pretty-format": "4.1.8",
+        "@vitest/utils": "4.1.8",
         "magic-string": "^0.30.21",
         "pathe": "^2.0.3"
       },
@@ -3453,9 +3453,9 @@
       }
     },
     "node_modules/@vitest/spy": {
-      "version": "4.1.7",
-      "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz",
-      "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==",
+      "version": "4.1.8",
+      "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz",
+      "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==",
       "dev": true,
       "license": "MIT",
       "funding": {
@@ -3463,13 +3463,13 @@
       }
     },
     "node_modules/@vitest/utils": {
-      "version": "4.1.7",
-      "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz",
-      "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==",
+      "version": "4.1.8",
+      "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz",
+      "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@vitest/pretty-format": "4.1.7",
+        "@vitest/pretty-format": "4.1.8",
         "convert-source-map": "^2.0.0",
         "tinyrainbow": "^3.1.0"
       },
@@ -3719,9 +3719,9 @@
       "license": "MIT"
     },
     "node_modules/baseline-browser-mapping": {
-      "version": "2.10.32",
-      "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz",
-      "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==",
+      "version": "2.10.33",
+      "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz",
+      "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==",
       "dev": true,
       "license": "Apache-2.0",
       "bin": {
@@ -4359,9 +4359,9 @@
       }
     },
     "node_modules/electron-to-chromium": {
-      "version": "1.5.363",
-      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.363.tgz",
-      "integrity": "sha512-VjUKPyWzGnT1fujlkEGC/BvN70Hh70KXtAqcmniXviYlJC/ivcT+BWGPyxWVbJZLfvtKR6dqg1L7T7pgAMBtWA==",
+      "version": "1.5.364",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz",
+      "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==",
       "dev": true,
       "license": "ISC"
     },
@@ -4464,9 +4464,9 @@
       }
     },
     "node_modules/eslint": {
-      "version": "10.4.0",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz",
-      "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==",
+      "version": "10.4.1",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.1.tgz",
+      "integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -4475,7 +4475,7 @@
         "@eslint/config-array": "^0.23.5",
         "@eslint/config-helpers": "^0.6.0",
         "@eslint/core": "^1.2.1",
-        "@eslint/plugin-kit": "^0.7.1",
+        "@eslint/plugin-kit": "^0.7.2",
         "@humanfs/node": "^0.16.6",
         "@humanwhocodes/module-importer": "^1.0.1",
         "@humanwhocodes/retry": "^0.4.2",
@@ -7132,9 +7132,9 @@
       "license": "MIT"
     },
     "node_modules/tinyexec": {
-      "version": "1.2.2",
-      "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz",
-      "integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==",
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
+      "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -7142,9 +7142,9 @@
       }
     },
     "node_modules/tinyglobby": {
-      "version": "0.2.16",
-      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
-      "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+      "version": "0.2.17",
+      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+      "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -7560,19 +7560,19 @@
       }
     },
     "node_modules/vitest": {
-      "version": "4.1.7",
-      "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz",
-      "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==",
+      "version": "4.1.8",
+      "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz",
+      "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@vitest/expect": "4.1.7",
-        "@vitest/mocker": "4.1.7",
-        "@vitest/pretty-format": "4.1.7",
-        "@vitest/runner": "4.1.7",
-        "@vitest/snapshot": "4.1.7",
-        "@vitest/spy": "4.1.7",
-        "@vitest/utils": "4.1.7",
+        "@vitest/expect": "4.1.8",
+        "@vitest/mocker": "4.1.8",
+        "@vitest/pretty-format": "4.1.8",
+        "@vitest/runner": "4.1.8",
+        "@vitest/snapshot": "4.1.8",
+        "@vitest/spy": "4.1.8",
+        "@vitest/utils": "4.1.8",
         "es-module-lexer": "^2.0.0",
         "expect-type": "^1.3.0",
         "magic-string": "^0.30.21",
@@ -7600,12 +7600,12 @@
         "@edge-runtime/vm": "*",
         "@opentelemetry/api": "^1.9.0",
         "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
-        "@vitest/browser-playwright": "4.1.7",
-        "@vitest/browser-preview": "4.1.7",
-        "@vitest/browser-webdriverio": "4.1.7",
-        "@vitest/coverage-istanbul": "4.1.7",
-        "@vitest/coverage-v8": "4.1.7",
-        "@vitest/ui": "4.1.7",
+        "@vitest/browser-playwright": "4.1.8",
+        "@vitest/browser-preview": "4.1.8",
+        "@vitest/browser-webdriverio": "4.1.8",
+        "@vitest/coverage-istanbul": "4.1.8",
+        "@vitest/coverage-v8": "4.1.8",
+        "@vitest/ui": "4.1.8",
         "happy-dom": "*",
         "jsdom": "*",
         "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"

+ 1 - 1
frontend/package.json

@@ -1,7 +1,7 @@
 {
   "name": "3x-ui-frontend",
   "private": true,
-  "version": "0.2.0",
+  "version": "0.2.5",
   "type": "module",
   "description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
   "engines": {

+ 63 - 0
frontend/public/openapi.json

@@ -4244,6 +4244,69 @@
         }
       }
     },
+    "/panel/api/nodes/updatePanel": {
+      "post": {
+        "tags": [
+          "Nodes"
+        ],
+        "summary": "Trigger the official panel self-updater on each given node (downloads the latest release and restarts). Only enabled, online nodes are updated; offline/disabled ones are reported as skipped. Returns a per-node result list.",
+        "operationId": "post_panel_api_nodes_updatePanel",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": {
+                "ids": [
+                  1,
+                  2,
+                  3
+                ]
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": [
+                    {
+                      "id": 1,
+                      "name": "de-1",
+                      "ok": true
+                    },
+                    {
+                      "id": 2,
+                      "name": "fr-1",
+                      "ok": false,
+                      "error": "node is offline"
+                    }
+                  ]
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/nodes/history/{id}/{metric}/{bucket}": {
       "get": {
         "tags": [

+ 16 - 0
frontend/src/api/queries/useNodeMutations.ts

@@ -8,6 +8,13 @@ import { ProbeResultSchema, type ProbeResult } from '@/schemas/node';
 
 export type { ProbeResult };
 
+export interface NodeUpdateResult {
+  id: number;
+  name?: string;
+  ok: boolean;
+  error?: string;
+}
+
 export function useNodeMutations() {
   const queryClient = useQueryClient();
   const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.nodes.root() });
@@ -44,12 +51,21 @@ export function useNodeMutations() {
     onSuccess: (msg) => { if (msg?.success) invalidate(); },
   });
 
+  const updatePanelsMut = useMutation({
+    mutationFn: (ids: number[]) =>
+      HttpUtil.post<NodeUpdateResult[]>('/panel/api/nodes/updatePanel', { ids }, {
+        headers: { 'Content-Type': 'application/json' },
+      }),
+    onSuccess: (msg) => { if (msg?.success) invalidate(); },
+  });
+
   return {
     create: (payload: Partial<NodeRecord>) => createMut.mutateAsync(payload),
     update: (id: number, payload: Partial<NodeRecord>) => updateMut.mutateAsync({ id, payload }),
     remove: (id: number) => removeMut.mutateAsync(id),
     setEnable: (id: number, enable: boolean) => setEnableMut.mutateAsync({ id, enable }),
     probe: (id: number) => probeMut.mutateAsync(id),
+    updatePanels: (ids: number[]): Promise<Msg<NodeUpdateResult[]>> => updatePanelsMut.mutateAsync(ids),
     testConnection: async (payload: Partial<NodeRecord>): Promise<Msg<ProbeResult>> => {
       const raw = await HttpUtil.post('/panel/api/nodes/test', payload);
       return parseMsg(raw, ProbeResultSchema, 'nodes/test');

+ 3 - 1
frontend/src/api/queries/useNodesQuery.ts

@@ -76,6 +76,8 @@ export function useNodesQuery() {
     nodes,
     totals,
     loading: query.isFetching,
-    fetched: query.data !== undefined,
+    fetched: query.data !== undefined || query.isError,
+    fetchError: query.error ? (query.error as Error).message : '',
+    refetch: query.refetch,
   };
 }

+ 2 - 1
frontend/src/api/queries/useStatusQuery.ts

@@ -30,7 +30,8 @@ export function useStatusQuery() {
 
   return {
     status,
-    fetched: query.data !== undefined,
+    fetched: query.data !== undefined || query.isError,
+    fetchError: query.error ? (query.error as Error).message : '',
     refresh,
   };
 }

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

@@ -292,7 +292,7 @@ export const InboundSchema = z.object({
   listen: z.string(),
   nodeId: z.number().int().nullable().optional(),
   port: z.number().int().min(1).max(65535),
-  protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel']),
+  protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun']),
   remark: z.string(),
   settings: z.unknown(),
   sniffing: z.unknown(),

+ 55 - 4
frontend/src/hooks/useClients.ts

@@ -68,6 +68,42 @@ const DEFAULT_SUMMARY: ClientsSummary = {
   total: 0, active: 0, online: [], depleted: [], expiring: [], deactive: [],
 };
 
+type ClientStatRow = ClientTraffic & { email?: string };
+
+// Mirror of the server's buildClientsSummary (web/service/client.go). The
+// client_stats WS event already carries every client's traffic, so the
+// summary card can be recomputed live from it instead of waiting for a list
+// refetch — keep the two in lockstep.
+export function computeClientsSummary(
+  stats: ClientStatRow[],
+  onlineSet: Set<string>,
+  expireDiffMs: number,
+  trafficDiffBytes: number,
+): ClientsSummary {
+  const now = Date.now();
+  const online: string[] = [];
+  const depleted: string[] = [];
+  const expiring: string[] = [];
+  const deactive: string[] = [];
+  let active = 0;
+  for (const c of stats) {
+    const email = c.email;
+    if (!email) continue;
+    const used = (c.up || 0) + (c.down || 0);
+    const total = c.total || 0;
+    const exhausted = total > 0 && used >= total;
+    const expired = (c.expiryTime || 0) > 0 && (c.expiryTime || 0) <= now;
+    if (c.enable && onlineSet.has(email)) online.push(email);
+    if (exhausted || expired) { depleted.push(email); continue; }
+    if (!c.enable) { deactive.push(email); continue; }
+    const nearExpiry = (c.expiryTime || 0) > 0 && (c.expiryTime || 0) - now < expireDiffMs;
+    const nearLimit = total > 0 && total - used < trafficDiffBytes;
+    if (nearExpiry || nearLimit) expiring.push(email);
+    else active += 1;
+  }
+  return { total: stats.length, active, online, depleted, expiring, deactive };
+}
+
 function buildQS(p: ClientQueryParams): string {
   const sp = new URLSearchParams();
   sp.set('page', String(p.page || 1));
@@ -176,13 +212,13 @@ export function useClients() {
   const clients = listQuery.data?.items ?? [];
   const total = listQuery.data?.total ?? 0;
   const filtered = listQuery.data?.filtered ?? 0;
-  const summary = listQuery.data?.summary ?? DEFAULT_SUMMARY;
   const allGroups = listQuery.data?.groups ?? [];
-  const fetched = listQuery.data !== undefined;
+  const fetched = listQuery.data !== undefined || listQuery.isError;
+  const fetchError = listQuery.error ? (listQuery.error as Error).message : '';
   const loading = listQuery.isFetching;
 
   const inbounds = inboundOptionsQuery.data ?? [];
-  const onlines = onlinesQuery.data ?? [];
+  const onlines = useMemo(() => onlinesQuery.data ?? [], [onlinesQuery.data]);
 
   const defaults = defaultsQuery.data ?? {};
   const subSettings: SubSettings = useMemo(() => ({
@@ -207,6 +243,18 @@ export function useClients() {
   const trafficDiff = ((defaults.trafficDiff as number) ?? 0) * 1073741824;
   const pageSize = (defaults.pageSize as number) ?? 0;
 
+  // Live summary: the client_stats WS event refreshes allClientStats every few
+  // seconds, so the top counters track reality without a page refresh. Falls
+  // back to the server-computed summary until the first event lands, and keeps
+  // the server's authoritative total for the headline count.
+  const [allClientStats, setAllClientStats] = useState<ClientStatRow[]>([]);
+  const summary = useMemo<ClientsSummary>(() => {
+    const serverSummary = listQuery.data?.summary ?? DEFAULT_SUMMARY;
+    if (allClientStats.length === 0) return serverSummary;
+    const live = computeClientsSummary(allClientStats, new Set(onlines), expireDiff, trafficDiff);
+    return { ...live, total: serverSummary.total || live.total };
+  }, [allClientStats, onlines, expireDiff, trafficDiff, listQuery.data?.summary]);
+
   // Client mutations (add/update/remove/attach/detach/resetTraffic/…) all
   // mutate inbound rows server-side too — adding a client appends to
   // settings.clients on each attached inbound, the slim list's per-inbound
@@ -216,6 +264,7 @@ export function useClients() {
   const invalidateAll = useCallback(
     () => {
       markLocalInvalidate();
+      setAllClientStats([]);
       return Promise.all([
         queryClient.invalidateQueries({ queryKey: keys.clients.root() }),
         queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
@@ -438,8 +487,9 @@ export function useClients() {
 
   const applyClientStatsEvent = useCallback((payload: unknown) => {
     if (!payload || typeof payload !== 'object') return;
-    const p = payload as { clients?: (ClientTraffic & { email?: string })[] };
+    const p = payload as { clients?: ClientStatRow[] };
     if (!Array.isArray(p.clients) || p.clients.length === 0) return;
+    setAllClientStats(p.clients);
     const byEmail = new Map<string, ClientTraffic>();
     for (const row of p.clients) {
       if (row && row.email) byEmail.set(row.email, row);
@@ -484,6 +534,7 @@ export function useClients() {
     onlines,
     loading,
     fetched,
+    fetchError,
     subSettings,
     ipLimitEnable,
     tgBotEnable,

+ 29 - 0
frontend/src/lib/panel-version.ts

@@ -0,0 +1,29 @@
+// Mirror of web/service/panel.go isNewerVersion: parse a vMAJOR.MINOR.PATCH tag
+// and report whether `latest` is ahead of `current`. When either side isn't a
+// clean three-part numeric tag, fall back to a normalized string inequality —
+// the same heuristic the Go side uses so the node "update available" badge
+// agrees with what the server would decide.
+function parseVersionParts(version: string): [number, number, number] | null {
+  const parts = version.trim().replace(/^v/, '').split('.');
+  if (parts.length !== 3) return null;
+  const out: number[] = [];
+  for (const part of parts) {
+    if (!/^\d+$/.test(part)) return null;
+    out.push(Number(part));
+  }
+  return [out[0], out[1], out[2]];
+}
+
+export function isPanelUpdateAvailable(latest: string, current: string): boolean {
+  if (!latest || !current) return false;
+  const a = parseVersionParts(latest);
+  const b = parseVersionParts(current);
+  if (!a || !b) {
+    return latest.trim().replace(/^v/, '') !== current.trim().replace(/^v/, '');
+  }
+  for (let i = 0; i < 3; i++) {
+    if (a[i] > b[i]) return true;
+    if (a[i] < b[i]) return false;
+  }
+  return false;
+}

+ 44 - 22
frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx

@@ -48,14 +48,15 @@ function defaultTcpMaskSettings(type: string): Record<string, unknown> {
 function defaultUdpMaskSettings(type: string): Record<string, unknown> {
   switch (type) {
     case 'salamander':
-    case 'mkcp-aes128gcm':
       return { password: '' };
-    case 'header-dns':
-      return { domain: '' };
+    case 'mkcp-legacy':
+      return { header: '', value: '' };
     case 'xdns':
       return { domains: [] };
     case 'xicmp':
-      return { ip: '0.0.0.0', id: 0 };
+      return { dgram: false, ips: [] };
+    case 'realm':
+      return { url: '', stunServers: [] };
     case 'header-custom':
       return { client: [], server: [] };
     case 'noise':
@@ -344,7 +345,7 @@ function UdpMasksList({
               size="small"
               icon={<PlusOutlined />}
               onClick={() => {
-                const def = isHysteria ? 'salamander' : 'mkcp-aes128gcm';
+                const def = isHysteria ? 'salamander' : 'mkcp-legacy';
                 add({ type: def, settings: defaultUdpMaskSettings(def) });
               }}
             />
@@ -391,16 +392,10 @@ function UdpMaskItem({
   const options = isHysteria
     ? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }]
     : [
-        { value: 'mkcp-aes128gcm', label: 'mKCP AES-128-GCM' },
-        { value: 'header-dns', label: 'Header DNS' },
-        { value: 'header-dtls', label: 'Header DTLS 1.2' },
-        { value: 'header-srtp', label: 'Header SRTP' },
-        { value: 'header-utp', label: 'Header uTP' },
-        { value: 'header-wechat', label: 'Header WeChat Video' },
-        { value: 'header-wireguard', label: 'Header WireGuard' },
-        { value: 'mkcp-original', label: 'mKCP Original' },
+        { value: 'mkcp-legacy', label: 'mKCP Legacy' },
         { value: 'xdns', label: 'xDNS' },
         { value: 'xicmp', label: 'xICMP' },
+        { value: 'realm', label: 'Realm' },
         { value: 'header-custom', label: 'Header Custom' },
         { value: 'noise', label: 'Noise' },
       ];
@@ -422,7 +417,7 @@ function UdpMaskItem({
       >
         {({ getFieldValue }) => {
           const type = getFieldValue([...absolutePath, 'type']) as string | undefined;
-          if (type === 'mkcp-aes128gcm' || type === 'salamander') {
+          if (type === 'salamander') {
             return (
               <Form.Item label="Password">
                 <Space.Compact block>
@@ -440,11 +435,26 @@ function UdpMaskItem({
               </Form.Item>
             );
           }
-          if (type === 'header-dns') {
+          if (type === 'mkcp-legacy') {
             return (
-              <Form.Item label="Domain" name={[fieldName, 'settings', 'domain']}>
-                <Input placeholder="e.g., www.example.com" />
-              </Form.Item>
+              <>
+                <Form.Item label="Header" name={[fieldName, 'settings', 'header']}>
+                  <Select
+                    options={[
+                      { value: '', label: 'Original / AES-128-GCM' },
+                      { value: 'dns', label: 'DNS' },
+                      { value: 'dtls', label: 'DTLS 1.2' },
+                      { value: 'srtp', label: 'SRTP' },
+                      { value: 'utp', label: 'uTP' },
+                      { value: 'wechat', label: 'WeChat Video' },
+                      { value: 'wireguard', label: 'WireGuard' },
+                    ]}
+                  />
+                </Form.Item>
+                <Form.Item label="Value" name={[fieldName, 'settings', 'value']}>
+                  <Input placeholder="password (AES-128-GCM) or domain (DNS header)" />
+                </Form.Item>
+              </>
             );
           }
           if (type === 'xdns') {
@@ -457,11 +467,23 @@ function UdpMaskItem({
           if (type === 'xicmp') {
             return (
               <>
-                <Form.Item label="IP" name={[fieldName, 'settings', 'ip']}>
-                  <Input placeholder="0.0.0.0" />
+                <Form.Item label="Dgram" name={[fieldName, 'settings', 'dgram']} valuePropName="checked">
+                  <Switch />
                 </Form.Item>
-                <Form.Item label="ID" name={[fieldName, 'settings', 'id']}>
-                  <InputNumber min={0} />
+                <Form.Item label="IPs" name={[fieldName, 'settings', 'ips']}>
+                  <Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
+                </Form.Item>
+              </>
+            );
+          }
+          if (type === 'realm') {
+            return (
+              <>
+                <Form.Item label="URL" name={[fieldName, 'settings', 'url']}>
+                  <Input placeholder="realm://token@host:port/id" />
+                </Form.Item>
+                <Form.Item label="STUN Servers" name={[fieldName, 'settings', 'stunServers']}>
+                  <Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} placeholder="host:port" />
                 </Form.Item>
               </>
             );

+ 3 - 8
frontend/src/lib/xray/inbound-tag.ts

@@ -49,12 +49,8 @@ function transportTagSuffix(bits: TransportBits): string {
   return 'any';
 }
 
-function isAnyListen(listen: string): boolean {
-  return listen === '' || listen === '0.0.0.0' || listen === '::' || listen === '::0';
-}
-
-function baseInboundTag(listen: string, port: number): string {
-  return isAnyListen(listen) ? `in-${port}` : `in-${listen}:${port}`;
+function baseInboundTag(port: number): string {
+  return `in-${port}`;
 }
 
 function nodeTagPrefix(nodeId: number | null | undefined): string {
@@ -62,7 +58,6 @@ function nodeTagPrefix(nodeId: number | null | undefined): string {
 }
 
 export interface InboundTagInput {
-  listen: string;
   port: number;
   nodeId: number | null | undefined;
   protocol: string;
@@ -74,7 +69,7 @@ export function composeInboundTag(input: InboundTagInput): string {
   const bits = inboundTransports(input.protocol, input.streamSettings, input.settings);
   return (
     nodeTagPrefix(input.nodeId)
-    + baseInboundTag(input.listen ?? '', input.port ?? 0)
+    + baseInboundTag(input.port ?? 0)
     + '-'
     + transportTagSuffix(bits)
   );

+ 13 - 11
frontend/src/lib/xray/outbound-form-adapter.ts

@@ -292,19 +292,20 @@ function blackholeFromWire(raw: Raw) {
 
 function dnsRuleFromWire(raw: unknown): DnsRuleForm {
   const r = asObject(raw);
-  const qtype = Array.isArray(r.qtype)
-    ? r.qtype.map((x) => String(x)).join(',')
-    : typeof r.qtype === 'number'
-      ? String(r.qtype)
-      : asString(r.qtype);
+  const rawQType = r.qType ?? r.qtype;
+  const qType = Array.isArray(rawQType)
+    ? rawQType.map((x) => String(x)).join(',')
+    : typeof rawQType === 'number'
+      ? String(rawQType)
+      : asString(rawQType);
   const domain = Array.isArray(r.domain)
     ? r.domain.map((x) => asString(x)).join(',')
     : asString(r.domain);
   const action = asString(r.action, 'direct');
-  const validAction = ['direct', 'reject', 'rejectIPv4', 'rejectIPv6'].includes(action)
+  const validAction = ['direct', 'drop', 'return', 'hijack'].includes(action)
     ? action
     : 'direct';
-  return { action: validAction as DnsRuleForm['action'], qtype, domain };
+  return { action: validAction as DnsRuleForm['action'], qType, domain, rCode: asNumber(r.rCode, 0) };
 }
 
 function dnsFromWire(raw: Raw): DnsOutboundFormSettings {
@@ -536,16 +537,17 @@ function blackholeToWire(s: { type: '' | 'none' | 'http' }) {
 }
 
 function dnsRuleToWire(r: DnsRuleForm) {
-  const action = ['direct', 'reject', 'rejectIPv4', 'rejectIPv6'].includes(r.action)
+  const action = ['direct', 'drop', 'return', 'hijack'].includes(r.action)
     ? r.action
     : 'direct';
   const result: Raw = { action };
-  const qtype = r.qtype.trim();
-  if (qtype) {
-    result.qtype = /^\d+$/.test(qtype) ? Number(qtype) : qtype;
+  const qType = r.qType.trim();
+  if (qType) {
+    result.qType = /^\d+$/.test(qType) ? Number(qType) : qType;
   }
   const domains = r.domain.split(',').map((d) => d.trim()).filter(Boolean);
   if (domains.length > 0) result.domain = domains;
+  if (r.rCode > 0) result.rCode = r.rCode;
   return result;
 }
 

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

@@ -777,6 +777,13 @@ export const sections: readonly Section[] = [
           { name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
         ],
       },
+      {
+        method: 'POST',
+        path: '/panel/api/nodes/updatePanel',
+        summary: 'Trigger the official panel self-updater on each given node (downloads the latest release and restarts). Only enabled, online nodes are updated; offline/disabled ones are reported as skipped. Returns a per-node result list.',
+        body: '{\n  "ids": [1, 2, 3]\n}',
+        response: '{\n  "success": true,\n  "obj": [\n    { "id": 1, "name": "de-1", "ok": true },\n    { "id": 2, "name": "fr-1", "ok": false, "error": "node is offline" }\n  ]\n}',
+      },
       {
         method: 'GET',
         path: '/panel/api/nodes/history/:id/:metric/:bucket',

+ 26 - 5
frontend/src/pages/clients/ClientsPage.tsx

@@ -13,6 +13,7 @@ import {
   Modal,
   Pagination,
   Popover,
+  Result,
   Row,
   Select,
   Space,
@@ -191,11 +192,12 @@ export default function ClientsPage() {
     summary: serverSummary,
     allGroups,
     setQuery,
-    inbounds, onlines, loading, fetched, subSettings,
+    inbounds, onlines, loading, fetched, fetchError, subSettings,
     ipLimitEnable, tgBotEnable, expireDiff, trafficDiff, pageSize,
     create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, bulkAttach, detach, bulkDetach,
     resetTraffic, resetAllTraffics, delDepleted, setEnable,
     applyTrafficEvent, applyClientStatsEvent,
+    refresh,
     hydrate,
   } = useClients();
 
@@ -625,11 +627,23 @@ export default function ClientsPage() {
       width: 90,
       render: (_v, record) => {
         const bucket = clientBucket(record);
-        if (bucket === 'depleted') return <Tag color="red">{t('depleted')}</Tag>;
-        if (record.enable && isOnline(record.email)) return <Tag color="green">{t('pages.clients.online')}</Tag>;
+        const lastOnline = record.traffic?.lastOnline ?? 0;
+        const lastOnlineTitle = `${t('lastOnline')}: ${lastOnline > 0 ? IntlUtil.formatDate(lastOnline, datepicker) : '-'}`;
+        if (bucket === 'depleted') return (
+          <Tooltip title={lastOnlineTitle}>
+            <Tag color="red">{t('depleted')}</Tag>
+          </Tooltip>
+        );
+        if (record.enable && isOnline(record.email)) return (
+          <Tag color="green"><span className="online-dot" />{t('pages.clients.online')}</Tag>
+        );
         if (!record.enable) return <Tag>{t('disabled')}</Tag>;
         if (bucket === 'expiring') return <Tag color="orange">{t('depletingSoon')}</Tag>;
-        return <Tag>{t('pages.clients.offline')}</Tag>;
+        return (
+          <Tooltip title={lastOnlineTitle}>
+            <Tag>{t('pages.clients.offline')}</Tag>
+          </Tooltip>
+        );
       },
     },
     {
@@ -732,7 +746,7 @@ export default function ClientsPage() {
       ),
     },
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  ], [t, togglingEmail, clientBucket, isOnline, inboundsById, filters, allGroups]);
+  ], [t, togglingEmail, clientBucket, isOnline, inboundsById, filters, allGroups, datepicker]);
 
   const tablePagination = {
     current: currentPage,
@@ -788,6 +802,13 @@ export default function ClientsPage() {
             <Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
               {!fetched ? (
                 <div className="loading-spacer" />
+              ) : fetchError ? (
+                <Result
+                  status="error"
+                  title={t('somethingWentWrong')}
+                  subTitle={fetchError}
+                  extra={<Button type="primary" loading={loading} onClick={refresh}>{t('refresh')}</Button>}
+                />
               ) : (
                 <Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
                   <Col span={24}>

+ 10 - 1
frontend/src/pages/groups/GroupsPage.tsx

@@ -10,6 +10,7 @@ import {
   Input,
   Layout,
   Modal,
+  Result,
   Row,
   Space,
   Spin,
@@ -97,7 +98,8 @@ export default function GroupsPage() {
   });
   const groups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]);
   const loading = groupsQuery.isFetching;
-  const fetched = groupsQuery.data !== undefined;
+  const fetched = groupsQuery.data !== undefined || groupsQuery.isError;
+  const fetchError = groupsQuery.error ? (groupsQuery.error as Error).message : '';
 
   const invalidate = useCallback(() => {
     queryClient.invalidateQueries({ queryKey: keys.clients.root() });
@@ -435,6 +437,13 @@ export default function GroupsPage() {
             <Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
               {!fetched ? (
                 <div className="loading-spacer" />
+              ) : fetchError ? (
+                <Result
+                  status="error"
+                  title={t('somethingWentWrong')}
+                  subTitle={fetchError}
+                  extra={<Button type="primary" loading={loading} onClick={() => groupsQuery.refetch()}>{t('refresh')}</Button>}
+                />
               ) : (
                 <Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
                   <Col span={24}>

+ 26 - 0
frontend/src/pages/inbounds/InboundsPage.tsx

@@ -1,11 +1,13 @@
 import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import {
+  Button,
   Card,
   Col,
   ConfigProvider,
   Layout,
   Modal,
+  Result,
   Row,
   Spin,
   Statistic,
@@ -39,6 +41,7 @@ const InboundFormModal = lazy(() => import('./form/InboundFormModal'));
 const InboundInfoModal = lazy(() => import('./info/InboundInfoModal'));
 const QrCodeModal = lazy(() => import('./qr/QrCodeModal'));
 const AttachClientsModal = lazy(() => import('./clients/AttachClientsModal'));
+const AttachExistingClientsModal = lazy(() => import('./clients/AttachExistingClientsModal'));
 const DetachClientsModal = lazy(() => import('./clients/DetachClientsModal'));
 const AddClientsToGroupModal = lazy(() => import('./clients/AddClientsToGroupModal'));
 
@@ -53,6 +56,7 @@ type RowAction =
   | 'resetTraffic'
   | 'delAllClients'
   | 'attachClients'
+  | 'attachExisting'
   | 'detachClients'
   | 'addToGroup'
   | 'clone';
@@ -72,6 +76,7 @@ export default function InboundsPage() {
 
   const {
     fetched,
+    fetchError,
     dbInbounds,
     clientCount,
     onlineClients,
@@ -129,6 +134,8 @@ export default function InboundsPage() {
 
   const [attachOpen, setAttachOpen] = useState(false);
   const [attachSource, setAttachSource] = useState<DBInbound | null>(null);
+  const [attachExistingOpen, setAttachExistingOpen] = useState(false);
+  const [attachExistingTarget, setAttachExistingTarget] = useState<DBInbound | null>(null);
   const [detachOpen, setDetachOpen] = useState(false);
   const [detachSource, setDetachSource] = useState<DBInbound | null>(null);
 
@@ -523,6 +530,10 @@ export default function InboundsPage() {
         setAttachSource(target);
         setAttachOpen(true);
         break;
+      case 'attachExisting':
+        setAttachExistingTarget(target);
+        setAttachExistingOpen(true);
+        break;
       case 'detachClients':
         setDetachSource(target);
         setDetachOpen(true);
@@ -551,6 +562,13 @@ export default function InboundsPage() {
             <Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
               {!fetched ? (
                 <div className="loading-spacer" />
+              ) : fetchError ? (
+                <Result
+                  status="error"
+                  title={t('somethingWentWrong')}
+                  subTitle={fetchError}
+                  extra={<Button type="primary" onClick={refresh}>{t('refresh')}</Button>}
+                />
               ) : (
                 <Row gutter={[isMobile ? 8 : 16, 12]}>
                   <Col span={24}>
@@ -653,6 +671,14 @@ export default function InboundsPage() {
             dbInbounds={dbInbounds}
           />
         </LazyMount>
+        <LazyMount when={attachExistingOpen}>
+          <AttachExistingClientsModal
+            open={attachExistingOpen}
+            onClose={() => setAttachExistingOpen(false)}
+            onAttached={refresh}
+            target={attachExistingTarget}
+          />
+        </LazyMount>
         <LazyMount when={detachOpen}>
           <DetachClientsModal
             open={detachOpen}

+ 233 - 0
frontend/src/pages/inbounds/clients/AttachExistingClientsModal.tsx

@@ -0,0 +1,233 @@
+import { useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Alert, Input, Modal, Select, Space, Spin, Table, Tag, Typography, message } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+
+import { HttpUtil } from '@/utils';
+import type { DBInbound } from '@/models/dbinbound';
+
+interface AttachExistingClientsModalProps {
+  open: boolean;
+  target: DBInbound | null;
+  onClose: () => void;
+  onAttached?: () => void;
+}
+
+interface BulkAttachResult {
+  attached?: string[];
+  skipped?: string[];
+  errors?: string[];
+}
+
+interface ClientRow {
+  email: string;
+  group: string;
+  enable: boolean;
+  alreadyAttached: boolean;
+}
+
+interface RawClient {
+  email?: string;
+  group?: string;
+  enable?: boolean;
+  inboundIds?: number[] | null;
+}
+
+export default function AttachExistingClientsModal({
+  open,
+  target,
+  onClose,
+  onAttached,
+}: AttachExistingClientsModalProps) {
+  const { t } = useTranslation();
+  const [messageApi, messageContextHolder] = message.useMessage();
+  const [loading, setLoading] = useState(false);
+  const [saving, setSaving] = useState(false);
+  const [clientRows, setClientRows] = useState<ClientRow[]>([]);
+  const [selectedEmails, setSelectedEmails] = useState<string[]>([]);
+  const [search, setSearch] = useState('');
+  const [groupFilter, setGroupFilter] = useState<string | undefined>(undefined);
+
+  useEffect(() => {
+    if (!open || !target) return;
+    let cancelled = false;
+    setLoading(true);
+    setSearch('');
+    setGroupFilter(undefined);
+    HttpUtil.get('/panel/api/clients/list', undefined, { silent: true })
+      .then((msg) => {
+        if (cancelled) return;
+        const list = Array.isArray(msg?.obj) ? (msg.obj as RawClient[]) : [];
+        const rows: ClientRow[] = list
+          .map((c) => ({
+            email: (c?.email || '').trim(),
+            group: (c?.group || '').trim(),
+            enable: c?.enable !== false,
+            alreadyAttached: Array.isArray(c?.inboundIds) && c.inboundIds.includes(target.id),
+          }))
+          .filter((r) => r.email);
+        setClientRows(rows);
+        setSelectedEmails(rows.filter((r) => !r.alreadyAttached).map((r) => r.email));
+      })
+      .finally(() => {
+        if (!cancelled) setLoading(false);
+      });
+    return () => {
+      cancelled = true;
+    };
+  }, [open, target]);
+
+  const groupOptions = useMemo(() => {
+    const set = new Set<string>();
+    for (const r of clientRows) if (r.group) set.add(r.group);
+    return [...set].sort((a, b) => a.localeCompare(b)).map((g) => ({ value: g, label: g }));
+  }, [clientRows]);
+
+  const attachableCount = useMemo(
+    () => clientRows.filter((r) => !r.alreadyAttached).length,
+    [clientRows],
+  );
+
+  const filteredRows = useMemo(() => {
+    const q = search.trim().toLowerCase();
+    return clientRows.filter((r) => {
+      if (groupFilter && r.group !== groupFilter) return false;
+      if (!q) return true;
+      return r.email.toLowerCase().includes(q) || r.group.toLowerCase().includes(q);
+    });
+  }, [clientRows, search, groupFilter]);
+
+  const columns: ColumnsType<ClientRow> = useMemo(
+    () => [
+      {
+        title: t('pages.inbounds.email'),
+        dataIndex: 'email',
+        key: 'email',
+        ellipsis: true,
+      },
+      {
+        title: t('pages.clients.group'),
+        dataIndex: 'group',
+        key: 'group',
+        width: 150,
+        ellipsis: true,
+        render: (group: string) =>
+          group ? <Tag color="geekblue">{group}</Tag> : <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>,
+      },
+      {
+        title: t('enable'),
+        key: 'status',
+        width: 140,
+        render: (_v, row) => {
+          if (row.alreadyAttached) return <Tag color="default">{t('pages.inbounds.attachExistingStatusAttached')}</Tag>;
+          return row.enable ? (
+            <Tag color="success">{t('enable')}</Tag>
+          ) : (
+            <Tag>{t('pages.inbounds.attachClientsStatusDisabled')}</Tag>
+          );
+        },
+      },
+    ],
+    [t],
+  );
+
+  async function submit() {
+    if (!target || selectedEmails.length === 0) return;
+    setSaving(true);
+    try {
+      const msg = await HttpUtil.post(
+        '/panel/api/clients/bulkAttach',
+        { emails: selectedEmails, inboundIds: [target.id] },
+        { headers: { 'Content-Type': 'application/json' } },
+      );
+      if (!msg?.success) {
+        messageApi.error(msg?.msg || t('somethingWentWrong'));
+        return;
+      }
+      const result = (msg.obj || {}) as BulkAttachResult;
+      const attached = result.attached?.length ?? 0;
+      const skipped = result.skipped?.length ?? 0;
+      const errors = result.errors?.length ?? 0;
+      if (errors > 0) {
+        messageApi.warning(t('pages.inbounds.attachClientsResultMixed', { attached, skipped, errors }));
+      } else {
+        messageApi.success(t('pages.inbounds.attachClientsResult', { attached, skipped }));
+      }
+      onAttached?.();
+      onClose();
+    } finally {
+      setSaving(false);
+    }
+  }
+
+  const noClients = !loading && clientRows.length === 0;
+
+  return (
+    <Modal
+      open={open}
+      onCancel={onClose}
+      onOk={submit}
+      okButtonProps={{ disabled: selectedEmails.length === 0, loading: saving }}
+      okText={t('pages.inbounds.attachClients')}
+      cancelText={t('cancel')}
+      title={t('pages.inbounds.attachExistingTitle', { remark: target?.tag ?? '' })}
+      width={680}
+    >
+      {messageContextHolder}
+      <Typography.Paragraph type="secondary">
+        {t('pages.inbounds.attachExistingDesc', { count: attachableCount })}
+      </Typography.Paragraph>
+
+      {noClients ? (
+        <Alert type="info" showIcon message={t('pages.inbounds.attachExistingNoClients')} />
+      ) : (
+        <Spin spinning={loading}>
+          <Space direction="vertical" size="small" style={{ width: '100%' }}>
+            <Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
+              <Space wrap>
+                <Input.Search
+                  allowClear
+                  value={search}
+                  onChange={(e) => setSearch(e.target.value)}
+                  placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')}
+                  style={{ width: 260 }}
+                />
+                {groupOptions.length > 0 && (
+                  <Select
+                    allowClear
+                    value={groupFilter}
+                    onChange={(v) => setGroupFilter(v)}
+                    options={groupOptions}
+                    placeholder={t('pages.clients.group')}
+                    style={{ minWidth: 160 }}
+                    optionFilterProp="label"
+                  />
+                )}
+              </Space>
+              <Typography.Text type="secondary">
+                {t('pages.inbounds.attachClientsSelectedCount', {
+                  selected: selectedEmails.length,
+                  total: attachableCount,
+                })}
+              </Typography.Text>
+            </Space>
+            <Table<ClientRow>
+              size="small"
+              rowKey="email"
+              columns={columns}
+              dataSource={filteredRows}
+              pagination={false}
+              scroll={{ y: 280 }}
+              rowSelection={{
+                selectedRowKeys: selectedEmails,
+                onChange: (keys) => setSelectedEmails(keys as string[]),
+                getCheckboxProps: (row) => ({ disabled: row.alreadyAttached }),
+                preserveSelectedRowKeys: true,
+              }}
+            />
+          </Space>
+        </Spin>
+      )}
+    </Modal>
+  );
+}

+ 1 - 0
frontend/src/pages/inbounds/clients/index.ts

@@ -1,3 +1,4 @@
 export { default as AttachClientsModal } from './AttachClientsModal';
+export { default as AttachExistingClientsModal } from './AttachExistingClientsModal';
 export { default as DetachClientsModal } from './DetachClientsModal';
 export { default as AddClientsToGroupModal } from './AddClientsToGroupModal';

+ 4 - 7
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -160,7 +160,6 @@ export default function InboundFormModal({
   const security = Form.useWatch(['streamSettings', 'security'], form) ?? 'none';
   const streamEnabled = canEnableStream({ protocol });
 
-  const wListen = Form.useWatch('listen', form) ?? '';
   const wPort = Form.useWatch('port', form);
   const wNodeId = Form.useWatch('nodeId', form) ?? null;
   const wTag = Form.useWatch('tag', form) ?? '';
@@ -169,7 +168,6 @@ export default function InboundFormModal({
   const autoTagRef = useRef(true);
   const lastWrittenTagRef = useRef('');
   const currentTagInput = (): InboundTagInput => ({
-    listen: typeof wListen === 'string' ? wListen : '',
     port: typeof wPort === 'number' ? wPort : 0,
     nodeId: typeof wNodeId === 'number' ? wNodeId : null,
     protocol,
@@ -293,7 +291,6 @@ export default function InboundFormModal({
     form.setFieldsValue(initial);
     const initialTag = (initial.tag ?? '') as string;
     autoTagRef.current = isAutoInboundTag(initialTag, {
-      listen: initial.listen ?? '',
       port: initial.port ?? 0,
       nodeId: initial.nodeId ?? null,
       protocol: initial.protocol,
@@ -329,7 +326,7 @@ export default function InboundFormModal({
       form.setFieldValue('tag', next);
     }
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [open, wListen, wPort, wNodeId, protocol, network, mixedUdpOn, wSsNetwork, wTunnelNetwork]);
+  }, [open, wPort, wNodeId, protocol, network, mixedUdpOn, wSsNetwork, wTunnelNetwork]);
 
   // Why: protocol picker reset cascades through the form — clearing the
   // settings DU branch and dropping a nodeId that no longer applies. The
@@ -625,7 +622,7 @@ export default function InboundFormModal({
     }
     cleaned[`${next}Settings`] = newStreamSlice(next);
     // mKCP wants a UDP mask wrapper on the FinalMask side; seed it with
-    // `mkcp-original` so the inbound boots with a sensible default
+    // `mkcp-legacy` so the inbound boots with a sensible default
     // instead of unobfuscated mKCP traffic. The user can still edit or
     // clear the mask via the FinalMask section.
     if (next === 'kcp') {
@@ -633,12 +630,12 @@ export default function InboundFormModal({
       const udp = Array.isArray(fm.udp) ? (fm.udp as unknown[]) : [];
       const hasMkcp = udp.some((m) => {
         const entry = m as { type?: string };
-        return entry?.type === 'mkcp-original';
+        return entry?.type === 'mkcp-legacy';
       });
       if (!hasMkcp) {
         cleaned.finalmask = {
           ...fm,
-          udp: [...udp, { type: 'mkcp-original', settings: {} }],
+          udp: [...udp, { type: 'mkcp-legacy', settings: { header: '', value: '' } }],
         };
       }
     }

+ 3 - 0
frontend/src/pages/inbounds/list/RowActions.tsx

@@ -49,6 +49,9 @@ export function buildRowActionsMenu({ record, subEnable, t, isMobile, hasClients
   items.push({ key: 'clipboard', icon: <CopyOutlined />, label: t('pages.inbounds.exportInbound') });
   items.push({ key: 'resetTraffic', icon: <RetweetOutlined />, label: t('pages.inbounds.resetTraffic') });
   items.push({ key: 'clone', icon: <BlockOutlined />, label: t('pages.inbounds.clone') });
+  if (isInboundMultiUser(record)) {
+    items.push({ key: 'attachExisting', icon: <UsergroupAddOutlined />, label: t('pages.inbounds.attachExistingClients') });
+  }
   if (isInboundMultiUser(record) && hasClients) {
     items.push({ key: 'attachClients', icon: <UsergroupAddOutlined />, label: t('pages.inbounds.attachClients') });
     items.push({ key: 'detachClients', icon: <UsergroupDeleteOutlined />, label: t('pages.inbounds.detachClients') });

+ 16 - 15
frontend/src/pages/inbounds/list/useInboundColumns.tsx

@@ -1,6 +1,7 @@
 import { useMemo, type ReactElement } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Popover, Switch, Tag, type TableColumnType } from 'antd';
+import { TeamOutlined } from '@ant-design/icons';
 
 import { SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
 import { InfinityIcon } from '@/components/ui';
@@ -152,49 +153,49 @@ export function useInboundColumns({
         title: t('clients'),
         key: 'clients',
         align: 'left',
-        width: 50,
+        width: 110,
         render: (_, record) => {
           const cc = clientCount[record.id];
           if (!cc) return null;
           return (
             <>
-              <Tag color="green" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>
-                {cc.clients}
+              <Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>
+                <TeamOutlined /> {cc.clients}
               </Tag>
-              {cc.deactive.length > 0 && (
+              {cc.active.length > 0 && (
                 <Popover
-                  title={t('disabled')}
+                  title={t('subscription.active')}
                   content={(
                     <div className="client-email-list">
-                      {cc.deactive.map((e) => <div key={e}>{e}</div>)}
+                      {cc.active.map((e) => <div key={e}>{e}</div>)}
                     </div>
                   )}
                 >
-                  <Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.deactive.length}</Tag>
+                  <Tag color="green" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.active.length}</Tag>
                 </Popover>
               )}
-              {cc.depleted.length > 0 && (
+              {cc.deactive.length > 0 && (
                 <Popover
-                  title={t('depleted')}
+                  title={t('disabled')}
                   content={(
                     <div className="client-email-list">
-                      {cc.depleted.map((e) => <div key={e}>{e}</div>)}
+                      {cc.deactive.map((e) => <div key={e}>{e}</div>)}
                     </div>
                   )}
                 >
-                  <Tag color="red" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.depleted.length}</Tag>
+                  <Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.deactive.length}</Tag>
                 </Popover>
               )}
-              {cc.expiring.length > 0 && (
+              {cc.depleted.length > 0 && (
                 <Popover
-                  title={t('depletingSoon')}
+                  title={t('depleted')}
                   content={(
                     <div className="client-email-list">
-                      {cc.expiring.map((e) => <div key={e}>{e}</div>)}
+                      {cc.depleted.map((e) => <div key={e}>{e}</div>)}
                     </div>
                   )}
                 >
-                  <Tag color="orange" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.expiring.length}</Tag>
+                  <Tag color="red" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.depleted.length}</Tag>
                 </Popover>
               )}
               {cc.online.length > 0 && (

+ 9 - 10
frontend/src/pages/inbounds/useInbounds.ts

@@ -142,14 +142,7 @@ export function useInbounds() {
       const clientStats = Array.isArray((dbInbound as { clientStats?: unknown }).clientStats)
         ? (dbInbound as unknown as { clientStats: { email: string; total: number; up: number; down: number; expiryTime: number }[] }).clientStats
         : [];
-      const allClients = inbound?.clients || [];
-      const statsEmails = new Set<string>();
-      for (const s of clientStats) {
-        if (s && s.email) statsEmails.add(s.email);
-      }
-      const clients = clientStats.length > 0
-        ? allClients.filter((c) => c && c.email && statsEmails.has(c.email))
-        : allClients;
+      const clients = inbound?.clients || [];
       const active: string[] = [];
       const deactive: string[] = [];
       const depleted: string[] = [];
@@ -248,17 +241,22 @@ export function useInbounds() {
     if (lastOnlineQuery.data) setLastOnlineMap(lastOnlineQuery.data);
   }, [lastOnlineQuery.data]);
 
-  const fetched = slimQuery.data !== undefined && defaultsQuery.data !== undefined;
+  const fetched = (slimQuery.data !== undefined || slimQuery.isError) && (defaultsQuery.data !== undefined || defaultsQuery.isError);
+  const fetchErrorSource = slimQuery.error || defaultsQuery.error;
+  const fetchError = fetchErrorSource ? (fetchErrorSource as Error).message : '';
 
   const refresh = useCallback(async () => {
     // Invalidate at the inbounds root so both `slim` (this page's list)
     // and `options` (the Clients page's inbound picker) refetch. Without
     // the options bucket, a freshly-created inbound stays invisible in
-    // the client add/edit modal until a full page reload.
+    // the client add/edit modal until a full page reload. The xray config
+    // response carries inboundTags for the routing-rule tag picker, so it
+    // needs invalidating too or that list stays stale until a hard refresh.
     await Promise.all([
       queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
       queryClient.invalidateQueries({ queryKey: keys.clients.onlines() }),
       queryClient.invalidateQueries({ queryKey: keys.clients.lastOnline() }),
+      queryClient.invalidateQueries({ queryKey: keys.xray.config() }),
     ]);
   }, [queryClient]);
 
@@ -373,6 +371,7 @@ export function useInbounds() {
 
   return {
     fetched,
+    fetchError,
     dbInbounds,
     clientCount,
     onlineClients,

+ 9 - 1
frontend/src/pages/index/IndexPage.tsx

@@ -8,6 +8,7 @@ import {
   Layout,
   message,
   Modal,
+  Result,
   Row,
   Space,
   Spin,
@@ -58,7 +59,7 @@ import './IndexPage.css';
 export default function IndexPage() {
   const { t } = useTranslation();
   const { isDark, isUltra, antdThemeConfig } = useTheme();
-  const { status, fetched, refresh } = useStatusQuery();
+  const { status, fetched, fetchError, refresh } = useStatusQuery();
   const { isMobile } = useMediaQuery();
   const [messageApi, messageContextHolder] = message.useMessage();
   useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
@@ -168,6 +169,13 @@ export default function IndexPage() {
             >
               {!fetched ? (
                 <div className="loading-spacer" />
+              ) : fetchError ? (
+                <Result
+                  status="error"
+                  title={t('somethingWentWrong')}
+                  subTitle={fetchError}
+                  extra={<Button type="primary" onClick={refresh}>{t('refresh')}</Button>}
+                />
               ) : (
                 <Row gutter={[isMobile ? 8 : 16, 12]}>
                   <Col span={24}>

+ 73 - 8
frontend/src/pages/nodes/NodeList.tsx

@@ -16,6 +16,7 @@ import type { BadgeProps } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
 import {
   ClusterOutlined,
+  CloudDownloadOutlined,
   DeleteOutlined,
   EditOutlined,
   ExclamationCircleOutlined,
@@ -30,17 +31,27 @@ import {
 
 import NodeHistoryPanel from './NodeHistoryPanel';
 import type { NodeRecord } from '@/api/queries/useNodesQuery';
+import { isPanelUpdateAvailable } from '@/lib/panel-version';
 import './NodeList.css';
 
 interface NodeListProps {
   nodes: NodeRecord[];
   loading?: boolean;
   isMobile?: boolean;
+  latestVersion?: string;
+  selectedIds: number[];
+  onSelectionChange: (ids: number[]) => void;
   onAdd: () => void;
   onEdit: (node: NodeRecord) => void;
   onDelete: (node: NodeRecord) => void;
   onProbe: (node: NodeRecord) => void;
   onToggleEnable: (node: NodeRecord, next: boolean) => void;
+  onUpdateNode: (node: NodeRecord) => void;
+  onUpdateSelected: () => void;
+}
+
+function isUpdateEligible(n: NodeRecord): boolean {
+  return !!n.enable && n.status === 'online';
 }
 
 interface NodeRow extends NodeRecord {
@@ -56,6 +67,20 @@ function badgeStatus(status?: string): BadgeProps['status'] {
   }
 }
 
+function StatusDot({ status }: { status?: string }) {
+  if (status === 'online') return <span className="online-dot" />;
+  return <Badge status={badgeStatus(status)} />;
+}
+
+function StatusLabel({ status }: { status?: string }) {
+  const { t } = useTranslation();
+  return (
+    <span style={status === 'online' ? { color: 'var(--ant-color-success)' } : undefined}>
+      {t(`pages.nodes.statusValues.${status || 'unknown'}`)}
+    </span>
+  );
+}
+
 function formatPct(p?: number): string {
   if (typeof p !== 'number' || Number.isNaN(p)) return '-';
   return `${p.toFixed(1)}%`;
@@ -88,11 +113,16 @@ export default function NodeList({
   nodes,
   loading = false,
   isMobile = false,
+  latestVersion = '',
+  selectedIds,
+  onSelectionChange,
   onAdd,
   onEdit,
   onDelete,
   onProbe,
   onToggleEnable,
+  onUpdateNode,
+  onUpdateSelected,
 }: NodeListProps) {
   const { t } = useTranslation();
   const relativeTime = useRelativeTime();
@@ -122,12 +152,17 @@ export default function NodeList({
     {
       title: t('pages.nodes.actions'),
       align: 'center',
-      width: 160,
+      width: 190,
       render: (_value, record) => (
         <Space>
           <Tooltip title={t('pages.nodes.probe')}>
             <Button type="text" size="small" icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
           </Tooltip>
+          {isUpdateEligible(record) && (
+            <Tooltip title={t('pages.nodes.updatePanel')}>
+              <Button type="text" size="small" icon={<CloudDownloadOutlined />} onClick={() => onUpdateNode(record)} />
+            </Tooltip>
+          )}
           <Tooltip title={t('edit')}>
             <Button type="text" size="small" icon={<EditOutlined />} onClick={() => onEdit(record)} />
           </Tooltip>
@@ -193,8 +228,8 @@ export default function NodeList({
       align: 'center',
       render: (_value, record) => (
         <Space size={4}>
-          <Badge status={badgeStatus(record.status)} />
-          <span>{t(`pages.nodes.statusValues.${record.status || 'unknown'}`)}</span>
+          <StatusDot status={record.status} />
+          <StatusLabel status={record.status} />
           {record.lastError && (
             <Tooltip title={record.lastError}>
               <ExclamationCircleOutlined style={{ color: 'var(--ant-color-warning)' }} />
@@ -227,7 +262,22 @@ export default function NodeList({
       title: t('pages.nodes.panelVersion') || 'Panel version',
       dataIndex: 'panelVersion',
       align: 'center',
-      render: (_value, record) => record.panelVersion || '-',
+      render: (_value, record) => {
+        const canUpdate = isUpdateEligible(record)
+          && isPanelUpdateAvailable(latestVersion, record.panelVersion || '');
+        return (
+          <Space size={4}>
+            <span>{record.panelVersion || '-'}</span>
+            {canUpdate && (
+              <Tooltip title={`${t('pages.nodes.updateAvailable')}: ${latestVersion}`}>
+                <Tag color="orange" style={{ margin: 0, cursor: 'pointer' }} onClick={() => onUpdateNode(record)}>
+                  {t('pages.nodes.updateAvailable')}
+                </Tag>
+              </Tooltip>
+            )}
+          </Space>
+        );
+      },
     },
     {
       title: t('pages.nodes.uptime'),
@@ -266,7 +316,7 @@ export default function NodeList({
       width: 120,
       render: (_value, record) => relativeTime(record.lastHeartbeat),
     },
-  ], [t, showAddress, relativeTime, onToggleEnable, onProbe, onEdit, onDelete]);
+  ], [t, showAddress, relativeTime, latestVersion, onToggleEnable, onProbe, onEdit, onDelete, onUpdateNode]);
 
   return (
     <Card size="small" hoverable>
@@ -274,6 +324,11 @@ export default function NodeList({
         <Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
           {t('pages.nodes.addNode')}
         </Button>
+        {selectedIds.length > 0 && (
+          <Button icon={<CloudDownloadOutlined />} onClick={onUpdateSelected}>
+            {t('pages.nodes.updateSelected', { count: selectedIds.length })}
+          </Button>
+        )}
       </div>
 
       {isMobile ? (
@@ -289,7 +344,7 @@ export default function NodeList({
                 <div key={record.id} className="node-card">
                   <div className="card-head" onClick={() => toggleExpanded(record.id)}>
                     <RightOutlined className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`} />
-                    <Badge status={badgeStatus(record.status)} />
+                    <StatusDot status={record.status} />
                     <span className="node-name">{record.name}</span>
                     <div className="card-actions" onClick={(e) => e.stopPropagation()}>
                       <Tooltip title={t('info')}>
@@ -313,6 +368,11 @@ export default function NodeList({
                               label: <><ThunderboltOutlined /> {t('pages.nodes.probe')}</>,
                               onClick: () => onProbe(record),
                             },
+                            ...(isUpdateEligible(record) ? [{
+                              key: 'update',
+                              label: <><CloudDownloadOutlined /> {t('pages.nodes.updatePanel')}</>,
+                              onClick: () => onUpdateNode(record),
+                            }] : []),
                             {
                               key: 'edit',
                               label: <><EditOutlined /> {t('edit')}</>,
@@ -378,8 +438,8 @@ export default function NodeList({
                 </div>
                 <div className="stat-row">
                   <span className="stat-label">{t('pages.nodes.status')}</span>
-                  <Badge status={badgeStatus(statsNode.status)} />
-                  <span>{t(`pages.nodes.statusValues.${statsNode.status || 'unknown'}`)}</span>
+                  <StatusDot status={statsNode.status} />
+                  <StatusLabel status={statsNode.status} />
                   {statsNode.lastError && (
                     <Tooltip title={statsNode.lastError}>
                       <ExclamationCircleOutlined style={{ color: 'var(--ant-color-warning)' }} />
@@ -439,6 +499,11 @@ export default function NodeList({
           scroll={{ x: 'max-content' }}
           size="middle"
           rowKey="id"
+          rowSelection={{
+            selectedRowKeys: selectedIds,
+            onChange: (keys) => onSelectionChange(keys as number[]),
+            getCheckboxProps: (record) => ({ disabled: !isUpdateEligible(record) }),
+          }}
           locale={{
             emptyText: (
               <div className="card-empty">

+ 74 - 3
frontend/src/pages/nodes/NodesPage.tsx

@@ -1,6 +1,7 @@
 import { useCallback, useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Card, Col, ConfigProvider, Layout, Modal, Row, Spin, Statistic, message } from 'antd';
+import { useQuery } from '@tanstack/react-query';
+import { Button, Card, Col, ConfigProvider, Layout, Modal, Result, Row, Spin, Statistic, message } from 'antd';
 import {
   CheckCircleOutlined,
   CloseCircleOutlined,
@@ -17,6 +18,8 @@ import AppSidebar from '@/layouts/AppSidebar';
 import NodeList from './NodeList';
 import NodeFormModal from './NodeFormModal';
 import { setMessageInstance } from '@/utils/messageBus';
+import { HttpUtil } from '@/utils';
+import type { PanelUpdateInfo } from '../index/PanelUpdateModal';
 
 export default function NodesPage() {
   const { t } = useTranslation();
@@ -26,12 +29,22 @@ export default function NodesPage() {
   const [messageApi, messageContextHolder] = message.useMessage();
   useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
 
-  const { nodes, loading, fetched, totals } = useNodesQuery();
-  const { create, update, remove, setEnable, testConnection, probe } = useNodeMutations();
+  const { nodes, loading, fetched, fetchError, refetch, totals } = useNodesQuery();
+  const { create, update, remove, setEnable, testConnection, probe, updatePanels } = useNodeMutations();
+
+  const { data: latestVersion = '' } = useQuery({
+    queryKey: ['server', 'panelUpdateInfo'],
+    queryFn: async () => {
+      const msg = await HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo');
+      return msg?.obj?.latestVersion || '';
+    },
+    staleTime: 5 * 60 * 1000,
+  });
 
   const [formOpen, setFormOpen] = useState(false);
   const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
   const [formNode, setFormNode] = useState<NodeRecord | null>(null);
+  const [selectedIds, setSelectedIds] = useState<number[]>([]);
 
   const onAdd = useCallback(() => {
     setFormMode('add');
@@ -81,6 +94,52 @@ export default function NodesPage() {
     await setEnable(node.id, next);
   }, [setEnable]);
 
+  const runUpdate = useCallback(async (ids: number[]) => {
+    const msg = await updatePanels(ids);
+    if (!msg?.success) {
+      messageApi.error(msg?.msg || t('somethingWentWrong'));
+      return;
+    }
+    const results = msg.obj ?? [];
+    const ok = results.filter((r) => r.ok).length;
+    const failed = results.length - ok;
+    if (failed === 0) {
+      messageApi.success(t('pages.nodes.toasts.updateStarted'));
+    } else {
+      const firstError = results.find((r) => !r.ok)?.error ?? '';
+      const base = t('pages.nodes.toasts.updateResult', { ok, failed });
+      messageApi.warning(firstError ? `${base} — ${firstError}` : base);
+    }
+    setSelectedIds([]);
+  }, [updatePanels, messageApi, t]);
+
+  const onUpdateNode = useCallback((node: NodeRecord) => {
+    modal.confirm({
+      title: t('pages.nodes.updateConfirmTitle', { count: 1 }),
+      content: t('pages.nodes.updateConfirmContent'),
+      okText: t('update'),
+      cancelText: t('cancel'),
+      onOk: () => runUpdate([node.id]),
+    });
+  }, [modal, t, runUpdate]);
+
+  const onUpdateSelected = useCallback(() => {
+    const eligible = nodes
+      .filter((n) => selectedIds.includes(n.id) && n.enable && n.status === 'online')
+      .map((n) => n.id);
+    if (eligible.length === 0) {
+      messageApi.warning(t('pages.nodes.toasts.updateNoneEligible'));
+      return;
+    }
+    modal.confirm({
+      title: t('pages.nodes.updateConfirmTitle', { count: eligible.length }),
+      content: t('pages.nodes.updateConfirmContent'),
+      okText: t('update'),
+      cancelText: t('cancel'),
+      onOk: () => runUpdate(eligible),
+    });
+  }, [modal, t, nodes, selectedIds, runUpdate, messageApi]);
+
   const pageClass = useMemo(() => {
     const classes = ['nodes-page'];
     if (isDark) classes.push('is-dark');
@@ -100,6 +159,13 @@ export default function NodesPage() {
             <Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
               {!fetched ? (
                 <div className="loading-spacer" />
+              ) : fetchError ? (
+                <Result
+                  status="error"
+                  title={t('somethingWentWrong')}
+                  subTitle={fetchError}
+                  extra={<Button type="primary" loading={loading} onClick={() => refetch()}>{t('refresh')}</Button>}
+                />
               ) : (
                 <Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
                   <Col span={24}>
@@ -142,11 +208,16 @@ export default function NodesPage() {
                       nodes={nodes}
                       loading={loading}
                       isMobile={isMobile}
+                      latestVersion={latestVersion}
+                      selectedIds={selectedIds}
+                      onSelectionChange={setSelectedIds}
                       onAdd={onAdd}
                       onEdit={onEdit}
                       onDelete={onDelete}
                       onProbe={onProbe}
                       onToggleEnable={onToggleEnable}
+                      onUpdateNode={onUpdateNode}
+                      onUpdateSelected={onUpdateSelected}
                     />
                   </Col>
                 </Row>

+ 5 - 2
frontend/src/pages/xray/outbounds/protocols/dns.tsx

@@ -35,7 +35,7 @@ export default function DnsFields() {
                 size="small"
                 type="primary"
                 icon={<PlusOutlined />}
-                onClick={() => add({ action: 'direct', qtype: '', domain: '' })}
+                onClick={() => add({ action: 'direct', qType: '', domain: '', rCode: 0 })}
               />
             </Form.Item>
             {fields.map((field, index) => (
@@ -54,12 +54,15 @@ export default function DnsFields() {
                     options={DNSRuleActions.map((a) => ({ value: a, label: a }))}
                   />
                 </Form.Item>
-                <Form.Item label="QType" name={[field.name, 'qtype']}>
+                <Form.Item label="QType" name={[field.name, 'qType']}>
                   <Input placeholder="1,3,23-24" />
                 </Form.Item>
                 <Form.Item label={t('domainName')} name={[field.name, 'domain']}>
                   <Input placeholder="domain:example.com" />
                 </Form.Item>
+                <Form.Item label="RCode" name={[field.name, 'rCode']}>
+                  <InputNumber min={0} max={65535} style={{ width: '100%' }} />
+                </Form.Item>
               </div>
             ))}
           </>

+ 4 - 3
frontend/src/schemas/forms/outbound-form.ts

@@ -29,7 +29,7 @@ import {
 //     the adapter wraps them as { reverse: { tag, sniffing } } on the wire.
 //   - blackhole `type` ('' | 'none' | 'http') is flat; the adapter wraps it
 //     as { response: { type } } on the wire (omitted when empty).
-//   - DNS rules carry `qtype` and `domain` as comma-joined strings (matches
+//   - DNS rules carry `qType` and `domain` as comma-joined strings (matches
 //     the legacy DNSRule UI). The adapter normalizes them on submit.
 //
 // All flat-form settings types are documented inline so the adapter has a
@@ -186,12 +186,13 @@ export const BlackholeOutboundFormSettingsSchema = z.object({
 });
 export type BlackholeOutboundFormSettings = z.infer<typeof BlackholeOutboundFormSettingsSchema>;
 
-// DNS rules: form holds qtype + domain as joined strings (the legacy UI
+// DNS rules: form holds qType + domain as joined strings (the legacy UI
 // binds to <Input>). Adapter parses them on submit per the DNSRule class.
 export const DnsRuleFormSchema = z.object({
   action: DNSRuleActionSchema.default('direct'),
-  qtype: z.string().default(''),
+  qType: z.string().default(''),
   domain: z.string().default(''),
+  rCode: z.number().int().min(0).max(65535).default(0),
 });
 export type DnsRuleForm = z.infer<typeof DnsRuleFormSchema>;
 

+ 1 - 1
frontend/src/schemas/primitives/options.ts

@@ -59,7 +59,7 @@ export const Address_Port_Strategy = Object.freeze({
   TXT_PORT_AND_ADDRESS: 'TxtPortAndAddress',
 });
 
-export const DNSRuleActions = Object.freeze(['direct', 'drop', 'reject', 'hijack'] as const);
+export const DNSRuleActions = Object.freeze(['direct', 'drop', 'return', 'hijack'] as const);
 
 export const TLS_VERSION_OPTION = Object.freeze({
   TLS10: '1.0',

+ 4 - 3
frontend/src/schemas/protocols/outbound/dns.ts

@@ -2,15 +2,16 @@ import { z } from 'zod';
 
 import { PortSchema } from '@/schemas/primitives';
 
-export const DNSRuleActionSchema = z.enum(['direct', 'reject', 'rejectIPv4', 'rejectIPv6']);
+export const DNSRuleActionSchema = z.enum(['direct', 'drop', 'return', 'hijack']);
 
-// On the wire `qtype` is either a number (DNS type code) or a string like
+// On the wire `qType` is either a number (DNS type code) or a string like
 // "A"/"AAAA"/"TXT"; the panel normalizes numeric strings to numbers in
 // toJson. `domain` is a string[] (split from a comma-joined input).
 export const DNSRuleSchema = z.object({
   action: DNSRuleActionSchema.default('direct'),
-  qtype: z.union([z.string(), z.number().int()]).optional(),
+  qType: z.union([z.string(), z.number().int()]).optional(),
   domain: z.array(z.string()).optional(),
+  rCode: z.number().int().min(0).max(65535).optional(),
 });
 export type DNSRule = z.infer<typeof DNSRuleSchema>;
 

+ 3 - 9
frontend/src/schemas/protocols/stream/finalmask.ts

@@ -5,7 +5,7 @@ import { z } from 'zod';
 // plus optional QUIC tuning. The `settings` sub-object is polymorphic on
 // `type`; we model the wire-faithful shape with a permissive
 // record-of-unknown for `settings` and leave per-type tightening to
-// Step 6 — there are ~13 UDP mask types plus 3 TCP mask types, each with
+// Step 6 — there are 8 UDP mask types plus 3 TCP mask types, each with
 // distinct setting fields, and modeling them all as discriminated unions
 // here would dwarf the rest of the stream module without buying anything
 // the safety net doesn't already cover.
@@ -21,19 +21,13 @@ export type TcpMask = z.infer<typeof TcpMaskSchema>;
 
 export const UdpMaskTypeSchema = z.enum([
   'salamander',
-  'mkcp-aes128gcm',
-  'mkcp-original',
-  'header-dns',
-  'header-dtls',
-  'header-srtp',
-  'header-utp',
-  'header-wechat',
-  'header-wireguard',
+  'mkcp-legacy',
   'header-custom',
   'xdns',
   'xicmp',
   'noise',
   'sudoku',
+  'realm',
 ]);
 export type UdpMaskType = z.infer<typeof UdpMaskTypeSchema>;
 

+ 20 - 0
frontend/src/styles/utils.css

@@ -21,3 +21,23 @@
   cursor: pointer;
   color: var(--ant-color-error);
 }
+
+.online-dot {
+  display: inline-block;
+  width: 7px;
+  height: 7px;
+  border-radius: 50%;
+  margin-inline-end: 5px;
+  vertical-align: middle;
+  background: var(--ant-color-success);
+  animation: online-blink 1.1s ease-in-out infinite;
+}
+
+@keyframes online-blink {
+  0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(82, 196, 26, 0.55); }
+  50% { opacity: 0.35; box-shadow: 0 0 0 4px rgba(82, 196, 26, 0); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .online-dot { animation: none; }
+}

+ 28 - 8
frontend/src/test/__snapshots__/finalmask.test.ts.snap

@@ -27,7 +27,11 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses combined byte-stably 1`
       "type": "salamander",
     },
     {
-      "type": "header-wireguard",
+      "settings": {
+        "header": "wireguard",
+        "value": "",
+      },
+      "type": "mkcp-legacy",
     },
   ],
 }
@@ -117,18 +121,24 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses udp-mask byte-stably 1`
     },
     {
       "settings": {
-        "password": "abcdef0123456789",
+        "header": "",
+        "value": "abcdef0123456789",
       },
-      "type": "mkcp-aes128gcm",
+      "type": "mkcp-legacy",
     },
     {
       "settings": {
-        "domain": "cloudflare.com",
+        "header": "dns",
+        "value": "cloudflare.com",
       },
-      "type": "header-dns",
+      "type": "mkcp-legacy",
     },
     {
-      "type": "header-wireguard",
+      "settings": {
+        "header": "wireguard",
+        "value": "",
+      },
+      "type": "mkcp-legacy",
     },
     {
       "settings": {
@@ -164,11 +174,21 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses udp-mask byte-stably 1`
     },
     {
       "settings": {
-        "id": 0,
-        "listenIp": "0.0.0.0",
+        "dgram": false,
+        "ips": [],
       },
       "type": "xicmp",
     },
+    {
+      "settings": {
+        "stunServers": [
+          "stun.l.google.com:19302",
+          "global.stun.twilio.com:3478",
+        ],
+        "url": "realm://[email protected]/my-realm",
+      },
+      "type": "realm",
+    },
   ],
 }
 `;

+ 62 - 0
frontend/src/test/clients-summary.test.ts

@@ -0,0 +1,62 @@
+import { describe, it, expect } from 'vitest';
+
+import { computeClientsSummary } from '@/hooks/useClients';
+import type { ClientTraffic } from '@/schemas/client';
+
+// Parity with web/service/client.go buildClientsSummary: the same client must
+// land in the same bucket whether the count comes from the server (list fetch)
+// or is recomputed live from the client_stats WS event. A mismatch would make
+// the summary card "jump" on refresh.
+type Row = ClientTraffic & { email?: string };
+
+const GB = 1024 * 1024 * 1024;
+const DAY = 86_400_000;
+
+function row(over: Partial<Row>): Row {
+  return { email: 'x', enable: true, up: 0, down: 0, total: 0, expiryTime: 0, ...over } as Row;
+}
+
+describe('computeClientsSummary', () => {
+  it('buckets each client the way the Go service does', () => {
+    const now = Date.now();
+    const stats: Row[] = [
+      row({ email: 'online@x', enable: true }),
+      row({ email: 'offline@x', enable: true }),
+      row({ email: 'disabled@x', enable: false }),
+      row({ email: 'exhausted@x', enable: true, total: 1 * GB, up: 1 * GB }),
+      row({ email: 'expired@x', enable: true, expiryTime: now - DAY }),
+      row({ email: 'nearexpiry@x', enable: true, expiryTime: now + DAY }),
+      row({ email: 'nearlimit@x', enable: true, total: 10 * GB, up: 9.9 * GB }),
+    ];
+    const online = new Set(['online@x', 'disabled@x']); // disabled-but-online must NOT count as online
+    const expireDiffMs = 3 * DAY;
+    const trafficDiffBytes = 1 * GB;
+
+    const s = computeClientsSummary(stats, online, expireDiffMs, trafficDiffBytes);
+
+    expect(s.total).toBe(7);
+    expect(s.online).toEqual(['online@x']);
+    expect(s.depleted.sort()).toEqual(['exhausted@x', 'expired@x']);
+    expect(s.deactive).toEqual(['disabled@x']);
+    expect(s.expiring.sort()).toEqual(['nearexpiry@x', 'nearlimit@x']);
+    expect(s.active).toBe(2); // online@x + offline@x
+  });
+
+  it('depleted wins over disabled and over online', () => {
+    const stats: Row[] = [
+      row({ email: 'a@x', enable: false, total: 1 * GB, up: 2 * GB }),
+    ];
+    const s = computeClientsSummary(stats, new Set(['a@x']), 0, 0);
+    expect(s.depleted).toEqual(['a@x']);
+    expect(s.deactive).toEqual([]);
+    expect(s.online).toEqual([]); // disabled is never online
+  });
+
+  it('unlimited + no expiry is active', () => {
+    const stats: Row[] = [row({ email: 'a@x', enable: true, total: 0, expiryTime: 0 })];
+    const s = computeClientsSummary(stats, new Set(), 3 * DAY, 1 * GB);
+    expect(s.active).toBe(1);
+    expect(s.expiring).toEqual([]);
+    expect(s.depleted).toEqual([]);
+  });
+});

+ 1 - 1
frontend/src/test/golden/fixtures/finalmask/combined.json

@@ -4,7 +4,7 @@
   ],
   "udp": [
     { "type": "salamander", "settings": { "password": "swordfish" } },
-    { "type": "header-wireguard" }
+    { "type": "mkcp-legacy", "settings": { "header": "wireguard", "value": "" } }
   ],
   "quicParams": {
     "congestion": "brutal",

+ 11 - 4
frontend/src/test/golden/fixtures/finalmask/udp-mask.json

@@ -1,9 +1,9 @@
 {
   "udp": [
     { "type": "salamander", "settings": { "password": "swordfish" } },
-    { "type": "mkcp-aes128gcm", "settings": { "password": "abcdef0123456789" } },
-    { "type": "header-dns", "settings": { "domain": "cloudflare.com" } },
-    { "type": "header-wireguard" },
+    { "type": "mkcp-legacy", "settings": { "header": "", "value": "abcdef0123456789" } },
+    { "type": "mkcp-legacy", "settings": { "header": "dns", "value": "cloudflare.com" } },
+    { "type": "mkcp-legacy", "settings": { "header": "wireguard", "value": "" } },
     {
       "type": "noise",
       "settings": {
@@ -23,7 +23,14 @@
     },
     {
       "type": "xicmp",
-      "settings": { "listenIp": "0.0.0.0", "id": 0 }
+      "settings": { "dgram": false, "ips": [] }
+    },
+    {
+      "type": "realm",
+      "settings": {
+        "url": "realm://[email protected]/my-realm",
+        "stunServers": ["stun.l.google.com:19302", "global.stun.twilio.com:3478"]
+      }
     }
   ]
 }

+ 4 - 5
frontend/src/test/inbound-tag.test.ts

@@ -7,7 +7,6 @@ import { composeInboundTag, isAutoInboundTag, type InboundTagInput } from '@/lib
 // tag the backend re-derives on save.
 describe('composeInboundTag transport suffix parity', () => {
   const base = (over: Partial<InboundTagInput>): InboundTagInput => ({
-    listen: '0.0.0.0',
     port: 443,
     nodeId: null,
     protocol: 'vless',
@@ -36,9 +35,9 @@ describe('composeInboundTag transport suffix parity', () => {
     expect(composeInboundTag(input)).toBe(want);
   });
 
-  it('scopes a non-any listen and node prefix', () => {
-    expect(composeInboundTag(base({ listen: '127.0.0.1', port: 8443, streamSettings: { network: 'tcp' } })))
-      .toBe('in-127.0.0.1:8443-tcp');
+  it('ignores the listen address and adds the node prefix', () => {
+    expect(composeInboundTag(base({ port: 8443, streamSettings: { network: 'tcp' } })))
+      .toBe('in-8443-tcp');
     expect(composeInboundTag(base({ nodeId: 1, port: 443, streamSettings: { network: 'tcp' } })))
       .toBe('n1-in-443-tcp');
   });
@@ -47,7 +46,7 @@ describe('composeInboundTag transport suffix parity', () => {
 // Parity with TestIsAutoGeneratedTag.
 describe('isAutoInboundTag', () => {
   const input: InboundTagInput = {
-    listen: '0.0.0.0', port: 443, nodeId: null, protocol: 'vless', streamSettings: { network: 'tcp' },
+    port: 443, nodeId: null, protocol: 'vless', streamSettings: { network: 'tcp' },
   };
 
   it('recognises canonical, dedup-suffixed and empty as auto', () => {

+ 15 - 5
frontend/src/test/outbound-form-adapter.test.ts

@@ -197,7 +197,7 @@ describe('outbound-form-adapter: round-trip', () => {
     expect(withType.settings).toEqual({ response: { type: 'http' } });
   });
 
-  it('dns rules normalize qtype numeric strings and split domains', () => {
+  it('dns rules normalize qType numeric strings, split domains, carry rCode', () => {
     const wire = {
       protocol: 'dns',
       settings: {
@@ -205,16 +205,26 @@ describe('outbound-form-adapter: round-trip', () => {
         rewriteAddress: '1.1.1.1',
         rewritePort: 53,
         rules: [
-          { action: 'direct', qtype: 'A,AAAA', domain: ['example.com', 'ext.org'] },
-          { action: 'reject', qtype: 28, domain: 'blocked.com' },
+          { action: 'direct', qType: 'A,AAAA', domain: ['example.com', 'ext.org'] },
+          { action: 'return', qType: 28, domain: 'blocked.com', rCode: 3 },
         ],
       },
     };
     const back = formValuesToWirePayload(rawOutboundToFormValues(wire));
     const settings = back.settings as Record<string, unknown>;
     const rules = settings.rules as Array<Record<string, unknown>>;
-    expect(rules[0]).toEqual({ action: 'direct', qtype: 'A,AAAA', domain: ['example.com', 'ext.org'] });
-    expect(rules[1]).toEqual({ action: 'reject', qtype: 28, domain: ['blocked.com'] });
+    expect(rules[0]).toEqual({ action: 'direct', qType: 'A,AAAA', domain: ['example.com', 'ext.org'] });
+    expect(rules[1]).toEqual({ action: 'return', qType: 28, domain: ['blocked.com'], rCode: 3 });
+  });
+
+  it('dns rules read the legacy qtype wire key for back-compat', () => {
+    const wire = {
+      protocol: 'dns',
+      settings: { rules: [{ action: 'direct', qtype: 'TXT' }] },
+    };
+    const back = formValuesToWirePayload(rawOutboundToFormValues(wire));
+    const rules = (back.settings as Record<string, unknown>).rules as Array<Record<string, unknown>>;
+    expect(rules[0]).toEqual({ action: 'direct', qType: 'TXT' });
   });
 
   it('freedom emits domainStrategy/redirect/fragment conditionally', () => {

+ 33 - 0
frontend/src/test/panel-version.test.ts

@@ -0,0 +1,33 @@
+import { describe, it, expect } from 'vitest';
+
+import { isPanelUpdateAvailable } from '@/lib/panel-version';
+
+// Parity with web/service/panel.go isNewerVersion.
+describe('isPanelUpdateAvailable', () => {
+  it('flags a strictly newer latest', () => {
+    expect(isPanelUpdateAvailable('2.6.5', '2.6.4')).toBe(true);
+    expect(isPanelUpdateAvailable('v2.7.0', 'v2.6.9')).toBe(true);
+    expect(isPanelUpdateAvailable('3.0.0', '2.9.9')).toBe(true);
+  });
+
+  it('returns false when equal or the node is ahead', () => {
+    expect(isPanelUpdateAvailable('2.6.4', '2.6.4')).toBe(false);
+    expect(isPanelUpdateAvailable('v2.6.4', '2.6.4')).toBe(false);
+    expect(isPanelUpdateAvailable('2.6.4', '2.6.5')).toBe(false);
+  });
+
+  it('ignores a leading v on either side', () => {
+    expect(isPanelUpdateAvailable('v2.6.5', '2.6.4')).toBe(true);
+    expect(isPanelUpdateAvailable('2.6.5', 'v2.6.4')).toBe(true);
+  });
+
+  it('never flags when a version is unknown', () => {
+    expect(isPanelUpdateAvailable('', '2.6.4')).toBe(false);
+    expect(isPanelUpdateAvailable('2.6.5', '')).toBe(false);
+  });
+
+  it('falls back to string inequality for non-semver tags', () => {
+    expect(isPanelUpdateAvailable('nightly-2', 'nightly-1')).toBe(true);
+    expect(isPanelUpdateAvailable('nightly-1', 'nightly-1')).toBe(false);
+  });
+});

+ 13 - 1
frontend/src/test/setup.components.ts

@@ -58,7 +58,19 @@ if (!i18next.isInitialized) {
   });
 }
 
-afterEach(() => {
+afterEach(async () => {
   cleanup();
   document.body.innerHTML = '';
+  /*
+   * React 19 defers passive-effect flushes onto a macrotask (setImmediate),
+   * whose callback reads `window.event`. If one is still queued when vitest
+   * tears down the jsdom environment, it fires after `window` is gone and
+   * throws "window is not defined". Drain a few macrotask ticks here so any
+   * pending callback runs while `window` still exists. Several ticks are used
+   * because a microtask resolving mid-drain (rc-trigger/AntD) can queue a new
+   * one behind the first.
+   */
+  for (let i = 0; i < 3; i += 1) {
+    await new Promise((resolve) => setTimeout(resolve, 0));
+  }
 });

+ 6 - 6
go.mod

@@ -7,7 +7,7 @@ require (
 	github.com/gin-contrib/sessions v1.1.0
 	github.com/gin-gonic/gin v1.12.0
 	github.com/go-ldap/ldap/v3 v3.4.13
-	github.com/go-playground/validator/v10 v10.30.2
+	github.com/go-playground/validator/v10 v10.30.3
 	github.com/goccy/go-json v0.10.6
 	github.com/goccy/go-yaml v1.19.2
 	github.com/google/uuid v1.6.0
@@ -17,7 +17,7 @@ require (
 	github.com/nicksnyder/go-i18n/v2 v2.6.1
 	github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
 	github.com/robfig/cron/v3 v3.0.1
-	github.com/shirou/gopsutil/v4 v4.26.4
+	github.com/shirou/gopsutil/v4 v4.26.5
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
 	github.com/valyala/fasthttp v1.71.0
 	github.com/xlzd/gotp v0.1.0
@@ -42,7 +42,7 @@ require (
 	github.com/bytedance/sonic/loader v0.5.1 // indirect
 	github.com/cloudflare/circl v1.6.3 // indirect
 	github.com/cloudwego/base64x v0.1.7 // indirect
-	github.com/ebitengine/purego v0.10.0 // indirect
+	github.com/ebitengine/purego v0.10.1 // indirect
 	github.com/gabriel-vasile/mimetype v1.4.13 // indirect
 	github.com/gin-contrib/sse v1.1.1 // indirect
 	github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
@@ -77,7 +77,7 @@ require (
 	github.com/quic-go/qpack v0.6.0 // indirect
 	github.com/quic-go/quic-go v0.59.1 // indirect
 	github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect
-	github.com/rogpeppe/go-internal v1.14.1 // indirect
+	github.com/rogpeppe/go-internal v1.15.0 // indirect
 	github.com/sagernet/sing v0.8.10 // indirect
 	github.com/sagernet/sing-shadowsocks v0.2.9 // indirect
 	github.com/tklauser/go-sysconf v0.4.0 // indirect
@@ -93,7 +93,7 @@ require (
 	go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
 	go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
 	golang.org/x/arch v0.27.0 // indirect
-	golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect
+	golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 // indirect
 	golang.org/x/mod v0.36.0 // indirect
 	golang.org/x/net v0.55.0
 	golang.org/x/sync v0.20.0 // indirect
@@ -101,7 +101,7 @@ require (
 	golang.org/x/tools v0.45.0 // indirect
 	golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
 	golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
 	google.golang.org/protobuf v1.36.11 // indirect
 	gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
 	lukechampine.com/blake3 v1.4.1 // indirect

+ 12 - 12
go.sum

@@ -23,8 +23,8 @@ github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
-github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
+github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
+github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
 github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
 github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
 github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 h1:Arcl6UOIS/kgO2nW3A65HN+7CMjSDP/gofXL4CZt1V4=
@@ -54,8 +54,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
 github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
 github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
 github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
-github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
-github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
+github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
+github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
 github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
 github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
 github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
@@ -162,14 +162,14 @@ github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af h1:er
 github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
 github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
 github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
-github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
-github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
+github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
 github.com/sagernet/sing v0.8.10 h1:V5VZffy8rm4dtBVKIpKa8vibRR2SiJprtu/10DFUalU=
 github.com/sagernet/sing v0.8.10/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA=
 github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM=
 github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8=
-github.com/shirou/gopsutil/v4 v4.26.4 h1:B4SXVbcwTyrocPHEmWBC4uCYr4Xcu3MK1TXqbprAOWY=
-github.com/shirou/gopsutil/v4 v4.26.4/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
+github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM=
+github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -238,8 +238,8 @@ golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU=
 golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
 golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
 golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
-golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw=
-golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
+golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 h1:4d4PbuBNwaxMXkXI8yiIYjydtMU+04RHeuSxJdgKftM=
+golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
 golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
 golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
 golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
@@ -265,8 +265,8 @@ golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7
 golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
 gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
 gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 h1:PvEgGJf9C/1u5CHkInMg7UFYYUoiaQmW2LbtH0pjB78=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
 google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
 google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
 google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

+ 23 - 31
sub/subService.go

@@ -1465,28 +1465,22 @@ func applyXhttpExtraParams(xhttp map[string]any, params map[string]string) {
 }
 
 var kcpMaskToHeaderType = map[string]string{
-	"header-dns":       "dns",
-	"header-dtls":      "dtls",
-	"header-srtp":      "srtp",
-	"header-utp":       "utp",
-	"header-wechat":    "wechat-video",
-	"header-wireguard": "wireguard",
+	"dns":       "dns",
+	"dtls":      "dtls",
+	"srtp":      "srtp",
+	"utp":       "utp",
+	"wechat":    "wechat-video",
+	"wireguard": "wireguard",
 }
 
 var validFinalMaskUDPTypes = map[string]struct{}{
-	"salamander":       {},
-	"mkcp-aes128gcm":   {},
-	"header-dns":       {},
-	"header-dtls":      {},
-	"header-srtp":      {},
-	"header-utp":       {},
-	"header-wechat":    {},
-	"header-wireguard": {},
-	"mkcp-original":    {},
-	"xdns":             {},
-	"xicmp":            {},
-	"noise":            {},
-	"header-custom":    {},
+	"salamander":    {},
+	"mkcp-legacy":   {},
+	"xdns":          {},
+	"xicmp":         {},
+	"noise":         {},
+	"header-custom": {},
+	"realm":         {},
 }
 
 var validFinalMaskTCPTypes = map[string]struct{}{
@@ -1557,21 +1551,19 @@ func extractKcpShareFields(stream map[string]any) kcpShareFields {
 		if mask == nil {
 			continue
 		}
-		maskType, _ := mask["type"].(string)
-		if mapped, ok := kcpMaskToHeaderType[maskType]; ok {
-			fields.headerType = mapped
+		if maskType, _ := mask["type"].(string); maskType != "mkcp-legacy" {
 			continue
 		}
 
-		switch maskType {
-		case "mkcp-original":
-			fields.seed = ""
-		case "mkcp-aes128gcm":
-			fields.seed = ""
-			settings, _ := mask["settings"].(map[string]any)
-			if value, ok := settings["password"].(string); ok && value != "" {
-				fields.seed = value
-			}
+		settings, _ := mask["settings"].(map[string]any)
+		header, _ := settings["header"].(string)
+		value, _ := settings["value"].(string)
+		if header == "" {
+			fields.seed = value
+			continue
+		}
+		if mapped, ok := kcpMaskToHeaderType[header]; ok {
+			fields.headerType = mapped
 		}
 	}
 

+ 40 - 0
sub/subService_test.go

@@ -665,6 +665,46 @@ func TestExtractKcpShareFields_ReadsAllFields(t *testing.T) {
 	}
 }
 
+func TestExtractKcpShareFields_FinalMaskLegacyHeader(t *testing.T) {
+	stream := map[string]any{
+		"finalmask": map[string]any{
+			"udp": []any{
+				map[string]any{
+					"type":     "mkcp-legacy",
+					"settings": map[string]any{"header": "wechat", "value": ""},
+				},
+			},
+		},
+	}
+	got := extractKcpShareFields(stream)
+	if got.headerType != "wechat-video" {
+		t.Fatalf("headerType = %q, want wechat-video", got.headerType)
+	}
+	if got.seed != "" {
+		t.Fatalf("seed = %q, want empty for header mask", got.seed)
+	}
+}
+
+func TestExtractKcpShareFields_FinalMaskLegacySeed(t *testing.T) {
+	stream := map[string]any{
+		"finalmask": map[string]any{
+			"udp": []any{
+				map[string]any{
+					"type":     "mkcp-legacy",
+					"settings": map[string]any{"header": "", "value": "obfs-pass"},
+				},
+			},
+		},
+	}
+	got := extractKcpShareFields(stream)
+	if got.headerType != "none" {
+		t.Fatalf("headerType = %q, want none for empty-header legacy mask", got.headerType)
+	}
+	if got.seed != "obfs-pass" {
+		t.Fatalf("seed = %q, want obfs-pass", got.seed)
+	}
+}
+
 func TestKcpShareFields_ApplyToParams(t *testing.T) {
 	params := map[string]string{}
 	kcpShareFields{headerType: "wechat-video", seed: "s", mtu: 1350, tti: 50}.applyToParams(params)

+ 17 - 0
web/controller/node.go

@@ -35,6 +35,7 @@ func (a *NodeController) initRouter(g *gin.RouterGroup) {
 
 	g.POST("/test", a.test)
 	g.POST("/probe/:id", a.probe)
+	g.POST("/updatePanel", a.updatePanel)
 	g.GET("/history/:id/:metric/:bucket", a.history)
 }
 
@@ -165,6 +166,22 @@ func (a *NodeController) probe(c *gin.Context) {
 	jsonObj(c, patch.ToUI(probeErr == nil), nil)
 }
 
+func (a *NodeController) updatePanel(c *gin.Context) {
+	var req struct {
+		Ids []int `json:"ids"`
+	}
+	if err := c.ShouldBindJSON(&req); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	if len(req.Ids) == 0 {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), fmt.Errorf("no nodes selected"))
+		return
+	}
+	results, err := a.nodeService.UpdatePanels(req.Ids)
+	jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.updateStarted"), results, err)
+}
+
 func (a *NodeController) history(c *gin.Context) {
 	id, err := strconv.Atoi(c.Param("id"))
 	if err != nil {

+ 8 - 0
web/runtime/remote.go

@@ -320,6 +320,14 @@ func (r *Remote) RestartXray(ctx context.Context) error {
 	return err
 }
 
+// UpdatePanel asks the node to run its own official self-updater (update.sh)
+// and restart onto the latest release. The node returns as soon as the job is
+// launched; the new version surfaces on the next heartbeat.
+func (r *Remote) UpdatePanel(ctx context.Context) error {
+	_, err := r.do(ctx, http.MethodPost, "panel/api/server/updatePanel", nil)
+	return err
+}
+
 func (r *Remote) ResetClientTraffic(ctx context.Context, _ *model.Inbound, email string) error {
 	_, err := r.do(ctx, http.MethodPost,
 		"panel/api/clients/resetTraffic/"+url.PathEscape(email), nil)

+ 24 - 0
web/service/client.go

@@ -475,6 +475,18 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
 		}
 	}
 
+	if client.SubID != "" {
+		var subTaken int64
+		if err := database.GetDB().Model(&model.ClientRecord{}).
+			Where("sub_id = ? AND email <> ?", client.SubID, client.Email).
+			Count(&subTaken).Error; err != nil {
+			return false, err
+		}
+		if subTaken > 0 {
+			return false, common.NewError("subId already in use:", client.SubID)
+		}
+	}
+
 	needRestart := false
 	for _, ibId := range payload.InboundIds {
 		inbound, getErr := inboundSvc.GetInbound(ibId)
@@ -646,6 +658,18 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
 		}
 	}
 
+	if updated.SubID != "" {
+		var subCollision int64
+		if err := database.GetDB().Model(&model.ClientRecord{}).
+			Where("sub_id = ? AND id <> ?", updated.SubID, id).
+			Count(&subCollision).Error; err != nil {
+			return false, err
+		}
+		if subCollision > 0 {
+			return false, common.NewError("Duplicate subId:", updated.SubID)
+		}
+	}
+
 	needRestart := false
 	for _, ibId := range inboundIds {
 		inbound, getErr := inboundSvc.GetInbound(ibId)

+ 1 - 1
web/service/inbound.go

@@ -762,7 +762,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 
 	tag := oldInbound.Tag
 	oldBits := inboundTransports(oldInbound.Protocol, oldInbound.StreamSettings, oldInbound.Settings)
-	oldTagWasAuto := isAutoGeneratedTag(tag, oldInbound.Listen, oldInbound.Port, oldInbound.NodeID, oldBits)
+	oldTagWasAuto := isAutoGeneratedTag(tag, oldInbound.Port, oldInbound.NodeID, oldBits)
 
 	db := database.GetDB()
 	tx := db.Begin()

+ 50 - 0
web/service/node.go

@@ -246,6 +246,56 @@ func (s *NodeService) SetEnable(id int, enable bool) error {
 	return db.Model(model.Node{}).Where("id = ?", id).Update("enable", enable).Error
 }
 
+// NodeUpdateResult reports the outcome of triggering a panel self-update on one
+// node so the UI can show per-node success/failure for a bulk request.
+type NodeUpdateResult struct {
+	Id    int    `json:"id"`
+	Name  string `json:"name"`
+	OK    bool   `json:"ok"`
+	Error string `json:"error,omitempty"`
+}
+
+// UpdatePanels triggers the official self-updater on each given node. Only
+// enabled, online nodes are eligible — an offline node can't be reached, so it
+// is reported as skipped rather than silently dropped.
+func (s *NodeService) UpdatePanels(ids []int) ([]NodeUpdateResult, error) {
+	mgr := runtime.GetManager()
+	if mgr == nil {
+		return nil, fmt.Errorf("runtime manager unavailable")
+	}
+	results := make([]NodeUpdateResult, 0, len(ids))
+	for _, id := range ids {
+		n, err := s.GetById(id)
+		if err != nil || n == nil {
+			results = append(results, NodeUpdateResult{Id: id, OK: false, Error: "node not found"})
+			continue
+		}
+		res := NodeUpdateResult{Id: id, Name: n.Name}
+		switch {
+		case !n.Enable:
+			res.Error = "node is disabled"
+		case n.Status != "online":
+			res.Error = "node is offline"
+		default:
+			remote, remoteErr := mgr.RemoteFor(n)
+			if remoteErr != nil {
+				res.Error = remoteErr.Error()
+				break
+			}
+			ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
+			updErr := remote.UpdatePanel(ctx)
+			cancel()
+			if updErr != nil {
+				res.Error = updErr.Error()
+			} else {
+				res.OK = true
+			}
+		}
+		results = append(results, res)
+	}
+	return results, nil
+}
+
 func (s *NodeService) UpdateHeartbeat(id int, p HeartbeatPatch) error {
 	db := database.GetDB()
 	updates := map[string]any{

+ 7 - 10
web/service/port_conflict.go

@@ -171,11 +171,8 @@ func sameNode(a, b *int) bool {
 	return *a == *b
 }
 
-func baseInboundTag(listen string, port int) string {
-	if isAnyListen(listen) {
-		return fmt.Sprintf("in-%v", port)
-	}
-	return fmt.Sprintf("in-%v:%v", listen, port)
+func baseInboundTag(port int) string {
+	return fmt.Sprintf("in-%v", port)
 }
 
 func transportTagSuffix(b transportBits) string {
@@ -200,12 +197,12 @@ func nodeTagPrefix(nodeID *int) string {
 	return fmt.Sprintf("n%d-", *nodeID)
 }
 
-func composeInboundTag(listen string, port int, nodeID *int, bits transportBits) string {
-	return nodeTagPrefix(nodeID) + baseInboundTag(listen, port) + "-" + transportTagSuffix(bits)
+func composeInboundTag(port int, nodeID *int, bits transportBits) string {
+	return nodeTagPrefix(nodeID) + baseInboundTag(port) + "-" + transportTagSuffix(bits)
 }
 
-func isAutoGeneratedTag(tag, listen string, port int, nodeID *int, bits transportBits) bool {
-	base := composeInboundTag(listen, port, nodeID, bits)
+func isAutoGeneratedTag(tag string, port int, nodeID *int, bits transportBits) bool {
+	base := composeInboundTag(port, nodeID, bits)
 	if tag == base {
 		return true
 	}
@@ -223,7 +220,7 @@ func isAutoGeneratedTag(tag, listen string, port int, nodeID *int, bits transpor
 
 func (s *InboundService) generateInboundTag(inbound *model.Inbound, ignoreId int) (string, error) {
 	bits := inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings)
-	candidate := composeInboundTag(inbound.Listen, inbound.Port, inbound.NodeID, bits)
+	candidate := composeInboundTag(inbound.Port, inbound.NodeID, bits)
 	exists, err := s.tagExists(candidate, ignoreId)
 	if err != nil {
 		return "", err

+ 17 - 17
web/service/port_conflict_test.go

@@ -331,10 +331,11 @@ func TestGenerateInboundTag_IgnoresSelfOnUpdate(t *testing.T) {
 	}
 }
 
-// specific listen address gets the listen-prefixed shape and same suffix.
-func TestGenerateInboundTag_SpecificListenSameDisambiguation(t *testing.T) {
+// the listen address never appears in the tag; the transport suffix still
+// keeps a udp inbound distinct from a tcp one on the same port.
+func TestGenerateInboundTag_ListenIgnoredTransportDisambiguates(t *testing.T) {
 	setupConflictDB(t)
-	seedInboundConflict(t, "in-1.2.3.4:443", "1.2.3.4", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
+	seedInboundConflict(t, "in-443-tcp", "1.2.3.4", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
 
 	svc := &InboundService{}
 	udp := &model.Inbound{
@@ -346,8 +347,8 @@ func TestGenerateInboundTag_SpecificListenSameDisambiguation(t *testing.T) {
 	if err != nil {
 		t.Fatalf("generateInboundTag: %v", err)
 	}
-	if got != "in-1.2.3.4:443-udp" {
-		t.Fatalf("expected in-1.2.3.4:443-udp, got %q", got)
+	if got != "in-443-udp" {
+		t.Fatalf("expected in-443-udp, got %q", got)
 	}
 }
 
@@ -644,26 +645,25 @@ func TestIsAutoGeneratedTag(t *testing.T) {
 	cases := []struct {
 		name   string
 		tag    string
-		listen string
 		port   int
 		nodeID *int
 		bits   transportBits
 		want   bool
 	}{
-		{"canonical", "in-443-tcp", "0.0.0.0", 443, nil, tcp, true},
-		{"canonical udp", "in-443-udp", "0.0.0.0", 443, nil, transportUDP, true},
-		{"dedup suffix", "in-443-tcp-2", "0.0.0.0", 443, nil, tcp, true},
-		{"listen scoped", "in-127.0.0.1:443-tcp", "127.0.0.1", 443, nil, tcp, true},
-		{"node prefixed", "n1-in-443-tcp", "0.0.0.0", 443, intPtr(1), tcp, true},
-		{"custom tag", "my-cool-tag", "0.0.0.0", 443, nil, tcp, false},
-		{"stale port", "in-443-tcp", "0.0.0.0", 8443, nil, tcp, false},
-		{"stale transport", "in-443-tcp", "0.0.0.0", 443, nil, transportUDP, false},
-		{"non-numeric suffix", "in-443-tcp-x", "0.0.0.0", 443, nil, tcp, false},
-		{"empty suffix", "in-443-tcp-", "0.0.0.0", 443, nil, tcp, false},
+		{"canonical", "in-443-tcp", 443, nil, tcp, true},
+		{"canonical udp", "in-443-udp", 443, nil, transportUDP, true},
+		{"dedup suffix", "in-443-tcp-2", 443, nil, tcp, true},
+		{"node prefixed", "n1-in-443-tcp", 443, intPtr(1), tcp, true},
+		{"legacy listen-scoped is now custom", "in-127.0.0.1:443-tcp", 443, nil, tcp, false},
+		{"custom tag", "my-cool-tag", 443, nil, tcp, false},
+		{"stale port", "in-443-tcp", 8443, nil, tcp, false},
+		{"stale transport", "in-443-tcp", 443, nil, transportUDP, false},
+		{"non-numeric suffix", "in-443-tcp-x", 443, nil, tcp, false},
+		{"empty suffix", "in-443-tcp-", 443, nil, tcp, false},
 	}
 	for _, c := range cases {
 		t.Run(c.name, func(t *testing.T) {
-			if got := isAutoGeneratedTag(c.tag, c.listen, c.port, c.nodeID, c.bits); got != c.want {
+			if got := isAutoGeneratedTag(c.tag, c.port, c.nodeID, c.bits); got != c.want {
 				t.Fatalf("isAutoGeneratedTag(%q) = %v, want %v", c.tag, got, c.want)
 			}
 		})

+ 15 - 1
web/translation/ar-EG.json

@@ -320,6 +320,11 @@
       "attachClientsSearchPlaceholder": "ابحث بالبريد أو التعليق",
       "attachClientsStatusDisabled": "معطل",
       "attachClientsSelectedCount": "{selected} من {total} محدد",
+      "attachExistingClients": "إرفاق العملاء الحاليين…",
+      "attachExistingTitle": "إرفاق العملاء الحاليين بـ «{remark}»",
+      "attachExistingDesc": "يرفق العملاء الحاليين ({count} متاح) بهذا الوارد — بنفس UUID/كلمة المرور وحركة المرور المشتركة. يتم تخطي العملاء الموجودين عليه بالفعل.",
+      "attachExistingNoClients": "لا يوجد عملاء بعد. أنشئ عملاء أولاً ثم أرفقهم هنا.",
+      "attachExistingStatusAttached": "مُرفق بالفعل",
       "detachClients": "فصل العملاء",
       "detachClientsTitle": "فصل عملاء من «{remark}»",
       "detachClientsDesc": "يزيل العميل (العملاء) المحدد من هذا الوارد فقط. تُحفظ سجلات العملاء (استخدم Delete للإزالة الكاملة). المصدر يحتوي على {count} عميل إجمالاً.",
@@ -832,6 +837,12 @@
       "panelVersion": "إصدار اللوحة",
       "actions": "العمليات",
       "probe": "فحص فوري",
+      "updatePanel": "تحديث اللوحة",
+      "updateSelected": "تحديث المحدد ({count})",
+      "updateAvailable": "تحديث متاح",
+      "upToDate": "محدّث",
+      "updateConfirmTitle": "تحديث {count} عقدة إلى أحدث إصدار؟",
+      "updateConfirmContent": "كل عقدة محددة ستنزّل أحدث إصدار وتعيد التشغيل عليه. يتم تحديث العقد المفعّلة والمتصلة فقط.",
       "testConnection": "اختبار الاتصال",
       "connectionOk": "الاتصال شغال ({ms} ms)",
       "connectionFailed": "فشل الاتصال",
@@ -853,7 +864,10 @@
         "deleted": "اتمسح النود",
         "test": "اختبار الاتصال",
         "fillRequired": "الاسم والعنوان والبورت وتوكن API كلهم مطلوبين",
-        "probeFailed": "فشل الفحص"
+        "probeFailed": "فشل الفحص",
+        "updateStarted": "بدأ تحديث اللوحة",
+        "updateResult": "تم بدء التحديث على {ok} عقدة، فشل {failed}",
+        "updateNoneEligible": "اختر عقدة واحدة على الأقل متصلة ومفعّلة"
       }
     },
     "settings": {

+ 15 - 1
web/translation/en-US.json

@@ -320,6 +320,11 @@
       "attachClientsSearchPlaceholder": "Search email or comment",
       "attachClientsStatusDisabled": "Disabled",
       "attachClientsSelectedCount": "{selected} of {total} selected",
+      "attachExistingClients": "Attach Existing Clients…",
+      "attachExistingTitle": "Attach existing clients to \"{remark}\"",
+      "attachExistingDesc": "Attaches existing clients ({count} available) to this inbound — same UUID/password and shared traffic. Clients already on it are skipped.",
+      "attachExistingNoClients": "No clients exist yet. Create clients first, then attach them here.",
+      "attachExistingStatusAttached": "Already attached",
       "detachClients": "Detach Clients",
       "detachClientsTitle": "Detach clients of \"{remark}\"",
       "detachClientsDesc": "Removes the selected client(s) from this inbound only. Client records themselves are kept (use Delete to remove fully). Source has {count} clients in total.",
@@ -832,6 +837,12 @@
       "panelVersion": "Panel Version",
       "actions": "Actions",
       "probe": "Probe Now",
+      "updatePanel": "Update Panel",
+      "updateSelected": "Update Selected ({count})",
+      "updateAvailable": "Update available",
+      "upToDate": "Up to date",
+      "updateConfirmTitle": "Update {count} node(s) to the latest version?",
+      "updateConfirmContent": "Each selected node downloads the latest release and restarts onto it. Only enabled, online nodes are updated.",
       "testConnection": "Test Connection",
       "connectionOk": "Connection OK ({ms} ms)",
       "connectionFailed": "Connection failed",
@@ -853,7 +864,10 @@
         "deleted": "Node deleted",
         "test": "Test connection",
         "fillRequired": "Name, address, port and API token are required",
-        "probeFailed": "Probe failed"
+        "probeFailed": "Probe failed",
+        "updateStarted": "Panel update started",
+        "updateResult": "Update triggered on {ok} node(s), {failed} failed",
+        "updateNoneEligible": "Select at least one online, enabled node"
       }
     },
     "settings": {

+ 15 - 1
web/translation/es-ES.json

@@ -320,6 +320,11 @@
       "attachClientsSearchPlaceholder": "Buscar email o comentario",
       "attachClientsStatusDisabled": "Deshabilitado",
       "attachClientsSelectedCount": "{selected} de {total} seleccionado(s)",
+      "attachExistingClients": "Asociar clientes existentes…",
+      "attachExistingTitle": "Asociar clientes existentes a «{remark}»",
+      "attachExistingDesc": "Asocia los clientes existentes ({count} disponibles) a esta entrada: mismo UUID/contraseña y tráfico compartido. Los clientes que ya están en ella se omiten.",
+      "attachExistingNoClients": "Aún no hay clientes. Cree clientes primero y luego asócielos aquí.",
+      "attachExistingStatusAttached": "Ya asociado",
       "detachClients": "Desasociar clientes",
       "detachClientsTitle": "Desasociar clientes de «{remark}»",
       "detachClientsDesc": "Quita el cliente o clientes seleccionados solo de esta entrada. Los registros se conservan (usa Delete para eliminar por completo). El origen tiene {count} cliente(s) en total.",
@@ -832,6 +837,12 @@
       "panelVersion": "Versión del panel",
       "actions": "Acciones",
       "probe": "Sondear ahora",
+      "updatePanel": "Actualizar panel",
+      "updateSelected": "Actualizar seleccionados ({count})",
+      "updateAvailable": "Actualización disponible",
+      "upToDate": "Actualizado",
+      "updateConfirmTitle": "¿Actualizar {count} nodo(s) a la última versión?",
+      "updateConfirmContent": "Cada nodo seleccionado descarga la última versión y se reinicia con ella. Solo se actualizan los nodos habilitados y en línea.",
       "testConnection": "Probar conexión",
       "connectionOk": "Conexión correcta ({ms} ms)",
       "connectionFailed": "Conexión fallida",
@@ -853,7 +864,10 @@
         "deleted": "Nodo eliminado",
         "test": "Probar conexión",
         "fillRequired": "El nombre, la dirección, el puerto y el token de API son obligatorios",
-        "probeFailed": "Sondeo fallido"
+        "probeFailed": "Sondeo fallido",
+        "updateStarted": "Actualización del panel iniciada",
+        "updateResult": "Actualización iniciada en {ok} nodo(s), {failed} fallaron",
+        "updateNoneEligible": "Selecciona al menos un nodo en línea y habilitado"
       }
     },
     "settings": {

+ 15 - 1
web/translation/fa-IR.json

@@ -320,6 +320,11 @@
       "attachClientsSearchPlaceholder": "جستجوی ایمیل یا توضیح",
       "attachClientsStatusDisabled": "غیرفعال",
       "attachClientsSelectedCount": "{selected} از {total} انتخاب‌شده",
+      "attachExistingClients": "الصاق کاربران موجود…",
+      "attachExistingTitle": "الصاق کاربران موجود به «{remark}»",
+      "attachExistingDesc": "کاربران موجود ({count} کاربر در دسترس) را به این ورودی الصاق می‌کند — با همان UUID/رمز و ترافیک مشترک. کاربرانی که از قبل روی این ورودی هستند نادیده گرفته می‌شوند.",
+      "attachExistingNoClients": "هنوز هیچ کاربری وجود ندارد. ابتدا کاربر بسازید، سپس اینجا الصاق کنید.",
+      "attachExistingStatusAttached": "از قبل الصاق‌شده",
       "detachClients": "جداسازی کاربران",
       "detachClientsTitle": "جداسازی کاربران از «{remark}»",
       "detachClientsDesc": "کاربر(های) انتخابی را تنها از این ورودی حذف می‌کند. خود رکورد کاربر حفظ می‌شود (برای حذف کامل از Delete استفاده کنید). مبدا در مجموع {count} کاربر دارد.",
@@ -832,6 +837,12 @@
       "panelVersion": "نسخه پنل",
       "actions": "عملیات",
       "probe": "بررسی فوری",
+      "updatePanel": "به‌روزرسانی پنل",
+      "updateSelected": "به‌روزرسانی انتخاب‌شده‌ها ({count})",
+      "updateAvailable": "به‌روزرسانی موجود",
+      "upToDate": "به‌روز",
+      "updateConfirmTitle": "{count} نود به آخرین نسخه به‌روزرسانی شوند؟",
+      "updateConfirmContent": "هر نود انتخاب‌شده آخرین نسخه را دانلود و روی آن ری‌استارت می‌شود. فقط نودهای فعال و آنلاین به‌روزرسانی می‌شوند.",
       "testConnection": "تست اتصال",
       "connectionOk": "اتصال موفق ({ms} میلی‌ثانیه)",
       "connectionFailed": "اتصال ناموفق",
@@ -853,7 +864,10 @@
         "deleted": "نود حذف شد",
         "test": "تست اتصال",
         "fillRequired": "نام، آدرس، پورت و توکن API الزامی است",
-        "probeFailed": "بررسی ناموفق"
+        "probeFailed": "بررسی ناموفق",
+        "updateStarted": "به‌روزرسانی پنل آغاز شد",
+        "updateResult": "به‌روزرسانی روی {ok} نود آغاز شد، {failed} ناموفق",
+        "updateNoneEligible": "حداقل یک نود آنلاین و فعال انتخاب کنید"
       }
     },
     "settings": {

+ 15 - 1
web/translation/id-ID.json

@@ -320,6 +320,11 @@
       "attachClientsSearchPlaceholder": "Cari email atau komentar",
       "attachClientsStatusDisabled": "Dinonaktifkan",
       "attachClientsSelectedCount": "{selected} dari {total} dipilih",
+      "attachExistingClients": "Lampirkan klien yang ada…",
+      "attachExistingTitle": "Lampirkan klien yang ada ke «{remark}»",
+      "attachExistingDesc": "Melampirkan klien yang ada ({count} tersedia) ke inbound ini — UUID/kata sandi sama dan trafik bersama. Klien yang sudah ada di sini dilewati.",
+      "attachExistingNoClients": "Belum ada klien. Buat klien dulu, lalu lampirkan di sini.",
+      "attachExistingStatusAttached": "Sudah dilampirkan",
       "detachClients": "Lepas klien",
       "detachClientsTitle": "Lepas klien dari «{remark}»",
       "detachClientsDesc": "Menghapus klien terpilih hanya dari inbound ini. Catatan klien tetap dipertahankan (gunakan Delete untuk menghapus sepenuhnya). Sumber memiliki total {count} klien.",
@@ -832,6 +837,12 @@
       "panelVersion": "Versi panel",
       "actions": "Aksi",
       "probe": "Probe Sekarang",
+      "updatePanel": "Perbarui Panel",
+      "updateSelected": "Perbarui Terpilih ({count})",
+      "updateAvailable": "Pembaruan tersedia",
+      "upToDate": "Terbaru",
+      "updateConfirmTitle": "Perbarui {count} node ke versi terbaru?",
+      "updateConfirmContent": "Setiap node terpilih mengunduh rilis terbaru dan memulai ulang. Hanya node aktif dan online yang diperbarui.",
       "testConnection": "Tes Koneksi",
       "connectionOk": "Koneksi OK ({ms} ms)",
       "connectionFailed": "Koneksi gagal",
@@ -853,7 +864,10 @@
         "deleted": "Node dihapus",
         "test": "Tes koneksi",
         "fillRequired": "Nama, alamat, port, dan token API wajib diisi",
-        "probeFailed": "Probe gagal"
+        "probeFailed": "Probe gagal",
+        "updateStarted": "Pembaruan panel dimulai",
+        "updateResult": "Pembaruan dipicu pada {ok} node, {failed} gagal",
+        "updateNoneEligible": "Pilih minimal satu node online dan aktif"
       }
     },
     "settings": {

+ 15 - 1
web/translation/ja-JP.json

@@ -320,6 +320,11 @@
       "attachClientsSearchPlaceholder": "メールまたはコメントを検索",
       "attachClientsStatusDisabled": "無効",
       "attachClientsSelectedCount": "{total} 中 {selected} 選択中",
+      "attachExistingClients": "既存のクライアントをアタッチ…",
+      "attachExistingTitle": "「{remark}」に既存のクライアントをアタッチ",
+      "attachExistingDesc": "既存のクライアント({count} 件)をこのインバウンドにアタッチします — 同じ UUID/パスワードと共有トラフィック。すでにアタッチ済みのクライアントはスキップされます。",
+      "attachExistingNoClients": "クライアントがまだありません。先にクライアントを作成してから、ここでアタッチしてください。",
+      "attachExistingStatusAttached": "アタッチ済み",
       "detachClients": "クライアントをデタッチ",
       "detachClientsTitle": "「{remark}」のクライアントをデタッチ",
       "detachClientsDesc": "選択したクライアントをこのインバウンドのみから外します。クライアントレコードは保持されます (完全に削除するには Delete を使用)。ソースには合計 {count} クライアントがあります。",
@@ -832,6 +837,12 @@
       "panelVersion": "パネルのバージョン",
       "actions": "操作",
       "probe": "今すぐプローブ",
+      "updatePanel": "パネルを更新",
+      "updateSelected": "選択を更新 ({count})",
+      "updateAvailable": "更新あり",
+      "upToDate": "最新",
+      "updateConfirmTitle": "{count} 個のノードを最新バージョンに更新しますか?",
+      "updateConfirmContent": "選択した各ノードは最新リリースをダウンロードして再起動します。有効かつオンラインのノードのみが更新されます。",
       "testConnection": "接続テスト",
       "connectionOk": "接続OK ({ms} ms)",
       "connectionFailed": "接続に失敗しました",
@@ -853,7 +864,10 @@
         "deleted": "ノードを削除しました",
         "test": "接続テスト",
         "fillRequired": "名前、アドレス、ポート、APIトークンは必須です",
-        "probeFailed": "プローブに失敗しました"
+        "probeFailed": "プローブに失敗しました",
+        "updateStarted": "パネルの更新を開始しました",
+        "updateResult": "{ok} 個のノードで更新を開始、{failed} 個失敗",
+        "updateNoneEligible": "オンラインで有効なノードを少なくとも1つ選択してください"
       }
     },
     "settings": {

+ 15 - 1
web/translation/pt-BR.json

@@ -320,6 +320,11 @@
       "attachClientsSearchPlaceholder": "Buscar email ou comentário",
       "attachClientsStatusDisabled": "Desabilitado",
       "attachClientsSelectedCount": "{selected} de {total} selecionado(s)",
+      "attachExistingClients": "Associar clientes existentes…",
+      "attachExistingTitle": "Associar clientes existentes a «{remark}»",
+      "attachExistingDesc": "Associa os clientes existentes ({count} disponíveis) a esta entrada — mesmo UUID/senha e tráfego compartilhado. Clientes que já estão nela são ignorados.",
+      "attachExistingNoClients": "Ainda não há clientes. Crie clientes primeiro e depois associe-os aqui.",
+      "attachExistingStatusAttached": "Já associado",
       "detachClients": "Desassociar clientes",
       "detachClientsTitle": "Desassociar clientes de «{remark}»",
       "detachClientsDesc": "Remove o(s) cliente(s) selecionado(s) apenas desta entrada. Os registros são mantidos (use Delete para remover completamente). A origem tem {count} cliente(s) no total.",
@@ -832,6 +837,12 @@
       "panelVersion": "Versão do painel",
       "actions": "Ações",
       "probe": "Sondar agora",
+      "updatePanel": "Atualizar painel",
+      "updateSelected": "Atualizar selecionados ({count})",
+      "updateAvailable": "Atualização disponível",
+      "upToDate": "Atualizado",
+      "updateConfirmTitle": "Atualizar {count} nó(s) para a versão mais recente?",
+      "updateConfirmContent": "Cada nó selecionado baixa a versão mais recente e reinicia nela. Apenas nós ativos e online são atualizados.",
       "testConnection": "Testar conexão",
       "connectionOk": "Conexão OK ({ms} ms)",
       "connectionFailed": "Falha na conexão",
@@ -853,7 +864,10 @@
         "deleted": "Nó excluído",
         "test": "Testar conexão",
         "fillRequired": "Nome, endereço, porta e token da API são obrigatórios",
-        "probeFailed": "Falha na sondagem"
+        "probeFailed": "Falha na sondagem",
+        "updateStarted": "Atualização do painel iniciada",
+        "updateResult": "Atualização iniciada em {ok} nó(s), {failed} falharam",
+        "updateNoneEligible": "Selecione pelo menos um nó online e ativo"
       }
     },
     "settings": {

+ 15 - 1
web/translation/ru-RU.json

@@ -320,6 +320,11 @@
       "attachClientsSearchPlaceholder": "Поиск email или комментария",
       "attachClientsStatusDisabled": "Отключено",
       "attachClientsSelectedCount": "{selected} из {total} выбрано",
+      "attachExistingClients": "Привязать существующих клиентов…",
+      "attachExistingTitle": "Привязать существующих клиентов к «{remark}»",
+      "attachExistingDesc": "Привязывает существующих клиентов (доступно {count}) к этому входящему — тот же UUID/пароль и общий трафик. Клиенты, уже привязанные к нему, пропускаются.",
+      "attachExistingNoClients": "Клиентов пока нет. Сначала создайте клиентов, затем привяжите их здесь.",
+      "attachExistingStatusAttached": "Уже привязан",
       "detachClients": "Отвязать клиентов",
       "detachClientsTitle": "Отвязать клиентов из «{remark}»",
       "detachClientsDesc": "Удаляет выбранных клиент(ов) только с этого входящего. Записи клиентов сохраняются (используйте Delete для полного удаления). У источника всего {count} клиент(ов).",
@@ -832,6 +837,12 @@
       "panelVersion": "Версия панели",
       "actions": "Действия",
       "probe": "Проверить сейчас",
+      "updatePanel": "Обновить панель",
+      "updateSelected": "Обновить выбранные ({count})",
+      "updateAvailable": "Доступно обновление",
+      "upToDate": "Актуально",
+      "updateConfirmTitle": "Обновить {count} узлов до последней версии?",
+      "updateConfirmContent": "Каждый выбранный узел загрузит последний релиз и перезапустится. Обновляются только включённые узлы в сети.",
       "testConnection": "Проверить соединение",
       "connectionOk": "Соединение в порядке ({ms} мс)",
       "connectionFailed": "Не удалось подключиться",
@@ -853,7 +864,10 @@
         "deleted": "Узел удалён",
         "test": "Проверить соединение",
         "fillRequired": "Имя, адрес, порт и токен API обязательны",
-        "probeFailed": "Проверка не удалась"
+        "probeFailed": "Проверка не удалась",
+        "updateStarted": "Обновление панели запущено",
+        "updateResult": "Обновление запущено на {ok} узлах, {failed} не удалось",
+        "updateNoneEligible": "Выберите хотя бы один включённый узел в сети"
       }
     },
     "settings": {

+ 15 - 1
web/translation/tr-TR.json

@@ -320,6 +320,11 @@
       "attachClientsSearchPlaceholder": "Email veya yorum ara",
       "attachClientsStatusDisabled": "Devre dışı",
       "attachClientsSelectedCount": "{total} içinden {selected} seçildi",
+      "attachExistingClients": "Mevcut istemcileri bağla…",
+      "attachExistingTitle": "«{remark}» gelenine mevcut istemcileri bağla",
+      "attachExistingDesc": "Mevcut istemcileri ({count} uygun) bu gelene bağlar — aynı UUID/parola ve paylaşılan trafik. Zaten bu gelende olan istemciler atlanır.",
+      "attachExistingNoClients": "Henüz istemci yok. Önce istemci oluşturun, ardından buraya bağlayın.",
+      "attachExistingStatusAttached": "Zaten bağlı",
       "detachClients": "İstemcileri çöz",
       "detachClientsTitle": "«{remark}» gelenindeki istemcileri çöz",
       "detachClientsDesc": "Seçilen istemcileri yalnızca bu gelenden kaldırır. İstemci kayıtları korunur (tamamen kaldırmak için Delete kullanın). Kaynakta toplam {count} istemci var.",
@@ -832,6 +837,12 @@
       "panelVersion": "Panel sürümü",
       "actions": "İşlemler",
       "probe": "Şimdi Test Et",
+      "updatePanel": "Paneli Güncelle",
+      "updateSelected": "Seçilenleri Güncelle ({count})",
+      "updateAvailable": "Güncelleme mevcut",
+      "upToDate": "Güncel",
+      "updateConfirmTitle": "{count} düğüm en son sürüme güncellensin mi?",
+      "updateConfirmContent": "Seçilen her düğüm en son sürümü indirir ve yeniden başlatılır. Yalnızca etkin ve çevrimiçi düğümler güncellenir.",
       "testConnection": "Bağlantıyı Test Et",
       "connectionOk": "Bağlantı tamam ({ms} ms)",
       "connectionFailed": "Bağlantı başarısız",
@@ -853,7 +864,10 @@
         "deleted": "Düğüm silindi",
         "test": "Bağlantıyı test et",
         "fillRequired": "Ad, adres, port ve API token gereklidir",
-        "probeFailed": "Test başarısız"
+        "probeFailed": "Test başarısız",
+        "updateStarted": "Panel güncellemesi başlatıldı",
+        "updateResult": "{ok} düğümde güncelleme başlatıldı, {failed} başarısız",
+        "updateNoneEligible": "En az bir çevrimiçi ve etkin düğüm seçin"
       }
     },
     "settings": {

+ 15 - 1
web/translation/uk-UA.json

@@ -320,6 +320,11 @@
       "attachClientsSearchPlaceholder": "Пошук email або коментаря",
       "attachClientsStatusDisabled": "Вимкнено",
       "attachClientsSelectedCount": "Обрано {selected} з {total}",
+      "attachExistingClients": "Прив'язати наявних клієнтів…",
+      "attachExistingTitle": "Прив'язати наявних клієнтів до «{remark}»",
+      "attachExistingDesc": "Прив'язує наявних клієнтів (доступно {count}) до цього вхідного — той самий UUID/пароль і спільний трафік. Клієнти, уже прив'язані до нього, пропускаються.",
+      "attachExistingNoClients": "Клієнтів поки немає. Спершу створіть клієнтів, потім прив'яжіть їх тут.",
+      "attachExistingStatusAttached": "Вже прив'язано",
       "detachClients": "Від'єднати клієнтів",
       "detachClientsTitle": "Від'єднати клієнтів з «{remark}»",
       "detachClientsDesc": "Видаляє обраних клієнт(ів) лише з цього вхідного. Записи клієнтів зберігаються (використовуйте Delete для повного видалення). У джерела всього {count} клієнт(ів).",
@@ -832,6 +837,12 @@
       "panelVersion": "Версія панелі",
       "actions": "Дії",
       "probe": "Перевірити зараз",
+      "updatePanel": "Оновити панель",
+      "updateSelected": "Оновити вибрані ({count})",
+      "updateAvailable": "Доступне оновлення",
+      "upToDate": "Актуально",
+      "updateConfirmTitle": "Оновити {count} вузлів до останньої версії?",
+      "updateConfirmContent": "Кожен вибраний вузол завантажить останній реліз і перезапуститься. Оновлюються лише увімкнені вузли в мережі.",
       "testConnection": "Перевірити з'єднання",
       "connectionOk": "З'єднання в порядку ({ms} мс)",
       "connectionFailed": "Помилка з'єднання",
@@ -853,7 +864,10 @@
         "deleted": "Вузол видалено",
         "test": "Перевірити з'єднання",
         "fillRequired": "Назва, адреса, порт та токен API є обов'язковими",
-        "probeFailed": "Помилка перевірки"
+        "probeFailed": "Помилка перевірки",
+        "updateStarted": "Оновлення панелі розпочато",
+        "updateResult": "Оновлення запущено на {ok} вузлах, {failed} не вдалося",
+        "updateNoneEligible": "Виберіть принаймні один увімкнений вузол у мережі"
       }
     },
     "settings": {

+ 15 - 1
web/translation/vi-VN.json

@@ -320,6 +320,11 @@
       "attachClientsSearchPlaceholder": "Tìm email hoặc ghi chú",
       "attachClientsStatusDisabled": "Đã tắt",
       "attachClientsSelectedCount": "Đã chọn {selected}/{total}",
+      "attachExistingClients": "Gắn client hiện có…",
+      "attachExistingTitle": "Gắn client hiện có vào «{remark}»",
+      "attachExistingDesc": "Gắn các client hiện có ({count} khả dụng) vào inbound này — cùng UUID/mật khẩu và lưu lượng chung. Các client đã có trên inbound sẽ được bỏ qua.",
+      "attachExistingNoClients": "Chưa có client nào. Hãy tạo client trước, rồi gắn vào đây.",
+      "attachExistingStatusAttached": "Đã gắn",
       "detachClients": "Tách client",
       "detachClientsTitle": "Tách client của «{remark}»",
       "detachClientsDesc": "Chỉ xóa client đã chọn khỏi inbound này. Hồ sơ client được giữ lại (dùng Delete để xóa hoàn toàn). Nguồn có tổng cộng {count} client.",
@@ -832,6 +837,12 @@
       "panelVersion": "Phiên bản panel",
       "actions": "Hành động",
       "probe": "Kiểm tra ngay",
+      "updatePanel": "Cập nhật bảng điều khiển",
+      "updateSelected": "Cập nhật đã chọn ({count})",
+      "updateAvailable": "Có bản cập nhật",
+      "upToDate": "Mới nhất",
+      "updateConfirmTitle": "Cập nhật {count} node lên phiên bản mới nhất?",
+      "updateConfirmContent": "Mỗi node đã chọn sẽ tải bản phát hành mới nhất và khởi động lại. Chỉ các node đang bật và trực tuyến được cập nhật.",
       "testConnection": "Kiểm tra kết nối",
       "connectionOk": "Kết nối OK ({ms} ms)",
       "connectionFailed": "Kết nối thất bại",
@@ -853,7 +864,10 @@
         "deleted": "Đã xóa nút",
         "test": "Kiểm tra kết nối",
         "fillRequired": "Tên, địa chỉ, cổng và token API là bắt buộc",
-        "probeFailed": "Kiểm tra thất bại"
+        "probeFailed": "Kiểm tra thất bại",
+        "updateStarted": "Đã bắt đầu cập nhật bảng điều khiển",
+        "updateResult": "Đã kích hoạt cập nhật trên {ok} node, {failed} thất bại",
+        "updateNoneEligible": "Chọn ít nhất một node trực tuyến và đang bật"
       }
     },
     "settings": {

+ 15 - 1
web/translation/zh-CN.json

@@ -320,6 +320,11 @@
       "attachClientsSearchPlaceholder": "搜索邮箱或备注",
       "attachClientsStatusDisabled": "已禁用",
       "attachClientsSelectedCount": "已选 {selected}/{total}",
+      "attachExistingClients": "附加现有客户端…",
+      "attachExistingTitle": "将现有客户端附加到 “{remark}”",
+      "attachExistingDesc": "将现有客户端(可用 {count} 个)附加到此入站 — 相同 UUID/密码和共享流量。已在此入站的客户端将被跳过。",
+      "attachExistingNoClients": "尚无客户端。请先创建客户端,然后在此附加。",
+      "attachExistingStatusAttached": "已附加",
       "detachClients": "分离客户端",
       "detachClientsTitle": "从 “{remark}” 分离客户端",
       "detachClientsDesc": "仅从此入站移除选中的客户端。客户端记录保留(使用 Delete 完全移除)。源共有 {count} 个客户端。",
@@ -832,6 +837,12 @@
       "panelVersion": "面板版本",
       "actions": "操作",
       "probe": "立即探测",
+      "updatePanel": "更新面板",
+      "updateSelected": "更新所选 ({count})",
+      "updateAvailable": "有可用更新",
+      "upToDate": "已是最新",
+      "updateConfirmTitle": "将 {count} 个节点更新到最新版本?",
+      "updateConfirmContent": "每个所选节点会下载最新版本并重启。仅更新已启用且在线的节点。",
       "testConnection": "测试连接",
       "connectionOk": "连接正常 ({ms} ms)",
       "connectionFailed": "连接失败",
@@ -853,7 +864,10 @@
         "deleted": "节点已删除",
         "test": "测试连接",
         "fillRequired": "名称、地址、端口和 API 令牌为必填项",
-        "probeFailed": "探测失败"
+        "probeFailed": "探测失败",
+        "updateStarted": "已开始更新面板",
+        "updateResult": "已在 {ok} 个节点上触发更新,{failed} 个失败",
+        "updateNoneEligible": "请至少选择一个在线且已启用的节点"
       }
     },
     "settings": {

+ 15 - 1
web/translation/zh-TW.json

@@ -320,6 +320,11 @@
       "attachClientsSearchPlaceholder": "搜尋電子郵件或備註",
       "attachClientsStatusDisabled": "已停用",
       "attachClientsSelectedCount": "已選 {selected}/{total}",
+      "attachExistingClients": "附加現有客戶端…",
+      "attachExistingTitle": "將現有客戶端附加到「{remark}」",
+      "attachExistingDesc": "將現有客戶端(可用 {count} 個)附加到此入站 — 相同 UUID/密碼與共享流量。已在此入站的客戶端將被略過。",
+      "attachExistingNoClients": "尚無客戶端。請先建立客戶端,然後在此附加。",
+      "attachExistingStatusAttached": "已附加",
       "detachClients": "分離客戶端",
       "detachClientsTitle": "從「{remark}」分離客戶端",
       "detachClientsDesc": "僅從此入站移除選取的客戶端。客戶端記錄保留(用 Delete 完全移除)。來源共有 {count} 個客戶端。",
@@ -832,6 +837,12 @@
       "panelVersion": "面板版本",
       "actions": "操作",
       "probe": "立即探測",
+      "updatePanel": "更新面板",
+      "updateSelected": "更新所選 ({count})",
+      "updateAvailable": "有可用更新",
+      "upToDate": "已是最新",
+      "updateConfirmTitle": "將 {count} 個節點更新到最新版本?",
+      "updateConfirmContent": "每個所選節點會下載最新版本並重新啟動。僅更新已啟用且在線的節點。",
       "testConnection": "測試連線",
       "connectionOk": "連線正常 ({ms} ms)",
       "connectionFailed": "連線失敗",
@@ -853,7 +864,10 @@
         "deleted": "節點已刪除",
         "test": "測試連線",
         "fillRequired": "名稱、位址、埠與 API 權杖為必填",
-        "probeFailed": "探測失敗"
+        "probeFailed": "探測失敗",
+        "updateStarted": "已開始更新面板",
+        "updateResult": "已在 {ok} 個節點上觸發更新,{failed} 個失敗",
+        "updateNoneEligible": "請至少選擇一個在線且已啟用的節點"
       }
     },
     "settings": {