10 Commits 84a689cf10 ... 3f6fe1167d

Autor SHA1 Mensagem Data
  MHSanaei 3f6fe1167d fix(sub): don't leak loopback bind IP into link host há 4 horas atrás
  MHSanaei 234cce408b @ há 6 horas atrás
  MHSanaei a7d763a542 fix(clients): persist sort selection across navigation há 7 horas atrás
  MHSanaei 80110f9404 fix(inbounds): reset id sequence on delete so old ids are reused há 7 horas atrás
  MHSanaei cf50952921 feat(inbounds): add multi-select and bulk delete há 7 horas atrás
  MHSanaei 6bb5a3b56b fix(inbounds): preserve client data on delete and show traffic in detail há 8 horas atrás
  MHSanaei a08bb91f58 fix(settings): reject spaces, '\' and control chars in URI path settings há 8 horas atrás
  MHSanaei 2fa7be86dc fix(clients): reject spaces, '/', '\' and control chars in subscription ID há 8 horas atrás
  MHSanaei a0865a67fd fix(clients): reject spaces, '/', '\' and control chars in client email há 9 horas atrás
  Sanaei d1882c7f29 refactor(frontend): reorganize source tree & break down oversized modals/tabs (#4698) há 10 horas atrás
100 ficheiros alterados com 5614 adições e 4250 exclusões
  1. 2 16
      .github/workflows/ci.yml
  2. 2 10
      .github/workflows/codeql.yml
  3. 4 8
      .github/workflows/release.yml
  4. 1 0
      frontend/.gitignore
  5. 662 0
      frontend/package-lock.json
  6. 3 0
      frontend/package.json
  7. 59 0
      frontend/public/openapi.json
  8. 0 0
      frontend/src/components/feedback/PromptModal.tsx
  9. 0 0
      frontend/src/components/feedback/TextModal.tsx
  10. 2 0
      frontend/src/components/feedback/index.ts
  11. 0 0
      frontend/src/components/form/DateTimePicker.css
  12. 0 0
      frontend/src/components/form/DateTimePicker.tsx
  13. 1 1
      frontend/src/components/form/HeaderMapEditor.tsx
  14. 0 0
      frontend/src/components/form/JsonEditor.css
  15. 0 0
      frontend/src/components/form/JsonEditor.tsx
  16. 3 0
      frontend/src/components/form/index.ts
  17. 0 0
      frontend/src/components/ui/InfinityIcon.tsx
  18. 0 0
      frontend/src/components/ui/InputAddon.css
  19. 0 0
      frontend/src/components/ui/InputAddon.tsx
  20. 0 0
      frontend/src/components/ui/SettingListItem.css
  21. 0 0
      frontend/src/components/ui/SettingListItem.tsx
  22. 3 0
      frontend/src/components/ui/index.ts
  23. 0 0
      frontend/src/components/utility/LazyMount.tsx
  24. 1 0
      frontend/src/components/utility/index.ts
  25. 0 0
      frontend/src/components/viz/Sparkline.css
  26. 0 0
      frontend/src/components/viz/Sparkline.tsx
  27. 1 0
      frontend/src/components/viz/index.ts
  28. 0 0
      frontend/src/layouts/AppSidebar.css
  29. 0 0
      frontend/src/layouts/AppSidebar.tsx
  30. 0 0
      frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx
  31. 1 0
      frontend/src/lib/xray/forms/transport/index.ts
  32. 1 1
      frontend/src/lib/xray/inbound-link.ts
  33. 6 0
      frontend/src/models/dbinbound.ts
  34. 1 1
      frontend/src/pages/api-docs/ApiDocsPage.tsx
  35. 7 0
      frontend/src/pages/api-docs/endpoints.ts
  36. 1 1
      frontend/src/pages/clients/ClientBulkAddModal.tsx
  37. 1 1
      frontend/src/pages/clients/ClientFormModal.tsx
  38. 1 1
      frontend/src/pages/clients/ClientInfoModal.tsx
  39. 1 1
      frontend/src/pages/clients/ClientQrModal.tsx
  40. 10 7
      frontend/src/pages/clients/ClientsPage.tsx
  41. 2 2
      frontend/src/pages/groups/GroupsPage.tsx
  42. 0 3129
      frontend/src/pages/inbounds/InboundFormModal.tsx
  43. 0 781
      frontend/src/pages/inbounds/InboundList.tsx
  44. 42 11
      frontend/src/pages/inbounds/InboundsPage.tsx
  45. 0 0
      frontend/src/pages/inbounds/clients/AddClientsToGroupModal.tsx
  46. 1 1
      frontend/src/pages/inbounds/clients/AttachClientsModal.tsx
  47. 0 0
      frontend/src/pages/inbounds/clients/DetachClientsModal.tsx
  48. 3 0
      frontend/src/pages/inbounds/clients/index.ts
  49. 123 0
      frontend/src/pages/inbounds/form/FallbacksCard.tsx
  50. 0 0
      frontend/src/pages/inbounds/form/InboundFormModal.css
  51. 868 0
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  52. 67 0
      frontend/src/pages/inbounds/form/SniffingTab.tsx
  53. 184 0
      frontend/src/pages/inbounds/form/advanced-editors.tsx
  54. 1 0
      frontend/src/pages/inbounds/form/index.ts
  55. 47 0
      frontend/src/pages/inbounds/form/protocols/accounts-list.tsx
  56. 20 0
      frontend/src/pages/inbounds/form/protocols/http.tsx
  57. 16 8
      frontend/src/pages/inbounds/form/protocols/hysteria.tsx
  58. 8 0
      frontend/src/pages/inbounds/form/protocols/index.ts
  59. 33 0
      frontend/src/pages/inbounds/form/protocols/mixed.tsx
  60. 67 0
      frontend/src/pages/inbounds/form/protocols/shadowsocks.tsx
  61. 93 0
      frontend/src/pages/inbounds/form/protocols/tun.tsx
  62. 37 0
      frontend/src/pages/inbounds/form/protocols/tunnel.tsx
  63. 60 0
      frontend/src/pages/inbounds/form/protocols/vless.tsx
  64. 120 0
      frontend/src/pages/inbounds/form/protocols/wireguard.tsx
  65. 2 0
      frontend/src/pages/inbounds/form/security/index.ts
  66. 143 0
      frontend/src/pages/inbounds/form/security/reality.tsx
  67. 309 0
      frontend/src/pages/inbounds/form/security/tls.tsx
  68. 136 0
      frontend/src/pages/inbounds/form/transport/external-proxy.tsx
  69. 29 0
      frontend/src/pages/inbounds/form/transport/grpc.tsx
  70. 37 0
      frontend/src/pages/inbounds/form/transport/httpupgrade.tsx
  71. 8 0
      frontend/src/pages/inbounds/form/transport/index.ts
  72. 34 0
      frontend/src/pages/inbounds/form/transport/kcp.tsx
  73. 164 0
      frontend/src/pages/inbounds/form/transport/raw.tsx
  74. 270 0
      frontend/src/pages/inbounds/form/transport/sockopt.tsx
  75. 37 0
      frontend/src/pages/inbounds/form/transport/ws.tsx
  76. 218 0
      frontend/src/pages/inbounds/form/transport/xhttp.tsx
  77. 187 0
      frontend/src/pages/inbounds/form/useInboundFallbacks.ts
  78. 205 0
      frontend/src/pages/inbounds/form/useSecurityActions.ts
  79. 0 0
      frontend/src/pages/inbounds/info/InboundInfoModal.css
  80. 12 262
      frontend/src/pages/inbounds/info/InboundInfoModal.tsx
  81. 170 0
      frontend/src/pages/inbounds/info/helpers.ts
  82. 1 0
      frontend/src/pages/inbounds/info/index.ts
  83. 87 0
      frontend/src/pages/inbounds/info/types.ts
  84. 20 0
      frontend/src/pages/inbounds/list/InboundList.css
  85. 260 0
      frontend/src/pages/inbounds/list/InboundList.tsx
  86. 141 0
      frontend/src/pages/inbounds/list/InboundStatsModal.tsx
  87. 81 0
      frontend/src/pages/inbounds/list/RowActions.tsx
  88. 106 0
      frontend/src/pages/inbounds/list/helpers.ts
  89. 2 0
      frontend/src/pages/inbounds/list/index.ts
  90. 89 0
      frontend/src/pages/inbounds/list/types.ts
  91. 290 0
      frontend/src/pages/inbounds/list/useInboundColumns.tsx
  92. 1 1
      frontend/src/pages/inbounds/qr/QrCodeModal.tsx
  93. 0 0
      frontend/src/pages/inbounds/qr/QrPanel.css
  94. 0 0
      frontend/src/pages/inbounds/qr/QrPanel.tsx
  95. 2 0
      frontend/src/pages/inbounds/qr/index.ts
  96. 3 3
      frontend/src/pages/index/IndexPage.tsx
  97. 1 1
      frontend/src/pages/index/SystemHistoryModal.tsx
  98. 1 1
      frontend/src/pages/index/XrayMetricsModal.tsx
  99. 1 1
      frontend/src/pages/nodes/NodeHistoryPanel.tsx
  100. 1 1
      frontend/src/pages/nodes/NodesPage.tsx

+ 2 - 16
.github/workflows/ci.yml

@@ -6,14 +6,7 @@ on:
       - "**.go"
       - "go.mod"
       - "go.sum"
-      - "**.js"
-      - "**.mjs"
-      - "**.cjs"
-      - "**.ts"
-      - "**.html"
-      - "**.css"
-      - "frontend/package.json"
-      - "frontend/package-lock.json"
+      - "frontend/**"
       - ".nvmrc"
   push:
     branches:
@@ -22,14 +15,7 @@ on:
       - "**.go"
       - "go.mod"
       - "go.sum"
-      - "**.js"
-      - "**.mjs"
-      - "**.cjs"
-      - "**.ts"
-      - "**.html"
-      - "**.css"
-      - "frontend/package.json"
-      - "frontend/package-lock.json"
+      - "frontend/**"
       - ".nvmrc"
 
 permissions:

+ 2 - 10
.github/workflows/codeql.yml

@@ -10,21 +10,13 @@ on:
       - "**.go"
       - "go.mod"
       - "go.sum"
-      - "**.js"
-      - "**.mjs"
-      - "**.cjs"
-      - "**.ts"
-      - "frontend/package-lock.json"
+      - "frontend/**"
   pull_request:
     paths:
       - "**.go"
       - "go.mod"
       - "go.sum"
-      - "**.js"
-      - "**.mjs"
-      - "**.cjs"
-      - "**.ts"
-      - "frontend/package-lock.json"
+      - "frontend/**"
   schedule:
     - cron: "18 2 * * 2"
 

+ 4 - 8
.github/workflows/release.yml

@@ -8,25 +8,21 @@ on:
     tags:
       - "v*.*.*"
     paths:
-      - "**.js"
-      - "**.css"
-      - "**.html"
-      - "**.sh"
       - "**.go"
       - "go.mod"
       - "go.sum"
+      - "**.sh"
+      - "frontend/**"
       - "x-ui.service.debian"
       - "x-ui.service.arch"
       - "x-ui.service.rhel"
   pull_request:
     paths:
-      - "**.js"
-      - "**.css"
-      - "**.html"
-      - "**.sh"
       - "**.go"
       - "go.mod"
       - "go.sum"
+      - "**.sh"
+      - "frontend/**"
       - "x-ui.service.debian"
       - "x-ui.service.arch"
       - "x-ui.service.rhel"

+ 1 - 0
frontend/.gitignore

@@ -1,3 +1,4 @@
 node_modules/
 .vite/
 *.log
+*.tsbuildinfo

+ 662 - 0
frontend/package-lock.json

@@ -31,6 +31,8 @@
       },
       "devDependencies": {
         "@eslint/js": "^10.0.1",
+        "@testing-library/dom": "^10.4.1",
+        "@testing-library/react": "^16.3.2",
         "@types/react": "^19.2.15",
         "@types/react-dom": "^19.2.3",
         "@types/swagger-ui-react": "^5.18.0",
@@ -38,6 +40,7 @@
         "eslint": "^10.4.0",
         "eslint-plugin-react-hooks": "^7.1.1",
         "globals": "^17.6.0",
+        "jsdom": "^29.1.1",
         "typescript": "^6.0.3",
         "typescript-eslint": "^8.60.0",
         "vite": "8.0.14",
@@ -141,6 +144,57 @@
         "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
       }
     },
+    "node_modules/@asamuzakjp/css-color": {
+      "version": "5.1.11",
+      "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
+      "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@asamuzakjp/generational-cache": "^1.0.1",
+        "@csstools/css-calc": "^3.2.0",
+        "@csstools/css-color-parser": "^4.1.0",
+        "@csstools/css-parser-algorithms": "^4.0.0",
+        "@csstools/css-tokenizer": "^4.0.0"
+      },
+      "engines": {
+        "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+      }
+    },
+    "node_modules/@asamuzakjp/dom-selector": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
+      "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@asamuzakjp/generational-cache": "^1.0.1",
+        "@asamuzakjp/nwsapi": "^2.3.9",
+        "bidi-js": "^1.0.3",
+        "css-tree": "^3.2.1",
+        "is-potential-custom-element-name": "^1.0.1"
+      },
+      "engines": {
+        "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+      }
+    },
+    "node_modules/@asamuzakjp/generational-cache": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
+      "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+      }
+    },
+    "node_modules/@asamuzakjp/nwsapi": {
+      "version": "2.3.9",
+      "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
+      "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@babel/code-frame": {
       "version": "7.29.7",
       "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -402,6 +456,19 @@
         "node": ">=6.9.0"
       }
     },
+    "node_modules/@bramus/specificity": {
+      "version": "2.4.2",
+      "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
+      "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "css-tree": "^3.0.0"
+      },
+      "bin": {
+        "specificity": "bin/cli.js"
+      }
+    },
     "node_modules/@codemirror/autocomplete": {
       "version": "6.20.2",
       "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.2.tgz",
@@ -505,6 +572,146 @@
         "w3c-keyname": "^2.2.4"
       }
     },
+    "node_modules/@csstools/color-helpers": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
+      "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT-0",
+      "engines": {
+        "node": ">=20.19.0"
+      }
+    },
+    "node_modules/@csstools/css-calc": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
+      "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.19.0"
+      },
+      "peerDependencies": {
+        "@csstools/css-parser-algorithms": "^4.0.0",
+        "@csstools/css-tokenizer": "^4.0.0"
+      }
+    },
+    "node_modules/@csstools/css-color-parser": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz",
+      "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "@csstools/color-helpers": "^6.0.2",
+        "@csstools/css-calc": "^3.2.1"
+      },
+      "engines": {
+        "node": ">=20.19.0"
+      },
+      "peerDependencies": {
+        "@csstools/css-parser-algorithms": "^4.0.0",
+        "@csstools/css-tokenizer": "^4.0.0"
+      }
+    },
+    "node_modules/@csstools/css-parser-algorithms": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+      "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.19.0"
+      },
+      "peerDependencies": {
+        "@csstools/css-tokenizer": "^4.0.0"
+      }
+    },
+    "node_modules/@csstools/css-syntax-patches-for-csstree": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz",
+      "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT-0",
+      "peerDependencies": {
+        "css-tree": "^3.2.1"
+      },
+      "peerDependenciesMeta": {
+        "css-tree": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@csstools/css-tokenizer": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+      "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.19.0"
+      }
+    },
     "node_modules/@emnapi/core": {
       "version": "1.10.0",
       "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@@ -679,6 +886,24 @@
         "node": "^20.19.0 || ^22.13.0 || >=24"
       }
     },
+    "node_modules/@exodus/bytes": {
+      "version": "1.15.1",
+      "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
+      "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+      },
+      "peerDependencies": {
+        "@noble/hashes": "^1.8.0 || ^2.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@noble/hashes": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/@humanfs/core": {
       "version": "0.19.2",
       "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -2629,6 +2854,54 @@
         "react": "^18 || ^19"
       }
     },
+    "node_modules/@testing-library/dom": {
+      "version": "10.4.1",
+      "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+      "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.10.4",
+        "@babel/runtime": "^7.12.5",
+        "@types/aria-query": "^5.0.1",
+        "aria-query": "5.3.0",
+        "dom-accessibility-api": "^0.5.9",
+        "lz-string": "^1.5.0",
+        "picocolors": "1.1.1",
+        "pretty-format": "^27.0.2"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@testing-library/react": {
+      "version": "16.3.2",
+      "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
+      "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.12.5"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "@testing-library/dom": "^10.0.0",
+        "@types/react": "^18.0.0 || ^19.0.0",
+        "@types/react-dom": "^18.0.0 || ^19.0.0",
+        "react": "^18.0.0 || ^19.0.0",
+        "react-dom": "^18.0.0 || ^19.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/@tybys/wasm-util": {
       "version": "0.10.2",
       "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
@@ -2640,6 +2913,13 @@
         "tslib": "^2.4.0"
       }
     },
+    "node_modules/@types/aria-query": {
+      "version": "5.0.4",
+      "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+      "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@types/chai": {
       "version": "5.2.3",
       "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -3249,6 +3529,29 @@
         "url": "https://github.com/sponsors/epoberezkin"
       }
     },
+    "node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/ansi-styles": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+      "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
     "node_modules/antd": {
       "version": "6.4.3",
       "resolved": "https://registry.npmjs.org/antd/-/antd-6.4.3.tgz",
@@ -3324,6 +3627,16 @@
       "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
       "license": "Python-2.0"
     },
+    "node_modules/aria-query": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+      "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "dequal": "^2.0.3"
+      }
+    },
     "node_modules/assertion-error": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -3418,6 +3731,16 @@
         "node": ">=6.0.0"
       }
     },
+    "node_modules/bidi-js": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+      "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "require-from-string": "^2.0.2"
+      }
+    },
     "node_modules/brace-expansion": {
       "version": "5.0.6",
       "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
@@ -3715,6 +4038,20 @@
         "node": ">= 8"
       }
     },
+    "node_modules/css-tree": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+      "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "mdn-data": "2.27.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+      }
+    },
     "node_modules/css.escape": {
       "version": "1.5.1",
       "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
@@ -3848,6 +4185,20 @@
         "node": ">=12"
       }
     },
+    "node_modules/data-urls": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+      "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "whatwg-mimetype": "^5.0.0",
+        "whatwg-url": "^16.0.0"
+      },
+      "engines": {
+        "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+      }
+    },
     "node_modules/dayjs": {
       "version": "1.11.21",
       "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
@@ -3871,6 +4222,13 @@
         }
       }
     },
+    "node_modules/decimal.js": {
+      "version": "10.6.0",
+      "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+      "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/decimal.js-light": {
       "version": "2.5.1",
       "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
@@ -3941,6 +4299,16 @@
         "node": ">=0.4.0"
       }
     },
+    "node_modules/dequal": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+      "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/detect-libc": {
       "version": "2.1.2",
       "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -3951,6 +4319,13 @@
         "node": ">=8"
       }
     },
+    "node_modules/dom-accessibility-api": {
+      "version": "0.5.16",
+      "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+      "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/dompurify": {
       "version": "3.4.7",
       "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz",
@@ -3990,6 +4365,19 @@
       "dev": true,
       "license": "ISC"
     },
+    "node_modules/entities": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
+      "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=20.19.0"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
     "node_modules/es-define-property": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -4663,6 +5051,19 @@
       "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==",
       "license": "CC0-1.0"
     },
+    "node_modules/html-encoding-sniffer": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+      "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@exodus/bytes": "^1.6.0"
+      },
+      "engines": {
+        "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+      }
+    },
     "node_modules/html-parse-stringify": {
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
@@ -4881,6 +5282,13 @@
       "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==",
       "license": "MIT"
     },
+    "node_modules/is-potential-custom-element-name": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+      "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/is-typed-array": {
       "version": "1.1.15",
       "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
@@ -4933,6 +5341,57 @@
         "js-yaml": "bin/js-yaml.js"
       }
     },
+    "node_modules/jsdom": {
+      "version": "29.1.1",
+      "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
+      "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@asamuzakjp/css-color": "^5.1.11",
+        "@asamuzakjp/dom-selector": "^7.1.1",
+        "@bramus/specificity": "^2.4.2",
+        "@csstools/css-syntax-patches-for-csstree": "^1.1.3",
+        "@exodus/bytes": "^1.15.0",
+        "css-tree": "^3.2.1",
+        "data-urls": "^7.0.0",
+        "decimal.js": "^10.6.0",
+        "html-encoding-sniffer": "^6.0.0",
+        "is-potential-custom-element-name": "^1.0.1",
+        "lru-cache": "^11.3.5",
+        "parse5": "^8.0.1",
+        "saxes": "^6.0.0",
+        "symbol-tree": "^3.2.4",
+        "tough-cookie": "^6.0.1",
+        "undici": "^7.25.0",
+        "w3c-xmlserializer": "^5.0.0",
+        "webidl-conversions": "^8.0.1",
+        "whatwg-mimetype": "^5.0.0",
+        "whatwg-url": "^16.0.1",
+        "xml-name-validator": "^5.0.0"
+      },
+      "engines": {
+        "node": "^20.19.0 || ^22.13.0 || >=24.0.0"
+      },
+      "peerDependencies": {
+        "canvas": "^3.0.0"
+      },
+      "peerDependenciesMeta": {
+        "canvas": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/jsdom/node_modules/lru-cache": {
+      "version": "11.5.1",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
+      "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
+      "dev": true,
+      "license": "BlueOak-1.0.0",
+      "engines": {
+        "node": "20 || >=22"
+      }
+    },
     "node_modules/jsesc": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -5350,6 +5809,16 @@
         "yallist": "^3.0.2"
       }
     },
+    "node_modules/lz-string": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+      "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "lz-string": "bin/bin.js"
+      }
+    },
     "node_modules/magic-string": {
       "version": "0.30.21",
       "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -5369,6 +5838,13 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/mdn-data": {
+      "version": "2.27.1",
+      "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+      "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+      "dev": true,
+      "license": "CC0-1.0"
+    },
     "node_modules/mime-db": {
       "version": "1.52.0",
       "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@@ -5639,6 +6115,19 @@
       "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
       "license": "MIT"
     },
+    "node_modules/parse5": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+      "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "entities": "^8.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/inikulin/parse5?sponsor=1"
+      }
+    },
     "node_modules/path-exists": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -5743,6 +6232,28 @@
         "node": ">= 0.8.0"
       }
     },
+    "node_modules/pretty-format": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+      "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-regex": "^5.0.1",
+        "ansi-styles": "^5.0.0",
+        "react-is": "^17.0.1"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/pretty-format/node_modules/react-is": {
+      "version": "17.0.2",
+      "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+      "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/prismjs": {
       "version": "1.30.0",
       "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
@@ -6165,6 +6676,16 @@
         "node": ">=0.10"
       }
     },
+    "node_modules/require-from-string": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+      "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/requires-port": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@@ -6240,6 +6761,19 @@
       ],
       "license": "MIT"
     },
+    "node_modules/saxes": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+      "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "xmlchars": "^2.2.0"
+      },
+      "engines": {
+        "node": ">=v12.22.7"
+      }
+    },
     "node_modules/scheduler": {
       "version": "0.27.0",
       "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -6568,6 +7102,13 @@
         "react-dom": ">=16.8.0 <20"
       }
     },
+    "node_modules/symbol-tree": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+      "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/throttle-debounce": {
       "version": "5.0.2",
       "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
@@ -6627,6 +7168,26 @@
         "node": ">=14.0.0"
       }
     },
+    "node_modules/tldts": {
+      "version": "7.4.2",
+      "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz",
+      "integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tldts-core": "^7.4.2"
+      },
+      "bin": {
+        "tldts": "bin/cli.js"
+      }
+    },
+    "node_modules/tldts-core": {
+      "version": "7.4.2",
+      "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz",
+      "integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/to-buffer": {
       "version": "1.2.2",
       "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz",
@@ -6647,6 +7208,32 @@
       "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==",
       "license": "MIT"
     },
+    "node_modules/tough-cookie": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
+      "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "tldts": "^7.0.5"
+      },
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/tr46": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+      "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "punycode": "^2.3.1"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
     "node_modules/tree-sitter": {
       "version": "0.21.1",
       "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.21.1.tgz",
@@ -6796,6 +7383,16 @@
         "typescript": ">=4.8.4 <6.1.0"
       }
     },
+    "node_modules/undici": {
+      "version": "7.26.0",
+      "resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz",
+      "integrity": "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.18.1"
+      }
+    },
     "node_modules/unraw": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/unraw/-/unraw-3.0.0.tgz",
@@ -7067,6 +7664,19 @@
       "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
       "license": "MIT"
     },
+    "node_modules/w3c-xmlserializer": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+      "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "xml-name-validator": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "node_modules/web-tree-sitter": {
       "version": "0.24.5",
       "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.24.5.tgz",
@@ -7074,6 +7684,41 @@
       "license": "MIT",
       "optional": true
     },
+    "node_modules/webidl-conversions": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+      "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/whatwg-mimetype": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+      "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/whatwg-url": {
+      "version": "16.0.1",
+      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+      "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@exodus/bytes": "^1.11.0",
+        "tr46": "^6.0.0",
+        "webidl-conversions": "^8.0.1"
+      },
+      "engines": {
+        "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+      }
+    },
     "node_modules/which": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -7153,6 +7798,23 @@
         "repeat-string": "^1.5.2"
       }
     },
+    "node_modules/xml-name-validator": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+      "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/xmlchars": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+      "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/yallist": {
       "version": "3.1.1",
       "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

+ 3 - 0
frontend/package.json

@@ -43,6 +43,8 @@
   },
   "devDependencies": {
     "@eslint/js": "^10.0.1",
+    "@testing-library/dom": "^10.4.1",
+    "@testing-library/react": "^16.3.2",
     "@types/react": "^19.2.15",
     "@types/react-dom": "^19.2.3",
     "@types/swagger-ui-react": "^5.18.0",
@@ -50,6 +52,7 @@
     "eslint": "^10.4.0",
     "eslint-plugin-react-hooks": "^7.1.1",
     "globals": "^17.6.0",
+    "jsdom": "^29.1.1",
     "typescript": "^6.0.3",
     "typescript-eslint": "^8.60.0",
     "vite": "8.0.14",

+ 59 - 0
frontend/public/openapi.json

@@ -615,6 +615,65 @@
         }
       }
     },
+    "/panel/api/inbounds/bulkDel": {
+      "post": {
+        "tags": [
+          "Inbounds"
+        ],
+        "summary": "Delete many inbounds in one call. Processes the list sequentially; failures are reported per id and the rest still proceed. Restarts xray at most once.",
+        "operationId": "post_panel_api_inbounds_bulkDel",
+        "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": {
+                    "deleted": 2,
+                    "skipped": [
+                      {
+                        "id": 3,
+                        "reason": "..."
+                      }
+                    ]
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/inbounds/update/{id}": {
       "post": {
         "tags": [

+ 0 - 0
frontend/src/components/PromptModal.tsx → frontend/src/components/feedback/PromptModal.tsx


+ 0 - 0
frontend/src/components/TextModal.tsx → frontend/src/components/feedback/TextModal.tsx


+ 2 - 0
frontend/src/components/feedback/index.ts

@@ -0,0 +1,2 @@
+export { default as PromptModal } from './PromptModal';
+export { default as TextModal } from './TextModal';

+ 0 - 0
frontend/src/components/DateTimePicker.css → frontend/src/components/form/DateTimePicker.css


+ 0 - 0
frontend/src/components/DateTimePicker.tsx → frontend/src/components/form/DateTimePicker.tsx


+ 1 - 1
frontend/src/components/HeaderMapEditor.tsx → frontend/src/components/form/HeaderMapEditor.tsx

@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
 import { Button, Input, Space } from 'antd';
 import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
 
-import InputAddon from '@/components/InputAddon';
+import { InputAddon } from '@/components/ui';
 
 // Reusable header-map editor. Handles the two wire shapes Xray uses for
 // HTTP-style header maps:

+ 0 - 0
frontend/src/components/JsonEditor.css → frontend/src/components/form/JsonEditor.css


+ 0 - 0
frontend/src/components/JsonEditor.tsx → frontend/src/components/form/JsonEditor.tsx


+ 3 - 0
frontend/src/components/form/index.ts

@@ -0,0 +1,3 @@
+export { default as DateTimePicker } from './DateTimePicker';
+export { default as JsonEditor } from './JsonEditor';
+export { default as HeaderMapEditor } from './HeaderMapEditor';

+ 0 - 0
frontend/src/components/InfinityIcon.tsx → frontend/src/components/ui/InfinityIcon.tsx


+ 0 - 0
frontend/src/components/InputAddon.css → frontend/src/components/ui/InputAddon.css


+ 0 - 0
frontend/src/components/InputAddon.tsx → frontend/src/components/ui/InputAddon.tsx


+ 0 - 0
frontend/src/components/SettingListItem.css → frontend/src/components/ui/SettingListItem.css


+ 0 - 0
frontend/src/components/SettingListItem.tsx → frontend/src/components/ui/SettingListItem.tsx


+ 3 - 0
frontend/src/components/ui/index.ts

@@ -0,0 +1,3 @@
+export { default as InputAddon } from './InputAddon';
+export { default as InfinityIcon } from './InfinityIcon';
+export { default as SettingListItem } from './SettingListItem';

+ 0 - 0
frontend/src/components/LazyMount.tsx → frontend/src/components/utility/LazyMount.tsx


+ 1 - 0
frontend/src/components/utility/index.ts

@@ -0,0 +1 @@
+export { default as LazyMount } from './LazyMount';

+ 0 - 0
frontend/src/components/Sparkline.css → frontend/src/components/viz/Sparkline.css


+ 0 - 0
frontend/src/components/Sparkline.tsx → frontend/src/components/viz/Sparkline.tsx


+ 1 - 0
frontend/src/components/viz/index.ts

@@ -0,0 +1 @@
+export { default as Sparkline } from './Sparkline';

+ 0 - 0
frontend/src/components/AppSidebar.css → frontend/src/layouts/AppSidebar.css


+ 0 - 0
frontend/src/components/AppSidebar.tsx → frontend/src/layouts/AppSidebar.tsx


+ 0 - 0
frontend/src/components/FinalMaskForm.tsx → frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx


+ 1 - 0
frontend/src/lib/xray/forms/transport/index.ts

@@ -0,0 +1 @@
+export { default as FinalMaskForm } from './FinalMaskForm';

+ 1 - 1
frontend/src/lib/xray/inbound-link.ts

@@ -2,7 +2,7 @@ import { Base64, Wireguard } from '@/utils';
 
 import type { Inbound } from '@/schemas/api/inbound';
 import type { VlessClient } from '@/schemas/protocols/inbound/vless';
-import type { VmessSecurity } from '@/schemas/protocols/inbound/vmess';
+import type { VmessSecurity } from '@/schemas/protocols/shared/vmess';
 import type {
   WireguardInboundPeer,
   WireguardInboundSettings,

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

@@ -190,6 +190,12 @@ export class DBInbound {
         this._clientStatsMap = null;
     }
 
+    toJSON(): Record<string, unknown> {
+        const out: Record<string, unknown> = { ...(this as unknown as Record<string, unknown>) };
+        delete out._clientStatsMap;
+        return out;
+    }
+
     getClientStats(email: string): ClientStats | undefined {
         if (!this._clientStatsMap) {
             this._clientStatsMap = new Map();

+ 1 - 1
frontend/src/pages/api-docs/ApiDocsPage.tsx

@@ -4,7 +4,7 @@ import SwaggerUI from 'swagger-ui-react';
 import 'swagger-ui-react/swagger-ui.css';
 
 import { useTheme } from '@/hooks/useTheme';
-import AppSidebar from '@/components/AppSidebar';
+import AppSidebar from '@/layouts/AppSidebar';
 import './ApiDocsPage.css';
 
 const basePath = window.X_UI_BASE_PATH || '';

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

@@ -149,6 +149,13 @@ export const sections: readonly Section[] = [
           { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
         ],
       },
+      {
+        method: 'POST',
+        path: '/panel/api/inbounds/bulkDel',
+        summary: 'Delete many inbounds in one call. Processes the list sequentially; failures are reported per id and the rest still proceed. Restarts xray at most once.',
+        body: '{\n  "ids": [1, 2, 3]\n}',
+        response: '{\n  "success": true,\n  "obj": {\n    "deleted": 2,\n    "skipped": [\n      { "id": 3, "reason": "..." }\n    ]\n  }\n}',
+      },
       {
         method: 'POST',
         path: '/panel/api/inbounds/update/:id',

+ 1 - 1
frontend/src/pages/clients/ClientBulkAddModal.tsx

@@ -7,7 +7,7 @@ import type { Dayjs } from 'dayjs';
 
 import { RandomUtil, SizeFormatter } from '@/utils';
 import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
-import DateTimePicker from '@/components/DateTimePicker';
+import { DateTimePicker } from '@/components/form';
 import { useClients, type InboundOption } from '@/hooks/useClients';
 import { ClientBulkAddFormSchema, type ClientBulkAddFormValues } from '@/schemas/client';
 

+ 1 - 1
frontend/src/pages/clients/ClientFormModal.tsx

@@ -20,7 +20,7 @@ import dayjs from 'dayjs';
 import type { Dayjs } from 'dayjs';
 
 import { HttpUtil, RandomUtil } from '@/utils';
-import DateTimePicker from '@/components/DateTimePicker';
+import { DateTimePicker } from '@/components/form';
 import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
 import type { ClientRecord, InboundOption } from '@/hooks/useClients';
 import { ClientFormSchema, ClientCreateFormSchema } from '@/schemas/client';

+ 1 - 1
frontend/src/pages/clients/ClientInfoModal.tsx

@@ -7,7 +7,7 @@ import { ClipboardManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
 import { useDatepicker } from '@/hooks/useDatepicker';
 import type { ClientRecord, InboundOption } from '@/hooks/useClients';
 import { isPostQuantumLink } from '@/lib/xray/inbound-link';
-import QrPanel from '@/pages/inbounds/QrPanel';
+import { QrPanel } from '@/pages/inbounds/qr';
 import './ClientInfoModal.css';
 
 const PROTOCOL_COLORS: Record<string, string> = {

+ 1 - 1
frontend/src/pages/clients/ClientQrModal.tsx

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
 import { Collapse, Modal, Spin } from 'antd';
 import { HttpUtil } from '@/utils';
 import { isPostQuantumLink } from '@/lib/xray/inbound-link';
-import QrPanel from '@/pages/inbounds/QrPanel';
+import { QrPanel } from '@/pages/inbounds/qr';
 import type { ClientRecord } from '@/hooks/useClients';
 
 interface SubSettings {

+ 10 - 7
frontend/src/pages/clients/ClientsPage.tsx

@@ -51,10 +51,10 @@ import { useWebSocket } from '@/hooks/useWebSocket';
 import { useClients } from '@/hooks/useClients';
 import { useDatepicker } from '@/hooks/useDatepicker';
 import type { ClientRecord, InboundOption } from '@/hooks/useClients';
-import AppSidebar from '@/components/AppSidebar';
+import AppSidebar from '@/layouts/AppSidebar';
 import { IntlUtil, SizeFormatter } from '@/utils';
 import { setMessageInstance } from '@/utils/messageBus';
-import LazyMount from '@/components/LazyMount';
+import { LazyMount } from '@/components/utility';
 const ClientFormModal = lazy(() => import('./ClientFormModal'));
 const ClientInfoModal = lazy(() => import('./ClientInfoModal'));
 const ClientQrModal = lazy(() => import('./ClientQrModal'));
@@ -115,6 +115,7 @@ type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
 interface PersistedFilterState {
   searchKey: string;
   filters: ClientFilters;
+  sort: string;
 }
 
 const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
@@ -145,9 +146,10 @@ function readFilterState(): PersistedFilterState {
         inboundIds: Array.isArray(fromRaw.inboundIds) ? fromRaw.inboundIds : [],
         groups: Array.isArray(fromRaw.groups) ? fromRaw.groups : [],
       },
+      sort: typeof raw.sort === 'string' ? raw.sort : '',
     };
   } catch {
-    return { searchKey: '', filters: emptyFilters() };
+    return { searchKey: '', filters: emptyFilters(), sort: '' };
   }
 }
 
@@ -224,8 +226,9 @@ export default function ClientsPage() {
   const [filters, setFilters] = useState<ClientFilters>(initial.filters);
   const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
 
-  const [sortColumn, setSortColumn] = useState<string | null>(DEFAULT_SORT.column);
-  const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(DEFAULT_SORT.order);
+  const initialSort = SORT_OPTIONS.find((o) => o.value === initial.sort) ?? DEFAULT_SORT;
+  const [sortColumn, setSortColumn] = useState<string | null>(initialSort.column);
+  const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(initialSort.order);
   const [currentPage, setCurrentPage] = useState(1);
   const [tablePageSize, setTablePageSize] = useState(25);
   // debouncedSearch lags behind the input so we don't spam the server on every
@@ -233,8 +236,8 @@ export default function ClientsPage() {
   const [debouncedSearch, setDebouncedSearch] = useState(searchKey);
 
   useEffect(() => {
-    localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({ searchKey, filters }));
-  }, [searchKey, filters]);
+    localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({ searchKey, filters, sort: sortValueFor(sortColumn, sortOrder) }));
+  }, [searchKey, filters, sortColumn, sortOrder]);
 
   useEffect(() => {
     const handle = window.setTimeout(() => setDebouncedSearch(searchKey), 300);

+ 2 - 2
frontend/src/pages/groups/GroupsPage.tsx

@@ -42,8 +42,8 @@ import { usePageTitle } from '@/hooks/usePageTitle';
 import { useClients } from '@/hooks/useClients';
 import { HttpUtil } from '@/utils';
 import { setMessageInstance } from '@/utils/messageBus';
-import AppSidebar from '@/components/AppSidebar';
-import LazyMount from '@/components/LazyMount';
+import AppSidebar from '@/layouts/AppSidebar';
+import { LazyMount } from '@/components/utility';
 import { keys } from '@/api/queryKeys';
 import {
   ClientRecordSchema,

+ 0 - 3129
frontend/src/pages/inbounds/InboundFormModal.tsx

@@ -1,3129 +0,0 @@
-import { useEffect, useRef, useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import dayjs from 'dayjs';
-import {
-  Button,
-  Card,
-  Checkbox,
-  Divider,
-  Empty,
-  Form,
-  Input,
-  InputNumber,
-  Modal,
-  Radio,
-  Select,
-  Space,
-  Switch,
-  Tabs,
-  Tooltip,
-  Typography,
-  message,
-} from 'antd';
-import {
-  ArrowDownOutlined,
-  ArrowUpOutlined,
-  DeleteOutlined,
-  MinusOutlined,
-  PlusOutlined,
-  ReloadOutlined,
-} from '@ant-design/icons';
-
-import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils';
-import {
-  rawInboundToFormValues,
-  formValuesToWirePayload,
-  pruneEmpty,
-  normalizeSniffing,
-  normalizeClients,
-  dropLegacyOptionalEmpties,
-} from '@/lib/xray/inbound-form-adapter';
-import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
-import {
-  canEnableReality,
-  canEnableStream,
-  canEnableTls,
-  isSS2022,
-} from '@/lib/xray/protocol-capabilities';
-import { SSMethodSchema } from '@/schemas/protocols/inbound/shadowsocks';
-import { getRandomRealityTarget } from '@/models/reality-targets';
-import {
-  InboundFormBaseSchema,
-  InboundFormSchema,
-  type FallbackRow,
-  type InboundFormValues,
-} from '@/schemas/forms/inbound-form';
-import { antdRule } from '@/utils/zodForm';
-import {
-  ALPN_OPTION,
-  Address_Port_Strategy,
-  DOMAIN_STRATEGY_OPTION,
-  Protocols,
-  SNIFFING_OPTION,
-  TCP_CONGESTION_OPTION,
-  TLS_CIPHER_OPTION,
-  TLS_VERSION_OPTION,
-  USAGE_OPTION,
-  UTLS_FINGERPRINT,
-} from '@/schemas/primitives';
-import {
-  HappyEyeballsSchema,
-  SockoptStreamSettingsSchema,
-} from '@/schemas/protocols/stream/sockopt';
-import { HysteriaStreamSettingsSchema } from '@/schemas/protocols/stream/hysteria';
-import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls';
-import { RealityStreamSettingsSchema } from '@/schemas/protocols/security/reality';
-import { SniffingSchema } from '@/schemas/primitives/sniffing';
-import { TcpStreamSettingsSchema } from '@/schemas/protocols/stream/tcp';
-import { KcpStreamSettingsSchema } from '@/schemas/protocols/stream/kcp';
-import { WsStreamSettingsSchema } from '@/schemas/protocols/stream/ws';
-import { GrpcStreamSettingsSchema } from '@/schemas/protocols/stream/grpc';
-import { HttpUpgradeStreamSettingsSchema } from '@/schemas/protocols/stream/httpupgrade';
-import { XHttpStreamSettingsSchema } from '@/schemas/protocols/stream/xhttp';
-import DateTimePicker from '@/components/DateTimePicker';
-import FinalMaskForm from '@/components/FinalMaskForm';
-import HeaderMapEditor from '@/components/HeaderMapEditor';
-import HysteriaMasqueradeForm from '@/components/HysteriaMasqueradeForm';
-import InputAddon from '@/components/InputAddon';
-import JsonEditor from '@/components/JsonEditor';
-import './InboundFormModal.css';
-import type { FormInstance } from 'antd';
-import type { NamePath } from 'antd/es/form/interface';
-
-const { TextArea } = Input;
-import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
-import type { NodeRecord } from '@/api/queries/useNodesQuery';
-
-// Pattern A rewrite of InboundFormModal. Built as a sibling file so the
-// build stays green while the rewrite progresses section by section.
-// InboundsPage continues to render the old InboundFormModal.tsx until the
-// atomic swap at the end (Core Decision 7).
-
-const { Text } = Typography;
-
-// Sub-editor for one slice of the form (settings, streamSettings, sniffing).
-// Holds a local text buffer so the user can type freely; on every keystroke
-// we try to JSON.parse and forward the result to form state. Invalid JSON
-// is held in the buffer until the next valid moment — no panic on partial
-// input. The buffer seeds once on mount; the modal's destroyOnHidden makes
-// each open a fresh editor instance, so we don't need to re-sync on outer
-// form changes.
-function AdvancedSliceEditor({
-  form,
-  path,
-  wrapKey,
-  minHeight,
-  maxHeight,
-}: {
-  form: FormInstance<InboundFormValues>;
-  path: NamePath;
-  // When set, the editor wraps the inner value with `{ [wrapKey]: ... }` so
-  // the JSON the user sees matches the wire shape's slice envelope (e.g.
-  // `{ "settings": { ... } }`). Edits unwrap the outer key before writing
-  // back to the form. Mirrors the legacy modal's wrappedConfigValue.
-  wrapKey?: string;
-  minHeight?: string;
-  maxHeight?: string;
-}) {
-  const serialize = (value: unknown): string => {
-    const inner = value ?? {};
-    return JSON.stringify(wrapKey ? { [wrapKey]: inner } : inner, null, 2);
-  };
-
-  // preserve: true so useWatch returns the full subtree from the form
-  // store — without it, useWatch goes through getFieldsValue() which
-  // filters out unregistered fields. Slices like `settings` would lose
-  // their `clients` / `fallbacks` sub-trees because those aren't bound
-  // to any Form.Item.
-  const watched = Form.useWatch(path, { form, preserve: true });
-  const lastEmitRef = useRef<string>('');
-  const [text, setText] = useState(() => {
-    const initial = serialize(form.getFieldValue(path));
-    lastEmitRef.current = initial;
-    return initial;
-  });
-
-  useEffect(() => {
-    const formStr = serialize(watched);
-    if (formStr === lastEmitRef.current) return;
-    setText(formStr);
-    lastEmitRef.current = formStr;
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [watched, wrapKey]);
-
-  return (
-    <JsonEditor
-      value={text}
-      minHeight={minHeight}
-      maxHeight={maxHeight}
-      onChange={(next) => {
-        setText(next);
-        try {
-          const parsed = JSON.parse(next);
-          const toWrite = wrapKey && parsed && typeof parsed === 'object' && !Array.isArray(parsed)
-            ? (parsed as Record<string, unknown>)[wrapKey] ?? {}
-            : parsed;
-          form.setFieldValue(path, toWrite);
-          lastEmitRef.current = JSON.stringify(wrapKey ? { [wrapKey]: toWrite } : toWrite, null, 2);
-        } catch {
-          // invalid JSON; keep buffer, don't push to form
-        }
-      }}
-    />
-  );
-}
-
-// The "All" editor shows the full inbound JSON in one editor: top-level
-// connection fields plus the three nested sub-objects (settings,
-// streamSettings, sniffing). Edits round-trip back to the form's slices,
-// mirroring the legacy modal's setAdvancedAllValue behavior. Reactivity
-// works the same way as AdvancedSliceEditor: useWatch on the slices we
-// care about, lastEmitRef as the "we wrote this" guard.
-function AdvancedAllEditor({
-  form,
-  streamEnabled,
-}: {
-  form: FormInstance<InboundFormValues>;
-  streamEnabled: boolean;
-}) {
-  // preserve: true — default useWatch returns only registered fields, so
-  // sub-trees we never bound (settings.clients/fallbacks, sniffing
-  // defaults, etc.) wouldn't show up. preserve switches the read to
-  // getFieldsValue(true) which returns the full form store.
-  const wListen = Form.useWatch('listen', { form, preserve: true });
-  const wPort = Form.useWatch('port', { form, preserve: true });
-  const wProtocol = Form.useWatch('protocol', { form, preserve: true });
-  const wTag = Form.useWatch('tag', { form, preserve: true });
-  const wSettings = Form.useWatch('settings', { form, preserve: true });
-  const wSniffing = Form.useWatch('sniffing', { form, preserve: true });
-  const wStream = Form.useWatch('streamSettings', { form, preserve: true });
-
-  const serialize = () => {
-    // Apply the same prune/normalize as the wire payload so the JSON
-    // shown here is what the panel actually POSTs (no empty defaults,
-    // disabled sniffing as { enabled: false }, finalmask dropped when
-    // there are no masks).
-    const settingsView = (pruneEmpty(wSettings ?? {}) ?? {}) as Record<string, unknown>;
-    if (typeof wProtocol === 'string' && Array.isArray(settingsView.clients)) {
-      settingsView.clients = normalizeClients(wProtocol, settingsView.clients);
-    }
-    const streamView = streamEnabled
-      ? ((pruneEmpty(wStream ?? {}) ?? {}) as Record<string, unknown>)
-      : undefined;
-    dropLegacyOptionalEmpties(settingsView, streamView);
-    const out: Record<string, unknown> = {
-      listen: wListen ?? '',
-      port: wPort ?? 0,
-      protocol: wProtocol ?? '',
-      tag: wTag ?? '',
-      settings: settingsView,
-      sniffing: normalizeSniffing(wSniffing as Parameters<typeof normalizeSniffing>[0]),
-    };
-    if (streamView) out.streamSettings = streamView;
-    return JSON.stringify(out, null, 2);
-  };
-
-  const lastEmitRef = useRef<string>('');
-  const [text, setText] = useState(() => {
-    const initial = serialize();
-    lastEmitRef.current = initial;
-    return initial;
-  });
-
-  useEffect(() => {
-    const formStr = serialize();
-    if (formStr === lastEmitRef.current) return;
-    setText(formStr);
-    lastEmitRef.current = formStr;
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [wListen, wPort, wProtocol, wTag, wSettings, wSniffing, wStream, streamEnabled]);
-
-  return (
-    <JsonEditor
-      value={text}
-      minHeight="340px"
-      maxHeight="560px"
-      onChange={(next) => {
-        setText(next);
-        let parsed: Record<string, unknown>;
-        try {
-          parsed = JSON.parse(next) as Record<string, unknown>;
-        } catch {
-          return;
-        }
-        if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return;
-        if (typeof parsed.listen === 'string') form.setFieldValue('listen', parsed.listen);
-        if (typeof parsed.port === 'number' && Number.isFinite(parsed.port)) {
-          form.setFieldValue('port', parsed.port);
-        }
-        if (typeof parsed.protocol === 'string') form.setFieldValue('protocol', parsed.protocol);
-        if (typeof parsed.tag === 'string') form.setFieldValue('tag', parsed.tag);
-        if (parsed.settings && typeof parsed.settings === 'object') {
-          form.setFieldValue('settings', parsed.settings);
-        }
-        if (parsed.sniffing && typeof parsed.sniffing === 'object') {
-          form.setFieldValue('sniffing', parsed.sniffing);
-        }
-        if (streamEnabled && parsed.streamSettings && typeof parsed.streamSettings === 'object') {
-          form.setFieldValue('streamSettings', parsed.streamSettings);
-        }
-        lastEmitRef.current = next;
-      }}
-    />
-  );
-}
-
-const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p }));
-const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'] as const;
-const NODE_ELIGIBLE_PROTOCOLS = new Set<string>([
-  Protocols.VLESS,
-  Protocols.VMESS,
-  Protocols.TROJAN,
-  Protocols.SHADOWSOCKS,
-  Protocols.HYSTERIA,
-  Protocols.WIREGUARD,
-]);
-
-interface InboundFormModalProps {
-  open: boolean;
-  onClose: () => void;
-  onSaved: () => void;
-  mode: 'add' | 'edit';
-  dbInbound: DBInbound | null;
-  dbInbounds: DBInbound[];
-  availableNodes?: NodeRecord[];
-}
-
-function buildAddModeValues(): InboundFormValues {
-  const settings = createDefaultInboundSettings('vless') ?? undefined;
-  return rawInboundToFormValues({
-    protocol: 'vless',
-    settings,
-    streamSettings: {
-      network: 'tcp',
-      security: 'none',
-      tcpSettings: TcpStreamSettingsSchema.parse({ header: { type: 'none' } }),
-    },
-    sniffing: SniffingSchema.parse({}),
-    port: RandomUtil.randomInteger(10000, 60000),
-    listen: '',
-    tag: '',
-    enable: true,
-    trafficReset: 'never',
-  });
-}
-
-export default function InboundFormModal({
-  open,
-  onClose,
-  onSaved,
-  mode,
-  dbInbound,
-  dbInbounds,
-  availableNodes,
-}: InboundFormModalProps) {
-  const { t } = useTranslation();
-  const [messageApi, messageContextHolder] = message.useMessage();
-  const [form] = Form.useForm<InboundFormValues>();
-  const [saving, setSaving] = useState(false);
-  const fallbackKeyRef = useRef(0);
-  const [fallbacks, setFallbacks] = useState<FallbackRow[]>([]);
-
-  const selectableNodes = (availableNodes || []).filter((n) => n.enable);
-  const protocol = (Form.useWatch('protocol', form) ?? '') as string;
-  const isNodeEligible = NODE_ELIGIBLE_PROTOCOLS.has(protocol);
-  const sniffingEnabled = Form.useWatch(['sniffing', 'enabled'], form) ?? false;
-  const vlessEncryption = Form.useWatch(['settings', 'encryption'], form) ?? '';
-  const ssMethod = Form.useWatch(['settings', 'method'], form);
-  const isSSWith2022 = isSS2022({
-    protocol,
-    settings: typeof ssMethod === 'string' ? { method: ssMethod } : {},
-  });
-  const mixedUdpOn = Form.useWatch(['settings', 'udp'], form) ?? false;
-  const network = Form.useWatch(['streamSettings', 'network'], form) ?? '';
-  const security = Form.useWatch(['streamSettings', 'security'], form) ?? 'none';
-  const streamEnabled = canEnableStream({ protocol });
-  const isFallbackHost =
-    (protocol === Protocols.VLESS || protocol === Protocols.TROJAN)
-    && network === 'tcp'
-    && (security === 'tls' || security === 'reality');
-
-  const fallbackChildOptions = (dbInbounds || [])
-    .filter((ib) => ib.id !== dbInbound?.id)
-    .map((ib) => ({
-      label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`,
-      value: ib.id,
-    }));
-
-  const loadFallbacks = async (masterId: number | null) => {
-    if (!masterId) {
-      setFallbacks([]);
-      return;
-    }
-    const msg = await HttpUtil.get(`/panel/api/inbounds/${masterId}/fallbacks`);
-    if (!msg?.success || !Array.isArray(msg.obj)) {
-      setFallbacks([]);
-      return;
-    }
-    setFallbacks(
-      (msg.obj as {
-        childId: number;
-        name?: string;
-        alpn?: string;
-        path?: string;
-        dest?: string;
-        xver?: number;
-      }[])
-        .map((r) => ({
-          rowKey: `fb-${++fallbackKeyRef.current}`,
-          childId: r.childId,
-          name: r.name || '',
-          alpn: r.alpn || '',
-          path: r.path || '',
-          dest: r.dest || '',
-          xver: r.xver || 0,
-        })),
-    );
-  };
-
-  const saveFallbacks = async (masterId: number) => {
-    if (!masterId) return true;
-    const payload = {
-      fallbacks: fallbacks.filter((c) => c.childId).map((c, i) => ({
-        childId: c.childId,
-        name: c.name,
-        alpn: c.alpn,
-        path: c.path,
-        dest: c.dest,
-        xver: Number(c.xver) || 0,
-        sortOrder: i,
-      })),
-    };
-    const msg = await HttpUtil.post(
-      `/panel/api/inbounds/${masterId}/fallbacks`,
-      payload,
-      { headers: { 'Content-Type': 'application/json' } },
-    );
-    return !!msg?.success;
-  };
-
-  // Derive a fallback row's SNI / ALPN / Path / xver from a child
-  // inbound's streamSettings — what the legacy panel auto-filled when an
-  // operator wired a fallback target. SNI/ALPN come straight off the
-  // child's TLS block; path depends on the child's transport (ws/grpc
-  // /httpupgrade carry an explicit path; tcp/kcp/xhttp have no path of
-  // their own). xver stays 0 unless the child explicitly opts in via
-  // PROXY-protocol sockopt.
-  const deriveFallbackDefaults = (childId: number): Partial<FallbackRow> => {
-    const child = (dbInbounds || []).find((ib) => ib.id === childId);
-    if (!child) return {};
-    const stream = coerceInboundJsonField(child.streamSettings);
-    const tls = (stream.tlsSettings as Record<string, unknown> | undefined) ?? {};
-    const network = typeof stream.network === 'string' ? stream.network : '';
-    const sni = typeof tls.serverName === 'string' ? tls.serverName : '';
-    const alpnArr = Array.isArray(tls.alpn) ? tls.alpn : [];
-    const alpn = alpnArr.filter((v) => typeof v === 'string').join(',');
-    let path = '';
-    if (network === 'ws') {
-      const ws = (stream.wsSettings as Record<string, unknown> | undefined) ?? {};
-      if (typeof ws.path === 'string') path = ws.path;
-    } else if (network === 'grpc') {
-      const grpc = (stream.grpcSettings as Record<string, unknown> | undefined) ?? {};
-      if (typeof grpc.serviceName === 'string') path = grpc.serviceName;
-    } else if (network === 'httpupgrade') {
-      const hu = (stream.httpupgradeSettings as Record<string, unknown> | undefined) ?? {};
-      if (typeof hu.path === 'string') path = hu.path;
-    } else if (network === 'xhttp') {
-      const xh = (stream.xhttpSettings as Record<string, unknown> | undefined) ?? {};
-      if (typeof xh.path === 'string') path = xh.path;
-    }
-    return { name: sni, alpn, path, xver: 0 };
-  };
-
-  const addFallback = () => {
-    setFallbacks((prev) => [...prev, {
-      rowKey: `fb-${++fallbackKeyRef.current}`,
-      childId: null,
-      name: '',
-      alpn: '',
-      path: '',
-      dest: '',
-      xver: 0,
-    }]);
-  };
-
-  const updateFallback = (rowKey: string, patch: Partial<FallbackRow>) => {
-    setFallbacks((prev) => prev.map((r) => {
-      if (r.rowKey !== rowKey) return r;
-      // When the picker selects a new child inbound and the row hasn't
-      // been hand-edited yet (sni/alpn/path/dest all blank, xver = 0),
-      // pull the SNI/ALPN/Path defaults off that child. Operators who
-      // intentionally typed values keep them — we only fill the empties.
-      if (typeof patch.childId === 'number' && patch.childId !== r.childId) {
-        const isPristine = !r.name && !r.alpn && !r.path && !r.dest && r.xver === 0;
-        if (isPristine) return { ...r, ...patch, ...deriveFallbackDefaults(patch.childId) };
-      }
-      return { ...r, ...patch };
-    }));
-  };
-
-  const removeFallback = (idx: number) => {
-    setFallbacks((prev) => prev.filter((_, i) => i !== idx));
-  };
-
-  // Move a fallback row up/down by swapping adjacent indices. The order
-  // is persisted via the fallback row's sortOrder (rebuilt by index on
-  // save), so reordering survives reloads.
-  const moveFallback = (idx: number, direction: -1 | 1) => {
-    setFallbacks((prev) => {
-      const target = idx + direction;
-      if (target < 0 || target >= prev.length) return prev;
-      const next = prev.slice();
-      [next[idx], next[target]] = [next[target], next[idx]];
-      return next;
-    });
-  };
-
-  // One-shot: add a fresh fallback row for every eligible inbound (i.e.
-  // every option in fallbackChildOptions) that is not already wired up.
-  // Convenient for operators who want catch-all routing to every host
-  // they manage on the panel.
-  const addAllFallbacks = () => {
-    setFallbacks((prev) => {
-      const alreadyHave = new Set(prev.map((r) => r.childId));
-      const additions = fallbackChildOptions
-        .filter((opt) => !alreadyHave.has(opt.value))
-        .map<FallbackRow>((opt) => {
-          const derived = deriveFallbackDefaults(opt.value);
-          return {
-            rowKey: `fb-${++fallbackKeyRef.current}`,
-            childId: opt.value,
-            name: derived.name ?? '',
-            alpn: derived.alpn ?? '',
-            path: derived.path ?? '',
-            dest: '',
-            xver: derived.xver ?? 0,
-          };
-        });
-      if (additions.length === 0) return prev;
-      return [...prev, ...additions];
-    });
-  };
-
-  const genRealityKeypair = async () => {
-    setSaving(true);
-    try {
-      const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert');
-      if (msg?.success) {
-        const obj = msg.obj as { privateKey: string; publicKey: string };
-        form.setFieldValue(['streamSettings', 'realitySettings', 'privateKey'], obj.privateKey);
-        form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'publicKey'], obj.publicKey);
-      }
-    } finally {
-      setSaving(false);
-    }
-  };
-
-  const clearRealityKeypair = () => {
-    form.setFieldValue(['streamSettings', 'realitySettings', 'privateKey'], '');
-    form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'publicKey'], '');
-  };
-
-  const genMldsa65 = async () => {
-    setSaving(true);
-    try {
-      const msg = await HttpUtil.get('/panel/api/server/getNewmldsa65');
-      if (msg?.success) {
-        const obj = msg.obj as { seed: string; verify: string };
-        form.setFieldValue(['streamSettings', 'realitySettings', 'mldsa65Seed'], obj.seed);
-        form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'mldsa65Verify'], obj.verify);
-      }
-    } finally {
-      setSaving(false);
-    }
-  };
-
-  const clearMldsa65 = () => {
-    form.setFieldValue(['streamSettings', 'realitySettings', 'mldsa65Seed'], '');
-    form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'mldsa65Verify'], '');
-  };
-
-  const randomizeRealityTarget = () => {
-    const tgt = getRandomRealityTarget() as { target: string; sni: string };
-    form.setFieldValue(['streamSettings', 'realitySettings', 'target'], tgt.target);
-    form.setFieldValue(
-      ['streamSettings', 'realitySettings', 'serverNames'],
-      tgt.sni.split(',').map((s) => s.trim()).filter(Boolean),
-    );
-  };
-
-  const randomizeShortIds = () => {
-    form.setFieldValue(
-      ['streamSettings', 'realitySettings', 'shortIds'],
-      RandomUtil.randomShortIds().split(',').map((s) => s.trim()).filter(Boolean),
-    );
-  };
-
-  const getNewEchCert = async () => {
-    const sni = form.getFieldValue(['streamSettings', 'tlsSettings', 'serverName']);
-    setSaving(true);
-    try {
-      const msg = await HttpUtil.post('/panel/api/server/getNewEchCert', { sni });
-      if (msg?.success) {
-        const obj = msg.obj as { echServerKeys: string; echConfigList: string };
-        form.setFieldValue(['streamSettings', 'tlsSettings', 'echServerKeys'], obj.echServerKeys);
-        form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'echConfigList'], obj.echConfigList);
-      }
-    } finally {
-      setSaving(false);
-    }
-  };
-
-  const clearEchCert = () => {
-    form.setFieldValue(['streamSettings', 'tlsSettings', 'echServerKeys'], '');
-    form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'echConfigList'], '');
-  };
-
-  const generateRandomPinHash = () => {
-    const bytes = new Uint8Array(32);
-    crypto.getRandomValues(bytes);
-    let binary = '';
-    for (const b of bytes) binary += String.fromCharCode(b);
-    const hash = btoa(binary);
-    const current = (form.getFieldValue(
-      ['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'],
-    ) as string[] | undefined) ?? [];
-    form.setFieldValue(
-      ['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'],
-      [...current, hash],
-    );
-  };
-
-  const setCertFromPanel = async (certName: number) => {
-    setSaving(true);
-    try {
-      const msg = await HttpUtil.post('/panel/setting/all', undefined, { silent: true });
-      if (msg?.success) {
-        const obj = msg.obj as { webCertFile?: string; webKeyFile?: string };
-        if (!obj.webCertFile && !obj.webKeyFile) {
-          messageApi.warning(t('pages.inbounds.setDefaultCertEmpty'));
-          return;
-        }
-        form.setFieldValue(
-          ['streamSettings', 'tlsSettings', 'certificates', certName, 'certificateFile'],
-          obj.webCertFile ?? '',
-        );
-        form.setFieldValue(
-          ['streamSettings', 'tlsSettings', 'certificates', certName, 'keyFile'],
-          obj.webKeyFile ?? '',
-        );
-      }
-    } finally {
-      setSaving(false);
-    }
-  };
-
-  const clearCertFiles = (certName: number) => {
-    form.setFieldValue(
-      ['streamSettings', 'tlsSettings', 'certificates', certName, 'certificateFile'],
-      '',
-    );
-    form.setFieldValue(
-      ['streamSettings', 'tlsSettings', 'certificates', certName, 'keyFile'],
-      '',
-    );
-  };
-
-  const onSecurityChange = async (next: string) => {
-    const current = (form.getFieldValue('streamSettings') as Record<string, unknown>) ?? {};
-    const cleaned: Record<string, unknown> = { ...current, security: next };
-    delete cleaned.tlsSettings;
-    delete cleaned.realitySettings;
-    if (next === 'tls') {
-      const tls = TlsStreamSettingsSchema.parse({}) as Record<string, unknown>;
-      tls.certificates = [{
-        useFile: true,
-        certificateFile: '',
-        keyFile: '',
-        certificate: [],
-        key: [],
-        oneTimeLoading: false,
-        usage: 'encipherment',
-        buildChain: false,
-      }];
-      cleaned.tlsSettings = tls;
-    }
-    if (next === 'reality') {
-      const reality = RealityStreamSettingsSchema.parse({}) as Record<string, unknown>;
-      const tgt = getRandomRealityTarget() as { target: string; sni: string };
-      reality.target = tgt.target;
-      reality.serverNames = tgt.sni.split(',').map((s) => s.trim()).filter(Boolean);
-      reality.shortIds = RandomUtil.randomShortIds().split(',').map((s) => s.trim()).filter(Boolean);
-      cleaned.realitySettings = reality;
-    }
-    form.setFieldValue('streamSettings', cleaned);
-    if (next === 'reality') {
-      try {
-        const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert');
-        if (msg?.success) {
-          const obj = msg.obj as { privateKey: string; publicKey: string };
-          form.setFieldValue(['streamSettings', 'realitySettings', 'privateKey'], obj.privateKey);
-          form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'publicKey'], obj.publicKey);
-        }
-      } catch {
-        // best-effort: leave keypair fields empty if server call fails
-      }
-    }
-  };
-  const xhttpMode = Form.useWatch(['streamSettings', 'xhttpSettings', 'mode'], form);
-  const xhttpObfsMode = Form.useWatch(['streamSettings', 'xhttpSettings', 'xPaddingObfsMode'], form) ?? false;
-  const xhttpSessionPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'sessionPlacement'], form);
-  const xhttpSeqPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'seqPlacement'], form);
-  const xhttpUplinkPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'uplinkDataPlacement'], form);
-
-  const toggleExternalProxy = (on: boolean) => {
-    if (on) {
-      const port = (form.getFieldValue('port') as number) ?? 443;
-      form.setFieldValue(['streamSettings', 'externalProxy'], [{
-        forceTls: 'same',
-        dest: typeof window !== 'undefined' ? window.location.hostname : '',
-        port,
-        remark: '',
-        sni: '',
-        fingerprint: '',
-        alpn: [],
-      }]);
-    } else {
-      form.setFieldValue(['streamSettings', 'externalProxy'], []);
-    }
-  };
-
-  const toggleSockopt = (on: boolean) => {
-    if (on) {
-      form.setFieldValue(
-        ['streamSettings', 'sockopt'],
-        SockoptStreamSettingsSchema.parse({}),
-      );
-    } else {
-      form.setFieldValue(['streamSettings', 'sockopt'], undefined);
-    }
-  };
-  const wgSecretKey = Form.useWatch(['settings', 'secretKey'], form);
-  const wgPubKey = typeof wgSecretKey === 'string' && wgSecretKey.length > 0
-    ? Wireguard.generateKeypair(wgSecretKey).publicKey
-    : '';
-
-  const regenInboundWg = () => {
-    const kp = Wireguard.generateKeypair();
-    form.setFieldValue(['settings', 'secretKey'], kp.privateKey);
-  };
-
-  const regenWgPeerKeypair = (peerName: number) => {
-    const kp = Wireguard.generateKeypair();
-    form.setFieldValue(['settings', 'peers', peerName, 'privateKey'], kp.privateKey);
-    form.setFieldValue(['settings', 'peers', peerName, 'publicKey'], kp.publicKey);
-  };
-
-  const matchesVlessAuth = (
-    block: { id?: string; label?: string } | undefined | null,
-    authId: string,
-  ) => {
-    if (block?.id === authId) return true;
-    const label = (block?.label || '').toLowerCase().replace(/[-_\s]/g, '');
-    if (authId === 'mlkem768') return label.includes('mlkem768');
-    if (authId === 'x25519') return label.includes('x25519');
-    return false;
-  };
-
-  const getNewVlessEnc = async (authId: string) => {
-    if (!authId) return;
-    setSaving(true);
-    try {
-      const msg = await HttpUtil.get('/panel/api/server/getNewVlessEnc');
-      if (!msg?.success) return;
-      const obj = msg.obj as {
-        auths?: { decryption: string; encryption: string; label?: string; id?: string }[];
-      };
-      const block = (obj.auths || []).find((a) => matchesVlessAuth(a, authId));
-      if (!block) return;
-      form.setFieldValue(['settings', 'decryption'], block.decryption);
-      form.setFieldValue(['settings', 'encryption'], block.encryption);
-    } finally {
-      setSaving(false);
-    }
-  };
-
-  const clearVlessEnc = () => {
-    form.setFieldValue(['settings', 'decryption'], 'none');
-    form.setFieldValue(['settings', 'encryption'], 'none');
-  };
-
-  const selectedVlessAuth = (() => {
-    const enc = typeof vlessEncryption === 'string' ? vlessEncryption : '';
-    if (!enc || enc === 'none') return 'None';
-    const parts = enc.split('.').filter(Boolean);
-    const authKey = parts[parts.length - 1] || '';
-    if (!authKey) return t('pages.inbounds.vlessAuthCustom');
-    return authKey.length > 300
-      ? t('pages.inbounds.vlessAuthMlkem768')
-      : t('pages.inbounds.vlessAuthX25519');
-  })();
-
-  useEffect(() => {
-    if (!open) return;
-    const initial = mode === 'edit' && dbInbound
-      ? rawInboundToFormValues(dbInbound)
-      : buildAddModeValues();
-    form.resetFields();
-    form.setFieldsValue(initial);
-    if (
-      mode === 'edit'
-      && dbInbound
-      && (dbInbound.protocol === Protocols.VLESS || dbInbound.protocol === Protocols.TROJAN)
-    ) {
-      loadFallbacks(dbInbound.id);
-    } else {
-      setFallbacks([]);
-    }
-
-  }, [open, mode, dbInbound, form]);
-
-  // Why: protocol picker reset cascades through the form — clearing the
-  // settings DU branch and dropping a nodeId that no longer applies. The
-  // legacy modal did this imperatively in onProtocolChange; here we hook
-  // into AntD's onValuesChange and let setFieldValue keep the rest of
-  // the form state intact.
-  const onValuesChange = (changed: Partial<InboundFormValues>) => {
-    if (mode === 'edit') return;
-    if ('protocol' in changed && typeof changed.protocol === 'string') {
-      const next = changed.protocol;
-      const settings = createDefaultInboundSettings(next) ?? undefined;
-      form.setFieldValue('settings', settings);
-      if (!NODE_ELIGIBLE_PROTOCOLS.has(next)) {
-        form.setFieldValue('nodeId', null);
-      }
-      // Hysteria uses its dedicated transport — force the network branch
-      // so the stream tab renders the hysteria sub-form, not the leftover
-      // tcpSettings from the previous protocol. When leaving hysteria,
-      // snap back to TCP so the standard network selector has a valid
-      // starting point.
-      if (next === Protocols.HYSTERIA) {
-        const tls = TlsStreamSettingsSchema.parse({}) as Record<string, unknown>;
-        tls.certificates = [{
-          useFile: true,
-          certificateFile: '',
-          keyFile: '',
-          certificate: [],
-          key: [],
-          oneTimeLoading: false,
-          usage: 'encipherment',
-          buildChain: false,
-        }];
-        form.setFieldValue('streamSettings', {
-          network: 'hysteria',
-          security: 'tls',
-          hysteriaSettings: HysteriaStreamSettingsSchema.parse({}),
-          tlsSettings: tls,
-          // Hysteria2 needs an obfs wrapper on the FinalMask side; seed
-          // it with salamander + a random password so the listener boots
-          // with a usable default. Re-selecting Hysteria from another
-          // protocol re-runs this and refreshes the password — that's
-          // intentional, the form was already being reset.
-          finalmask: {
-            tcp: [],
-            udp: [{
-              type: 'salamander',
-              settings: { password: RandomUtil.randomLowerAndNum(16) },
-            }],
-          },
-        });
-      } else {
-        const current = form.getFieldValue('streamSettings') as { network?: string } | undefined;
-        if (current?.network === 'hysteria') {
-          form.setFieldValue('streamSettings', { network: 'tcp', security: 'none', tcpSettings: {} });
-        }
-      }
-    }
-  };
-
-  const submit = async () => {
-    try {
-      await form.validateFields();
-    } catch {
-      return;
-    }
-    // Why getFieldsValue(true) instead of the validateFields return value:
-    // rc-component/form's validateFields filters its output by REGISTERED
-    // name paths. settings.clients and settings.fallbacks have no Form.Item
-    // bound to them (clients are managed via the standalone Client modal,
-    // not inside this inbound modal) — so validateFields would drop them
-    // and the update wire payload would silently delete every client on
-    // every save. getFieldsValue(true) returns the entire form store and
-    // keeps those sub-trees intact.
-    const values = form.getFieldsValue(true) as InboundFormValues;
-    const parsed = InboundFormSchema.safeParse(values);
-    if (!parsed.success) {
-      const issue = parsed.error.issues[0];
-      const path = Array.isArray(issue?.path) && issue.path.length > 0
-        ? issue.path.join('.')
-        : '';
-      const baseMsg = issue?.message ?? 'somethingWentWrong';
-      const display = path ? `${path}: ${baseMsg}` : baseMsg;
-      messageApi.error(t(baseMsg, { defaultValue: display }));
-      console.error('[InboundFormModal] schema validation failed', {
-        path: issue?.path,
-        message: issue?.message,
-        values,
-      });
-      return;
-    }
-    setSaving(true);
-    try {
-      const payload = formValuesToWirePayload(parsed.data);
-      const url = mode === 'edit' && dbInbound
-        ? `/panel/api/inbounds/update/${dbInbound.id}`
-        : '/panel/api/inbounds/add';
-      const msg = await HttpUtil.post(url, payload);
-      if (msg?.success) {
-        if (isFallbackHost) {
-          const obj = msg.obj as { id?: number; Id?: number } | null;
-          const masterId = mode === 'edit'
-            ? dbInbound!.id
-            : (obj?.id ?? obj?.Id ?? 0);
-          if (masterId) await saveFallbacks(masterId);
-        }
-        onSaved();
-        onClose();
-      }
-    } finally {
-      setSaving(false);
-    }
-  };
-
-  const title = mode === 'edit'
-    ? t('pages.inbounds.modifyInbound')
-    : t('pages.inbounds.addInbound');
-
-  const okText = mode === 'edit'
-    ? t('pages.clients.submitEdit')
-    : t('create');
-
-  const basicTab = (
-    <>
-      <Form.Item name="tag" hidden noStyle><Input /></Form.Item>
-      <Form.Item name="up" hidden noStyle><InputNumber /></Form.Item>
-      <Form.Item name="down" hidden noStyle><InputNumber /></Form.Item>
-      <Form.Item name="total" hidden noStyle><InputNumber /></Form.Item>
-      <Form.Item name="expiryTime" hidden noStyle><InputNumber /></Form.Item>
-      <Form.Item name="lastTrafficResetTime" hidden noStyle><InputNumber /></Form.Item>
-      <Form.Item name="clientStats" hidden noStyle><Input /></Form.Item>
-
-      <Form.Item name="enable" label={t('enable')} valuePropName="checked">
-        <Switch />
-      </Form.Item>
-
-      <Form.Item name="remark" label={t('pages.inbounds.remark')}>
-        <Input />
-      </Form.Item>
-
-      {selectableNodes.length > 0 && isNodeEligible && (
-        <Form.Item name="nodeId" label={t('pages.inbounds.deployTo')}>
-          <Select
-            disabled={mode === 'edit'}
-            placeholder={t('pages.inbounds.localPanel')}
-            allowClear
-            options={selectableNodes.map((n) => ({
-              value: n.id,
-              label: `${n.name}${n.status === 'offline' ? ' (offline)' : ''}`,
-              disabled: n.status === 'offline',
-            }))}
-          />
-        </Form.Item>
-      )}
-
-      <Form.Item name="protocol" label={t('pages.inbounds.protocol')}>
-        <Select disabled={mode === 'edit'} options={PROTOCOL_OPTIONS} />
-      </Form.Item>
-
-      <Form.Item name="listen" label={t('pages.inbounds.address')}>
-        <Input placeholder={t('pages.inbounds.monitorDesc')} />
-      </Form.Item>
-
-      <Form.Item
-        name="port"
-        label={t('pages.inbounds.port')}
-        rules={[antdRule(InboundFormBaseSchema.shape.port, t)]}
-      >
-        <InputNumber min={1} max={65535} />
-      </Form.Item>
-
-      <Form.Item
-        label={
-          <Tooltip title={t('pages.inbounds.meansNoLimit')}>
-            {t('pages.inbounds.totalFlow')}
-          </Tooltip>
-        }
-      >
-        <Form.Item
-          noStyle
-          shouldUpdate={(prev, curr) => prev.total !== curr.total}
-        >
-          {({ getFieldValue, setFieldValue }) => {
-            const totalBytes = (getFieldValue('total') as number) ?? 0;
-            const totalGB = totalBytes
-              ? Math.round((totalBytes / SizeFormatter.ONE_GB) * 100) / 100
-              : 0;
-            return (
-              <InputNumber
-                value={totalGB}
-                min={0}
-                step={1}
-                onChange={(v) => {
-                  const bytes = NumberFormatter.toFixed(
-                    (Number(v) || 0) * SizeFormatter.ONE_GB,
-                    0,
-                  );
-                  setFieldValue('total', bytes);
-                }}
-              />
-            );
-          }}
-        </Form.Item>
-      </Form.Item>
-
-      <Form.Item name="trafficReset" label={t('pages.inbounds.periodicTrafficResetTitle')}>
-        <Select
-          options={TRAFFIC_RESETS.map((r) => ({
-            value: r,
-            label: t(`pages.inbounds.periodicTrafficReset.${r}`),
-          }))}
-        />
-      </Form.Item>
-
-      <Form.Item
-        label={
-          <Tooltip title={t('pages.inbounds.leaveBlankToNeverExpire')}>
-            {t('pages.inbounds.expireDate')}
-          </Tooltip>
-        }
-      >
-        <Form.Item
-          noStyle
-          shouldUpdate={(prev, curr) => prev.expiryTime !== curr.expiryTime}
-        >
-          {({ getFieldValue, setFieldValue }) => {
-            const expiry = (getFieldValue('expiryTime') as number) ?? 0;
-            return (
-              <DateTimePicker
-                value={expiry > 0 ? dayjs(expiry) : null}
-                onChange={(d) => setFieldValue('expiryTime', d ? d.valueOf() : 0)}
-              />
-            );
-          }}
-        </Form.Item>
-      </Form.Item>
-    </>
-  );
-
-  const fallbacksCard = (
-    <Card size="small" className="mt-12" title={t('pages.inbounds.fallbacks.title') || 'Fallbacks'}>
-      {fallbacks.length === 0 && (
-        <Empty
-          description={t('pages.inbounds.fallbacks.empty') || 'No fallbacks yet'}
-          styles={{ image: { height: 40 } }}
-          style={{ margin: '8px 0 12px' }}
-        />
-      )}
-      {fallbacks.map((record, idx) => (
-        <div
-          key={record.rowKey}
-          style={{ border: '1px solid var(--app-border-tertiary)', borderRadius: 6, padding: '10px 12px', marginBottom: 8 }}
-        >
-          <Space.Compact block style={{ marginBottom: 6 }}>
-            <Select
-              value={record.childId}
-              options={fallbackChildOptions}
-              placeholder={t('pages.inbounds.fallbacks.pickInbound') || 'Pick an inbound'}
-              showSearch={{
-                filterOption: (input, option) =>
-                  ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
-              }}
-              style={{ width: '100%' }}
-              onChange={(v) => updateFallback(record.rowKey, { childId: v })}
-            />
-            <Button
-              disabled={idx === 0}
-              onClick={() => moveFallback(idx, -1)}
-              title={t('pages.inbounds.form.moveUp')}
-            >
-              <ArrowUpOutlined />
-            </Button>
-            <Button
-              disabled={idx === fallbacks.length - 1}
-              onClick={() => moveFallback(idx, 1)}
-              title={t('pages.inbounds.form.moveDown')}
-            >
-              <ArrowDownOutlined />
-            </Button>
-            <Button danger onClick={() => removeFallback(idx)}>
-              <DeleteOutlined />
-            </Button>
-          </Space.Compact>
-          <Space.Compact block>
-            <InputAddon>SNI</InputAddon>
-            <Input
-              placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
-              value={record.name}
-              onChange={(e) => updateFallback(record.rowKey, { name: e.target.value })}
-            />
-            <InputAddon>ALPN</InputAddon>
-            <Input
-              placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
-              value={record.alpn}
-              onChange={(e) => updateFallback(record.rowKey, { alpn: e.target.value })}
-            />
-            <InputAddon>Path</InputAddon>
-            <Input
-              placeholder="/"
-              value={record.path}
-              onChange={(e) => updateFallback(record.rowKey, { path: e.target.value })}
-            />
-            <InputAddon>Dest</InputAddon>
-            <Input
-              placeholder={t('pages.inbounds.fallbacks.destPlaceholder') || 'auto'}
-              value={record.dest}
-              onChange={(e) => updateFallback(record.rowKey, { dest: e.target.value })}
-            />
-            <InputAddon>xver</InputAddon>
-            <InputNumber
-              min={0}
-              max={2}
-              value={record.xver}
-              onChange={(v) => updateFallback(record.rowKey, { xver: Number(v) || 0 })}
-            />
-          </Space.Compact>
-        </div>
-      ))}
-      <Space>
-        <Button size="small" onClick={addFallback}>
-          <PlusOutlined /> {t('pages.inbounds.fallbacks.add') || 'Add fallback'}
-        </Button>
-        <Button
-          size="small"
-          onClick={addAllFallbacks}
-          disabled={fallbackChildOptions.length === 0
-            || fallbacks.length >= fallbackChildOptions.length}
-          title={t('pages.inbounds.form.addAllFallbackTooltip')}
-        >
-          {t('pages.inbounds.form.addAll')}
-        </Button>
-      </Space>
-    </Card>
-  );
-
-  const protocolTab = (
-    <>
-      {protocol === Protocols.WIREGUARD && (
-        <>
-          <Form.Item label={t('pages.xray.wireguard.secretKey')}>
-            <Space.Compact block>
-              <Form.Item name={['settings', 'secretKey']} noStyle>
-                <Input style={{ width: 'calc(100% - 32px)' }} />
-              </Form.Item>
-              <Button icon={<ReloadOutlined />} onClick={regenInboundWg} />
-            </Space.Compact>
-          </Form.Item>
-          <Form.Item label={t('pages.xray.wireguard.publicKey')}>
-            <Input value={wgPubKey} disabled />
-          </Form.Item>
-          <Form.Item name={['settings', 'mtu']} label="MTU">
-            <InputNumber />
-          </Form.Item>
-          <Form.Item
-            name={['settings', 'noKernelTun']}
-            label={t('pages.inbounds.info.noKernelTun')}
-            valuePropName="checked"
-          >
-            <Switch />
-          </Form.Item>
-          <Form.List name={['settings', 'peers']}>
-            {(fields, { add, remove }) => (
-              <>
-                <Form.Item label={t('pages.inbounds.form.peers')}>
-                  <Button
-                    size="small"
-                    onClick={() => {
-                      const kp = Wireguard.generateKeypair();
-                      add({
-                        privateKey: kp.privateKey,
-                        publicKey: kp.publicKey,
-                        allowedIPs: ['10.0.0.2/32'],
-                        keepAlive: 0,
-                      });
-                    }}
-                  >
-                    <PlusOutlined /> {t('pages.inbounds.form.addPeer')}
-                  </Button>
-                </Form.Item>
-                {fields.map((field, idx) => (
-                  <div key={field.key} className="wg-peer">
-                    <Divider titlePlacement="center">
-                      <Space>
-                        <span>{t('pages.inbounds.info.peerNumber', { n: idx + 1 })}</span>
-                        {fields.length > 1 && (
-                          <Button
-                            size="small"
-                            danger
-                            icon={<MinusOutlined />}
-                            onClick={() => remove(field.name)}
-                          />
-                        )}
-                      </Space>
-                    </Divider>
-                    <Form.Item label={t('pages.xray.wireguard.secretKey')}>
-                      <Space.Compact block>
-                        <Form.Item name={[field.name, 'privateKey']} noStyle>
-                          <Input style={{ width: 'calc(100% - 32px)' }} />
-                        </Form.Item>
-                        <Button
-                          icon={<ReloadOutlined />}
-                          onClick={() => regenWgPeerKeypair(field.name)}
-                        />
-                      </Space.Compact>
-                    </Form.Item>
-                    <Form.Item name={[field.name, 'publicKey']} label={t('pages.xray.wireguard.publicKey')}>
-                      <Input />
-                    </Form.Item>
-                    <Form.Item name={[field.name, 'preSharedKey']} label="PSK">
-                      <Input />
-                    </Form.Item>
-                    <Form.List name={[field.name, 'allowedIPs']}>
-                      {(ipFields, { add: addIp, remove: removeIp }) => (
-                        <Form.Item label={t('pages.xray.wireguard.allowedIPs')}>
-                          <Button size="small" onClick={() => addIp('')}>
-                            <PlusOutlined />
-                          </Button>
-                          {ipFields.map((ipField) => (
-                            <Space.Compact key={ipField.key} block className="mt-4">
-                              <Form.Item name={ipField.name} noStyle>
-                                <Input />
-                              </Form.Item>
-                              {ipFields.length > 1 && (
-                                <Button size="small" onClick={() => removeIp(ipField.name)}>
-                                  <MinusOutlined />
-                                </Button>
-                              )}
-                            </Space.Compact>
-                          ))}
-                        </Form.Item>
-                      )}
-                    </Form.List>
-                    <Form.Item name={[field.name, 'keepAlive']} label={t('pages.inbounds.form.keepAlive')}>
-                      <InputNumber min={0} />
-                    </Form.Item>
-                  </div>
-                ))}
-              </>
-            )}
-          </Form.List>
-        </>
-      )}
-
-      {protocol === Protocols.TUN && (
-        <>
-          <Form.Item name={['settings', 'name']} label={t('pages.inbounds.info.interfaceName')}>
-            <Input placeholder="xray0" />
-          </Form.Item>
-          <Form.Item name={['settings', 'mtu']} label="MTU">
-            <InputNumber min={0} />
-          </Form.Item>
-          <Form.List name={['settings', 'gateway']}>
-            {(fields, { add, remove }) => (
-              <Form.Item label={t('pages.inbounds.info.gateway')}>
-                <Button size="small" onClick={() => add('')}>
-                  <PlusOutlined />
-                </Button>
-                {fields.map((field, j) => (
-                  <Space.Compact key={field.key} block className="mt-4">
-                    <Form.Item name={field.name} noStyle>
-                      <Input placeholder={j === 0 ? '10.0.0.1/16' : 'fc00::1/64'} />
-                    </Form.Item>
-                    <Button size="small" onClick={() => remove(field.name)}>
-                      <MinusOutlined />
-                    </Button>
-                  </Space.Compact>
-                ))}
-              </Form.Item>
-            )}
-          </Form.List>
-          <Form.List name={['settings', 'dns']}>
-            {(fields, { add, remove }) => (
-              <Form.Item label="DNS">
-                <Button size="small" onClick={() => add('')}>
-                  <PlusOutlined />
-                </Button>
-                {fields.map((field, j) => (
-                  <Space.Compact key={field.key} block className="mt-4">
-                    <Form.Item name={field.name} noStyle>
-                      <Input placeholder={j === 0 ? '1.1.1.1' : '8.8.8.8'} />
-                    </Form.Item>
-                    <Button size="small" onClick={() => remove(field.name)}>
-                      <MinusOutlined />
-                    </Button>
-                  </Space.Compact>
-                ))}
-              </Form.Item>
-            )}
-          </Form.List>
-          <Form.Item name={['settings', 'userLevel']} label={t('pages.xray.tun.userLevel')}>
-            <InputNumber min={0} />
-          </Form.Item>
-          <Form.List name={['settings', 'autoSystemRoutingTable']}>
-            {(fields, { add, remove }) => (
-              <Form.Item
-                label={
-                  <Tooltip title={t('pages.inbounds.form.autoSystemRoutesTooltip')}>
-                    {t('pages.inbounds.info.autoSystemRoutes')}
-                  </Tooltip>
-                }
-              >
-                <Button size="small" onClick={() => add('')}>
-                  <PlusOutlined />
-                </Button>
-                {fields.map((field, j) => (
-                  <Space.Compact key={field.key} block className="mt-4">
-                    <Form.Item name={field.name} noStyle>
-                      <Input placeholder={j === 0 ? '0.0.0.0/0' : '::/0'} />
-                    </Form.Item>
-                    <Button size="small" onClick={() => remove(field.name)}>
-                      <MinusOutlined />
-                    </Button>
-                  </Space.Compact>
-                ))}
-              </Form.Item>
-            )}
-          </Form.List>
-          <Form.Item
-            name={['settings', 'autoOutboundsInterface']}
-            label={
-              <Tooltip title={t('pages.inbounds.form.autoOutboundsInterfaceTooltip')}>
-                {t('pages.inbounds.form.autoOutboundsInterface')}
-              </Tooltip>
-            }
-          >
-            <Input placeholder="auto" />
-          </Form.Item>
-        </>
-      )}
-
-      {protocol === Protocols.TUNNEL && (
-        <>
-          <Form.Item name={['settings', 'rewriteAddress']} label={t('pages.inbounds.form.rewriteAddress')}>
-            <Input />
-          </Form.Item>
-          <Form.Item name={['settings', 'rewritePort']} label={t('pages.inbounds.form.rewritePort')}>
-            <InputNumber min={0} max={65535} />
-          </Form.Item>
-          <Form.Item name={['settings', 'allowedNetwork']} label={t('pages.inbounds.form.allowedNetwork')}>
-            <Select
-              options={[
-                { value: 'tcp,udp', label: 'TCP, UDP' },
-                { value: 'tcp', label: 'TCP' },
-                { value: 'udp', label: 'UDP' },
-              ]}
-            />
-          </Form.Item>
-          <Form.Item label={t('pages.inbounds.portMap')} name={['settings', 'portMap']}>
-            <HeaderMapEditor mode="v1" />
-          </Form.Item>
-          <Form.Item
-            name={['settings', 'followRedirect']}
-            label={t('pages.inbounds.form.followRedirect')}
-            valuePropName="checked"
-          >
-            <Switch />
-          </Form.Item>
-        </>
-      )}
-
-      {(protocol === Protocols.HTTP || protocol === Protocols.MIXED) && (
-        <>
-          <Form.List name={['settings', 'accounts']}>
-            {(fields, { add, remove }) => (
-              <>
-                <Form.Item label={t('pages.inbounds.form.accounts')}>
-                  <Button
-                    size="small"
-                    onClick={() => add({
-                      user: RandomUtil.randomLowerAndNum(8),
-                      pass: RandomUtil.randomLowerAndNum(12),
-                    })}
-                  >
-                    <PlusOutlined /> {t('add')}
-                  </Button>
-                </Form.Item>
-                {fields.length > 0 && (
-                  <Form.Item wrapperCol={{ span: 24 }}>
-                    {fields.map((field, idx) => (
-                      <Space.Compact key={field.key} className="mb-8" block>
-                        <InputAddon>{String(idx + 1)}</InputAddon>
-                        <Form.Item name={[field.name, 'user']} noStyle>
-                          <Input placeholder={t('username')} />
-                        </Form.Item>
-                        <Form.Item name={[field.name, 'pass']} noStyle>
-                          <Input placeholder={t('password')} />
-                        </Form.Item>
-                        <Button onClick={() => remove(field.name)}>
-                          <MinusOutlined />
-                        </Button>
-                      </Space.Compact>
-                    ))}
-                  </Form.Item>
-                )}
-              </>
-            )}
-          </Form.List>
-          {protocol === Protocols.HTTP && (
-            <Form.Item
-              name={['settings', 'allowTransparent']}
-              label={t('pages.inbounds.form.allowTransparent')}
-              valuePropName="checked"
-            >
-              <Switch />
-            </Form.Item>
-          )}
-          {protocol === Protocols.MIXED && (
-            <>
-              <Form.Item name={['settings', 'auth']} label={t('pages.inbounds.info.auth')}>
-                <Select
-                  options={[
-                    { value: 'noauth', label: 'noauth' },
-                    { value: 'password', label: 'password' },
-                  ]}
-                />
-              </Form.Item>
-              <Form.Item
-                name={['settings', 'udp']}
-                label="UDP"
-                valuePropName="checked"
-              >
-                <Switch />
-              </Form.Item>
-              {mixedUdpOn && (
-                <Form.Item name={['settings', 'ip']} label="UDP IP">
-                  <Input />
-                </Form.Item>
-              )}
-            </>
-          )}
-        </>
-      )}
-
-      {protocol === Protocols.SHADOWSOCKS && (
-        <>
-          <Form.Item name={['settings', 'method']} label={t('pages.inbounds.form.encryptionMethod')}>
-            <Select
-              onChange={(v) => {
-                form.setFieldValue(
-                  ['settings', 'password'],
-                  RandomUtil.randomShadowsocksPassword(v as string),
-                );
-              }}
-              options={SSMethodSchema.options.map((m) => ({ value: m, label: m }))}
-            />
-          </Form.Item>
-          {isSSWith2022 && (
-            <Form.Item label={t('password')}>
-              <Space.Compact block>
-                <Form.Item name={['settings', 'password']} noStyle>
-                  <Input style={{ width: 'calc(100% - 32px)' }} />
-                </Form.Item>
-                <Button
-                  icon={<ReloadOutlined />}
-                  onClick={() => {
-                    const method = form.getFieldValue(['settings', 'method']);
-                    form.setFieldValue(
-                      ['settings', 'password'],
-                      RandomUtil.randomShadowsocksPassword(method as string),
-                    );
-                  }}
-                />
-              </Space.Compact>
-            </Form.Item>
-          )}
-          <Form.Item name={['settings', 'network']} label={t('pages.inbounds.network')}>
-            <Select
-              style={{ width: 120 }}
-              options={[
-                { value: 'tcp,udp', label: 'TCP, UDP' },
-                { value: 'tcp', label: 'TCP' },
-                { value: 'udp', label: 'UDP' },
-              ]}
-            />
-          </Form.Item>
-          <Form.Item
-            name={['settings', 'ivCheck']}
-            label="ivCheck"
-            valuePropName="checked"
-          >
-            <Switch />
-          </Form.Item>
-        </>
-      )}
-
-      {protocol === Protocols.VLESS && (
-        <>
-          <Form.Item name={['settings', 'decryption']} label={t('pages.inbounds.decryption')}>
-            <Input />
-          </Form.Item>
-          <Form.Item name={['settings', 'encryption']} label={t('pages.inbounds.encryption')}>
-            <Input />
-          </Form.Item>
-          <Form.Item label=" ">
-            <Space size={8} wrap>
-              <Button type="primary" loading={saving} onClick={() => getNewVlessEnc('x25519')}>
-                {t('pages.inbounds.vlessAuthX25519')}
-              </Button>
-              <Button type="primary" loading={saving} onClick={() => getNewVlessEnc('mlkem768')}>
-                {t('pages.inbounds.vlessAuthMlkem768')}
-              </Button>
-              <Button danger onClick={clearVlessEnc}>{t('clear')}</Button>
-            </Space>
-            <Text type="secondary" className="vless-auth-state">
-              {t('pages.inbounds.vlessAuthSelected', { auth: selectedVlessAuth })}
-            </Text>
-          </Form.Item>
-          {network === 'tcp' && (security === 'tls' || security === 'reality') && (
-            <Form.Item
-              label={t('pages.inbounds.form.visionTestseed')}
-              extra="Applies only to clients using the xtls-rprx-vision flow; ignored otherwise."
-            >
-              <Space.Compact block>
-                {[900, 500, 900, 256].map((def, i) => (
-                  <Form.Item key={i} name={['settings', 'testseed', i]} noStyle initialValue={def}>
-                    <InputNumber min={1} style={{ width: '25%' }} />
-                  </Form.Item>
-                ))}
-              </Space.Compact>
-            </Form.Item>
-          )}
-        </>
-      )}
-
-      {isFallbackHost && fallbacksCard}
-    </>
-  );
-
-  // Switching `network` swaps which per-network key (tcpSettings,
-  // wsSettings, grpcSettings, ...) appears on the wire. Clear the old
-  // network's blob and seed the new one with the schema defaults so the
-  // Form.Items inside it have valid initial values (KCP needs MTU=1350
-  // etc., not empty strings).
-  // Seed each network's settings blob with its Zod schema defaults so
-  // every Form.Item inside the network sub-form has a defined starting
-  // value. XHTTP in particular has ~20 fields (sessionPlacement,
-  // seqPlacement, xPaddingMethod, uplinkHTTPMethod, ...) whose value
-  // is the literal "" sentinel meaning "let xray-core pick its
-  // default". Without seeding "", the Form.Item reads `undefined` and
-  // the Select shows blank instead of the "Default (path)" option.
-  const newStreamSlice = (n: string): Record<string, unknown> => {
-    switch (n) {
-      case 'tcp': return TcpStreamSettingsSchema.parse({ header: { type: 'none' } });
-      case 'kcp': return KcpStreamSettingsSchema.parse({});
-      case 'ws': return WsStreamSettingsSchema.parse({});
-      case 'grpc': return GrpcStreamSettingsSchema.parse({});
-      case 'httpupgrade': return HttpUpgradeStreamSettingsSchema.parse({});
-      case 'xhttp': return XHttpStreamSettingsSchema.parse({});
-      default: return {};
-    }
-  };
-  const onNetworkChange = (next: string) => {
-    const ALL = ['tcpSettings', 'kcpSettings', 'wsSettings', 'grpcSettings', 'httpupgradeSettings', 'xhttpSettings'];
-    const current = (form.getFieldValue('streamSettings') as Record<string, unknown>) ?? {};
-    const cleaned: Record<string, unknown> = { ...current, network: next };
-    for (const k of ALL) {
-      if (k !== `${next}Settings`) delete cleaned[k];
-    }
-    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
-    // instead of unobfuscated mKCP traffic. The user can still edit or
-    // clear the mask via the FinalMask section.
-    if (next === 'kcp') {
-      const fm = (cleaned.finalmask as Record<string, unknown> | undefined) ?? {};
-      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';
-      });
-      if (!hasMkcp) {
-        cleaned.finalmask = {
-          ...fm,
-          udp: [...udp, { type: 'mkcp-original', settings: {} }],
-        };
-      }
-    }
-    form.setFieldValue('streamSettings', cleaned);
-  };
-
-  const streamTab = (
-    <>
-      {protocol !== Protocols.HYSTERIA && (
-        <Form.Item label={t('transmission')} name={['streamSettings', 'network']}>
-          <Select
-            style={{ width: '75%' }}
-            onChange={onNetworkChange}
-            options={[
-              { value: 'tcp', label: 'RAW' },
-              { value: 'kcp', label: 'mKCP' },
-              { value: 'ws', label: 'WebSocket' },
-              { value: 'grpc', label: 'gRPC' },
-              { value: 'httpupgrade', label: 'HTTPUpgrade' },
-              { value: 'xhttp', label: 'XHTTP' },
-            ]}
-          />
-        </Form.Item>
-      )}
-
-      {/* Inbound Hysteria stream sub-form. The transport for hysteria
-          isn't user-selectable (always 'hysteria'), so the network
-          dropdown is hidden above. Fields here mirror the legacy
-          HysteriaStreamSettings inbound class: version is locked to 2,
-          auth + udpIdleTimeout are required, masquerade is an optional
-          sub-object that lets xray-core disguise the listener as an
-          HTTP server when probed. */}
-      {protocol === Protocols.HYSTERIA && (
-        <>
-          <Form.Item
-            label={t('pages.inbounds.form.version')}
-            name={['streamSettings', 'hysteriaSettings', 'version']}
-          >
-            <InputNumber min={2} max={2} disabled />
-          </Form.Item>
-          <Form.Item
-            label={t('pages.inbounds.form.udpIdleTimeout')}
-            name={['streamSettings', 'hysteriaSettings', 'udpIdleTimeout']}
-          >
-            <InputNumber min={1} style={{ width: '100%' }} />
-          </Form.Item>
-
-          <HysteriaMasqueradeForm form={form} />
-        </>
-      )}
-
-      {network === 'tcp' && (
-        <>
-          <Form.Item
-            name={['streamSettings', 'tcpSettings', 'acceptProxyProtocol']}
-            label={t('pages.inbounds.form.proxyProtocol')}
-            valuePropName="checked"
-          >
-            <Switch />
-          </Form.Item>
-          <Form.Item label={`HTTP ${t('camouflage')}`}>
-            <Form.Item
-              noStyle
-              shouldUpdate={(prev, curr) =>
-                prev.streamSettings?.tcpSettings?.header?.type
-                !== curr.streamSettings?.tcpSettings?.header?.type
-              }
-            >
-              {({ getFieldValue, setFieldValue }) => {
-                const headerType = getFieldValue(
-                  ['streamSettings', 'tcpSettings', 'header', 'type'],
-                ) as string | undefined;
-                return (
-                  <Switch
-                    checked={headerType === 'http'}
-                    onChange={(v) => {
-                      setFieldValue(
-                        ['streamSettings', 'tcpSettings', 'header'],
-                        v
-                          ? {
-                            type: 'http',
-                            request: {
-                              version: '1.1',
-                              method: 'GET',
-                              path: ['/'],
-                              headers: {},
-                            },
-                            response: {
-                              version: '1.1',
-                              status: '200',
-                              reason: 'OK',
-                              headers: {},
-                            },
-                          }
-                          : { type: 'none' },
-                      );
-                    }}
-                  />
-                );
-              }}
-            </Form.Item>
-          </Form.Item>
-          {/* Per Xray docs (transports/raw.html#httpheaderobject), the
-              `request` object is honored only by outbound proxies; the
-              inbound listener reads `response`. Showing Host / Path /
-              Method / Version / request-headers on the inbound side was
-              a regression from this modal's earlier iteration — those
-              inputs wrote to the wire but xray-core ignored them. The
-              inbound modal now only exposes the response side. */}
-          <Form.Item
-            noStyle
-            shouldUpdate={(prev, curr) =>
-              prev.streamSettings?.tcpSettings?.header?.type
-              !== curr.streamSettings?.tcpSettings?.header?.type
-            }
-          >
-            {({ getFieldValue }) => {
-              const headerType = getFieldValue(
-                ['streamSettings', 'tcpSettings', 'header', 'type'],
-              ) as string | undefined;
-              if (headerType !== 'http') return null;
-              return (
-                <>
-                  <Form.Item
-                    label={t('pages.inbounds.form.requestVersion')}
-                    name={[
-                      'streamSettings', 'tcpSettings', 'header',
-                      'request', 'version',
-                    ]}
-                  >
-                    <Input placeholder="1.1" />
-                  </Form.Item>
-                  <Form.Item
-                    label={t('pages.inbounds.form.requestMethod')}
-                    name={[
-                      'streamSettings', 'tcpSettings', 'header',
-                      'request', 'method',
-                    ]}
-                  >
-                    <Input placeholder="GET" />
-                  </Form.Item>
-                  <Form.Item
-                    label={t('pages.inbounds.form.requestPath')}
-                    name={[
-                      'streamSettings', 'tcpSettings', 'header',
-                      'request', 'path',
-                    ]}
-                    getValueProps={(v) => ({ value: Array.isArray(v) ? v.join(',') : v })}
-                    getValueFromEvent={(e) => {
-                      const raw = (e?.target?.value ?? '') as string;
-                      const parts = raw.split(',').map((s) => s.trim()).filter(Boolean);
-                      return parts.length > 0 ? parts : ['/'];
-                    }}
-                  >
-                    <Input placeholder="/" />
-                  </Form.Item>
-                  <Form.Item
-                    label={t('pages.inbounds.form.requestHeaders')}
-                    name={[
-                      'streamSettings', 'tcpSettings', 'header',
-                      'request', 'headers',
-                    ]}
-                  >
-                    <HeaderMapEditor mode="v2" />
-                  </Form.Item>
-                  <Form.Item
-                    label={t('pages.inbounds.form.responseVersion')}
-                    name={[
-                      'streamSettings', 'tcpSettings', 'header',
-                      'response', 'version',
-                    ]}
-                  >
-                    <Input placeholder="1.1" />
-                  </Form.Item>
-                  <Form.Item
-                    label={t('pages.inbounds.form.responseStatus')}
-                    name={[
-                      'streamSettings', 'tcpSettings', 'header',
-                      'response', 'status',
-                    ]}
-                  >
-                    <Input placeholder="200" />
-                  </Form.Item>
-                  <Form.Item
-                    label={t('pages.inbounds.form.responseReason')}
-                    name={[
-                      'streamSettings', 'tcpSettings', 'header',
-                      'response', 'reason',
-                    ]}
-                  >
-                    <Input placeholder="OK" />
-                  </Form.Item>
-                  <Form.Item
-                    label={t('pages.inbounds.form.responseHeaders')}
-                    name={[
-                      'streamSettings', 'tcpSettings', 'header',
-                      'response', 'headers',
-                    ]}
-                  >
-                    <HeaderMapEditor mode="v2" />
-                  </Form.Item>
-                </>
-              );
-            }}
-          </Form.Item>
-        </>
-      )}
-
-      {network === 'ws' && (
-        <>
-          <Form.Item
-            name={['streamSettings', 'wsSettings', 'acceptProxyProtocol']}
-            label={t('pages.inbounds.form.proxyProtocol')}
-            valuePropName="checked"
-          >
-            <Switch />
-          </Form.Item>
-          <Form.Item name={['streamSettings', 'wsSettings', 'host']} label={t('host')}>
-            <Input />
-          </Form.Item>
-          <Form.Item name={['streamSettings', 'wsSettings', 'path']} label={t('path')}>
-            <Input />
-          </Form.Item>
-          <Form.Item
-            name={['streamSettings', 'wsSettings', 'heartbeatPeriod']}
-            label={t('pages.inbounds.form.heartbeatPeriod')}
-          >
-            <InputNumber min={0} />
-          </Form.Item>
-          <Form.Item
-            label={t('pages.inbounds.form.headers')}
-            name={['streamSettings', 'wsSettings', 'headers']}
-          >
-            <HeaderMapEditor mode="v1" />
-          </Form.Item>
-        </>
-      )}
-
-      {network === 'grpc' && (
-        <>
-          <Form.Item
-            name={['streamSettings', 'grpcSettings', 'serviceName']}
-            label={t('pages.inbounds.form.serviceName')}
-          >
-            <Input />
-          </Form.Item>
-          <Form.Item
-            name={['streamSettings', 'grpcSettings', 'authority']}
-            label={t('pages.inbounds.form.authority')}
-          >
-            <Input />
-          </Form.Item>
-          <Form.Item
-            name={['streamSettings', 'grpcSettings', 'multiMode']}
-            label={t('pages.inbounds.form.multiMode')}
-            valuePropName="checked"
-          >
-            <Switch />
-          </Form.Item>
-        </>
-      )}
-
-      {network === 'xhttp' && (
-        <>
-          <Form.Item name={['streamSettings', 'xhttpSettings', 'host']} label={t('host')}>
-            <Input />
-          </Form.Item>
-          <Form.Item name={['streamSettings', 'xhttpSettings', 'path']} label={t('path')}>
-            <Input />
-          </Form.Item>
-          <Form.Item name={['streamSettings', 'xhttpSettings', 'mode']} label={t('pages.inbounds.info.mode')}>
-            <Select
-              style={{ width: '50%' }}
-              options={(['auto', 'packet-up', 'stream-up', 'stream-one'] as const).map((m) => ({
-                value: m,
-                label: m,
-              }))}
-            />
-          </Form.Item>
-          {xhttpMode === 'packet-up' && (
-            <>
-              <Form.Item
-                name={['streamSettings', 'xhttpSettings', 'scMaxBufferedPosts']}
-                label={t('pages.inbounds.form.maxBufferedUpload')}
-              >
-                <InputNumber />
-              </Form.Item>
-              <Form.Item
-                name={['streamSettings', 'xhttpSettings', 'scMaxEachPostBytes']}
-                label={t('pages.inbounds.form.maxUploadSize')}
-              >
-                <Input />
-              </Form.Item>
-            </>
-          )}
-          {xhttpMode === 'stream-up' && (
-            <Form.Item
-              name={['streamSettings', 'xhttpSettings', 'scStreamUpServerSecs']}
-              label={t('pages.inbounds.form.streamUpServer')}
-            >
-              <Input />
-            </Form.Item>
-          )}
-          <Form.Item
-            name={['streamSettings', 'xhttpSettings', 'serverMaxHeaderBytes']}
-            label={t('pages.inbounds.form.serverMaxHeaderBytes')}
-          >
-            <InputNumber min={0} placeholder="0 (default)" />
-          </Form.Item>
-          <Form.Item
-            name={['streamSettings', 'xhttpSettings', 'xPaddingBytes']}
-            label={t('pages.inbounds.form.paddingBytes')}
-          >
-            <Input />
-          </Form.Item>
-          <Form.Item
-            name={['streamSettings', 'xhttpSettings', 'headers']}
-            label={t('pages.inbounds.form.headers')}
-          >
-            <HeaderMapEditor mode="v1" />
-          </Form.Item>
-          <Form.Item
-            name={['streamSettings', 'xhttpSettings', 'uplinkHTTPMethod']}
-            label={t('pages.inbounds.form.uplinkHttpMethod')}
-          >
-            <Select
-              options={[
-                { value: '', label: 'Default (POST)' },
-                { value: 'POST', label: 'POST' },
-                { value: 'PUT', label: 'PUT' },
-                {
-                  value: 'GET',
-                  label: 'GET (packet-up only)',
-                  disabled: xhttpMode !== 'packet-up',
-                },
-              ]}
-            />
-          </Form.Item>
-          <Form.Item
-            name={['streamSettings', 'xhttpSettings', 'xPaddingObfsMode']}
-            label={t('pages.inbounds.form.paddingObfsMode')}
-            valuePropName="checked"
-          >
-            <Switch />
-          </Form.Item>
-          {xhttpObfsMode && (
-            <>
-              <Form.Item
-                name={['streamSettings', 'xhttpSettings', 'xPaddingKey']}
-                label={t('pages.inbounds.form.paddingKey')}
-              >
-                <Input placeholder="x_padding" />
-              </Form.Item>
-              <Form.Item
-                name={['streamSettings', 'xhttpSettings', 'xPaddingHeader']}
-                label={t('pages.inbounds.form.paddingHeader')}
-              >
-                <Input placeholder="X-Padding" />
-              </Form.Item>
-              <Form.Item
-                name={['streamSettings', 'xhttpSettings', 'xPaddingPlacement']}
-                label={t('pages.inbounds.form.paddingPlacement')}
-              >
-                <Select
-                  options={[
-                    { value: '', label: 'Default (queryInHeader)' },
-                    { value: 'queryInHeader', label: 'queryInHeader' },
-                    { value: 'header', label: 'header' },
-                    { value: 'cookie', label: 'cookie' },
-                    { value: 'query', label: 'query' },
-                  ]}
-                />
-              </Form.Item>
-              <Form.Item
-                name={['streamSettings', 'xhttpSettings', 'xPaddingMethod']}
-                label={t('pages.inbounds.form.paddingMethod')}
-              >
-                <Select
-                  options={[
-                    { value: '', label: 'Default (repeat-x)' },
-                    { value: 'repeat-x', label: 'repeat-x' },
-                    { value: 'tokenish', label: 'tokenish' },
-                  ]}
-                />
-              </Form.Item>
-            </>
-          )}
-          <Form.Item
-            name={['streamSettings', 'xhttpSettings', 'sessionPlacement']}
-            label={t('pages.inbounds.form.sessionPlacement')}
-          >
-            <Select
-              options={[
-                { value: '', label: 'Default (path)' },
-                { value: 'path', label: 'path' },
-                { value: 'header', label: 'header' },
-                { value: 'cookie', label: 'cookie' },
-                { value: 'query', label: 'query' },
-              ]}
-            />
-          </Form.Item>
-          {xhttpSessionPlacement && xhttpSessionPlacement !== 'path' && (
-            <Form.Item
-              name={['streamSettings', 'xhttpSettings', 'sessionKey']}
-              label={t('pages.inbounds.form.sessionKey')}
-            >
-              <Input placeholder="x_session" />
-            </Form.Item>
-          )}
-          <Form.Item
-            name={['streamSettings', 'xhttpSettings', 'seqPlacement']}
-            label={t('pages.inbounds.form.sequencePlacement')}
-          >
-            <Select
-              options={[
-                { value: '', label: 'Default (path)' },
-                { value: 'path', label: 'path' },
-                { value: 'header', label: 'header' },
-                { value: 'cookie', label: 'cookie' },
-                { value: 'query', label: 'query' },
-              ]}
-            />
-          </Form.Item>
-          {xhttpSeqPlacement && xhttpSeqPlacement !== 'path' && (
-            <Form.Item
-              name={['streamSettings', 'xhttpSettings', 'seqKey']}
-              label={t('pages.inbounds.form.sequenceKey')}
-            >
-              <Input placeholder="x_seq" />
-            </Form.Item>
-          )}
-          {xhttpMode === 'packet-up' && (
-            <>
-              <Form.Item
-                name={['streamSettings', 'xhttpSettings', 'uplinkDataPlacement']}
-                label={t('pages.inbounds.form.uplinkDataPlacement')}
-              >
-                <Select
-                  options={[
-                    { value: '', label: 'Default (body)' },
-                    { value: 'body', label: 'body' },
-                    { value: 'header', label: 'header' },
-                    { value: 'cookie', label: 'cookie' },
-                    { value: 'query', label: 'query' },
-                  ]}
-                />
-              </Form.Item>
-              {xhttpUplinkPlacement && xhttpUplinkPlacement !== 'body' && (
-                <Form.Item
-                  name={['streamSettings', 'xhttpSettings', 'uplinkDataKey']}
-                  label={t('pages.inbounds.form.uplinkDataKey')}
-                >
-                  <Input placeholder="x_data" />
-                </Form.Item>
-              )}
-            </>
-          )}
-          <Form.Item
-            name={['streamSettings', 'xhttpSettings', 'noSSEHeader']}
-            label={t('pages.inbounds.form.noSseHeader')}
-            valuePropName="checked"
-          >
-            <Switch />
-          </Form.Item>
-        </>
-      )}
-
-      {network === 'httpupgrade' && (
-        <>
-          <Form.Item
-            name={['streamSettings', 'httpupgradeSettings', 'acceptProxyProtocol']}
-            label={t('pages.inbounds.form.proxyProtocol')}
-            valuePropName="checked"
-          >
-            <Switch />
-          </Form.Item>
-          <Form.Item
-            name={['streamSettings', 'httpupgradeSettings', 'host']}
-            label={t('host')}
-          >
-            <Input />
-          </Form.Item>
-          <Form.Item
-            name={['streamSettings', 'httpupgradeSettings', 'path']}
-            label={t('path')}
-          >
-            <Input />
-          </Form.Item>
-          <Form.Item
-            label={t('pages.inbounds.form.headers')}
-            name={['streamSettings', 'httpupgradeSettings', 'headers']}
-          >
-            <HeaderMapEditor mode="v1" />
-          </Form.Item>
-        </>
-      )}
-
-      {network === 'kcp' && (
-        <>
-          <Form.Item name={['streamSettings', 'kcpSettings', 'mtu']} label="MTU">
-            <InputNumber min={576} max={1460} />
-          </Form.Item>
-          <Form.Item name={['streamSettings', 'kcpSettings', 'tti']} label={t('pages.inbounds.form.ttiMs')}>
-            <InputNumber min={10} max={100} />
-          </Form.Item>
-          <Form.Item name={['streamSettings', 'kcpSettings', 'uplinkCapacity']} label={t('pages.inbounds.form.uplinkMbps')}>
-            <InputNumber min={0} />
-          </Form.Item>
-          <Form.Item name={['streamSettings', 'kcpSettings', 'downlinkCapacity']} label={t('pages.inbounds.form.downlinkMbps')}>
-            <InputNumber min={0} />
-          </Form.Item>
-          <Form.Item
-            name={['streamSettings', 'kcpSettings', 'cwndMultiplier']}
-            label={t('pages.inbounds.form.cwndMultiplier')}
-          >
-            <InputNumber min={1} />
-          </Form.Item>
-          <Form.Item
-            name={['streamSettings', 'kcpSettings', 'maxSendingWindow']}
-            label={t('pages.inbounds.form.maxSendingWindow')}
-          >
-            <InputNumber min={0} />
-          </Form.Item>
-        </>
-      )}
-
-      <Form.Item
-        noStyle
-        shouldUpdate={(prev, curr) => {
-          const a = (prev.streamSettings as { externalProxy?: unknown[] } | undefined)?.externalProxy;
-          const b = (curr.streamSettings as { externalProxy?: unknown[] } | undefined)?.externalProxy;
-          return (Array.isArray(a) ? a.length : 0) !== (Array.isArray(b) ? b.length : 0);
-        }}
-      >
-        {({ getFieldValue }) => {
-          const arr = getFieldValue(['streamSettings', 'externalProxy']);
-          const on = Array.isArray(arr) && arr.length > 0;
-          return (
-            <>
-              <Form.Item label={t('pages.inbounds.form.externalProxy')}>
-                <Switch checked={on} onChange={toggleExternalProxy} />
-              </Form.Item>
-              {on && (
-                <Form.List name={['streamSettings', 'externalProxy']}>
-                  {(fields, { add, remove }) => (
-                    <>
-                      <Form.Item label=" " colon={false}>
-                        <Button
-                          size="small"
-                          type="primary"
-                          onClick={() => add({
-                            forceTls: 'same',
-                            dest: '',
-                            port: 443,
-                            remark: '',
-                            sni: '',
-                            fingerprint: '',
-                            alpn: [],
-                          })}
-                        >
-                          <PlusOutlined />
-                        </Button>
-                      </Form.Item>
-                      <Form.Item wrapperCol={{ span: 24 }}>
-                        {fields.map((field) => (
-                          <div key={field.key} style={{ margin: '8px 0' }}>
-                            <Space.Compact block>
-                              <Form.Item name={[field.name, 'forceTls']} noStyle>
-                                <Select
-                                  style={{ width: '20%' }}
-                                  options={[
-                                    { value: 'same', label: t('pages.inbounds.same') },
-                                    { value: 'none', label: t('none') },
-                                    { value: 'tls', label: 'TLS' },
-                                  ]}
-                                />
-                              </Form.Item>
-                              <Form.Item name={[field.name, 'dest']} noStyle>
-                                <Input style={{ width: '30%' }} placeholder={t('host')} />
-                              </Form.Item>
-                              <Form.Item name={[field.name, 'port']} noStyle>
-                                <InputNumber style={{ width: '15%' }} min={1} max={65535} />
-                              </Form.Item>
-                              <Form.Item name={[field.name, 'remark']} noStyle>
-                                <Input style={{ width: '25%' }} placeholder={t('pages.inbounds.remark')} />
-                              </Form.Item>
-                              <InputAddon onClick={() => remove(field.name)}>
-                                <MinusOutlined />
-                              </InputAddon>
-                            </Space.Compact>
-                            <Form.Item
-                              noStyle
-                              shouldUpdate={(prev, curr) =>
-                                prev.streamSettings?.externalProxy?.[field.name]?.forceTls
-                                !== curr.streamSettings?.externalProxy?.[field.name]?.forceTls
-                              }
-                            >
-                              {({ getFieldValue }) => {
-                                const ft = getFieldValue([
-                                  'streamSettings', 'externalProxy', field.name, 'forceTls',
-                                ]);
-                                if (ft !== 'tls') return null;
-                                return (
-                                  <Space.Compact style={{ marginTop: 6 }} block>
-                                    <Form.Item name={[field.name, 'sni']} noStyle>
-                                      <Input style={{ width: '30%' }} placeholder={t('pages.inbounds.form.sniPlaceholder')} />
-                                    </Form.Item>
-                                    <Form.Item name={[field.name, 'fingerprint']} noStyle>
-                                      <Select
-                                        style={{ width: '30%' }}
-                                        placeholder={t('pages.inbounds.form.fingerprint')}
-                                        options={[
-                                          { value: '', label: t('pages.inbounds.form.defaultOption') },
-                                          ...Object.values(UTLS_FINGERPRINT).map((fp) => ({
-                                            value: fp,
-                                            label: fp,
-                                          })),
-                                        ]}
-                                      />
-                                    </Form.Item>
-                                    <Form.Item name={[field.name, 'alpn']} noStyle>
-                                      <Select
-                                        mode="multiple"
-                                        style={{ width: '40%' }}
-                                        placeholder="ALPN"
-                                        options={Object.values(ALPN_OPTION).map((a) => ({
-                                          value: a,
-                                          label: a,
-                                        }))}
-                                      />
-                                    </Form.Item>
-                                  </Space.Compact>
-                                );
-                              }}
-                            </Form.Item>
-                          </div>
-                        ))}
-                      </Form.Item>
-                    </>
-                  )}
-                </Form.List>
-              )}
-            </>
-          );
-        }}
-      </Form.Item>
-
-      <Form.Item
-        noStyle
-        shouldUpdate={(prev, curr) => {
-          const a = (prev.streamSettings as { sockopt?: object } | undefined)?.sockopt;
-          const b = (curr.streamSettings as { sockopt?: object } | undefined)?.sockopt;
-          return !!a !== !!b;
-        }}
-      >
-        {({ getFieldValue }) => {
-          const sock = getFieldValue(['streamSettings', 'sockopt']);
-          const on = !!sock && typeof sock === 'object' && Object.keys(sock).length > 0;
-          return (
-            <>
-              <Form.Item label="Sockopt">
-                <Switch checked={on} onChange={toggleSockopt} />
-              </Form.Item>
-              {on && (
-                <>
-                  <Form.Item name={['streamSettings', 'sockopt', 'mark']} label={t('pages.inbounds.form.routeMark')}>
-                    <InputNumber min={0} />
-                  </Form.Item>
-                  <Form.Item
-                    name={['streamSettings', 'sockopt', 'tcpKeepAliveInterval']}
-                    label={t('pages.inbounds.form.tcpKeepAliveInterval')}
-                  >
-                    <InputNumber min={0} />
-                  </Form.Item>
-                  <Form.Item
-                    name={['streamSettings', 'sockopt', 'tcpKeepAliveIdle']}
-                    label={t('pages.inbounds.form.tcpKeepAliveIdle')}
-                  >
-                    <InputNumber min={0} />
-                  </Form.Item>
-                  <Form.Item name={['streamSettings', 'sockopt', 'tcpMaxSeg']} label={t('pages.inbounds.form.tcpMaxSeg')}>
-                    <InputNumber min={0} />
-                  </Form.Item>
-                  <Form.Item
-                    name={['streamSettings', 'sockopt', 'tcpUserTimeout']}
-                    label={t('pages.inbounds.form.tcpUserTimeout')}
-                  >
-                    <InputNumber min={0} />
-                  </Form.Item>
-                  <Form.Item
-                    name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
-                    label={t('pages.inbounds.form.tcpWindowClamp')}
-                  >
-                    <InputNumber min={0} />
-                  </Form.Item>
-                  <Form.Item
-                    name={['streamSettings', 'sockopt', 'acceptProxyProtocol']}
-                    label={t('pages.inbounds.form.proxyProtocol')}
-                    valuePropName="checked"
-                  >
-                    <Switch />
-                  </Form.Item>
-                  <Form.Item
-                    name={['streamSettings', 'sockopt', 'tcpFastOpen']}
-                    label={t('pages.inbounds.form.tcpFastOpen')}
-                    valuePropName="checked"
-                  >
-                    <Switch />
-                  </Form.Item>
-                  <Form.Item
-                    name={['streamSettings', 'sockopt', 'tcpMptcp']}
-                    label={t('pages.inbounds.form.multipathTcp')}
-                    valuePropName="checked"
-                  >
-                    <Switch />
-                  </Form.Item>
-                  <Form.Item
-                    name={['streamSettings', 'sockopt', 'penetrate']}
-                    label={t('pages.inbounds.form.penetrate')}
-                    valuePropName="checked"
-                  >
-                    <Switch />
-                  </Form.Item>
-                  <Form.Item
-                    name={['streamSettings', 'sockopt', 'V6Only']}
-                    label={t('pages.inbounds.form.v6Only')}
-                    valuePropName="checked"
-                  >
-                    <Switch />
-                  </Form.Item>
-                  <Form.Item
-                    name={['streamSettings', 'sockopt', 'domainStrategy']}
-                    label={t('pages.xray.wireguard.domainStrategy')}
-                  >
-                    <Select
-                      style={{ width: '50%' }}
-                      options={Object.values(DOMAIN_STRATEGY_OPTION).map((d) => ({ value: d, label: d }))}
-                    />
-                  </Form.Item>
-                  <Form.Item
-                    name={['streamSettings', 'sockopt', 'tcpcongestion']}
-                    label={t('pages.inbounds.form.tcpCongestion')}
-                  >
-                    <Select
-                      style={{ width: '50%' }}
-                      options={Object.values(TCP_CONGESTION_OPTION).map((c) => ({ value: c, label: c }))}
-                    />
-                  </Form.Item>
-                  <Form.Item name={['streamSettings', 'sockopt', 'tproxy']} label="TProxy">
-                    <Select
-                      style={{ width: '50%' }}
-                      options={[
-                        { value: 'off', label: 'Off' },
-                        { value: 'redirect', label: 'Redirect' },
-                        { value: 'tproxy', label: 'TProxy' },
-                      ]}
-                    />
-                  </Form.Item>
-                  <Form.Item name={['streamSettings', 'sockopt', 'dialerProxy']} label={t('pages.inbounds.form.dialerProxy')}>
-                    <Input />
-                  </Form.Item>
-                  <Form.Item
-                    name={['streamSettings', 'sockopt', 'interfaceName']}
-                    label={t('pages.inbounds.info.interfaceName')}
-                  >
-                    <Input />
-                  </Form.Item>
-                  <Form.Item
-                    name={['streamSettings', 'sockopt', 'trustedXForwardedFor']}
-                    label={t('pages.inbounds.form.trustedXForwardedFor')}
-                  >
-                    <Select
-                      mode="tags"
-                      style={{ width: '100%' }}
-                      tokenSeparators={[',']}
-                      options={[
-                        { value: 'CF-Connecting-IP', label: 'CF-Connecting-IP' },
-                        { value: 'X-Real-IP', label: 'X-Real-IP' },
-                        { value: 'True-Client-IP', label: 'True-Client-IP' },
-                        { value: 'X-Client-IP', label: 'X-Client-IP' },
-                      ]}
-                    />
-                  </Form.Item>
-                  <Form.Item
-                    name={['streamSettings', 'sockopt', 'addressPortStrategy']}
-                    label={t('pages.inbounds.form.addressPortStrategy')}
-                  >
-                    <Select
-                      style={{ width: '50%' }}
-                      options={Object.values(Address_Port_Strategy).map((v) => ({ value: v, label: v }))}
-                    />
-                  </Form.Item>
-                  <Form.Item shouldUpdate noStyle>
-                    {({ getFieldValue, setFieldValue }) => {
-                      const he = getFieldValue(['streamSettings', 'sockopt', 'happyEyeballs']);
-                      const hasHe = he != null;
-                      return (
-                        <>
-                          <Form.Item label="Happy Eyeballs">
-                            <Switch
-                              checked={hasHe}
-                              onChange={(v) => {
-                                setFieldValue(
-                                  ['streamSettings', 'sockopt', 'happyEyeballs'],
-                                  v ? HappyEyeballsSchema.parse({}) : undefined,
-                                );
-                              }}
-                            />
-                          </Form.Item>
-                          {hasHe && (
-                            <>
-                              <Form.Item
-                                name={['streamSettings', 'sockopt', 'happyEyeballs', 'tryDelayMs']}
-                                label={t('pages.inbounds.form.tryDelayMs')}
-                              >
-                                <InputNumber min={0} placeholder="0 disabled — 250 recommended" />
-                              </Form.Item>
-                              <Form.Item
-                                name={['streamSettings', 'sockopt', 'happyEyeballs', 'prioritizeIPv6']}
-                                label={t('pages.inbounds.form.prioritizeIPv6')}
-                                valuePropName="checked"
-                              >
-                                <Switch />
-                              </Form.Item>
-                              <Form.Item
-                                name={['streamSettings', 'sockopt', 'happyEyeballs', 'interleave']}
-                                label={t('pages.inbounds.form.interleave')}
-                              >
-                                <InputNumber min={1} />
-                              </Form.Item>
-                              <Form.Item
-                                name={['streamSettings', 'sockopt', 'happyEyeballs', 'maxConcurrentTry']}
-                                label={t('pages.inbounds.form.maxConcurrentTry')}
-                              >
-                                <InputNumber min={0} />
-                              </Form.Item>
-                            </>
-                          )}
-                        </>
-                      );
-                    }}
-                  </Form.Item>
-                  <Form.List name={['streamSettings', 'sockopt', 'customSockopt']}>
-                    {(fields, { add, remove }) => (
-                      <>
-                        <Form.Item label={t('pages.inbounds.form.customSockopt')}>
-                          <Button
-                            type="dashed"
-                            size="small"
-                            onClick={() => add({ type: 'int', level: '6', opt: '', value: '' })}
-                          >
-                            + {t('pages.inbounds.form.addCustomOption')}
-                          </Button>
-                        </Form.Item>
-                        {fields.map((field) => (
-                          <Space.Compact key={field.key} style={{ display: 'flex', marginBottom: 8 }}>
-                            <Form.Item name={[field.name, 'system']} noStyle>
-                              <Select
-                                placeholder="all"
-                                allowClear
-                                style={{ width: 100 }}
-                                options={[
-                                  { value: 'linux', label: 'linux' },
-                                  { value: 'windows', label: 'windows' },
-                                  { value: 'darwin', label: 'darwin' },
-                                ]}
-                              />
-                            </Form.Item>
-                            <Form.Item name={[field.name, 'type']} noStyle>
-                              <Select
-                                style={{ width: 80 }}
-                                options={[
-                                  { value: 'int', label: 'int' },
-                                  { value: 'str', label: 'str' },
-                                ]}
-                              />
-                            </Form.Item>
-                            <Form.Item name={[field.name, 'level']} noStyle>
-                              <Input placeholder="level (6=TCP)" style={{ width: 100 }} />
-                            </Form.Item>
-                            <Form.Item name={[field.name, 'opt']} noStyle>
-                              <Input placeholder="opt" style={{ width: 120 }} />
-                            </Form.Item>
-                            <Form.Item name={[field.name, 'value']} noStyle>
-                              <Input placeholder="value" style={{ flex: 1 }} />
-                            </Form.Item>
-                            <Button danger onClick={() => remove(field.name)}>−</Button>
-                          </Space.Compact>
-                        ))}
-                      </>
-                    )}
-                  </Form.List>
-                </>
-              )}
-            </>
-          );
-        }}
-      </Form.Item>
-
-      <FinalMaskForm
-        name={['streamSettings', 'finalmask']}
-        network={network as string}
-        protocol={protocol}
-        form={form}
-      />
-    </>
-  );
-
-  const securityTab = (
-    <>
-      <Form.Item name={['streamSettings', 'security']} hidden noStyle>
-        <Input />
-      </Form.Item>
-      <Form.Item label={t('pages.inbounds.securityTab')}>
-        <Form.Item
-          noStyle
-          shouldUpdate={(prev, curr) =>
-            prev.streamSettings?.security !== curr.streamSettings?.security
-            || prev.streamSettings?.network !== curr.streamSettings?.network
-            || prev.protocol !== curr.protocol
-          }
-        >
-          {({ getFieldValue }) => {
-            const sec = getFieldValue(['streamSettings', 'security']) ?? 'none';
-            const net = getFieldValue(['streamSettings', 'network']) ?? '';
-            const proto = getFieldValue('protocol') ?? '';
-            const tlsOk = canEnableTls({ protocol: proto, streamSettings: { network: net, security: sec } });
-            const realityOk = canEnableReality({ protocol: proto, streamSettings: { network: net, security: sec } });
-            const tlsOnly = proto === Protocols.HYSTERIA;
-            return (
-              <Radio.Group
-                value={sec}
-                buttonStyle="solid"
-                disabled={!tlsOk}
-                onChange={(e) => onSecurityChange(e.target.value)}
-              >
-                {!tlsOnly && <Radio.Button value="none">{t('none')}</Radio.Button>}
-                <Radio.Button value="tls">TLS</Radio.Button>
-                {realityOk && <Radio.Button value="reality">Reality</Radio.Button>}
-              </Radio.Group>
-            );
-          }}
-        </Form.Item>
-      </Form.Item>
-
-      <Form.Item
-        noStyle
-        shouldUpdate={(prev, curr) =>
-          prev.streamSettings?.security !== curr.streamSettings?.security
-        }
-      >
-        {({ getFieldValue }) => {
-          const sec = getFieldValue(['streamSettings', 'security']);
-          if (sec !== 'tls') return null;
-          return (
-            <>
-              <Form.Item name={['streamSettings', 'tlsSettings', 'serverName']} label="SNI">
-                <Input placeholder={t('pages.inbounds.form.serverNameIndication')} />
-              </Form.Item>
-              <Form.Item name={['streamSettings', 'tlsSettings', 'cipherSuites']} label={t('pages.inbounds.form.cipherSuites')}>
-                <Select
-                  options={[
-                    { value: '', label: t('pages.inbounds.form.autoOption') },
-                    ...Object.entries(TLS_CIPHER_OPTION).map(([k, v]) => ({ value: v, label: k })),
-                  ]}
-                />
-              </Form.Item>
-              <Form.Item label={t('pages.inbounds.form.minMaxVersion')}>
-                <Space.Compact block>
-                  <Form.Item name={['streamSettings', 'tlsSettings', 'minVersion']} noStyle>
-                    <Select
-                      style={{ width: '50%' }}
-                      options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))}
-                    />
-                  </Form.Item>
-                  <Form.Item name={['streamSettings', 'tlsSettings', 'maxVersion']} noStyle>
-                    <Select
-                      style={{ width: '50%' }}
-                      options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))}
-                    />
-                  </Form.Item>
-                </Space.Compact>
-              </Form.Item>
-              <Form.Item
-                name={['streamSettings', 'tlsSettings', 'settings', 'fingerprint']}
-                label="uTLS"
-              >
-                <Select
-                  options={[
-                    { value: '', label: 'None' },
-                    ...Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp })),
-                  ]}
-                />
-              </Form.Item>
-              <Form.Item name={['streamSettings', 'tlsSettings', 'alpn']} label="ALPN">
-                <Select
-                  mode="multiple"
-                  tokenSeparators={[',']}
-                  style={{ width: '100%' }}
-                  options={Object.values(ALPN_OPTION).map((a) => ({ value: a, label: a }))}
-                />
-              </Form.Item>
-              <Form.Item
-                name={['streamSettings', 'tlsSettings', 'rejectUnknownSni']}
-                label={t('pages.inbounds.form.rejectUnknownSni')}
-                valuePropName="checked"
-              >
-                <Switch />
-              </Form.Item>
-              <Form.Item
-                name={['streamSettings', 'tlsSettings', 'disableSystemRoot']}
-                label={t('pages.inbounds.form.disableSystemRoot')}
-                valuePropName="checked"
-              >
-                <Switch />
-              </Form.Item>
-              <Form.Item
-                name={['streamSettings', 'tlsSettings', 'enableSessionResumption']}
-                label={t('pages.inbounds.form.sessionResumption')}
-                valuePropName="checked"
-              >
-                <Switch />
-              </Form.Item>
-
-              <Form.List name={['streamSettings', 'tlsSettings', 'certificates']}>
-                {(certFields, { add, remove }) => (
-                  <>
-                    <Form.Item label={t('certificate')}>
-                      <Button
-                        type="primary"
-                        size="small"
-                        onClick={() => add({
-                          useFile: true,
-                          certificateFile: '',
-                          keyFile: '',
-                          certificate: [],
-                          key: [],
-                          oneTimeLoading: false,
-                          usage: 'encipherment',
-                          buildChain: false,
-                        })}
-                      >
-                        <PlusOutlined />
-                      </Button>
-                    </Form.Item>
-                    {certFields.map((certField, idx) => (
-                      <div key={certField.key}>
-                        <Form.Item
-                          name={[certField.name, 'useFile']}
-                          label={`${t('certificate')} ${idx + 1}`}
-                        >
-                          <Radio.Group buttonStyle="solid">
-                            <Radio.Button value={true}>
-                              {t('pages.inbounds.certificatePath')}
-                            </Radio.Button>
-                            <Radio.Button value={false}>
-                              {t('pages.inbounds.certificateContent')}
-                            </Radio.Button>
-                          </Radio.Group>
-                        </Form.Item>
-                        {certFields.length > 1 && (
-                          <Form.Item label=" ">
-                            <Button
-                              size="small"
-                              danger
-                              onClick={() => remove(certField.name)}
-                            >
-                              <MinusOutlined /> {t('remove')}
-                            </Button>
-                          </Form.Item>
-                        )}
-                        <Form.Item
-                          noStyle
-                          shouldUpdate={(prev, curr) =>
-                            prev.streamSettings?.tlsSettings?.certificates?.[certField.name]?.useFile
-                            !== curr.streamSettings?.tlsSettings?.certificates?.[certField.name]?.useFile
-                          }
-                        >
-                          {({ getFieldValue }) => {
-                            const useFile = getFieldValue([
-                              'streamSettings', 'tlsSettings', 'certificates',
-                              certField.name, 'useFile',
-                            ]);
-                            return useFile ? (
-                              <>
-                                <Form.Item
-                                  name={[certField.name, 'certificateFile']}
-                                  label={t('pages.inbounds.publicKey')}
-                                >
-                                  <Input />
-                                </Form.Item>
-                                <Form.Item
-                                  name={[certField.name, 'keyFile']}
-                                  label={t('pages.inbounds.privatekey')}
-                                >
-                                  <Input />
-                                </Form.Item>
-                                <Form.Item label=" ">
-                                  <Space>
-                                    <Button
-                                      type="primary"
-                                      loading={saving}
-                                      onClick={() => setCertFromPanel(certField.name)}
-                                    >
-                                      {t('pages.inbounds.setDefaultCert')}
-                                    </Button>
-                                    <Button danger onClick={() => clearCertFiles(certField.name)}>
-                                      {t('clear')}
-                                    </Button>
-                                  </Space>
-                                </Form.Item>
-                              </>
-                            ) : (
-                              <>
-                                <Form.Item
-                                  name={[certField.name, 'certificate']}
-                                  label={t('pages.inbounds.publicKey')}
-                                  normalize={(v) => typeof v === 'string'
-                                    ? v.split('\n')
-                                    : v}
-                                  getValueProps={(v) => ({
-                                    value: Array.isArray(v) ? v.join('\n') : v,
-                                  })}
-                                >
-                                  <TextArea autoSize={{ minRows: 3, maxRows: 8 }} />
-                                </Form.Item>
-                                <Form.Item
-                                  name={[certField.name, 'key']}
-                                  label={t('pages.inbounds.privatekey')}
-                                  normalize={(v) => typeof v === 'string'
-                                    ? v.split('\n')
-                                    : v}
-                                  getValueProps={(v) => ({
-                                    value: Array.isArray(v) ? v.join('\n') : v,
-                                  })}
-                                >
-                                  <TextArea autoSize={{ minRows: 3, maxRows: 8 }} />
-                                </Form.Item>
-                              </>
-                            );
-                          }}
-                        </Form.Item>
-                        <Form.Item
-                          name={[certField.name, 'oneTimeLoading']}
-                          label={t('pages.inbounds.form.oneTimeLoading')}
-                          valuePropName="checked"
-                        >
-                          <Switch />
-                        </Form.Item>
-                        <Form.Item
-                          name={[certField.name, 'usage']}
-                          label={t('pages.inbounds.form.usageOption')}
-                        >
-                          <Select
-                            style={{ width: '50%' }}
-                            options={Object.values(USAGE_OPTION).map((u) => ({ value: u, label: u }))}
-                          />
-                        </Form.Item>
-                        <Form.Item
-                          noStyle
-                          shouldUpdate={(prev, curr) =>
-                            prev.streamSettings?.tlsSettings?.certificates?.[certField.name]?.usage
-                            !== curr.streamSettings?.tlsSettings?.certificates?.[certField.name]?.usage
-                          }
-                        >
-                          {({ getFieldValue }) => {
-                            const usage = getFieldValue([
-                              'streamSettings', 'tlsSettings', 'certificates',
-                              certField.name, 'usage',
-                            ]);
-                            if (usage !== 'issue') return null;
-                            return (
-                              <Form.Item
-                                name={[certField.name, 'buildChain']}
-                                label={t('pages.inbounds.form.buildChain')}
-                                valuePropName="checked"
-                              >
-                                <Switch />
-                              </Form.Item>
-                            );
-                          }}
-                        </Form.Item>
-                      </div>
-                    ))}
-                  </>
-                )}
-              </Form.List>
-
-              <Form.Item name={['streamSettings', 'tlsSettings', 'echServerKeys']} label={t('pages.inbounds.form.echKey')}>
-                <Input />
-              </Form.Item>
-              <Form.Item
-                name={['streamSettings', 'tlsSettings', 'settings', 'echConfigList']}
-                label={t('pages.inbounds.form.echConfig')}
-              >
-                <Input />
-              </Form.Item>
-              <Form.Item
-                label={t('pages.inbounds.form.pinnedPeerCertSha256')}
-                tooltip={t('pages.inbounds.form.pinnedPeerCertSha256Tip')}
-              >
-                <Space.Compact block>
-                  <Form.Item
-                    name={['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256']}
-                    noStyle
-                  >
-                    <Select
-                      mode="tags"
-                      tokenSeparators={[',', ' ']}
-                      placeholder={t('pages.inbounds.form.pinnedPeerCertSha256Placeholder')}
-                      style={{ width: 'calc(100% - 32px)' }}
-                    />
-                  </Form.Item>
-                  <Button
-                    icon={<ReloadOutlined />}
-                    onClick={generateRandomPinHash}
-                    title={t('pages.inbounds.form.generateRandomPin')}
-                  />
-                </Space.Compact>
-              </Form.Item>
-              <Form.Item label=" ">
-                <Space>
-                  <Button type="primary" loading={saving} onClick={getNewEchCert}>
-                    {t('pages.inbounds.form.getNewEchCert')}
-                  </Button>
-                  <Button danger onClick={clearEchCert}>{t('clear')}</Button>
-                </Space>
-              </Form.Item>
-            </>
-          );
-        }}
-      </Form.Item>
-
-      <Form.Item
-        noStyle
-        shouldUpdate={(prev, curr) =>
-          prev.streamSettings?.security !== curr.streamSettings?.security
-        }
-      >
-        {({ getFieldValue }) => {
-          const sec = getFieldValue(['streamSettings', 'security']);
-          if (sec !== 'reality') return null;
-          return (
-            <>
-              <Form.Item
-                name={['streamSettings', 'realitySettings', 'show']}
-                label={t('pages.inbounds.form.show')}
-                valuePropName="checked"
-              >
-                <Switch />
-              </Form.Item>
-              <Form.Item name={['streamSettings', 'realitySettings', 'xver']} label={t('pages.inbounds.form.xver')}>
-                <InputNumber min={0} />
-              </Form.Item>
-              <Form.Item
-                name={['streamSettings', 'realitySettings', 'settings', 'fingerprint']}
-                label="uTLS"
-              >
-                <Select
-                  options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))}
-                />
-              </Form.Item>
-              <Form.Item label={t('pages.inbounds.form.target')}>
-                <Space.Compact block>
-                  <Form.Item name={['streamSettings', 'realitySettings', 'target']} noStyle>
-                    <Input style={{ width: 'calc(100% - 32px)' }} />
-                  </Form.Item>
-                  <Button icon={<ReloadOutlined />} onClick={randomizeRealityTarget} />
-                </Space.Compact>
-              </Form.Item>
-              <Form.Item label="SNI">
-                <Space.Compact block style={{ display: 'flex' }}>
-                  <Form.Item
-                    name={['streamSettings', 'realitySettings', 'serverNames']}
-                    noStyle
-                  >
-                    <Select mode="tags" tokenSeparators={[',']} style={{ flex: 1 }} />
-                  </Form.Item>
-                  <Button icon={<ReloadOutlined />} onClick={randomizeRealityTarget} />
-                </Space.Compact>
-              </Form.Item>
-              <Form.Item
-                name={['streamSettings', 'realitySettings', 'maxTimediff']}
-                label={t('pages.inbounds.form.maxTimeDiff')}
-              >
-                <InputNumber min={0} />
-              </Form.Item>
-              <Form.Item
-                name={['streamSettings', 'realitySettings', 'minClientVer']}
-                label={t('pages.inbounds.form.minClientVer')}
-              >
-                <Input placeholder="25.9.11" />
-              </Form.Item>
-              <Form.Item
-                name={['streamSettings', 'realitySettings', 'maxClientVer']}
-                label={t('pages.inbounds.form.maxClientVer')}
-              >
-                <Input placeholder="25.9.11" />
-              </Form.Item>
-              <Form.Item label={t('pages.inbounds.form.shortIds')}>
-                <Space.Compact block style={{ display: 'flex' }}>
-                  <Form.Item
-                    name={['streamSettings', 'realitySettings', 'shortIds']}
-                    noStyle
-                  >
-                    <Select mode="tags" tokenSeparators={[',']} style={{ flex: 1 }} />
-                  </Form.Item>
-                  <Button icon={<ReloadOutlined />} onClick={randomizeShortIds} />
-                </Space.Compact>
-              </Form.Item>
-              <Form.Item
-                name={['streamSettings', 'realitySettings', 'settings', 'spiderX']}
-                label={t('pages.inbounds.form.spiderX')}
-              >
-                <Input />
-              </Form.Item>
-              <Form.Item
-                name={['streamSettings', 'realitySettings', 'settings', 'publicKey']}
-                label={t('pages.inbounds.publicKey')}
-              >
-                <Input.TextArea autoSize={{ minRows: 1, maxRows: 4 }} />
-              </Form.Item>
-              <Form.Item
-                name={['streamSettings', 'realitySettings', 'privateKey']}
-                label={t('pages.inbounds.privatekey')}
-              >
-                <Input.TextArea autoSize={{ minRows: 1, maxRows: 4 }} />
-              </Form.Item>
-              <Form.Item label=" ">
-                <Space>
-                  <Button type="primary" loading={saving} onClick={genRealityKeypair}>
-                    {t('pages.inbounds.form.getNewCert')}
-                  </Button>
-                  <Button danger onClick={clearRealityKeypair}>{t('clear')}</Button>
-                </Space>
-              </Form.Item>
-              <Form.Item
-                name={['streamSettings', 'realitySettings', 'mldsa65Seed']}
-                label={t('pages.inbounds.form.mldsa65Seed')}
-              >
-                <Input.TextArea autoSize={{ minRows: 2, maxRows: 6 }} />
-              </Form.Item>
-              <Form.Item
-                name={['streamSettings', 'realitySettings', 'settings', 'mldsa65Verify']}
-                label={t('pages.inbounds.form.mldsa65Verify')}
-              >
-                <Input.TextArea autoSize={{ minRows: 2, maxRows: 6 }} />
-              </Form.Item>
-              <Form.Item label=" ">
-                <Space>
-                  <Button type="primary" loading={saving} onClick={genMldsa65}>
-                    {t('pages.inbounds.form.getNewSeed')}
-                  </Button>
-                  <Button danger onClick={clearMldsa65}>{t('clear')}</Button>
-                </Space>
-              </Form.Item>
-            </>
-          );
-        }}
-      </Form.Item>
-    </>
-  );
-
-  const advancedTab = (
-    <div className="advanced-shell">
-      <div className="advanced-panel">
-        <div className="advanced-panel__header">
-          <div>
-            <div className="advanced-panel__title">{t('pages.inbounds.advanced.title')}</div>
-            <div className="advanced-panel__subtitle">{t('pages.inbounds.advanced.subtitle')}</div>
-          </div>
-        </div>
-        <Tabs
-          className="advanced-inner-tabs"
-          items={[
-            {
-              key: 'all',
-              label: t('pages.inbounds.advanced.all'),
-              children: (
-                <>
-                  <div className="advanced-editor-meta">
-                    {t('pages.inbounds.advanced.allHelp')}
-                  </div>
-                  <AdvancedAllEditor form={form} streamEnabled={streamEnabled} />
-                </>
-              ),
-            },
-            {
-              key: 'settings',
-              label: t('pages.inbounds.advanced.settings'),
-              children: (
-                <>
-                  <div className="advanced-editor-meta">
-                    {t('pages.inbounds.advanced.settingsHelp')}{' '}
-                    <code>{'{ settings: { ... } }'}</code>.
-                  </div>
-                  <AdvancedSliceEditor
-                    form={form}
-                    path="settings"
-                    wrapKey="settings"
-                    minHeight="320px"
-                    maxHeight="540px"
-                  />
-                </>
-              ),
-            },
-            ...(streamEnabled
-              ? [{
-                key: 'stream',
-                label: t('pages.inbounds.advanced.stream'),
-                children: (
-                  <>
-                    <div className="advanced-editor-meta">
-                      {t('pages.inbounds.advanced.streamHelp')}{' '}
-                      <code>{'{ streamSettings: { ... } }'}</code>.
-                    </div>
-                    <AdvancedSliceEditor
-                      form={form}
-                      path="streamSettings"
-                      wrapKey="streamSettings"
-                      minHeight="320px"
-                      maxHeight="540px"
-                    />
-                  </>
-                ),
-              }]
-              : []),
-            {
-              key: 'sniffing',
-              label: t('pages.inbounds.advanced.sniffing'),
-              children: (
-                <>
-                  <div className="advanced-editor-meta">
-                    {t('pages.inbounds.advanced.sniffingHelp')}{' '}
-                    <code>{'{ sniffing: { ... } }'}</code>.
-                  </div>
-                  <AdvancedSliceEditor
-                    form={form}
-                    path="sniffing"
-                    wrapKey="sniffing"
-                    minHeight="240px"
-                    maxHeight="420px"
-                  />
-                </>
-              ),
-            },
-          ]}
-        />
-      </div>
-    </div>
-  );
-
-  const sniffingTab = (
-    <>
-      <Form.Item name={['sniffing', 'enabled']} label={t('enable')} valuePropName="checked">
-        <Switch />
-      </Form.Item>
-
-      {sniffingEnabled && (
-        <>
-          <Form.Item name={['sniffing', 'destOverride']} wrapperCol={{ span: 24 }}>
-            <Checkbox.Group>
-              {Object.entries(SNIFFING_OPTION).map(([key, value]) => (
-                <Checkbox key={key} value={value}>{key}</Checkbox>
-              ))}
-            </Checkbox.Group>
-          </Form.Item>
-
-          <Form.Item
-            name={['sniffing', 'metadataOnly']}
-            label={t('pages.inbounds.sniffingMetadataOnly')}
-            valuePropName="checked"
-          >
-            <Switch />
-          </Form.Item>
-
-          <Form.Item
-            name={['sniffing', 'routeOnly']}
-            label={t('pages.inbounds.sniffingRouteOnly')}
-            valuePropName="checked"
-          >
-            <Switch />
-          </Form.Item>
-
-          <Form.Item
-            name={['sniffing', 'ipsExcluded']}
-            label={t('pages.inbounds.sniffingIpsExcluded')}
-          >
-            <Select
-              mode="tags"
-              tokenSeparators={[',']}
-              placeholder="IP/CIDR/geoip:*/ext:*"
-              style={{ width: '100%' }}
-            />
-          </Form.Item>
-
-          <Form.Item
-            name={['sniffing', 'domainsExcluded']}
-            label={t('pages.inbounds.sniffingDomainsExcluded')}
-          >
-            <Select
-              mode="tags"
-              tokenSeparators={[',']}
-              placeholder="domain:*/ext:*"
-              style={{ width: '100%' }}
-            />
-          </Form.Item>
-        </>
-      )}
-    </>
-  );
-
-  return (
-    <>
-      {messageContextHolder}
-      <Modal
-        open={open}
-        title={title}
-        okText={okText}
-        cancelText={t('close')}
-        confirmLoading={saving}
-        mask={{ closable: false }}
-        width={780}
-        onOk={submit}
-        onCancel={onClose}
-        destroyOnHidden
-      >
-        <Form
-          form={form}
-          colon={false}
-          labelCol={{ sm: { span: 8 } }}
-          wrapperCol={{ sm: { span: 14 } }}
-          onValuesChange={onValuesChange}
-        >
-          <Tabs items={[
-            // forceRender on every tab so all Form.Items register at modal
-            // open, not lazily on first visit. Without it, AntD's items API
-            // lazy-mounts inactive tabs — their fields don't register, so
-            // Form.useWatch on a parent path (e.g. 'sniffing') returns the
-            // partial-view {} until the user touches the tab and the
-            // inner Form.Item for `sniffing.enabled` registers.
-            { key: 'basic', label: t('pages.xray.basicTemplate'), children: basicTab, forceRender: true },
-            ...(([
-              Protocols.VLESS,
-              Protocols.SHADOWSOCKS,
-              Protocols.HTTP,
-              Protocols.MIXED,
-              Protocols.TUNNEL,
-              Protocols.TUN,
-              Protocols.WIREGUARD,
-            ] as string[]).includes(protocol) || isFallbackHost
-              ? [{ key: 'protocol', label: t('pages.inbounds.protocol'), children: protocolTab, forceRender: true }]
-              : []),
-            ...(streamEnabled
-              ? [
-                { key: 'stream', label: t('pages.inbounds.streamTab'), children: streamTab, forceRender: true },
-                { key: 'security', label: t('pages.inbounds.securityTab'), children: securityTab, forceRender: true },
-              ]
-              : []),
-            { key: 'sniffing', label: t('pages.inbounds.sniffingTab'), children: sniffingTab, forceRender: true },
-            { key: 'advanced', label: t('pages.xray.advancedTemplate'), children: advancedTab, forceRender: true },
-          ]} />
-        </Form>
-      </Modal>
-    </>
-  );
-}

+ 0 - 781
frontend/src/pages/inbounds/InboundList.tsx

@@ -1,781 +0,0 @@
-import { useCallback, useMemo, useState, type ReactElement } from 'react';
-import { useTranslation } from 'react-i18next';
-import {
-  Button,
-  Card,
-  Dropdown,
-  Modal,
-  Popover,
-  Space,
-  Switch,
-  Table,
-  Tag,
-  Tooltip,
-  type TableColumnType,
-  type MenuProps,
-} from 'antd';
-import {
-  PlusOutlined,
-  MenuOutlined,
-  MoreOutlined,
-  EditOutlined,
-  QrcodeOutlined,
-  CopyOutlined,
-  ExportOutlined,
-  ImportOutlined,
-  ReloadOutlined,
-  RetweetOutlined,
-  BlockOutlined,
-  DeleteOutlined,
-  InfoCircleOutlined,
-  TagsOutlined,
-  UsergroupAddOutlined,
-  UsergroupDeleteOutlined,
-} from '@ant-design/icons';
-
-import { HttpUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
-import InfinityIcon from '@/components/InfinityIcon';
-import { useDatepicker } from '@/hooks/useDatepicker';
-import type { NodeRecord } from '@/api/queries/useNodesQuery';
-import { isSSMultiUser } from '@/lib/xray/protocol-capabilities';
-import { coerceInboundJsonField } from '@/models/dbinbound';
-import './InboundList.css';
-
-interface StreamHints {
-  network: string;
-  isTls: boolean;
-  isReality: boolean;
-}
-
-function readStreamHints(streamSettings: unknown): StreamHints {
-  const stream = coerceInboundJsonField(streamSettings) as { network?: string; security?: string };
-  return {
-    network: stream.network ?? '',
-    isTls: stream.security === 'tls',
-    isReality: stream.security === 'reality',
-  };
-}
-
-// Display label for a network value. All known transports render in
-// upper-case for visual consistency with the TCP/UDP/TLS/Reality tags
-// already shown alongside; compound names (`httpupgrade`, `splithttp`,
-// `xhttp`) get a tiny touch of casing so they don't read as one word.
-function networkLabel(network: string): string {
-  const n = (network || '').toLowerCase();
-  if (!n) return 'TCP';
-  switch (n) {
-    case 'httpupgrade': return 'HTTPUpgrade';
-    case 'splithttp': return 'SplitHTTP';
-    case 'xhttp': return 'XHTTP';
-  }
-  return n.toUpperCase();
-}
-
-// Returns the underlying L4 protocol for transports whose name isn't
-// already TCP/UDP. `kcp` and `quic` both ride on UDP; everything else
-// (`ws`, `grpc`, `http`, `httpupgrade`, `xhttp`) is TCP-based and gets
-// no extra tag (the transport name implies TCP).
-function networkL4(network: string): 'UDP' | '' {
-  const n = (network || '').toLowerCase();
-  if (n === 'kcp' || n === 'quic') return 'UDP';
-  return '';
-}
-
-// Shadowsocks settings.network ("tcp" / "udp" / "tcp,udp") and Tunnel
-// settings.allowedNetwork (same shape, different field name) both carry
-// the L4 transport list independent of streamSettings. Returns a
-// comma-separated label.
-function commaNetworkLabel(raw: string): string {
-  const parts = (raw || 'tcp').toLowerCase().split(',').map((p) => p.trim()).filter(Boolean);
-  if (parts.length === 0) return 'TCP';
-  return parts.map(networkLabel).join(',');
-}
-
-function shadowsocksNetworkLabel(settings: unknown): string {
-  return commaNetworkLabel(readSettings(settings).network || '');
-}
-
-function tunnelNetworkLabel(settings: unknown): string {
-  return commaNetworkLabel(readSettings(settings).allowedNetwork || '');
-}
-
-// Mixed (socks+http combo) is always TCP at L4; settings.udp=true adds
-// UDP-associate support on the same port (SOCKS5 UDP).
-function mixedNetworkLabel(settings: unknown): string {
-  const st = coerceInboundJsonField(settings) as { udp?: boolean };
-  return st.udp ? 'TCP,UDP' : 'TCP';
-}
-
-function readSettings(settings: unknown): { method?: string; network?: string; allowedNetwork?: string } {
-  return coerceInboundJsonField(settings) as { method?: string; network?: string; allowedNetwork?: string };
-}
-
-export function isInboundMultiUser(record: { protocol: string; settings: unknown }): boolean {
-  switch (record.protocol) {
-    case 'vmess':
-    case 'vless':
-    case 'trojan':
-    case 'hysteria':
-      return true;
-    case 'shadowsocks':
-      return isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(record.settings) });
-    default:
-      return false;
-  }
-}
-
-type ProtocolFlags = {
-  isVMess?: boolean;
-  isVLess?: boolean;
-  isTrojan?: boolean;
-  isSS?: boolean;
-  isHysteria?: boolean;
-  isMixed?: boolean;
-  isHTTP?: boolean;
-  isWireguard?: boolean;
-  isTunnel?: boolean;
-};
-
-interface DBInboundRecord extends ProtocolFlags {
-  id: number;
-  enable: boolean;
-  remark: string;
-  port: number;
-  protocol: string;
-  up: number;
-  down: number;
-  total: number;
-  expiryTime: number;
-  _expiryTime: { valueOf(): number } | null;
-  nodeId?: number | null;
-  settings: unknown;
-  streamSettings: unknown;
-}
-
-export interface ClientCountEntry {
-  clients: number;
-  active: string[];
-  deactive: string[];
-  depleted: string[];
-  expiring: string[];
-  online: string[];
-}
-
-export type RowAction =
-  | 'edit'
-  | 'showInfo'
-  | 'qrcode'
-  | 'export'
-  | 'subs'
-  | 'clipboard'
-  | 'delete'
-  | 'resetTraffic'
-  | 'delAllClients'
-  | 'clone';
-
-export type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
-
-interface InboundListProps {
-  dbInbounds: DBInboundRecord[];
-  clientCount: Record<number, ClientCountEntry>;
-  onlineClients: string[];
-  lastOnlineMap: Record<string, number>;
-  expireDiff: number;
-  trafficDiff: number;
-  pageSize: number;
-  isMobile: boolean;
-  subEnable: boolean;
-  nodesById: Map<number, NodeRecord>;
-  hasActiveNode: boolean;
-  onAddInbound: () => void;
-  onGeneralAction: (key: GeneralAction) => void;
-  onRowAction: (action: { key: RowAction; dbInbound: DBInboundRecord }) => void;
-}
-
-type SortKey =
-  | 'id'
-  | 'enable'
-  | 'remark'
-  | 'port'
-  | 'protocol'
-  | 'traffic'
-  | 'expiryTime'
-  | 'node'
-  | 'clients';
-
-type SortOrder = 'ascend' | 'descend' | null;
-
-const SORT_FNS: Record<SortKey, (a: DBInboundRecord, b: DBInboundRecord, ctx: { nodesById: Map<number, NodeRecord>; clientCount: Record<number, ClientCountEntry> }) => number> = {
-  id: (a, b) => a.id - b.id,
-  enable: (a, b) => Number(a.enable) - Number(b.enable),
-  remark: (a, b) => (a.remark || '').localeCompare(b.remark || ''),
-  port: (a, b) => a.port - b.port,
-  protocol: (a, b) => a.protocol.localeCompare(b.protocol),
-  traffic: (a, b) => (a.up + a.down) - (b.up + b.down),
-  expiryTime: (a, b) => (a.expiryTime || Infinity) - (b.expiryTime || Infinity),
-  node: (a, b, ctx) => {
-    const nameA = ctx.nodesById.get(a.nodeId ?? -1)?.name ?? (a.nodeId == null ? '￿' : `node #${a.nodeId}`);
-    const nameB = ctx.nodesById.get(b.nodeId ?? -1)?.name ?? (b.nodeId == null ? '￿' : `node #${b.nodeId}`);
-    return nameA.localeCompare(nameB);
-  },
-  clients: (a, b, ctx) => (ctx.clientCount[a.id]?.clients || 0) - (ctx.clientCount[b.id]?.clients || 0),
-};
-
-function showQrCodeMenu(dbInbound: DBInboundRecord): boolean {
-  if (dbInbound.isWireguard) return true;
-  if (dbInbound.isSS) {
-    return !isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(dbInbound.settings) });
-  }
-  return false;
-}
-
-interface RowActionsMenuProps {
-  record: DBInboundRecord;
-  subEnable: boolean;
-  hasClients: boolean;
-  onClick: (key: RowAction) => void;
-  isMobile?: boolean;
-}
-
-function buildRowActionsMenu({ record, subEnable, t, isMobile, hasClients }: { record: DBInboundRecord; subEnable: boolean; t: (k: string) => string; isMobile?: boolean; hasClients?: boolean }): MenuProps['items'] {
-  const items: MenuProps['items'] = [];
-  if (isMobile) {
-    items.push({ key: 'edit', icon: <EditOutlined />, label: t('edit') });
-  }
-  if (showQrCodeMenu(record)) {
-    items.push({ key: 'qrcode', icon: <QrcodeOutlined />, label: t('qrCode') });
-  }
-  if (isInboundMultiUser(record)) {
-    items.push({ key: 'export', icon: <ExportOutlined />, label: t('pages.inbounds.export') });
-    if (subEnable) {
-      items.push({
-        key: 'subs',
-        icon: <ExportOutlined />,
-        label: `${t('pages.inbounds.export')} — ${t('pages.settings.subSettings')}`,
-      });
-    }
-  } else {
-    items.push({ key: 'showInfo', icon: <InfoCircleOutlined />, label: t('pages.inbounds.inboundInfo') });
-  }
-  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) && hasClients) {
-    items.push({ key: 'attachClients', icon: <UsergroupAddOutlined />, label: t('pages.inbounds.attachClients') });
-    items.push({ key: 'detachClients', icon: <UsergroupDeleteOutlined />, label: t('pages.inbounds.detachClients') });
-    items.push({ key: 'addToGroup', icon: <TagsOutlined />, label: t('pages.inbounds.addClientsToGroup') });
-    items.push({ type: 'divider' });
-    items.push({ key: 'delAllClients', icon: <UsergroupDeleteOutlined />, danger: true, label: t('pages.inbounds.delAllClients') });
-  } else {
-    items.push({ type: 'divider' });
-  }
-  items.push({ key: 'delete', icon: <DeleteOutlined />, danger: true, label: t('delete') });
-  return items;
-}
-
-function RowActionsCell({ record, subEnable, hasClients, onClick }: RowActionsMenuProps) {
-  const { t } = useTranslation();
-  return (
-    <div className="action-buttons">
-      <Button type="text" size="small" icon={<EditOutlined />} onClick={() => onClick('edit')} />
-      <Dropdown
-        trigger={['click']}
-        menu={{
-          items: buildRowActionsMenu({ record, subEnable, t, hasClients }),
-          onClick: ({ key }) => onClick(key as RowAction),
-        }}
-      >
-        <Button type="text" size="small" icon={<MoreOutlined />} />
-      </Dropdown>
-    </div>
-  );
-}
-
-export default function InboundList({
-  dbInbounds,
-  clientCount,
-  lastOnlineMap: _lastOnlineMap,
-  expireDiff,
-  trafficDiff,
-  pageSize,
-  isMobile,
-  subEnable,
-  nodesById,
-  hasActiveNode,
-  onAddInbound,
-  onGeneralAction,
-  onRowAction,
-}: InboundListProps) {
-  const { t } = useTranslation();
-  const { datepicker } = useDatepicker();
-  const [sortKey, setSortKey] = useState<SortKey | null>(null);
-  const [sortOrder, setSortOrder] = useState<SortOrder>(null);
-  const [statsRecord, setStatsRecord] = useState<DBInboundRecord | null>(null);
-
-  const onSwitchEnable = useCallback(async (dbInbound: DBInboundRecord, next: boolean) => {
-    const previous = dbInbound.enable;
-    dbInbound.enable = next;
-    try {
-      const formData = new FormData();
-      formData.append('enable', String(next));
-      const msg = await HttpUtil.post(`/panel/api/inbounds/setEnable/${dbInbound.id}`, formData);
-      if (!msg?.success) dbInbound.enable = previous;
-    } catch {
-      dbInbound.enable = previous;
-    }
-  }, []);
-
-  const sortedInbounds = useMemo(() => {
-    if (!sortKey || !sortOrder) return dbInbounds;
-    const fn = SORT_FNS[sortKey];
-    if (!fn) return dbInbounds;
-    const sorted = [...dbInbounds].sort((a, b) => fn(a, b, { nodesById, clientCount }));
-    return sortOrder === 'descend' ? sorted.reverse() : sorted;
-  }, [dbInbounds, sortKey, sortOrder, nodesById, clientCount]);
-
-  const hasAnyRemark = useMemo(
-    () => dbInbounds.some((i) => typeof i.remark === 'string' && i.remark.trim() !== ''),
-    [dbInbounds],
-  );
-
-  const sorterFor = useCallback((key: SortKey) => ({
-    sorter: true as const,
-    showSorterTooltip: false,
-    sortOrder: sortKey === key ? sortOrder : null,
-    sortDirections: ['ascend' as const, 'descend' as const],
-  }), [sortKey, sortOrder]);
-
-  const columns: TableColumnType<DBInboundRecord>[] = useMemo(() => {
-    const cols: TableColumnType<DBInboundRecord>[] = [
-      {
-        title: 'ID',
-        dataIndex: 'id',
-        key: 'id',
-        align: 'right',
-        width: 30,
-        ...sorterFor('id'),
-      },
-      {
-        title: t('pages.inbounds.operate'),
-        key: 'action',
-        align: 'center',
-        width: 60,
-        render: (_, record) => (
-          <RowActionsCell
-            record={record}
-            subEnable={subEnable}
-            hasClients={(clientCount[record.id]?.clients || 0) > 0}
-            onClick={(key) => onRowAction({ key, dbInbound: record })}
-          />
-        ),
-      },
-      {
-        title: t('pages.inbounds.enable'),
-        key: 'enable',
-        align: 'center',
-        width: 35,
-        ...sorterFor('enable'),
-        render: (_, record) => (
-          <Switch
-            checked={record.enable}
-            onChange={(next) => onSwitchEnable(record, next)}
-          />
-        ),
-      },
-    ];
-
-    if (hasAnyRemark) {
-      cols.push({
-        title: t('pages.inbounds.remark'),
-        dataIndex: 'remark',
-        key: 'remark',
-        align: 'center',
-        width: 60,
-        ...sorterFor('remark'),
-      });
-    }
-
-    if (hasActiveNode) {
-      cols.push({
-        title: t('pages.inbounds.node'),
-        key: 'node',
-        align: 'center',
-        width: 60,
-        ...sorterFor('node'),
-        render: (_, record) => {
-          if (record.nodeId == null) {
-            return <Tag color="default">{t('pages.inbounds.localPanel')}</Tag>;
-          }
-          const node = nodesById.get(record.nodeId);
-          if (!node) {
-            return <Tag color="orange">node #{record.nodeId}</Tag>;
-          }
-          return (
-            <Tag color={node.status === 'online' ? 'blue' : 'red'}>{node.name}</Tag>
-          );
-        },
-      });
-    }
-
-    cols.push(
-      {
-        title: t('pages.inbounds.port'),
-        dataIndex: 'port',
-        key: 'port',
-        align: 'center',
-        width: 40,
-        ...sorterFor('port'),
-      },
-      {
-        title: t('pages.inbounds.protocol'),
-        key: 'protocol',
-        align: 'left',
-        width: 130,
-        ...sorterFor('protocol'),
-        render: (_, record) => {
-          const tags: ReactElement[] = [<Tag key="p" color="purple">{record.protocol}</Tag>];
-          if (record.isWireguard || record.isHysteria) {
-            tags.push(<Tag key="n" color="green">UDP</Tag>);
-          } else if (record.isSS) {
-            const stream = readStreamHints(record.streamSettings);
-            tags.push(<Tag key="n" color="green">{shadowsocksNetworkLabel(record.settings)}</Tag>);
-            if (stream.isTls) tags.push(<Tag key="tls" color="blue">TLS</Tag>);
-          } else if (record.isTunnel) {
-            tags.push(<Tag key="n" color="green">{tunnelNetworkLabel(record.settings)}</Tag>);
-          } else if (record.isMixed) {
-            tags.push(<Tag key="n" color="green">{mixedNetworkLabel(record.settings)}</Tag>);
-          } else if (record.isVMess || record.isVLess || record.isTrojan) {
-            const stream = readStreamHints(record.streamSettings);
-            tags.push(<Tag key="n" color="green">{networkLabel(stream.network)}</Tag>);
-            const l4 = networkL4(stream.network);
-            if (l4) tags.push(<Tag key="l4" color="green">{l4}</Tag>);
-            if (stream.isTls) tags.push(<Tag key="tls" color="blue">TLS</Tag>);
-            if (stream.isReality) tags.push(<Tag key="reality" color="blue">Reality</Tag>);
-          }
-          return <div className="protocol-tags">{tags}</div>;
-        },
-      },
-      {
-        title: t('clients'),
-        key: 'clients',
-        align: 'left',
-        width: 50,
-        ...sorterFor('clients'),
-        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>
-              {cc.deactive.length > 0 && (
-                <Popover
-                  title={t('disabled')}
-                  content={(
-                    <div className="client-email-list">
-                      {cc.deactive.map((e) => <div key={e}>{e}</div>)}
-                    </div>
-                  )}
-                >
-                  <Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.deactive.length}</Tag>
-                </Popover>
-              )}
-              {cc.depleted.length > 0 && (
-                <Popover
-                  title={t('depleted')}
-                  content={(
-                    <div className="client-email-list">
-                      {cc.depleted.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>
-                </Popover>
-              )}
-              {cc.expiring.length > 0 && (
-                <Popover
-                  title={t('depletingSoon')}
-                  content={(
-                    <div className="client-email-list">
-                      {cc.expiring.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>
-                </Popover>
-              )}
-              {cc.online.length > 0 && (
-                <Popover
-                  title={t('online')}
-                  content={(
-                    <div className="client-email-list">
-                      {cc.online.map((e) => <div key={e}>{e}</div>)}
-                    </div>
-                  )}
-                >
-                  <Tag color="blue" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.online.length}</Tag>
-                </Popover>
-              )}
-            </>
-          );
-        },
-      },
-      {
-        title: t('pages.inbounds.traffic'),
-        key: 'traffic',
-        align: 'center',
-        width: 90,
-        ...sorterFor('traffic'),
-        render: (_, record) => (
-          <Popover
-            content={(
-              <table cellPadding={2}>
-                <tbody>
-                  <tr>
-                    <td>↑ {SizeFormatter.sizeFormat(record.up)}</td>
-                    <td>↓ {SizeFormatter.sizeFormat(record.down)}</td>
-                  </tr>
-                  {record.total > 0 && record.up + record.down < record.total && (
-                    <tr>
-                      <td>{t('remained')}</td>
-                      <td>{SizeFormatter.sizeFormat(record.total - record.up - record.down)}</td>
-                    </tr>
-                  )}
-                </tbody>
-              </table>
-            )}
-          >
-            <Tag color={ColorUtils.usageColor(record.up + record.down, trafficDiff, record.total)}>
-              {SizeFormatter.sizeFormat(record.up + record.down)} /
-              {' '}
-              {record.total > 0 ? SizeFormatter.sizeFormat(record.total) : <InfinityIcon />}
-            </Tag>
-          </Popover>
-        ),
-      },
-      {
-        title: t('pages.inbounds.expireDate'),
-        key: 'expiryTime',
-        align: 'center',
-        width: 40,
-        ...sorterFor('expiryTime'),
-        render: (_, record) => {
-          if (record.expiryTime > 0) {
-            return (
-              <Popover content={IntlUtil.formatDate(record.expiryTime, datepicker)}>
-                <Tag color={ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)} style={{ minWidth: 50 }}>
-                  {IntlUtil.formatRelativeTime(record.expiryTime)}
-                </Tag>
-              </Popover>
-            );
-          }
-          return <Tag color="purple"><InfinityIcon /></Tag>;
-        },
-      },
-    );
-
-    return cols;
-  }, [t, hasAnyRemark, hasActiveNode, nodesById, clientCount, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable, sorterFor]);
-
-  const paginationFor = (rows: DBInboundRecord[]) => {
-    const size = pageSize > 0 ? pageSize : rows.length || 1;
-    return { pageSize: size, showSizeChanger: false, hideOnSinglePage: true };
-  };
-
-  const generalActionsMenu: MenuProps = {
-    items: [
-      { key: 'import', icon: <ImportOutlined />, label: t('pages.inbounds.importInbound') },
-      { key: 'export', icon: <ExportOutlined />, label: t('pages.inbounds.export') },
-      ...(subEnable
-        ? [{ key: 'subs', icon: <ExportOutlined />, label: `${t('pages.inbounds.export')} — ${t('pages.settings.subSettings')}` }]
-        : []),
-      { key: 'resetInbounds', icon: <ReloadOutlined />, label: t('pages.inbounds.resetAllTraffic') },
-    ],
-    onClick: ({ key }) => onGeneralAction(key as GeneralAction),
-  };
-
-  return (
-    <Card
-      hoverable
-      title={(
-        <Space>
-          <Button type="primary" onClick={onAddInbound} icon={<PlusOutlined />}>
-            {!isMobile && t('pages.inbounds.addInbound')}
-          </Button>
-          <Dropdown trigger={['click']} menu={generalActionsMenu}>
-            <Button type="primary" icon={<MenuOutlined />}>
-              {!isMobile && t('pages.inbounds.generalActions')}
-            </Button>
-          </Dropdown>
-        </Space>
-      )}
-    >
-      <Space orientation="vertical" style={{ width: '100%' }}>
-        {isMobile ? (
-          <div className="inbound-cards">
-            {sortedInbounds.length === 0 ? (
-              <div className="card-empty">
-                <ImportOutlined style={{ fontSize: 28, opacity: 0.5 }} />
-                <div>{t('noData')}</div>
-              </div>
-            ) : (
-              sortedInbounds.map((record) => (
-                <div key={record.id} className="inbound-card">
-                  <div className="card-head">
-                    <span className="card-id">#{record.id}</span>
-                    <span className="tag-name">{record.remark}</span>
-                    <div className="card-actions" onClick={(e) => e.stopPropagation()}>
-                      <Tooltip title={t('pages.inbounds.inboundInfo')}>
-                        <InfoCircleOutlined className="row-action-trigger" onClick={() => setStatsRecord(record)} />
-                      </Tooltip>
-                      <Switch
-                        checked={record.enable}
-                        size="small"
-                        onChange={(next) => onSwitchEnable(record, next)}
-                      />
-                      <Dropdown
-                        trigger={['click']}
-                        placement="bottomRight"
-                        menu={{
-                          items: buildRowActionsMenu({ record, subEnable, t, isMobile: true, hasClients: (clientCount[record.id]?.clients || 0) > 0 }),
-                          onClick: ({ key }) => onRowAction({ key: key as RowAction, dbInbound: record }),
-                        }}
-                      >
-                        <MoreOutlined className="row-action-trigger" onClick={(e) => e.preventDefault()} />
-                      </Dropdown>
-                    </div>
-                  </div>
-                </div>
-              ))
-            )}
-          </div>
-        ) : (
-          <Table
-            columns={columns}
-            dataSource={sortedInbounds}
-            rowKey={(r) => r.id}
-            pagination={paginationFor(sortedInbounds)}
-            scroll={{ x: 1000 }}
-            style={{ marginTop: 10 }}
-            size="small"
-            locale={{
-              emptyText: (
-                <div className="card-empty">
-                  <ImportOutlined style={{ fontSize: 32, marginBottom: 8 }} />
-                  <div>{t('noData')}</div>
-                </div>
-              ),
-            }}
-            onChange={(_p, _f, sorter) => {
-              const single = Array.isArray(sorter) ? sorter[0] : sorter;
-              const colKey = (single?.columnKey || single?.field) as SortKey | undefined;
-              setSortKey(colKey || null);
-              setSortOrder((single?.order as SortOrder) || null);
-            }}
-          />
-        )}
-      </Space>
-
-      <Modal
-        open={isMobile && !!statsRecord}
-        footer={null}
-        width={360}
-        centered
-        title={statsRecord ? `#${statsRecord.id} ${statsRecord.remark || ''}`.trim() : ''}
-        onCancel={() => setStatsRecord(null)}
-        destroyOnHidden
-      >
-        {statsRecord && (
-          <div className="card-stats">
-            <div className="stat-row">
-              <span className="stat-label">{t('pages.inbounds.protocol')}</span>
-              <Tag color="purple">{statsRecord.protocol}</Tag>
-              {(statsRecord.isWireguard || statsRecord.isHysteria) && (
-                <Tag color="green">UDP</Tag>
-              )}
-              {statsRecord.isSS && (() => {
-                const stream = readStreamHints(statsRecord.streamSettings);
-                return (
-                  <>
-                    <Tag color="green">{shadowsocksNetworkLabel(statsRecord.settings)}</Tag>
-                    {stream.isTls && <Tag color="blue">TLS</Tag>}
-                  </>
-                );
-              })()}
-              {statsRecord.isTunnel && (
-                <Tag color="green">{tunnelNetworkLabel(statsRecord.settings)}</Tag>
-              )}
-              {statsRecord.isMixed && (
-                <Tag color="green">{mixedNetworkLabel(statsRecord.settings)}</Tag>
-              )}
-              {(statsRecord.isVMess || statsRecord.isVLess || statsRecord.isTrojan) && (() => {
-                const stream = readStreamHints(statsRecord.streamSettings);
-                const l4 = networkL4(stream.network);
-                return (
-                  <>
-                    <Tag color="green">{networkLabel(stream.network)}</Tag>
-                    {l4 && <Tag color="green">{l4}</Tag>}
-                    {stream.isTls && <Tag color="blue">TLS</Tag>}
-                    {stream.isReality && <Tag color="blue">Reality</Tag>}
-                  </>
-                );
-              })()}
-            </div>
-            <div className="stat-row">
-              <span className="stat-label">{t('pages.inbounds.port')}</span>
-              <Tag>{statsRecord.port}</Tag>
-            </div>
-            {hasActiveNode && (
-              <div className="stat-row">
-                <span className="stat-label">{t('pages.inbounds.node')}</span>
-                {statsRecord.nodeId == null ? (
-                  <Tag color="default">{t('pages.inbounds.localPanel')}</Tag>
-                ) : nodesById.get(statsRecord.nodeId) ? (
-                  <Tag color={nodesById.get(statsRecord.nodeId)!.status === 'online' ? 'blue' : 'red'}>
-                    {nodesById.get(statsRecord.nodeId)!.name}
-                  </Tag>
-                ) : (
-                  <Tag color="orange">#{statsRecord.nodeId}</Tag>
-                )}
-              </div>
-            )}
-            <div className="stat-row">
-              <span className="stat-label">{t('pages.inbounds.traffic')}</span>
-              <Tag color={ColorUtils.usageColor(statsRecord.up + statsRecord.down, trafficDiff, statsRecord.total)}>
-                {SizeFormatter.sizeFormat(statsRecord.up + statsRecord.down)} /
-                {' '}
-                {statsRecord.total > 0 ? SizeFormatter.sizeFormat(statsRecord.total) : <InfinityIcon />}
-              </Tag>
-            </div>
-            {clientCount[statsRecord.id] && (
-              <div className="stat-row">
-                <span className="stat-label">{t('clients')}</span>
-                <Tag color="green" className="client-count-tag">{clientCount[statsRecord.id].clients}</Tag>
-                {clientCount[statsRecord.id].online.length > 0 && (
-                  <Tag color="blue">{clientCount[statsRecord.id].online.length} {t('online')}</Tag>
-                )}
-                {clientCount[statsRecord.id].depleted.length > 0 && (
-                  <Tag color="red">{clientCount[statsRecord.id].depleted.length} {t('depleted')}</Tag>
-                )}
-                {clientCount[statsRecord.id].expiring.length > 0 && (
-                  <Tag color="orange">{clientCount[statsRecord.id].expiring.length} {t('depletingSoon')}</Tag>
-                )}
-              </div>
-            )}
-            <div className="stat-row">
-              <span className="stat-label">{t('pages.inbounds.expireDate')}</span>
-              {statsRecord.expiryTime > 0 ? (
-                <Tag color={ColorUtils.usageColor(Date.now(), expireDiff, statsRecord._expiryTime)}>
-                  {IntlUtil.formatRelativeTime(statsRecord.expiryTime)}
-                </Tag>
-              ) : (
-                <Tag color="purple"><InfinityIcon /></Tag>
-              )}
-            </div>
-          </div>
-        )}
-      </Modal>
-    </Card>
-  );
-}

+ 42 - 11
frontend/src/pages/inbounds/InboundsPage.tsx

@@ -28,19 +28,19 @@ import { useTheme } from '@/hooks/useTheme';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { useWebSocket } from '@/hooks/useWebSocket';
 import { useNodesQuery } from '@/api/queries/useNodesQuery';
-import AppSidebar from '@/components/AppSidebar';
-const TextModal = lazy(() => import('@/components/TextModal'));
-const PromptModal = lazy(() => import('@/components/PromptModal'));
+import AppSidebar from '@/layouts/AppSidebar';
+const TextModal = lazy(() => import('@/components/feedback/TextModal'));
+const PromptModal = lazy(() => import('@/components/feedback/PromptModal'));
 
 import { useInbounds } from './useInbounds';
-import InboundList from './InboundList';
-import LazyMount from '@/components/LazyMount';
-const InboundFormModal = lazy(() => import('./InboundFormModal'));
-const InboundInfoModal = lazy(() => import('./InboundInfoModal'));
-const QrCodeModal = lazy(() => import('./QrCodeModal'));
-const AttachClientsModal = lazy(() => import('./AttachClientsModal'));
-const DetachClientsModal = lazy(() => import('./DetachClientsModal'));
-const AddClientsToGroupModal = lazy(() => import('./AddClientsToGroupModal'));
+import { InboundList } from './list';
+import { LazyMount } from '@/components/utility';
+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 DetachClientsModal = lazy(() => import('./clients/DetachClientsModal'));
+const AddClientsToGroupModal = lazy(() => import('./clients/AddClientsToGroupModal'));
 
 type RowAction =
   | 'edit'
@@ -357,6 +357,36 @@ export default function InboundsPage() {
     });
   }, [modal, refresh, t]);
 
+  const confirmBulkDelete = useCallback((ids: number[]) => new Promise<boolean>((resolve) => {
+    if (ids.length === 0) {
+      resolve(false);
+      return;
+    }
+    modal.confirm({
+      title: t('pages.inbounds.bulkDeleteConfirmTitle', { count: ids.length }),
+      content: t('pages.inbounds.bulkDeleteConfirmContent'),
+      okText: t('delete'),
+      okType: 'danger',
+      cancelText: t('cancel'),
+      onOk: async () => {
+        const msg = await HttpUtil.post('/panel/api/inbounds/bulkDel', { ids }, { headers: { 'Content-Type': 'application/json' } });
+        const obj = (msg?.obj ?? {}) as { deleted?: number; skipped?: { id: number; reason: string }[] };
+        const ok = obj.deleted ?? 0;
+        const skipped = obj.skipped ?? [];
+        if (msg?.success && skipped.length === 0) {
+          messageApi.success(t('pages.inbounds.toasts.bulkDeleted', { count: ok }));
+        } else {
+          const firstError = skipped[0]?.reason ?? msg?.msg ?? '';
+          const base = t('pages.inbounds.toasts.bulkDeletedMixed', { ok, failed: skipped.length });
+          messageApi.warning(firstError ? `${base} — ${firstError}` : base);
+        }
+        await refresh();
+        resolve(true);
+      },
+      onCancel: () => resolve(false),
+    });
+  }), [modal, refresh, t, messageApi]);
+
   const confirmResetTraffic = useCallback((dbInbound: DBInbound) => {
     modal.confirm({
       title: t('pages.inbounds.resetConfirmTitle', { remark: dbInbound.remark }),
@@ -567,6 +597,7 @@ export default function InboundsPage() {
                       onAddInbound={onAddInbound}
                       onGeneralAction={onGeneralAction}
                       onRowAction={({ key, dbInbound }) => onRowAction({ key, dbInbound: dbInbound as unknown as DBInbound })}
+                      onBulkDelete={confirmBulkDelete}
                     />
                   </Col>
                 </Row>

+ 0 - 0
frontend/src/pages/inbounds/AddClientsToGroupModal.tsx → frontend/src/pages/inbounds/clients/AddClientsToGroupModal.tsx


+ 1 - 1
frontend/src/pages/inbounds/AttachClientsModal.tsx → frontend/src/pages/inbounds/clients/AttachClientsModal.tsx

@@ -5,7 +5,7 @@ import type { ColumnsType } from 'antd/es/table';
 
 import { HttpUtil } from '@/utils';
 import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
-import { isInboundMultiUser } from './InboundList';
+import { isInboundMultiUser } from '../list';
 
 interface AttachClientsModalProps {
   open: boolean;

+ 0 - 0
frontend/src/pages/inbounds/DetachClientsModal.tsx → frontend/src/pages/inbounds/clients/DetachClientsModal.tsx


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

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

+ 123 - 0
frontend/src/pages/inbounds/form/FallbacksCard.tsx

@@ -0,0 +1,123 @@
+import { useTranslation } from 'react-i18next';
+import { Button, Card, Empty, Input, InputNumber, Select, Space } from 'antd';
+import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
+
+import { InputAddon } from '@/components/ui';
+import type { FallbackRow } from '@/schemas/forms/inbound-form';
+
+interface FallbacksCardProps {
+  fallbacks: FallbackRow[];
+  fallbackChildOptions: { label: string; value: number }[];
+  addFallback: () => void;
+  updateFallback: (rowKey: string, patch: Partial<FallbackRow>) => void;
+  removeFallback: (idx: number) => void;
+  moveFallback: (idx: number, direction: -1 | 1) => void;
+  addAllFallbacks: () => void;
+}
+
+export default function FallbacksCard({
+  fallbacks,
+  fallbackChildOptions,
+  addFallback,
+  updateFallback,
+  removeFallback,
+  moveFallback,
+  addAllFallbacks,
+}: FallbacksCardProps) {
+  const { t } = useTranslation();
+  return (
+    <Card size="small" className="mt-12" title={t('pages.inbounds.fallbacks.title') || 'Fallbacks'}>
+      {fallbacks.length === 0 && (
+        <Empty
+          description={t('pages.inbounds.fallbacks.empty') || 'No fallbacks yet'}
+          styles={{ image: { height: 40 } }}
+          style={{ margin: '8px 0 12px' }}
+        />
+      )}
+      {fallbacks.map((record, idx) => (
+        <div
+          key={record.rowKey}
+          style={{ border: '1px solid var(--app-border-tertiary)', borderRadius: 6, padding: '10px 12px', marginBottom: 8 }}
+        >
+          <Space.Compact block style={{ marginBottom: 6 }}>
+            <Select
+              value={record.childId}
+              options={fallbackChildOptions}
+              placeholder={t('pages.inbounds.fallbacks.pickInbound') || 'Pick an inbound'}
+              showSearch={{
+                filterOption: (input, option) =>
+                  ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
+              }}
+              style={{ width: '100%' }}
+              onChange={(v) => updateFallback(record.rowKey, { childId: v })}
+            />
+            <Button
+              disabled={idx === 0}
+              onClick={() => moveFallback(idx, -1)}
+              title={t('pages.inbounds.form.moveUp')}
+            >
+              <ArrowUpOutlined />
+            </Button>
+            <Button
+              disabled={idx === fallbacks.length - 1}
+              onClick={() => moveFallback(idx, 1)}
+              title={t('pages.inbounds.form.moveDown')}
+            >
+              <ArrowDownOutlined />
+            </Button>
+            <Button danger onClick={() => removeFallback(idx)}>
+              <DeleteOutlined />
+            </Button>
+          </Space.Compact>
+          <Space.Compact block>
+            <InputAddon>SNI</InputAddon>
+            <Input
+              placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
+              value={record.name}
+              onChange={(e) => updateFallback(record.rowKey, { name: e.target.value })}
+            />
+            <InputAddon>ALPN</InputAddon>
+            <Input
+              placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
+              value={record.alpn}
+              onChange={(e) => updateFallback(record.rowKey, { alpn: e.target.value })}
+            />
+            <InputAddon>Path</InputAddon>
+            <Input
+              placeholder="/"
+              value={record.path}
+              onChange={(e) => updateFallback(record.rowKey, { path: e.target.value })}
+            />
+            <InputAddon>Dest</InputAddon>
+            <Input
+              placeholder={t('pages.inbounds.fallbacks.destPlaceholder') || 'auto'}
+              value={record.dest}
+              onChange={(e) => updateFallback(record.rowKey, { dest: e.target.value })}
+            />
+            <InputAddon>xver</InputAddon>
+            <InputNumber
+              min={0}
+              max={2}
+              value={record.xver}
+              onChange={(v) => updateFallback(record.rowKey, { xver: Number(v) || 0 })}
+            />
+          </Space.Compact>
+        </div>
+      ))}
+      <Space>
+        <Button size="small" onClick={addFallback}>
+          <PlusOutlined /> {t('pages.inbounds.fallbacks.add') || 'Add fallback'}
+        </Button>
+        <Button
+          size="small"
+          onClick={addAllFallbacks}
+          disabled={fallbackChildOptions.length === 0
+            || fallbacks.length >= fallbackChildOptions.length}
+          title={t('pages.inbounds.form.addAllFallbackTooltip')}
+        >
+          {t('pages.inbounds.form.addAll')}
+        </Button>
+      </Space>
+    </Card>
+  );
+}

+ 0 - 0
frontend/src/pages/inbounds/InboundFormModal.css → frontend/src/pages/inbounds/form/InboundFormModal.css


+ 868 - 0
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -0,0 +1,868 @@
+import { useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import dayjs from 'dayjs';
+import {
+  Form,
+  Input,
+  InputNumber,
+  Modal,
+  Radio,
+  Select,
+  Switch,
+  Tabs,
+  Tooltip,
+  message,
+} from 'antd';
+
+import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils';
+import {
+  rawInboundToFormValues,
+  formValuesToWirePayload,
+} from '@/lib/xray/inbound-form-adapter';
+import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
+import {
+  canEnableReality,
+  canEnableStream,
+  canEnableTls,
+  isSS2022,
+} from '@/lib/xray/protocol-capabilities';
+import {
+  InboundFormBaseSchema,
+  InboundFormSchema,
+  type InboundFormValues,
+} from '@/schemas/forms/inbound-form';
+import { antdRule } from '@/utils/zodForm';
+import { Protocols } from '@/schemas/primitives';
+import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
+import { HysteriaStreamSettingsSchema } from '@/schemas/protocols/stream/hysteria';
+import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls';
+import { SniffingSchema } from '@/schemas/primitives/sniffing';
+import { TcpStreamSettingsSchema } from '@/schemas/protocols/stream/tcp';
+import { KcpStreamSettingsSchema } from '@/schemas/protocols/stream/kcp';
+import { WsStreamSettingsSchema } from '@/schemas/protocols/stream/ws';
+import { GrpcStreamSettingsSchema } from '@/schemas/protocols/stream/grpc';
+import { HttpUpgradeStreamSettingsSchema } from '@/schemas/protocols/stream/httpupgrade';
+import { XHttpStreamSettingsSchema } from '@/schemas/protocols/stream/xhttp';
+import { DateTimePicker } from '@/components/form';
+import { FinalMaskForm } from '@/lib/xray/forms/transport';
+import './InboundFormModal.css';
+
+import { AdvancedAllEditor, AdvancedSliceEditor } from './advanced-editors';
+import {
+  HttpFields,
+  HysteriaFields,
+  MixedFields,
+  ShadowsocksFields,
+  TunFields,
+  TunnelFields,
+  VlessFields,
+  WireguardFields,
+} from './protocols';
+import {
+  ExternalProxyForm,
+  GrpcForm,
+  HttpUpgradeForm,
+  KcpForm,
+  RawForm,
+  SockoptForm,
+  WsForm,
+  XhttpForm,
+} from './transport';
+import { RealityForm, TlsForm } from './security';
+import { useSecurityActions } from './useSecurityActions';
+import { useInboundFallbacks } from './useInboundFallbacks';
+import FallbacksCard from './FallbacksCard';
+import SniffingTab from './SniffingTab';
+
+import type { DBInbound } from '@/models/dbinbound';
+import type { NodeRecord } from '@/api/queries/useNodesQuery';
+
+
+const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p }));
+const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'] as const;
+const NODE_ELIGIBLE_PROTOCOLS = new Set<string>([
+  Protocols.VLESS,
+  Protocols.VMESS,
+  Protocols.TROJAN,
+  Protocols.SHADOWSOCKS,
+  Protocols.HYSTERIA,
+  Protocols.WIREGUARD,
+]);
+
+interface InboundFormModalProps {
+  open: boolean;
+  onClose: () => void;
+  onSaved: () => void;
+  mode: 'add' | 'edit';
+  dbInbound: DBInbound | null;
+  dbInbounds: DBInbound[];
+  availableNodes?: NodeRecord[];
+}
+
+function buildAddModeValues(): InboundFormValues {
+  const settings = createDefaultInboundSettings('vless') ?? undefined;
+  return rawInboundToFormValues({
+    protocol: 'vless',
+    settings,
+    streamSettings: {
+      network: 'tcp',
+      security: 'none',
+      tcpSettings: TcpStreamSettingsSchema.parse({ header: { type: 'none' } }),
+    },
+    sniffing: SniffingSchema.parse({}),
+    port: RandomUtil.randomInteger(10000, 60000),
+    listen: '',
+    tag: '',
+    enable: true,
+    trafficReset: 'never',
+  });
+}
+
+export default function InboundFormModal({
+  open,
+  onClose,
+  onSaved,
+  mode,
+  dbInbound,
+  dbInbounds,
+  availableNodes,
+}: InboundFormModalProps) {
+  const { t } = useTranslation();
+  const [messageApi, messageContextHolder] = message.useMessage();
+  const [form] = Form.useForm<InboundFormValues>();
+  const [saving, setSaving] = useState(false);
+  const {
+    fallbacks,
+    fallbackChildOptions,
+    loadFallbacks,
+    saveFallbacks,
+    addFallback,
+    updateFallback,
+    removeFallback,
+    moveFallback,
+    addAllFallbacks,
+  } = useInboundFallbacks(dbInbound, dbInbounds);
+
+  const selectableNodes = (availableNodes || []).filter((n) => n.enable);
+  const protocol = (Form.useWatch('protocol', form) ?? '') as string;
+  const isNodeEligible = NODE_ELIGIBLE_PROTOCOLS.has(protocol);
+  const sniffingEnabled = Form.useWatch(['sniffing', 'enabled'], form) ?? false;
+  const vlessEncryption = Form.useWatch(['settings', 'encryption'], form) ?? '';
+  const ssMethod = Form.useWatch(['settings', 'method'], form);
+  const isSSWith2022 = isSS2022({
+    protocol,
+    settings: typeof ssMethod === 'string' ? { method: ssMethod } : {},
+  });
+  const mixedUdpOn = Form.useWatch(['settings', 'udp'], form) ?? false;
+  const network = Form.useWatch(['streamSettings', 'network'], form) ?? '';
+  const security = Form.useWatch(['streamSettings', 'security'], form) ?? 'none';
+  const streamEnabled = canEnableStream({ protocol });
+  const isFallbackHost =
+    (protocol === Protocols.VLESS || protocol === Protocols.TROJAN)
+    && network === 'tcp'
+    && (security === 'tls' || security === 'reality');
+
+  const {
+    genRealityKeypair,
+    clearRealityKeypair,
+    genMldsa65,
+    clearMldsa65,
+    randomizeRealityTarget,
+    randomizeShortIds,
+    getNewEchCert,
+    clearEchCert,
+    generateRandomPinHash,
+    setCertFromPanel,
+    clearCertFiles,
+    onSecurityChange,
+  } = useSecurityActions({ form, setSaving, messageApi });
+
+  const toggleExternalProxy = (on: boolean) => {
+    if (on) {
+      const port = (form.getFieldValue('port') as number) ?? 443;
+      form.setFieldValue(['streamSettings', 'externalProxy'], [{
+        forceTls: 'same',
+        dest: typeof window !== 'undefined' ? window.location.hostname : '',
+        port,
+        remark: '',
+        sni: '',
+        fingerprint: '',
+        alpn: [],
+      }]);
+    } else {
+      form.setFieldValue(['streamSettings', 'externalProxy'], []);
+    }
+  };
+
+  const toggleSockopt = (on: boolean) => {
+    if (on) {
+      form.setFieldValue(
+        ['streamSettings', 'sockopt'],
+        SockoptStreamSettingsSchema.parse({}),
+      );
+    } else {
+      form.setFieldValue(['streamSettings', 'sockopt'], undefined);
+    }
+  };
+  const wgSecretKey = Form.useWatch(['settings', 'secretKey'], form);
+  const wgPubKey = typeof wgSecretKey === 'string' && wgSecretKey.length > 0
+    ? Wireguard.generateKeypair(wgSecretKey).publicKey
+    : '';
+
+  const regenInboundWg = () => {
+    const kp = Wireguard.generateKeypair();
+    form.setFieldValue(['settings', 'secretKey'], kp.privateKey);
+  };
+
+  const regenWgPeerKeypair = (peerName: number) => {
+    const kp = Wireguard.generateKeypair();
+    form.setFieldValue(['settings', 'peers', peerName, 'privateKey'], kp.privateKey);
+    form.setFieldValue(['settings', 'peers', peerName, 'publicKey'], kp.publicKey);
+  };
+
+  const matchesVlessAuth = (
+    block: { id?: string; label?: string } | undefined | null,
+    authId: string,
+  ) => {
+    if (block?.id === authId) return true;
+    const label = (block?.label || '').toLowerCase().replace(/[-_\s]/g, '');
+    if (authId === 'mlkem768') return label.includes('mlkem768');
+    if (authId === 'x25519') return label.includes('x25519');
+    return false;
+  };
+
+  const getNewVlessEnc = async (authId: string) => {
+    if (!authId) return;
+    setSaving(true);
+    try {
+      const msg = await HttpUtil.get('/panel/api/server/getNewVlessEnc');
+      if (!msg?.success) return;
+      const obj = msg.obj as {
+        auths?: { decryption: string; encryption: string; label?: string; id?: string }[];
+      };
+      const block = (obj.auths || []).find((a) => matchesVlessAuth(a, authId));
+      if (!block) return;
+      form.setFieldValue(['settings', 'decryption'], block.decryption);
+      form.setFieldValue(['settings', 'encryption'], block.encryption);
+    } finally {
+      setSaving(false);
+    }
+  };
+
+  const clearVlessEnc = () => {
+    form.setFieldValue(['settings', 'decryption'], 'none');
+    form.setFieldValue(['settings', 'encryption'], 'none');
+  };
+
+  const selectedVlessAuth = (() => {
+    const enc = typeof vlessEncryption === 'string' ? vlessEncryption : '';
+    if (!enc || enc === 'none') return 'None';
+    const parts = enc.split('.').filter(Boolean);
+    const authKey = parts[parts.length - 1] || '';
+    if (!authKey) return t('pages.inbounds.vlessAuthCustom');
+    return authKey.length > 300
+      ? t('pages.inbounds.vlessAuthMlkem768')
+      : t('pages.inbounds.vlessAuthX25519');
+  })();
+
+  useEffect(() => {
+    if (!open) return;
+    const initial = mode === 'edit' && dbInbound
+      ? rawInboundToFormValues(dbInbound)
+      : buildAddModeValues();
+    form.resetFields();
+    form.setFieldsValue(initial);
+    if (
+      mode === 'edit'
+      && dbInbound
+      && (dbInbound.protocol === Protocols.VLESS || dbInbound.protocol === Protocols.TROJAN)
+    ) {
+      loadFallbacks(dbInbound.id);
+    } else {
+      loadFallbacks(null);
+    }
+
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [open, mode, dbInbound, form]);
+
+  // Why: protocol picker reset cascades through the form — clearing the
+  // settings DU branch and dropping a nodeId that no longer applies. The
+  // legacy modal did this imperatively in onProtocolChange; here we hook
+  // into AntD's onValuesChange and let setFieldValue keep the rest of
+  // the form state intact.
+  const onValuesChange = (changed: Partial<InboundFormValues>) => {
+    if (mode === 'edit') return;
+    if ('protocol' in changed && typeof changed.protocol === 'string') {
+      const next = changed.protocol;
+      const settings = createDefaultInboundSettings(next) ?? undefined;
+      form.setFieldValue('settings', settings);
+      if (!NODE_ELIGIBLE_PROTOCOLS.has(next)) {
+        form.setFieldValue('nodeId', null);
+      }
+      // Hysteria uses its dedicated transport — force the network branch
+      // so the stream tab renders the hysteria sub-form, not the leftover
+      // tcpSettings from the previous protocol. When leaving hysteria,
+      // snap back to TCP so the standard network selector has a valid
+      // starting point.
+      if (next === Protocols.HYSTERIA) {
+        const tls = TlsStreamSettingsSchema.parse({}) as Record<string, unknown>;
+        tls.certificates = [{
+          useFile: true,
+          certificateFile: '',
+          keyFile: '',
+          certificate: [],
+          key: [],
+          oneTimeLoading: false,
+          usage: 'encipherment',
+          buildChain: false,
+        }];
+        form.setFieldValue('streamSettings', {
+          network: 'hysteria',
+          security: 'tls',
+          hysteriaSettings: HysteriaStreamSettingsSchema.parse({}),
+          tlsSettings: tls,
+          // Hysteria2 needs an obfs wrapper on the FinalMask side; seed
+          // it with salamander + a random password so the listener boots
+          // with a usable default. Re-selecting Hysteria from another
+          // protocol re-runs this and refreshes the password — that's
+          // intentional, the form was already being reset.
+          finalmask: {
+            tcp: [],
+            udp: [{
+              type: 'salamander',
+              settings: { password: RandomUtil.randomLowerAndNum(16) },
+            }],
+          },
+        });
+      } else {
+        const current = form.getFieldValue('streamSettings') as { network?: string } | undefined;
+        if (current?.network === 'hysteria') {
+          form.setFieldValue('streamSettings', { network: 'tcp', security: 'none', tcpSettings: {} });
+        }
+      }
+    }
+  };
+
+  const submit = async () => {
+    try {
+      await form.validateFields();
+    } catch {
+      return;
+    }
+    // Why getFieldsValue(true) instead of the validateFields return value:
+    // rc-component/form's validateFields filters its output by REGISTERED
+    // name paths. settings.clients and settings.fallbacks have no Form.Item
+    // bound to them (clients are managed via the standalone Client modal,
+    // not inside this inbound modal) — so validateFields would drop them
+    // and the update wire payload would silently delete every client on
+    // every save. getFieldsValue(true) returns the entire form store and
+    // keeps those sub-trees intact.
+    const values = form.getFieldsValue(true) as InboundFormValues;
+    const parsed = InboundFormSchema.safeParse(values);
+    if (!parsed.success) {
+      const issue = parsed.error.issues[0];
+      const path = Array.isArray(issue?.path) && issue.path.length > 0
+        ? issue.path.join('.')
+        : '';
+      const baseMsg = issue?.message ?? 'somethingWentWrong';
+      const display = path ? `${path}: ${baseMsg}` : baseMsg;
+      messageApi.error(t(baseMsg, { defaultValue: display }));
+      console.error('[InboundFormModal] schema validation failed', {
+        path: issue?.path,
+        message: issue?.message,
+        values,
+      });
+      return;
+    }
+    setSaving(true);
+    try {
+      const payload = formValuesToWirePayload(parsed.data);
+      const url = mode === 'edit' && dbInbound
+        ? `/panel/api/inbounds/update/${dbInbound.id}`
+        : '/panel/api/inbounds/add';
+      const msg = await HttpUtil.post(url, payload);
+      if (msg?.success) {
+        if (isFallbackHost) {
+          const obj = msg.obj as { id?: number; Id?: number } | null;
+          const masterId = mode === 'edit'
+            ? dbInbound!.id
+            : (obj?.id ?? obj?.Id ?? 0);
+          if (masterId) await saveFallbacks(masterId);
+        }
+        onSaved();
+        onClose();
+      }
+    } finally {
+      setSaving(false);
+    }
+  };
+
+  const title = mode === 'edit'
+    ? t('pages.inbounds.modifyInbound')
+    : t('pages.inbounds.addInbound');
+
+  const okText = mode === 'edit'
+    ? t('pages.clients.submitEdit')
+    : t('create');
+
+  const basicTab = (
+    <>
+      <Form.Item name="tag" hidden noStyle><Input /></Form.Item>
+      <Form.Item name="up" hidden noStyle><InputNumber /></Form.Item>
+      <Form.Item name="down" hidden noStyle><InputNumber /></Form.Item>
+      <Form.Item name="total" hidden noStyle><InputNumber /></Form.Item>
+      <Form.Item name="expiryTime" hidden noStyle><InputNumber /></Form.Item>
+      <Form.Item name="lastTrafficResetTime" hidden noStyle><InputNumber /></Form.Item>
+      <Form.Item name="clientStats" hidden noStyle><Input /></Form.Item>
+
+      <Form.Item name="enable" label={t('enable')} valuePropName="checked">
+        <Switch />
+      </Form.Item>
+
+      <Form.Item name="remark" label={t('pages.inbounds.remark')}>
+        <Input />
+      </Form.Item>
+
+      {selectableNodes.length > 0 && isNodeEligible && (
+        <Form.Item name="nodeId" label={t('pages.inbounds.deployTo')}>
+          <Select
+            disabled={mode === 'edit'}
+            placeholder={t('pages.inbounds.localPanel')}
+            allowClear
+            options={selectableNodes.map((n) => ({
+              value: n.id,
+              label: `${n.name}${n.status === 'offline' ? ' (offline)' : ''}`,
+              disabled: n.status === 'offline',
+            }))}
+          />
+        </Form.Item>
+      )}
+
+      <Form.Item name="protocol" label={t('pages.inbounds.protocol')}>
+        <Select disabled={mode === 'edit'} options={PROTOCOL_OPTIONS} />
+      </Form.Item>
+
+      <Form.Item name="listen" label={t('pages.inbounds.address')}>
+        <Input placeholder={t('pages.inbounds.monitorDesc')} />
+      </Form.Item>
+
+      <Form.Item
+        name="port"
+        label={t('pages.inbounds.port')}
+        rules={[antdRule(InboundFormBaseSchema.shape.port, t)]}
+      >
+        <InputNumber min={1} max={65535} />
+      </Form.Item>
+
+      <Form.Item
+        label={
+          <Tooltip title={t('pages.inbounds.meansNoLimit')}>
+            {t('pages.inbounds.totalFlow')}
+          </Tooltip>
+        }
+      >
+        <Form.Item
+          noStyle
+          shouldUpdate={(prev, curr) => prev.total !== curr.total}
+        >
+          {({ getFieldValue, setFieldValue }) => {
+            const totalBytes = (getFieldValue('total') as number) ?? 0;
+            const totalGB = totalBytes
+              ? Math.round((totalBytes / SizeFormatter.ONE_GB) * 100) / 100
+              : 0;
+            return (
+              <InputNumber
+                value={totalGB}
+                min={0}
+                step={1}
+                onChange={(v) => {
+                  const bytes = NumberFormatter.toFixed(
+                    (Number(v) || 0) * SizeFormatter.ONE_GB,
+                    0,
+                  );
+                  setFieldValue('total', bytes);
+                }}
+              />
+            );
+          }}
+        </Form.Item>
+      </Form.Item>
+
+      <Form.Item name="trafficReset" label={t('pages.inbounds.periodicTrafficResetTitle')}>
+        <Select
+          options={TRAFFIC_RESETS.map((r) => ({
+            value: r,
+            label: t(`pages.inbounds.periodicTrafficReset.${r}`),
+          }))}
+        />
+      </Form.Item>
+
+      <Form.Item
+        label={
+          <Tooltip title={t('pages.inbounds.leaveBlankToNeverExpire')}>
+            {t('pages.inbounds.expireDate')}
+          </Tooltip>
+        }
+      >
+        <Form.Item
+          noStyle
+          shouldUpdate={(prev, curr) => prev.expiryTime !== curr.expiryTime}
+        >
+          {({ getFieldValue, setFieldValue }) => {
+            const expiry = (getFieldValue('expiryTime') as number) ?? 0;
+            return (
+              <DateTimePicker
+                value={expiry > 0 ? dayjs(expiry) : null}
+                onChange={(d) => setFieldValue('expiryTime', d ? d.valueOf() : 0)}
+              />
+            );
+          }}
+        </Form.Item>
+      </Form.Item>
+    </>
+  );
+
+  const fallbacksCard = (
+    <FallbacksCard
+      fallbacks={fallbacks}
+      fallbackChildOptions={fallbackChildOptions}
+      addFallback={addFallback}
+      updateFallback={updateFallback}
+      removeFallback={removeFallback}
+      moveFallback={moveFallback}
+      addAllFallbacks={addAllFallbacks}
+    />
+  );
+
+  const protocolTab = (
+    <>
+      {protocol === Protocols.WIREGUARD && <WireguardFields wgPubKey={wgPubKey} regenInboundWg={regenInboundWg} regenWgPeerKeypair={regenWgPeerKeypair} />}
+
+      {protocol === Protocols.TUN && <TunFields />}
+
+      {protocol === Protocols.TUNNEL && <TunnelFields />}
+
+      {protocol === Protocols.HTTP && <HttpFields />}
+      {protocol === Protocols.MIXED && <MixedFields mixedUdpOn={mixedUdpOn} />}
+
+      {protocol === Protocols.SHADOWSOCKS && <ShadowsocksFields form={form} isSSWith2022={isSSWith2022} />}
+
+      {protocol === Protocols.VLESS && <VlessFields saving={saving} selectedVlessAuth={selectedVlessAuth} network={network} security={security} getNewVlessEnc={getNewVlessEnc} clearVlessEnc={clearVlessEnc} />}
+
+      {isFallbackHost && fallbacksCard}
+    </>
+  );
+
+  // Switching `network` swaps which per-network key (tcpSettings,
+  // wsSettings, grpcSettings, ...) appears on the wire. Clear the old
+  // network's blob and seed the new one with the schema defaults so the
+  // Form.Items inside it have valid initial values (KCP needs MTU=1350
+  // etc., not empty strings).
+  // Seed each network's settings blob with its Zod schema defaults so
+  // every Form.Item inside the network sub-form has a defined starting
+  // value. XHTTP in particular has ~20 fields (sessionPlacement,
+  // seqPlacement, xPaddingMethod, uplinkHTTPMethod, ...) whose value
+  // is the literal "" sentinel meaning "let xray-core pick its
+  // default". Without seeding "", the Form.Item reads `undefined` and
+  // the Select shows blank instead of the "Default (path)" option.
+  const newStreamSlice = (n: string): Record<string, unknown> => {
+    switch (n) {
+      case 'tcp': return TcpStreamSettingsSchema.parse({ header: { type: 'none' } });
+      case 'kcp': return KcpStreamSettingsSchema.parse({});
+      case 'ws': return WsStreamSettingsSchema.parse({});
+      case 'grpc': return GrpcStreamSettingsSchema.parse({});
+      case 'httpupgrade': return HttpUpgradeStreamSettingsSchema.parse({});
+      case 'xhttp': return XHttpStreamSettingsSchema.parse({});
+      default: return {};
+    }
+  };
+  const onNetworkChange = (next: string) => {
+    const ALL = ['tcpSettings', 'kcpSettings', 'wsSettings', 'grpcSettings', 'httpupgradeSettings', 'xhttpSettings'];
+    const current = (form.getFieldValue('streamSettings') as Record<string, unknown>) ?? {};
+    const cleaned: Record<string, unknown> = { ...current, network: next };
+    for (const k of ALL) {
+      if (k !== `${next}Settings`) delete cleaned[k];
+    }
+    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
+    // instead of unobfuscated mKCP traffic. The user can still edit or
+    // clear the mask via the FinalMask section.
+    if (next === 'kcp') {
+      const fm = (cleaned.finalmask as Record<string, unknown> | undefined) ?? {};
+      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';
+      });
+      if (!hasMkcp) {
+        cleaned.finalmask = {
+          ...fm,
+          udp: [...udp, { type: 'mkcp-original', settings: {} }],
+        };
+      }
+    }
+    form.setFieldValue('streamSettings', cleaned);
+  };
+
+  const streamTab = (
+    <>
+      {protocol !== Protocols.HYSTERIA && (
+        <Form.Item label={t('transmission')} name={['streamSettings', 'network']}>
+          <Select
+            style={{ width: '75%' }}
+            onChange={onNetworkChange}
+            options={[
+              { value: 'tcp', label: 'RAW' },
+              { value: 'kcp', label: 'mKCP' },
+              { value: 'ws', label: 'WebSocket' },
+              { value: 'grpc', label: 'gRPC' },
+              { value: 'httpupgrade', label: 'HTTPUpgrade' },
+              { value: 'xhttp', label: 'XHTTP' },
+            ]}
+          />
+        </Form.Item>
+      )}
+
+      {/* Inbound Hysteria stream sub-form. The transport for hysteria
+          isn't user-selectable (always 'hysteria'), so the network
+          dropdown is hidden above. Fields here mirror the legacy
+          HysteriaStreamSettings inbound class: version is locked to 2,
+          auth + udpIdleTimeout are required, masquerade is an optional
+          sub-object that lets xray-core disguise the listener as an
+          HTTP server when probed. */}
+      {protocol === Protocols.HYSTERIA && <HysteriaFields form={form} />}
+
+      {network === 'tcp' && <RawForm />}
+
+      {network === 'ws' && <WsForm />}
+
+      {network === 'grpc' && <GrpcForm />}
+
+      {network === 'xhttp' && <XhttpForm form={form} />}
+
+      {network === 'httpupgrade' && <HttpUpgradeForm />}
+
+      {network === 'kcp' && <KcpForm />}
+
+      <ExternalProxyForm toggleExternalProxy={toggleExternalProxy} />
+
+      <SockoptForm toggleSockopt={toggleSockopt} />
+
+      <FinalMaskForm
+        name={['streamSettings', 'finalmask']}
+        network={network as string}
+        protocol={protocol}
+        form={form}
+      />
+    </>
+  );
+
+  const securityTab = (
+    <>
+      <Form.Item name={['streamSettings', 'security']} hidden noStyle>
+        <Input />
+      </Form.Item>
+      <Form.Item label={t('pages.inbounds.securityTab')}>
+        <Form.Item
+          noStyle
+          shouldUpdate={(prev, curr) =>
+            prev.streamSettings?.security !== curr.streamSettings?.security
+            || prev.streamSettings?.network !== curr.streamSettings?.network
+            || prev.protocol !== curr.protocol
+          }
+        >
+          {({ getFieldValue }) => {
+            const sec = getFieldValue(['streamSettings', 'security']) ?? 'none';
+            const net = getFieldValue(['streamSettings', 'network']) ?? '';
+            const proto = getFieldValue('protocol') ?? '';
+            const tlsOk = canEnableTls({ protocol: proto, streamSettings: { network: net, security: sec } });
+            const realityOk = canEnableReality({ protocol: proto, streamSettings: { network: net, security: sec } });
+            const tlsOnly = proto === Protocols.HYSTERIA;
+            return (
+              <Radio.Group
+                value={sec}
+                buttonStyle="solid"
+                disabled={!tlsOk}
+                onChange={(e) => onSecurityChange(e.target.value)}
+              >
+                {!tlsOnly && <Radio.Button value="none">{t('none')}</Radio.Button>}
+                <Radio.Button value="tls">TLS</Radio.Button>
+                {realityOk && <Radio.Button value="reality">Reality</Radio.Button>}
+              </Radio.Group>
+            );
+          }}
+        </Form.Item>
+      </Form.Item>
+
+      {security === 'tls' && (
+        <TlsForm
+          saving={saving}
+          setCertFromPanel={setCertFromPanel}
+          clearCertFiles={clearCertFiles}
+          generateRandomPinHash={generateRandomPinHash}
+          getNewEchCert={getNewEchCert}
+          clearEchCert={clearEchCert}
+        />
+      )}
+
+      {security === 'reality' && (
+        <RealityForm
+          saving={saving}
+          randomizeRealityTarget={randomizeRealityTarget}
+          randomizeShortIds={randomizeShortIds}
+          genRealityKeypair={genRealityKeypair}
+          clearRealityKeypair={clearRealityKeypair}
+          genMldsa65={genMldsa65}
+          clearMldsa65={clearMldsa65}
+        />
+      )}
+    </>
+  );
+
+  const advancedTab = (
+    <div className="advanced-shell">
+      <div className="advanced-panel">
+        <div className="advanced-panel__header">
+          <div>
+            <div className="advanced-panel__title">{t('pages.inbounds.advanced.title')}</div>
+            <div className="advanced-panel__subtitle">{t('pages.inbounds.advanced.subtitle')}</div>
+          </div>
+        </div>
+        <Tabs
+          className="advanced-inner-tabs"
+          items={[
+            {
+              key: 'all',
+              label: t('pages.inbounds.advanced.all'),
+              children: (
+                <>
+                  <div className="advanced-editor-meta">
+                    {t('pages.inbounds.advanced.allHelp')}
+                  </div>
+                  <AdvancedAllEditor form={form} streamEnabled={streamEnabled} />
+                </>
+              ),
+            },
+            {
+              key: 'settings',
+              label: t('pages.inbounds.advanced.settings'),
+              children: (
+                <>
+                  <div className="advanced-editor-meta">
+                    {t('pages.inbounds.advanced.settingsHelp')}{' '}
+                    <code>{'{ settings: { ... } }'}</code>.
+                  </div>
+                  <AdvancedSliceEditor
+                    form={form}
+                    path="settings"
+                    wrapKey="settings"
+                    minHeight="320px"
+                    maxHeight="540px"
+                  />
+                </>
+              ),
+            },
+            ...(streamEnabled
+              ? [{
+                key: 'stream',
+                label: t('pages.inbounds.advanced.stream'),
+                children: (
+                  <>
+                    <div className="advanced-editor-meta">
+                      {t('pages.inbounds.advanced.streamHelp')}{' '}
+                      <code>{'{ streamSettings: { ... } }'}</code>.
+                    </div>
+                    <AdvancedSliceEditor
+                      form={form}
+                      path="streamSettings"
+                      wrapKey="streamSettings"
+                      minHeight="320px"
+                      maxHeight="540px"
+                    />
+                  </>
+                ),
+              }]
+              : []),
+            {
+              key: 'sniffing',
+              label: t('pages.inbounds.advanced.sniffing'),
+              children: (
+                <>
+                  <div className="advanced-editor-meta">
+                    {t('pages.inbounds.advanced.sniffingHelp')}{' '}
+                    <code>{'{ sniffing: { ... } }'}</code>.
+                  </div>
+                  <AdvancedSliceEditor
+                    form={form}
+                    path="sniffing"
+                    wrapKey="sniffing"
+                    minHeight="240px"
+                    maxHeight="420px"
+                  />
+                </>
+              ),
+            },
+          ]}
+        />
+      </div>
+    </div>
+  );
+
+  const sniffingTab = <SniffingTab sniffingEnabled={sniffingEnabled} />;
+
+  return (
+    <>
+      {messageContextHolder}
+      <Modal
+        open={open}
+        title={title}
+        okText={okText}
+        cancelText={t('close')}
+        confirmLoading={saving}
+        mask={{ closable: false }}
+        width={780}
+        onOk={submit}
+        onCancel={onClose}
+        destroyOnHidden
+      >
+        <Form
+          form={form}
+          colon={false}
+          labelCol={{ sm: { span: 8 } }}
+          wrapperCol={{ sm: { span: 14 } }}
+          onValuesChange={onValuesChange}
+        >
+          <Tabs items={[
+            // forceRender on every tab so all Form.Items register at modal
+            // open, not lazily on first visit. Without it, AntD's items API
+            // lazy-mounts inactive tabs — their fields don't register, so
+            // Form.useWatch on a parent path (e.g. 'sniffing') returns the
+            // partial-view {} until the user touches the tab and the
+            // inner Form.Item for `sniffing.enabled` registers.
+            { key: 'basic', label: t('pages.xray.basicTemplate'), children: basicTab, forceRender: true },
+            ...(([
+              Protocols.VLESS,
+              Protocols.SHADOWSOCKS,
+              Protocols.HTTP,
+              Protocols.MIXED,
+              Protocols.TUNNEL,
+              Protocols.TUN,
+              Protocols.WIREGUARD,
+            ] as string[]).includes(protocol) || isFallbackHost
+              ? [{ key: 'protocol', label: t('pages.inbounds.protocol'), children: protocolTab, forceRender: true }]
+              : []),
+            ...(streamEnabled
+              ? [
+                { key: 'stream', label: t('pages.inbounds.streamTab'), children: streamTab, forceRender: true },
+                { key: 'security', label: t('pages.inbounds.securityTab'), children: securityTab, forceRender: true },
+              ]
+              : []),
+            { key: 'sniffing', label: t('pages.inbounds.sniffingTab'), children: sniffingTab, forceRender: true },
+            { key: 'advanced', label: t('pages.xray.advancedTemplate'), children: advancedTab, forceRender: true },
+          ]} />
+        </Form>
+      </Modal>
+    </>
+  );
+}

+ 67 - 0
frontend/src/pages/inbounds/form/SniffingTab.tsx

@@ -0,0 +1,67 @@
+import { useTranslation } from 'react-i18next';
+import { Checkbox, Form, Select, Switch } from 'antd';
+
+import { SNIFFING_OPTION } from '@/schemas/primitives';
+
+export default function SniffingTab({ sniffingEnabled }: { sniffingEnabled: boolean }) {
+  const { t } = useTranslation();
+  return (
+    <>
+      <Form.Item name={['sniffing', 'enabled']} label={t('enable')} valuePropName="checked">
+        <Switch />
+      </Form.Item>
+
+      {sniffingEnabled && (
+        <>
+          <Form.Item name={['sniffing', 'destOverride']} wrapperCol={{ span: 24 }}>
+            <Checkbox.Group>
+              {Object.entries(SNIFFING_OPTION).map(([key, value]) => (
+                <Checkbox key={key} value={value}>{key}</Checkbox>
+              ))}
+            </Checkbox.Group>
+          </Form.Item>
+
+          <Form.Item
+            name={['sniffing', 'metadataOnly']}
+            label={t('pages.inbounds.sniffingMetadataOnly')}
+            valuePropName="checked"
+          >
+            <Switch />
+          </Form.Item>
+
+          <Form.Item
+            name={['sniffing', 'routeOnly']}
+            label={t('pages.inbounds.sniffingRouteOnly')}
+            valuePropName="checked"
+          >
+            <Switch />
+          </Form.Item>
+
+          <Form.Item
+            name={['sniffing', 'ipsExcluded']}
+            label={t('pages.inbounds.sniffingIpsExcluded')}
+          >
+            <Select
+              mode="tags"
+              tokenSeparators={[',']}
+              placeholder="IP/CIDR/geoip:*/ext:*"
+              style={{ width: '100%' }}
+            />
+          </Form.Item>
+
+          <Form.Item
+            name={['sniffing', 'domainsExcluded']}
+            label={t('pages.inbounds.sniffingDomainsExcluded')}
+          >
+            <Select
+              mode="tags"
+              tokenSeparators={[',']}
+              placeholder="domain:*/ext:*"
+              style={{ width: '100%' }}
+            />
+          </Form.Item>
+        </>
+      )}
+    </>
+  );
+}

+ 184 - 0
frontend/src/pages/inbounds/form/advanced-editors.tsx

@@ -0,0 +1,184 @@
+import { useEffect, useRef, useState } from 'react';
+import { Form, type FormInstance } from 'antd';
+import type { NamePath } from 'antd/es/form/interface';
+
+import { JsonEditor } from '@/components/form';
+import {
+  pruneEmpty,
+  normalizeSniffing,
+  normalizeClients,
+  dropLegacyOptionalEmpties,
+} from '@/lib/xray/inbound-form-adapter';
+import type { InboundFormValues } from '@/schemas/forms/inbound-form';
+
+// Sub-editor for one slice of the form (settings, streamSettings, sniffing).
+// Holds a local text buffer so the user can type freely; on every keystroke
+// we try to JSON.parse and forward the result to form state. Invalid JSON
+// is held in the buffer until the next valid moment — no panic on partial
+// input. The buffer seeds once on mount; the modal's destroyOnHidden makes
+// each open a fresh editor instance, so we don't need to re-sync on outer
+// form changes.
+export function AdvancedSliceEditor({
+  form,
+  path,
+  wrapKey,
+  minHeight,
+  maxHeight,
+}: {
+  form: FormInstance<InboundFormValues>;
+  path: NamePath;
+  // When set, the editor wraps the inner value with `{ [wrapKey]: ... }` so
+  // the JSON the user sees matches the wire shape's slice envelope (e.g.
+  // `{ "settings": { ... } }`). Edits unwrap the outer key before writing
+  // back to the form. Mirrors the legacy modal's wrappedConfigValue.
+  wrapKey?: string;
+  minHeight?: string;
+  maxHeight?: string;
+}) {
+  const serialize = (value: unknown): string => {
+    const inner = value ?? {};
+    return JSON.stringify(wrapKey ? { [wrapKey]: inner } : inner, null, 2);
+  };
+
+  // preserve: true so useWatch returns the full subtree from the form
+  // store — without it, useWatch goes through getFieldsValue() which
+  // filters out unregistered fields. Slices like `settings` would lose
+  // their `clients` / `fallbacks` sub-trees because those aren't bound
+  // to any Form.Item.
+  const watched = Form.useWatch(path, { form, preserve: true });
+  const lastEmitRef = useRef<string>('');
+  const [text, setText] = useState(() => {
+    const initial = serialize(form.getFieldValue(path));
+    lastEmitRef.current = initial;
+    return initial;
+  });
+
+  useEffect(() => {
+    const formStr = serialize(watched);
+    if (formStr === lastEmitRef.current) return;
+    setText(formStr);
+    lastEmitRef.current = formStr;
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [watched, wrapKey]);
+
+  return (
+    <JsonEditor
+      value={text}
+      minHeight={minHeight}
+      maxHeight={maxHeight}
+      onChange={(next) => {
+        setText(next);
+        try {
+          const parsed = JSON.parse(next);
+          const toWrite = wrapKey && parsed && typeof parsed === 'object' && !Array.isArray(parsed)
+            ? (parsed as Record<string, unknown>)[wrapKey] ?? {}
+            : parsed;
+          form.setFieldValue(path, toWrite);
+          lastEmitRef.current = JSON.stringify(wrapKey ? { [wrapKey]: toWrite } : toWrite, null, 2);
+        } catch {
+          // invalid JSON; keep buffer, don't push to form
+        }
+      }}
+    />
+  );
+}
+
+// The "All" editor shows the full inbound JSON in one editor: top-level
+// connection fields plus the three nested sub-objects (settings,
+// streamSettings, sniffing). Edits round-trip back to the form's slices,
+// mirroring the legacy modal's setAdvancedAllValue behavior. Reactivity
+// works the same way as AdvancedSliceEditor: useWatch on the slices we
+// care about, lastEmitRef as the "we wrote this" guard.
+export function AdvancedAllEditor({
+  form,
+  streamEnabled,
+}: {
+  form: FormInstance<InboundFormValues>;
+  streamEnabled: boolean;
+}) {
+  // preserve: true — default useWatch returns only registered fields, so
+  // sub-trees we never bound (settings.clients/fallbacks, sniffing
+  // defaults, etc.) wouldn't show up. preserve switches the read to
+  // getFieldsValue(true) which returns the full form store.
+  const wListen = Form.useWatch('listen', { form, preserve: true });
+  const wPort = Form.useWatch('port', { form, preserve: true });
+  const wProtocol = Form.useWatch('protocol', { form, preserve: true });
+  const wTag = Form.useWatch('tag', { form, preserve: true });
+  const wSettings = Form.useWatch('settings', { form, preserve: true });
+  const wSniffing = Form.useWatch('sniffing', { form, preserve: true });
+  const wStream = Form.useWatch('streamSettings', { form, preserve: true });
+
+  const serialize = () => {
+    // Apply the same prune/normalize as the wire payload so the JSON
+    // shown here is what the panel actually POSTs (no empty defaults,
+    // disabled sniffing as { enabled: false }, finalmask dropped when
+    // there are no masks).
+    const settingsView = (pruneEmpty(wSettings ?? {}) ?? {}) as Record<string, unknown>;
+    if (typeof wProtocol === 'string' && Array.isArray(settingsView.clients)) {
+      settingsView.clients = normalizeClients(wProtocol, settingsView.clients);
+    }
+    const streamView = streamEnabled
+      ? ((pruneEmpty(wStream ?? {}) ?? {}) as Record<string, unknown>)
+      : undefined;
+    dropLegacyOptionalEmpties(settingsView, streamView);
+    const out: Record<string, unknown> = {
+      listen: wListen ?? '',
+      port: wPort ?? 0,
+      protocol: wProtocol ?? '',
+      tag: wTag ?? '',
+      settings: settingsView,
+      sniffing: normalizeSniffing(wSniffing as Parameters<typeof normalizeSniffing>[0]),
+    };
+    if (streamView) out.streamSettings = streamView;
+    return JSON.stringify(out, null, 2);
+  };
+
+  const lastEmitRef = useRef<string>('');
+  const [text, setText] = useState(() => {
+    const initial = serialize();
+    lastEmitRef.current = initial;
+    return initial;
+  });
+
+  useEffect(() => {
+    const formStr = serialize();
+    if (formStr === lastEmitRef.current) return;
+    setText(formStr);
+    lastEmitRef.current = formStr;
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [wListen, wPort, wProtocol, wTag, wSettings, wSniffing, wStream, streamEnabled]);
+
+  return (
+    <JsonEditor
+      value={text}
+      minHeight="340px"
+      maxHeight="560px"
+      onChange={(next) => {
+        setText(next);
+        let parsed: Record<string, unknown>;
+        try {
+          parsed = JSON.parse(next) as Record<string, unknown>;
+        } catch {
+          return;
+        }
+        if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return;
+        if (typeof parsed.listen === 'string') form.setFieldValue('listen', parsed.listen);
+        if (typeof parsed.port === 'number' && Number.isFinite(parsed.port)) {
+          form.setFieldValue('port', parsed.port);
+        }
+        if (typeof parsed.protocol === 'string') form.setFieldValue('protocol', parsed.protocol);
+        if (typeof parsed.tag === 'string') form.setFieldValue('tag', parsed.tag);
+        if (parsed.settings && typeof parsed.settings === 'object') {
+          form.setFieldValue('settings', parsed.settings);
+        }
+        if (parsed.sniffing && typeof parsed.sniffing === 'object') {
+          form.setFieldValue('sniffing', parsed.sniffing);
+        }
+        if (streamEnabled && parsed.streamSettings && typeof parsed.streamSettings === 'object') {
+          form.setFieldValue('streamSettings', parsed.streamSettings);
+        }
+        lastEmitRef.current = next;
+      }}
+    />
+  );
+}

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

@@ -0,0 +1 @@
+export { default as InboundFormModal } from './InboundFormModal';

+ 47 - 0
frontend/src/pages/inbounds/form/protocols/accounts-list.tsx

@@ -0,0 +1,47 @@
+import { useTranslation } from 'react-i18next';
+import { Button, Form, Input, Space } from 'antd';
+import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
+
+import { RandomUtil } from '@/utils';
+import { InputAddon } from '@/components/ui';
+
+export default function AccountsList() {
+  const { t } = useTranslation();
+  return (
+    <Form.List name={['settings', 'accounts']}>
+      {(fields, { add, remove }) => (
+        <>
+          <Form.Item label={t('pages.inbounds.form.accounts')}>
+            <Button
+              size="small"
+              onClick={() => add({
+                user: RandomUtil.randomLowerAndNum(8),
+                pass: RandomUtil.randomLowerAndNum(12),
+              })}
+            >
+              <PlusOutlined /> {t('add')}
+            </Button>
+          </Form.Item>
+          {fields.length > 0 && (
+            <Form.Item wrapperCol={{ span: 24 }}>
+              {fields.map((field, idx) => (
+                <Space.Compact key={field.key} className="mb-8" block>
+                  <InputAddon>{String(idx + 1)}</InputAddon>
+                  <Form.Item name={[field.name, 'user']} noStyle>
+                    <Input placeholder={t('username')} />
+                  </Form.Item>
+                  <Form.Item name={[field.name, 'pass']} noStyle>
+                    <Input placeholder={t('password')} />
+                  </Form.Item>
+                  <Button onClick={() => remove(field.name)}>
+                    <MinusOutlined />
+                  </Button>
+                </Space.Compact>
+              ))}
+            </Form.Item>
+          )}
+        </>
+      )}
+    </Form.List>
+  );
+}

+ 20 - 0
frontend/src/pages/inbounds/form/protocols/http.tsx

@@ -0,0 +1,20 @@
+import { useTranslation } from 'react-i18next';
+import { Form, Switch } from 'antd';
+
+import AccountsList from './accounts-list';
+
+export default function HttpFields() {
+  const { t } = useTranslation();
+  return (
+    <>
+      <AccountsList />
+      <Form.Item
+        name={['settings', 'allowTransparent']}
+        label={t('pages.inbounds.form.allowTransparent')}
+        valuePropName="checked"
+      >
+        <Switch />
+      </Form.Item>
+    </>
+  );
+}

+ 16 - 8
frontend/src/components/HysteriaMasqueradeForm.tsx → frontend/src/pages/inbounds/form/protocols/hysteria.tsx

@@ -1,19 +1,27 @@
 import { useTranslation } from 'react-i18next';
-import { Form, Input, InputNumber, Select, Switch } from 'antd';
-import type { FormInstance } from 'antd';
+import { Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
 
-import HeaderMapEditor from '@/components/HeaderMapEditor';
+import { HeaderMapEditor } from '@/components/form';
 
 const MASQ_PATH = ['streamSettings', 'hysteriaSettings', 'masquerade'];
 
-interface HysteriaMasqueradeFormProps {
-  form: FormInstance;
-}
-
-export default function HysteriaMasqueradeForm({ form }: HysteriaMasqueradeFormProps) {
+export default function HysteriaFields({ form }: { form: FormInstance }) {
   const { t } = useTranslation();
   return (
     <>
+      <Form.Item
+        label={t('pages.inbounds.form.version')}
+        name={['streamSettings', 'hysteriaSettings', 'version']}
+      >
+        <InputNumber min={2} max={2} disabled />
+      </Form.Item>
+      <Form.Item
+        label={t('pages.inbounds.form.udpIdleTimeout')}
+        name={['streamSettings', 'hysteriaSettings', 'udpIdleTimeout']}
+      >
+        <InputNumber min={1} style={{ width: '100%' }} />
+      </Form.Item>
+
       <Form.Item label={t('pages.inbounds.form.masquerade')}>
         <Form.Item shouldUpdate noStyle>
           {() => {

+ 8 - 0
frontend/src/pages/inbounds/form/protocols/index.ts

@@ -0,0 +1,8 @@
+export { default as TunFields } from './tun';
+export { default as TunnelFields } from './tunnel';
+export { default as ShadowsocksFields } from './shadowsocks';
+export { default as WireguardFields } from './wireguard';
+export { default as HysteriaFields } from './hysteria';
+export { default as HttpFields } from './http';
+export { default as MixedFields } from './mixed';
+export { default as VlessFields } from './vless';

+ 33 - 0
frontend/src/pages/inbounds/form/protocols/mixed.tsx

@@ -0,0 +1,33 @@
+import { useTranslation } from 'react-i18next';
+import { Form, Input, Select, Switch } from 'antd';
+
+import AccountsList from './accounts-list';
+
+export default function MixedFields({ mixedUdpOn }: { mixedUdpOn: boolean }) {
+  const { t } = useTranslation();
+  return (
+    <>
+      <AccountsList />
+      <Form.Item name={['settings', 'auth']} label={t('pages.inbounds.info.auth')}>
+        <Select
+          options={[
+            { value: 'noauth', label: 'noauth' },
+            { value: 'password', label: 'password' },
+          ]}
+        />
+      </Form.Item>
+      <Form.Item
+        name={['settings', 'udp']}
+        label="UDP"
+        valuePropName="checked"
+      >
+        <Switch />
+      </Form.Item>
+      {mixedUdpOn && (
+        <Form.Item name={['settings', 'ip']} label="UDP IP">
+          <Input />
+        </Form.Item>
+      )}
+    </>
+  );
+}

+ 67 - 0
frontend/src/pages/inbounds/form/protocols/shadowsocks.tsx

@@ -0,0 +1,67 @@
+import { useTranslation } from 'react-i18next';
+import { Button, Form, Input, Select, Space, Switch, type FormInstance } from 'antd';
+import { ReloadOutlined } from '@ant-design/icons';
+
+import { RandomUtil } from '@/utils';
+import { SSMethodSchema } from '@/schemas/protocols/shared/shadowsocks';
+import type { InboundFormValues } from '@/schemas/forms/inbound-form';
+
+interface ShadowsocksFieldsProps {
+  form: FormInstance<InboundFormValues>;
+  isSSWith2022: boolean;
+}
+
+export default function ShadowsocksFields({ form, isSSWith2022 }: ShadowsocksFieldsProps) {
+  const { t } = useTranslation();
+  return (
+    <>
+      <Form.Item name={['settings', 'method']} label={t('pages.inbounds.form.encryptionMethod')}>
+        <Select
+          onChange={(v) => {
+            form.setFieldValue(
+              ['settings', 'password'],
+              RandomUtil.randomShadowsocksPassword(v as string),
+            );
+          }}
+          options={SSMethodSchema.options.map((m) => ({ value: m, label: m }))}
+        />
+      </Form.Item>
+      {isSSWith2022 && (
+        <Form.Item label={t('password')}>
+          <Space.Compact block>
+            <Form.Item name={['settings', 'password']} noStyle>
+              <Input style={{ width: 'calc(100% - 32px)' }} />
+            </Form.Item>
+            <Button
+              icon={<ReloadOutlined />}
+              onClick={() => {
+                const method = form.getFieldValue(['settings', 'method']);
+                form.setFieldValue(
+                  ['settings', 'password'],
+                  RandomUtil.randomShadowsocksPassword(method as string),
+                );
+              }}
+            />
+          </Space.Compact>
+        </Form.Item>
+      )}
+      <Form.Item name={['settings', 'network']} label={t('pages.inbounds.network')}>
+        <Select
+          style={{ width: 120 }}
+          options={[
+            { value: 'tcp,udp', label: 'TCP, UDP' },
+            { value: 'tcp', label: 'TCP' },
+            { value: 'udp', label: 'UDP' },
+          ]}
+        />
+      </Form.Item>
+      <Form.Item
+        name={['settings', 'ivCheck']}
+        label="ivCheck"
+        valuePropName="checked"
+      >
+        <Switch />
+      </Form.Item>
+    </>
+  );
+}

+ 93 - 0
frontend/src/pages/inbounds/form/protocols/tun.tsx

@@ -0,0 +1,93 @@
+import { useTranslation } from 'react-i18next';
+import { Button, Form, Input, InputNumber, Space, Tooltip } from 'antd';
+import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
+
+export default function TunFields() {
+  const { t } = useTranslation();
+  return (
+    <>
+      <Form.Item name={['settings', 'name']} label={t('pages.inbounds.info.interfaceName')}>
+        <Input placeholder="xray0" />
+      </Form.Item>
+      <Form.Item name={['settings', 'mtu']} label="MTU">
+        <InputNumber min={0} />
+      </Form.Item>
+      <Form.List name={['settings', 'gateway']}>
+        {(fields, { add, remove }) => (
+          <Form.Item label={t('pages.inbounds.info.gateway')}>
+            <Button size="small" onClick={() => add('')}>
+              <PlusOutlined />
+            </Button>
+            {fields.map((field, j) => (
+              <Space.Compact key={field.key} block className="mt-4">
+                <Form.Item name={field.name} noStyle>
+                  <Input placeholder={j === 0 ? '10.0.0.1/16' : 'fc00::1/64'} />
+                </Form.Item>
+                <Button size="small" onClick={() => remove(field.name)}>
+                  <MinusOutlined />
+                </Button>
+              </Space.Compact>
+            ))}
+          </Form.Item>
+        )}
+      </Form.List>
+      <Form.List name={['settings', 'dns']}>
+        {(fields, { add, remove }) => (
+          <Form.Item label="DNS">
+            <Button size="small" onClick={() => add('')}>
+              <PlusOutlined />
+            </Button>
+            {fields.map((field, j) => (
+              <Space.Compact key={field.key} block className="mt-4">
+                <Form.Item name={field.name} noStyle>
+                  <Input placeholder={j === 0 ? '1.1.1.1' : '8.8.8.8'} />
+                </Form.Item>
+                <Button size="small" onClick={() => remove(field.name)}>
+                  <MinusOutlined />
+                </Button>
+              </Space.Compact>
+            ))}
+          </Form.Item>
+        )}
+      </Form.List>
+      <Form.Item name={['settings', 'userLevel']} label={t('pages.xray.tun.userLevel')}>
+        <InputNumber min={0} />
+      </Form.Item>
+      <Form.List name={['settings', 'autoSystemRoutingTable']}>
+        {(fields, { add, remove }) => (
+          <Form.Item
+            label={
+              <Tooltip title={t('pages.inbounds.form.autoSystemRoutesTooltip')}>
+                {t('pages.inbounds.info.autoSystemRoutes')}
+              </Tooltip>
+            }
+          >
+            <Button size="small" onClick={() => add('')}>
+              <PlusOutlined />
+            </Button>
+            {fields.map((field, j) => (
+              <Space.Compact key={field.key} block className="mt-4">
+                <Form.Item name={field.name} noStyle>
+                  <Input placeholder={j === 0 ? '0.0.0.0/0' : '::/0'} />
+                </Form.Item>
+                <Button size="small" onClick={() => remove(field.name)}>
+                  <MinusOutlined />
+                </Button>
+              </Space.Compact>
+            ))}
+          </Form.Item>
+        )}
+      </Form.List>
+      <Form.Item
+        name={['settings', 'autoOutboundsInterface']}
+        label={
+          <Tooltip title={t('pages.inbounds.form.autoOutboundsInterfaceTooltip')}>
+            {t('pages.inbounds.form.autoOutboundsInterface')}
+          </Tooltip>
+        }
+      >
+        <Input placeholder="auto" />
+      </Form.Item>
+    </>
+  );
+}

+ 37 - 0
frontend/src/pages/inbounds/form/protocols/tunnel.tsx

@@ -0,0 +1,37 @@
+import { useTranslation } from 'react-i18next';
+import { Form, Input, InputNumber, Select, Switch } from 'antd';
+
+import { HeaderMapEditor } from '@/components/form';
+
+export default function TunnelFields() {
+  const { t } = useTranslation();
+  return (
+    <>
+      <Form.Item name={['settings', 'rewriteAddress']} label={t('pages.inbounds.form.rewriteAddress')}>
+        <Input />
+      </Form.Item>
+      <Form.Item name={['settings', 'rewritePort']} label={t('pages.inbounds.form.rewritePort')}>
+        <InputNumber min={0} max={65535} />
+      </Form.Item>
+      <Form.Item name={['settings', 'allowedNetwork']} label={t('pages.inbounds.form.allowedNetwork')}>
+        <Select
+          options={[
+            { value: 'tcp,udp', label: 'TCP, UDP' },
+            { value: 'tcp', label: 'TCP' },
+            { value: 'udp', label: 'UDP' },
+          ]}
+        />
+      </Form.Item>
+      <Form.Item label={t('pages.inbounds.portMap')} name={['settings', 'portMap']}>
+        <HeaderMapEditor mode="v1" />
+      </Form.Item>
+      <Form.Item
+        name={['settings', 'followRedirect']}
+        label={t('pages.inbounds.form.followRedirect')}
+        valuePropName="checked"
+      >
+        <Switch />
+      </Form.Item>
+    </>
+  );
+}

+ 60 - 0
frontend/src/pages/inbounds/form/protocols/vless.tsx

@@ -0,0 +1,60 @@
+import { useTranslation } from 'react-i18next';
+import { Button, Form, Input, InputNumber, Space, Typography } from 'antd';
+
+interface VlessFieldsProps {
+  saving: boolean;
+  selectedVlessAuth: string;
+  network: string;
+  security: string;
+  getNewVlessEnc: (kind: 'x25519' | 'mlkem768') => void;
+  clearVlessEnc: () => void;
+}
+
+export default function VlessFields({
+  saving,
+  selectedVlessAuth,
+  network,
+  security,
+  getNewVlessEnc,
+  clearVlessEnc,
+}: VlessFieldsProps) {
+  const { t } = useTranslation();
+  return (
+    <>
+      <Form.Item name={['settings', 'decryption']} label={t('pages.inbounds.decryption')}>
+        <Input />
+      </Form.Item>
+      <Form.Item name={['settings', 'encryption']} label={t('pages.inbounds.encryption')}>
+        <Input />
+      </Form.Item>
+      <Form.Item label=" ">
+        <Space size={8} wrap>
+          <Button type="primary" loading={saving} onClick={() => getNewVlessEnc('x25519')}>
+            {t('pages.inbounds.vlessAuthX25519')}
+          </Button>
+          <Button type="primary" loading={saving} onClick={() => getNewVlessEnc('mlkem768')}>
+            {t('pages.inbounds.vlessAuthMlkem768')}
+          </Button>
+          <Button danger onClick={clearVlessEnc}>{t('clear')}</Button>
+        </Space>
+        <Typography.Text type="secondary" className="vless-auth-state">
+          {t('pages.inbounds.vlessAuthSelected', { auth: selectedVlessAuth })}
+        </Typography.Text>
+      </Form.Item>
+      {network === 'tcp' && (security === 'tls' || security === 'reality') && (
+        <Form.Item
+          label={t('pages.inbounds.form.visionTestseed')}
+          extra="Applies only to clients using the xtls-rprx-vision flow; ignored otherwise."
+        >
+          <Space.Compact block>
+            {[900, 500, 900, 256].map((def, i) => (
+              <Form.Item key={i} name={['settings', 'testseed', i]} noStyle initialValue={def}>
+                <InputNumber min={1} style={{ width: '25%' }} />
+              </Form.Item>
+            ))}
+          </Space.Compact>
+        </Form.Item>
+      )}
+    </>
+  );
+}

+ 120 - 0
frontend/src/pages/inbounds/form/protocols/wireguard.tsx

@@ -0,0 +1,120 @@
+import { useTranslation } from 'react-i18next';
+import { Button, Divider, Form, Input, InputNumber, Space, Switch } from 'antd';
+import { MinusOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
+
+import { Wireguard } from '@/utils';
+
+interface WireguardFieldsProps {
+  wgPubKey: string;
+  regenInboundWg: () => void;
+  regenWgPeerKeypair: (name: number) => void;
+}
+
+export default function WireguardFields({ wgPubKey, regenInboundWg, regenWgPeerKeypair }: WireguardFieldsProps) {
+  const { t } = useTranslation();
+  return (
+    <>
+      <Form.Item label={t('pages.xray.wireguard.secretKey')}>
+        <Space.Compact block>
+          <Form.Item name={['settings', 'secretKey']} noStyle>
+            <Input style={{ width: 'calc(100% - 32px)' }} />
+          </Form.Item>
+          <Button icon={<ReloadOutlined />} onClick={regenInboundWg} />
+        </Space.Compact>
+      </Form.Item>
+      <Form.Item label={t('pages.xray.wireguard.publicKey')}>
+        <Input value={wgPubKey} disabled />
+      </Form.Item>
+      <Form.Item name={['settings', 'mtu']} label="MTU">
+        <InputNumber />
+      </Form.Item>
+      <Form.Item
+        name={['settings', 'noKernelTun']}
+        label={t('pages.inbounds.info.noKernelTun')}
+        valuePropName="checked"
+      >
+        <Switch />
+      </Form.Item>
+      <Form.List name={['settings', 'peers']}>
+        {(fields, { add, remove }) => (
+          <>
+            <Form.Item label={t('pages.inbounds.form.peers')}>
+              <Button
+                size="small"
+                onClick={() => {
+                  const kp = Wireguard.generateKeypair();
+                  add({
+                    privateKey: kp.privateKey,
+                    publicKey: kp.publicKey,
+                    allowedIPs: ['10.0.0.2/32'],
+                    keepAlive: 0,
+                  });
+                }}
+              >
+                <PlusOutlined /> {t('pages.inbounds.form.addPeer')}
+              </Button>
+            </Form.Item>
+            {fields.map((field, idx) => (
+              <div key={field.key} className="wg-peer">
+                <Divider titlePlacement="center">
+                  <Space>
+                    <span>{t('pages.inbounds.info.peerNumber', { n: idx + 1 })}</span>
+                    {fields.length > 1 && (
+                      <Button
+                        size="small"
+                        danger
+                        icon={<MinusOutlined />}
+                        onClick={() => remove(field.name)}
+                      />
+                    )}
+                  </Space>
+                </Divider>
+                <Form.Item label={t('pages.xray.wireguard.secretKey')}>
+                  <Space.Compact block>
+                    <Form.Item name={[field.name, 'privateKey']} noStyle>
+                      <Input style={{ width: 'calc(100% - 32px)' }} />
+                    </Form.Item>
+                    <Button
+                      icon={<ReloadOutlined />}
+                      onClick={() => regenWgPeerKeypair(field.name)}
+                    />
+                  </Space.Compact>
+                </Form.Item>
+                <Form.Item name={[field.name, 'publicKey']} label={t('pages.xray.wireguard.publicKey')}>
+                  <Input />
+                </Form.Item>
+                <Form.Item name={[field.name, 'preSharedKey']} label="PSK">
+                  <Input />
+                </Form.Item>
+                <Form.List name={[field.name, 'allowedIPs']}>
+                  {(ipFields, { add: addIp, remove: removeIp }) => (
+                    <Form.Item label={t('pages.xray.wireguard.allowedIPs')}>
+                      <Button size="small" onClick={() => addIp('')}>
+                        <PlusOutlined />
+                      </Button>
+                      {ipFields.map((ipField) => (
+                        <Space.Compact key={ipField.key} block className="mt-4">
+                          <Form.Item name={ipField.name} noStyle>
+                            <Input />
+                          </Form.Item>
+                          {ipFields.length > 1 && (
+                            <Button size="small" onClick={() => removeIp(ipField.name)}>
+                              <MinusOutlined />
+                            </Button>
+                          )}
+                        </Space.Compact>
+                      ))}
+                    </Form.Item>
+                  )}
+                </Form.List>
+                <Form.Item name={[field.name, 'keepAlive']} label={t('pages.inbounds.form.keepAlive')}>
+                  <InputNumber min={0} />
+                </Form.Item>
+              </div>
+            ))}
+          </>
+        )}
+      </Form.List>
+    </>
+  );
+}

+ 2 - 0
frontend/src/pages/inbounds/form/security/index.ts

@@ -0,0 +1,2 @@
+export { default as TlsForm } from './tls';
+export { default as RealityForm } from './reality';

+ 143 - 0
frontend/src/pages/inbounds/form/security/reality.tsx

@@ -0,0 +1,143 @@
+import { useTranslation } from 'react-i18next';
+import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
+import { ReloadOutlined } from '@ant-design/icons';
+
+import { UTLS_FINGERPRINT } from '@/schemas/primitives';
+
+interface RealityFormProps {
+  saving: boolean;
+  randomizeRealityTarget: () => void;
+  randomizeShortIds: () => void;
+  genRealityKeypair: () => void;
+  clearRealityKeypair: () => void;
+  genMldsa65: () => void;
+  clearMldsa65: () => void;
+}
+
+export default function RealityForm({
+  saving,
+  randomizeRealityTarget,
+  randomizeShortIds,
+  genRealityKeypair,
+  clearRealityKeypair,
+  genMldsa65,
+  clearMldsa65,
+}: RealityFormProps) {
+  const { t } = useTranslation();
+  return (
+    <>
+      <Form.Item
+        name={['streamSettings', 'realitySettings', 'show']}
+        label={t('pages.inbounds.form.show')}
+        valuePropName="checked"
+      >
+        <Switch />
+      </Form.Item>
+      <Form.Item name={['streamSettings', 'realitySettings', 'xver']} label={t('pages.inbounds.form.xver')}>
+        <InputNumber min={0} />
+      </Form.Item>
+      <Form.Item
+        name={['streamSettings', 'realitySettings', 'settings', 'fingerprint']}
+        label="uTLS"
+      >
+        <Select
+          options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))}
+        />
+      </Form.Item>
+      <Form.Item label={t('pages.inbounds.form.target')}>
+        <Space.Compact block>
+          <Form.Item name={['streamSettings', 'realitySettings', 'target']} noStyle>
+            <Input style={{ width: 'calc(100% - 32px)' }} />
+          </Form.Item>
+          <Button icon={<ReloadOutlined />} onClick={randomizeRealityTarget} />
+        </Space.Compact>
+      </Form.Item>
+      <Form.Item label="SNI">
+        <Space.Compact block style={{ display: 'flex' }}>
+          <Form.Item
+            name={['streamSettings', 'realitySettings', 'serverNames']}
+            noStyle
+          >
+            <Select mode="tags" tokenSeparators={[',']} style={{ flex: 1 }} />
+          </Form.Item>
+          <Button icon={<ReloadOutlined />} onClick={randomizeRealityTarget} />
+        </Space.Compact>
+      </Form.Item>
+      <Form.Item
+        name={['streamSettings', 'realitySettings', 'maxTimediff']}
+        label={t('pages.inbounds.form.maxTimeDiff')}
+      >
+        <InputNumber min={0} />
+      </Form.Item>
+      <Form.Item
+        name={['streamSettings', 'realitySettings', 'minClientVer']}
+        label={t('pages.inbounds.form.minClientVer')}
+      >
+        <Input placeholder="25.9.11" />
+      </Form.Item>
+      <Form.Item
+        name={['streamSettings', 'realitySettings', 'maxClientVer']}
+        label={t('pages.inbounds.form.maxClientVer')}
+      >
+        <Input placeholder="25.9.11" />
+      </Form.Item>
+      <Form.Item label={t('pages.inbounds.form.shortIds')}>
+        <Space.Compact block style={{ display: 'flex' }}>
+          <Form.Item
+            name={['streamSettings', 'realitySettings', 'shortIds']}
+            noStyle
+          >
+            <Select mode="tags" tokenSeparators={[',']} style={{ flex: 1 }} />
+          </Form.Item>
+          <Button icon={<ReloadOutlined />} onClick={randomizeShortIds} />
+        </Space.Compact>
+      </Form.Item>
+      <Form.Item
+        name={['streamSettings', 'realitySettings', 'settings', 'spiderX']}
+        label={t('pages.inbounds.form.spiderX')}
+      >
+        <Input />
+      </Form.Item>
+      <Form.Item
+        name={['streamSettings', 'realitySettings', 'settings', 'publicKey']}
+        label={t('pages.inbounds.publicKey')}
+      >
+        <Input.TextArea autoSize={{ minRows: 1, maxRows: 4 }} />
+      </Form.Item>
+      <Form.Item
+        name={['streamSettings', 'realitySettings', 'privateKey']}
+        label={t('pages.inbounds.privatekey')}
+      >
+        <Input.TextArea autoSize={{ minRows: 1, maxRows: 4 }} />
+      </Form.Item>
+      <Form.Item label=" ">
+        <Space>
+          <Button type="primary" loading={saving} onClick={genRealityKeypair}>
+            {t('pages.inbounds.form.getNewCert')}
+          </Button>
+          <Button danger onClick={clearRealityKeypair}>{t('clear')}</Button>
+        </Space>
+      </Form.Item>
+      <Form.Item
+        name={['streamSettings', 'realitySettings', 'mldsa65Seed']}
+        label={t('pages.inbounds.form.mldsa65Seed')}
+      >
+        <Input.TextArea autoSize={{ minRows: 2, maxRows: 6 }} />
+      </Form.Item>
+      <Form.Item
+        name={['streamSettings', 'realitySettings', 'settings', 'mldsa65Verify']}
+        label={t('pages.inbounds.form.mldsa65Verify')}
+      >
+        <Input.TextArea autoSize={{ minRows: 2, maxRows: 6 }} />
+      </Form.Item>
+      <Form.Item label=" ">
+        <Space>
+          <Button type="primary" loading={saving} onClick={genMldsa65}>
+            {t('pages.inbounds.form.getNewSeed')}
+          </Button>
+          <Button danger onClick={clearMldsa65}>{t('clear')}</Button>
+        </Space>
+      </Form.Item>
+    </>
+  );
+}

+ 309 - 0
frontend/src/pages/inbounds/form/security/tls.tsx

@@ -0,0 +1,309 @@
+import { useTranslation } from 'react-i18next';
+import { Button, Form, Input, Radio, Select, Space, Switch } from 'antd';
+import { MinusOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
+
+import {
+  ALPN_OPTION,
+  TLS_CIPHER_OPTION,
+  TLS_VERSION_OPTION,
+  USAGE_OPTION,
+  UTLS_FINGERPRINT,
+} from '@/schemas/primitives';
+
+const { TextArea } = Input;
+
+interface TlsFormProps {
+  saving: boolean;
+  setCertFromPanel: (certName: number) => void;
+  clearCertFiles: (certName: number) => void;
+  generateRandomPinHash: () => void;
+  getNewEchCert: () => void;
+  clearEchCert: () => void;
+}
+
+export default function TlsForm({
+  saving,
+  setCertFromPanel,
+  clearCertFiles,
+  generateRandomPinHash,
+  getNewEchCert,
+  clearEchCert,
+}: TlsFormProps) {
+  const { t } = useTranslation();
+  return (
+    <>
+      <Form.Item name={['streamSettings', 'tlsSettings', 'serverName']} label="SNI">
+        <Input placeholder={t('pages.inbounds.form.serverNameIndication')} />
+      </Form.Item>
+      <Form.Item name={['streamSettings', 'tlsSettings', 'cipherSuites']} label={t('pages.inbounds.form.cipherSuites')}>
+        <Select
+          options={[
+            { value: '', label: t('pages.inbounds.form.autoOption') },
+            ...Object.entries(TLS_CIPHER_OPTION).map(([k, v]) => ({ value: v, label: k })),
+          ]}
+        />
+      </Form.Item>
+      <Form.Item label={t('pages.inbounds.form.minMaxVersion')}>
+        <Space.Compact block>
+          <Form.Item name={['streamSettings', 'tlsSettings', 'minVersion']} noStyle>
+            <Select
+              style={{ width: '50%' }}
+              options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))}
+            />
+          </Form.Item>
+          <Form.Item name={['streamSettings', 'tlsSettings', 'maxVersion']} noStyle>
+            <Select
+              style={{ width: '50%' }}
+              options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))}
+            />
+          </Form.Item>
+        </Space.Compact>
+      </Form.Item>
+      <Form.Item
+        name={['streamSettings', 'tlsSettings', 'settings', 'fingerprint']}
+        label="uTLS"
+      >
+        <Select
+          options={[
+            { value: '', label: 'None' },
+            ...Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp })),
+          ]}
+        />
+      </Form.Item>
+      <Form.Item name={['streamSettings', 'tlsSettings', 'alpn']} label="ALPN">
+        <Select
+          mode="multiple"
+          tokenSeparators={[',']}
+          style={{ width: '100%' }}
+          options={Object.values(ALPN_OPTION).map((a) => ({ value: a, label: a }))}
+        />
+      </Form.Item>
+      <Form.Item
+        name={['streamSettings', 'tlsSettings', 'rejectUnknownSni']}
+        label={t('pages.inbounds.form.rejectUnknownSni')}
+        valuePropName="checked"
+      >
+        <Switch />
+      </Form.Item>
+      <Form.Item
+        name={['streamSettings', 'tlsSettings', 'disableSystemRoot']}
+        label={t('pages.inbounds.form.disableSystemRoot')}
+        valuePropName="checked"
+      >
+        <Switch />
+      </Form.Item>
+      <Form.Item
+        name={['streamSettings', 'tlsSettings', 'enableSessionResumption']}
+        label={t('pages.inbounds.form.sessionResumption')}
+        valuePropName="checked"
+      >
+        <Switch />
+      </Form.Item>
+
+      <Form.List name={['streamSettings', 'tlsSettings', 'certificates']}>
+        {(certFields, { add, remove }) => (
+          <>
+            <Form.Item label={t('certificate')}>
+              <Button
+                type="primary"
+                size="small"
+                onClick={() => add({
+                  useFile: true,
+                  certificateFile: '',
+                  keyFile: '',
+                  certificate: [],
+                  key: [],
+                  oneTimeLoading: false,
+                  usage: 'encipherment',
+                  buildChain: false,
+                })}
+              >
+                <PlusOutlined />
+              </Button>
+            </Form.Item>
+            {certFields.map((certField, idx) => (
+              <div key={certField.key}>
+                <Form.Item
+                  name={[certField.name, 'useFile']}
+                  label={`${t('certificate')} ${idx + 1}`}
+                >
+                  <Radio.Group buttonStyle="solid">
+                    <Radio.Button value={true}>
+                      {t('pages.inbounds.certificatePath')}
+                    </Radio.Button>
+                    <Radio.Button value={false}>
+                      {t('pages.inbounds.certificateContent')}
+                    </Radio.Button>
+                  </Radio.Group>
+                </Form.Item>
+                {certFields.length > 1 && (
+                  <Form.Item label=" ">
+                    <Button
+                      size="small"
+                      danger
+                      onClick={() => remove(certField.name)}
+                    >
+                      <MinusOutlined /> {t('remove')}
+                    </Button>
+                  </Form.Item>
+                )}
+                <Form.Item
+                  noStyle
+                  shouldUpdate={(prev, curr) =>
+                    prev.streamSettings?.tlsSettings?.certificates?.[certField.name]?.useFile
+                    !== curr.streamSettings?.tlsSettings?.certificates?.[certField.name]?.useFile
+                  }
+                >
+                  {({ getFieldValue }) => {
+                    const useFile = getFieldValue([
+                      'streamSettings', 'tlsSettings', 'certificates',
+                      certField.name, 'useFile',
+                    ]);
+                    return useFile ? (
+                      <>
+                        <Form.Item
+                          name={[certField.name, 'certificateFile']}
+                          label={t('pages.inbounds.publicKey')}
+                        >
+                          <Input />
+                        </Form.Item>
+                        <Form.Item
+                          name={[certField.name, 'keyFile']}
+                          label={t('pages.inbounds.privatekey')}
+                        >
+                          <Input />
+                        </Form.Item>
+                        <Form.Item label=" ">
+                          <Space>
+                            <Button
+                              type="primary"
+                              loading={saving}
+                              onClick={() => setCertFromPanel(certField.name)}
+                            >
+                              {t('pages.inbounds.setDefaultCert')}
+                            </Button>
+                            <Button danger onClick={() => clearCertFiles(certField.name)}>
+                              {t('clear')}
+                            </Button>
+                          </Space>
+                        </Form.Item>
+                      </>
+                    ) : (
+                      <>
+                        <Form.Item
+                          name={[certField.name, 'certificate']}
+                          label={t('pages.inbounds.publicKey')}
+                          normalize={(v) => typeof v === 'string'
+                            ? v.split('\n')
+                            : v}
+                          getValueProps={(v) => ({
+                            value: Array.isArray(v) ? v.join('\n') : v,
+                          })}
+                        >
+                          <TextArea autoSize={{ minRows: 3, maxRows: 8 }} />
+                        </Form.Item>
+                        <Form.Item
+                          name={[certField.name, 'key']}
+                          label={t('pages.inbounds.privatekey')}
+                          normalize={(v) => typeof v === 'string'
+                            ? v.split('\n')
+                            : v}
+                          getValueProps={(v) => ({
+                            value: Array.isArray(v) ? v.join('\n') : v,
+                          })}
+                        >
+                          <TextArea autoSize={{ minRows: 3, maxRows: 8 }} />
+                        </Form.Item>
+                      </>
+                    );
+                  }}
+                </Form.Item>
+                <Form.Item
+                  name={[certField.name, 'oneTimeLoading']}
+                  label={t('pages.inbounds.form.oneTimeLoading')}
+                  valuePropName="checked"
+                >
+                  <Switch />
+                </Form.Item>
+                <Form.Item
+                  name={[certField.name, 'usage']}
+                  label={t('pages.inbounds.form.usageOption')}
+                >
+                  <Select
+                    style={{ width: '50%' }}
+                    options={Object.values(USAGE_OPTION).map((u) => ({ value: u, label: u }))}
+                  />
+                </Form.Item>
+                <Form.Item
+                  noStyle
+                  shouldUpdate={(prev, curr) =>
+                    prev.streamSettings?.tlsSettings?.certificates?.[certField.name]?.usage
+                    !== curr.streamSettings?.tlsSettings?.certificates?.[certField.name]?.usage
+                  }
+                >
+                  {({ getFieldValue }) => {
+                    const usage = getFieldValue([
+                      'streamSettings', 'tlsSettings', 'certificates',
+                      certField.name, 'usage',
+                    ]);
+                    if (usage !== 'issue') return null;
+                    return (
+                      <Form.Item
+                        name={[certField.name, 'buildChain']}
+                        label={t('pages.inbounds.form.buildChain')}
+                        valuePropName="checked"
+                      >
+                        <Switch />
+                      </Form.Item>
+                    );
+                  }}
+                </Form.Item>
+              </div>
+            ))}
+          </>
+        )}
+      </Form.List>
+
+      <Form.Item name={['streamSettings', 'tlsSettings', 'echServerKeys']} label={t('pages.inbounds.form.echKey')}>
+        <Input />
+      </Form.Item>
+      <Form.Item
+        name={['streamSettings', 'tlsSettings', 'settings', 'echConfigList']}
+        label={t('pages.inbounds.form.echConfig')}
+      >
+        <Input />
+      </Form.Item>
+      <Form.Item
+        label={t('pages.inbounds.form.pinnedPeerCertSha256')}
+        tooltip={t('pages.inbounds.form.pinnedPeerCertSha256Tip')}
+      >
+        <Space.Compact block>
+          <Form.Item
+            name={['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256']}
+            noStyle
+          >
+            <Select
+              mode="tags"
+              tokenSeparators={[',', ' ']}
+              placeholder={t('pages.inbounds.form.pinnedPeerCertSha256Placeholder')}
+              style={{ width: 'calc(100% - 32px)' }}
+            />
+          </Form.Item>
+          <Button
+            icon={<ReloadOutlined />}
+            onClick={generateRandomPinHash}
+            title={t('pages.inbounds.form.generateRandomPin')}
+          />
+        </Space.Compact>
+      </Form.Item>
+      <Form.Item label=" ">
+        <Space>
+          <Button type="primary" loading={saving} onClick={getNewEchCert}>
+            {t('pages.inbounds.form.getNewEchCert')}
+          </Button>
+          <Button danger onClick={clearEchCert}>{t('clear')}</Button>
+        </Space>
+      </Form.Item>
+    </>
+  );
+}

+ 136 - 0
frontend/src/pages/inbounds/form/transport/external-proxy.tsx

@@ -0,0 +1,136 @@
+import { useTranslation } from 'react-i18next';
+import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
+import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
+
+import { InputAddon } from '@/components/ui';
+import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
+
+export default function ExternalProxyForm({
+  toggleExternalProxy,
+}: {
+  toggleExternalProxy: (on: boolean) => void;
+}) {
+  const { t } = useTranslation();
+  return (
+    <Form.Item
+      noStyle
+      shouldUpdate={(prev, curr) => {
+        const a = (prev.streamSettings as { externalProxy?: unknown[] } | undefined)?.externalProxy;
+        const b = (curr.streamSettings as { externalProxy?: unknown[] } | undefined)?.externalProxy;
+        return (Array.isArray(a) ? a.length : 0) !== (Array.isArray(b) ? b.length : 0);
+      }}
+    >
+      {({ getFieldValue }) => {
+        const arr = getFieldValue(['streamSettings', 'externalProxy']);
+        const on = Array.isArray(arr) && arr.length > 0;
+        return (
+          <>
+            <Form.Item label={t('pages.inbounds.form.externalProxy')}>
+              <Switch checked={on} onChange={toggleExternalProxy} />
+            </Form.Item>
+            {on && (
+              <Form.List name={['streamSettings', 'externalProxy']}>
+                {(fields, { add, remove }) => (
+                  <>
+                    <Form.Item label=" " colon={false}>
+                      <Button
+                        size="small"
+                        type="primary"
+                        onClick={() => add({
+                          forceTls: 'same',
+                          dest: '',
+                          port: 443,
+                          remark: '',
+                          sni: '',
+                          fingerprint: '',
+                          alpn: [],
+                        })}
+                      >
+                        <PlusOutlined />
+                      </Button>
+                    </Form.Item>
+                    <Form.Item wrapperCol={{ span: 24 }}>
+                      {fields.map((field) => (
+                        <div key={field.key} style={{ margin: '8px 0' }}>
+                          <Space.Compact block>
+                            <Form.Item name={[field.name, 'forceTls']} noStyle>
+                              <Select
+                                style={{ width: '20%' }}
+                                options={[
+                                  { value: 'same', label: t('pages.inbounds.same') },
+                                  { value: 'none', label: t('none') },
+                                  { value: 'tls', label: 'TLS' },
+                                ]}
+                              />
+                            </Form.Item>
+                            <Form.Item name={[field.name, 'dest']} noStyle>
+                              <Input style={{ width: '30%' }} placeholder={t('host')} />
+                            </Form.Item>
+                            <Form.Item name={[field.name, 'port']} noStyle>
+                              <InputNumber style={{ width: '15%' }} min={1} max={65535} />
+                            </Form.Item>
+                            <Form.Item name={[field.name, 'remark']} noStyle>
+                              <Input style={{ width: '25%' }} placeholder={t('pages.inbounds.remark')} />
+                            </Form.Item>
+                            <InputAddon onClick={() => remove(field.name)}>
+                              <MinusOutlined />
+                            </InputAddon>
+                          </Space.Compact>
+                          <Form.Item
+                            noStyle
+                            shouldUpdate={(prev, curr) =>
+                              prev.streamSettings?.externalProxy?.[field.name]?.forceTls
+                              !== curr.streamSettings?.externalProxy?.[field.name]?.forceTls
+                            }
+                          >
+                            {({ getFieldValue }) => {
+                              const ft = getFieldValue([
+                                'streamSettings', 'externalProxy', field.name, 'forceTls',
+                              ]);
+                              if (ft !== 'tls') return null;
+                              return (
+                                <Space.Compact style={{ marginTop: 6 }} block>
+                                  <Form.Item name={[field.name, 'sni']} noStyle>
+                                    <Input style={{ width: '30%' }} placeholder={t('pages.inbounds.form.sniPlaceholder')} />
+                                  </Form.Item>
+                                  <Form.Item name={[field.name, 'fingerprint']} noStyle>
+                                    <Select
+                                      style={{ width: '30%' }}
+                                      placeholder={t('pages.inbounds.form.fingerprint')}
+                                      options={[
+                                        { value: '', label: t('pages.inbounds.form.defaultOption') },
+                                        ...Object.values(UTLS_FINGERPRINT).map((fp) => ({
+                                          value: fp,
+                                          label: fp,
+                                        })),
+                                      ]}
+                                    />
+                                  </Form.Item>
+                                  <Form.Item name={[field.name, 'alpn']} noStyle>
+                                    <Select
+                                      mode="multiple"
+                                      style={{ width: '40%' }}
+                                      placeholder="ALPN"
+                                      options={Object.values(ALPN_OPTION).map((a) => ({
+                                        value: a,
+                                        label: a,
+                                      }))}
+                                    />
+                                  </Form.Item>
+                                </Space.Compact>
+                              );
+                            }}
+                          </Form.Item>
+                        </div>
+                      ))}
+                    </Form.Item>
+                  </>
+                )}
+              </Form.List>
+            )}
+          </>
+        );
+      }}
+    </Form.Item>
+  );
+}

+ 29 - 0
frontend/src/pages/inbounds/form/transport/grpc.tsx

@@ -0,0 +1,29 @@
+import { useTranslation } from 'react-i18next';
+import { Form, Input, Switch } from 'antd';
+
+export default function GrpcForm() {
+  const { t } = useTranslation();
+  return (
+    <>
+      <Form.Item
+        name={['streamSettings', 'grpcSettings', 'serviceName']}
+        label={t('pages.inbounds.form.serviceName')}
+      >
+        <Input />
+      </Form.Item>
+      <Form.Item
+        name={['streamSettings', 'grpcSettings', 'authority']}
+        label={t('pages.inbounds.form.authority')}
+      >
+        <Input />
+      </Form.Item>
+      <Form.Item
+        name={['streamSettings', 'grpcSettings', 'multiMode']}
+        label={t('pages.inbounds.form.multiMode')}
+        valuePropName="checked"
+      >
+        <Switch />
+      </Form.Item>
+    </>
+  );
+}

+ 37 - 0
frontend/src/pages/inbounds/form/transport/httpupgrade.tsx

@@ -0,0 +1,37 @@
+import { useTranslation } from 'react-i18next';
+import { Form, Input, Switch } from 'antd';
+
+import { HeaderMapEditor } from '@/components/form';
+
+export default function HttpUpgradeForm() {
+  const { t } = useTranslation();
+  return (
+    <>
+      <Form.Item
+        name={['streamSettings', 'httpupgradeSettings', 'acceptProxyProtocol']}
+        label={t('pages.inbounds.form.proxyProtocol')}
+        valuePropName="checked"
+      >
+        <Switch />
+      </Form.Item>
+      <Form.Item
+        name={['streamSettings', 'httpupgradeSettings', 'host']}
+        label={t('host')}
+      >
+        <Input />
+      </Form.Item>
+      <Form.Item
+        name={['streamSettings', 'httpupgradeSettings', 'path']}
+        label={t('path')}
+      >
+        <Input />
+      </Form.Item>
+      <Form.Item
+        label={t('pages.inbounds.form.headers')}
+        name={['streamSettings', 'httpupgradeSettings', 'headers']}
+      >
+        <HeaderMapEditor mode="v1" />
+      </Form.Item>
+    </>
+  );
+}

+ 8 - 0
frontend/src/pages/inbounds/form/transport/index.ts

@@ -0,0 +1,8 @@
+export { default as RawForm } from './raw';
+export { default as WsForm } from './ws';
+export { default as GrpcForm } from './grpc';
+export { default as XhttpForm } from './xhttp';
+export { default as HttpUpgradeForm } from './httpupgrade';
+export { default as KcpForm } from './kcp';
+export { default as ExternalProxyForm } from './external-proxy';
+export { default as SockoptForm } from './sockopt';

+ 34 - 0
frontend/src/pages/inbounds/form/transport/kcp.tsx

@@ -0,0 +1,34 @@
+import { useTranslation } from 'react-i18next';
+import { Form, InputNumber } from 'antd';
+
+export default function KcpForm() {
+  const { t } = useTranslation();
+  return (
+    <>
+      <Form.Item name={['streamSettings', 'kcpSettings', 'mtu']} label="MTU">
+        <InputNumber min={576} max={1460} />
+      </Form.Item>
+      <Form.Item name={['streamSettings', 'kcpSettings', 'tti']} label={t('pages.inbounds.form.ttiMs')}>
+        <InputNumber min={10} max={100} />
+      </Form.Item>
+      <Form.Item name={['streamSettings', 'kcpSettings', 'uplinkCapacity']} label={t('pages.inbounds.form.uplinkMbps')}>
+        <InputNumber min={0} />
+      </Form.Item>
+      <Form.Item name={['streamSettings', 'kcpSettings', 'downlinkCapacity']} label={t('pages.inbounds.form.downlinkMbps')}>
+        <InputNumber min={0} />
+      </Form.Item>
+      <Form.Item
+        name={['streamSettings', 'kcpSettings', 'cwndMultiplier']}
+        label={t('pages.inbounds.form.cwndMultiplier')}
+      >
+        <InputNumber min={1} />
+      </Form.Item>
+      <Form.Item
+        name={['streamSettings', 'kcpSettings', 'maxSendingWindow']}
+        label={t('pages.inbounds.form.maxSendingWindow')}
+      >
+        <InputNumber min={0} />
+      </Form.Item>
+    </>
+  );
+}

+ 164 - 0
frontend/src/pages/inbounds/form/transport/raw.tsx

@@ -0,0 +1,164 @@
+import { useTranslation } from 'react-i18next';
+import { Form, Input, Switch } from 'antd';
+
+import { HeaderMapEditor } from '@/components/form';
+
+export default function RawForm() {
+  const { t } = useTranslation();
+  return (
+    <>
+      <Form.Item
+        name={['streamSettings', 'tcpSettings', 'acceptProxyProtocol']}
+        label={t('pages.inbounds.form.proxyProtocol')}
+        valuePropName="checked"
+      >
+        <Switch />
+      </Form.Item>
+      <Form.Item label={`HTTP ${t('camouflage')}`}>
+        <Form.Item
+          noStyle
+          shouldUpdate={(prev, curr) =>
+            prev.streamSettings?.tcpSettings?.header?.type
+            !== curr.streamSettings?.tcpSettings?.header?.type
+          }
+        >
+          {({ getFieldValue, setFieldValue }) => {
+            const headerType = getFieldValue(
+              ['streamSettings', 'tcpSettings', 'header', 'type'],
+            ) as string | undefined;
+            return (
+              <Switch
+                checked={headerType === 'http'}
+                onChange={(v) => {
+                  setFieldValue(
+                    ['streamSettings', 'tcpSettings', 'header'],
+                    v
+                      ? {
+                        type: 'http',
+                        request: {
+                          version: '1.1',
+                          method: 'GET',
+                          path: ['/'],
+                          headers: {},
+                        },
+                        response: {
+                          version: '1.1',
+                          status: '200',
+                          reason: 'OK',
+                          headers: {},
+                        },
+                      }
+                      : { type: 'none' },
+                  );
+                }}
+              />
+            );
+          }}
+        </Form.Item>
+      </Form.Item>
+      {/* Per Xray docs (transports/raw.html#httpheaderobject), the
+          `request` object is honored only by outbound proxies; the
+          inbound listener reads `response`. Showing Host / Path /
+          Method / Version / request-headers on the inbound side was
+          a regression from this modal's earlier iteration — those
+          inputs wrote to the wire but xray-core ignored them. The
+          inbound modal now only exposes the response side. */}
+      <Form.Item
+        noStyle
+        shouldUpdate={(prev, curr) =>
+          prev.streamSettings?.tcpSettings?.header?.type
+          !== curr.streamSettings?.tcpSettings?.header?.type
+        }
+      >
+        {({ getFieldValue }) => {
+          const headerType = getFieldValue(
+            ['streamSettings', 'tcpSettings', 'header', 'type'],
+          ) as string | undefined;
+          if (headerType !== 'http') return null;
+          return (
+            <>
+              <Form.Item
+                label={t('pages.inbounds.form.requestVersion')}
+                name={[
+                  'streamSettings', 'tcpSettings', 'header',
+                  'request', 'version',
+                ]}
+              >
+                <Input placeholder="1.1" />
+              </Form.Item>
+              <Form.Item
+                label={t('pages.inbounds.form.requestMethod')}
+                name={[
+                  'streamSettings', 'tcpSettings', 'header',
+                  'request', 'method',
+                ]}
+              >
+                <Input placeholder="GET" />
+              </Form.Item>
+              <Form.Item
+                label={t('pages.inbounds.form.requestPath')}
+                name={[
+                  'streamSettings', 'tcpSettings', 'header',
+                  'request', 'path',
+                ]}
+                getValueProps={(v) => ({ value: Array.isArray(v) ? v.join(',') : v })}
+                getValueFromEvent={(e) => {
+                  const raw = (e?.target?.value ?? '') as string;
+                  const parts = raw.split(',').map((s) => s.trim()).filter(Boolean);
+                  return parts.length > 0 ? parts : ['/'];
+                }}
+              >
+                <Input placeholder="/" />
+              </Form.Item>
+              <Form.Item
+                label={t('pages.inbounds.form.requestHeaders')}
+                name={[
+                  'streamSettings', 'tcpSettings', 'header',
+                  'request', 'headers',
+                ]}
+              >
+                <HeaderMapEditor mode="v2" />
+              </Form.Item>
+              <Form.Item
+                label={t('pages.inbounds.form.responseVersion')}
+                name={[
+                  'streamSettings', 'tcpSettings', 'header',
+                  'response', 'version',
+                ]}
+              >
+                <Input placeholder="1.1" />
+              </Form.Item>
+              <Form.Item
+                label={t('pages.inbounds.form.responseStatus')}
+                name={[
+                  'streamSettings', 'tcpSettings', 'header',
+                  'response', 'status',
+                ]}
+              >
+                <Input placeholder="200" />
+              </Form.Item>
+              <Form.Item
+                label={t('pages.inbounds.form.responseReason')}
+                name={[
+                  'streamSettings', 'tcpSettings', 'header',
+                  'response', 'reason',
+                ]}
+              >
+                <Input placeholder="OK" />
+              </Form.Item>
+              <Form.Item
+                label={t('pages.inbounds.form.responseHeaders')}
+                name={[
+                  'streamSettings', 'tcpSettings', 'header',
+                  'response', 'headers',
+                ]}
+              >
+                <HeaderMapEditor mode="v2" />
+              </Form.Item>
+            </>
+          );
+        }}
+      </Form.Item>
+    </>
+  );
+}

+ 270 - 0
frontend/src/pages/inbounds/form/transport/sockopt.tsx

@@ -0,0 +1,270 @@
+import { useTranslation } from 'react-i18next';
+import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
+
+import {
+  Address_Port_Strategy,
+  DOMAIN_STRATEGY_OPTION,
+  TCP_CONGESTION_OPTION,
+} from '@/schemas/primitives';
+import { HappyEyeballsSchema } from '@/schemas/protocols/stream/sockopt';
+
+export default function SockoptForm({
+  toggleSockopt,
+}: {
+  toggleSockopt: (on: boolean) => void;
+}) {
+  const { t } = useTranslation();
+  return (
+    <Form.Item
+      noStyle
+      shouldUpdate={(prev, curr) => {
+        const a = (prev.streamSettings as { sockopt?: object } | undefined)?.sockopt;
+        const b = (curr.streamSettings as { sockopt?: object } | undefined)?.sockopt;
+        return !!a !== !!b;
+      }}
+    >
+      {({ getFieldValue }) => {
+        const sock = getFieldValue(['streamSettings', 'sockopt']);
+        const on = !!sock && typeof sock === 'object' && Object.keys(sock).length > 0;
+        return (
+          <>
+            <Form.Item label="Sockopt">
+              <Switch checked={on} onChange={toggleSockopt} />
+            </Form.Item>
+            {on && (
+              <>
+                <Form.Item name={['streamSettings', 'sockopt', 'mark']} label={t('pages.inbounds.form.routeMark')}>
+                  <InputNumber min={0} />
+                </Form.Item>
+                <Form.Item
+                  name={['streamSettings', 'sockopt', 'tcpKeepAliveInterval']}
+                  label={t('pages.inbounds.form.tcpKeepAliveInterval')}
+                >
+                  <InputNumber min={0} />
+                </Form.Item>
+                <Form.Item
+                  name={['streamSettings', 'sockopt', 'tcpKeepAliveIdle']}
+                  label={t('pages.inbounds.form.tcpKeepAliveIdle')}
+                >
+                  <InputNumber min={0} />
+                </Form.Item>
+                <Form.Item name={['streamSettings', 'sockopt', 'tcpMaxSeg']} label={t('pages.inbounds.form.tcpMaxSeg')}>
+                  <InputNumber min={0} />
+                </Form.Item>
+                <Form.Item
+                  name={['streamSettings', 'sockopt', 'tcpUserTimeout']}
+                  label={t('pages.inbounds.form.tcpUserTimeout')}
+                >
+                  <InputNumber min={0} />
+                </Form.Item>
+                <Form.Item
+                  name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
+                  label={t('pages.inbounds.form.tcpWindowClamp')}
+                >
+                  <InputNumber min={0} />
+                </Form.Item>
+                <Form.Item
+                  name={['streamSettings', 'sockopt', 'acceptProxyProtocol']}
+                  label={t('pages.inbounds.form.proxyProtocol')}
+                  valuePropName="checked"
+                >
+                  <Switch />
+                </Form.Item>
+                <Form.Item
+                  name={['streamSettings', 'sockopt', 'tcpFastOpen']}
+                  label={t('pages.inbounds.form.tcpFastOpen')}
+                  valuePropName="checked"
+                >
+                  <Switch />
+                </Form.Item>
+                <Form.Item
+                  name={['streamSettings', 'sockopt', 'tcpMptcp']}
+                  label={t('pages.inbounds.form.multipathTcp')}
+                  valuePropName="checked"
+                >
+                  <Switch />
+                </Form.Item>
+                <Form.Item
+                  name={['streamSettings', 'sockopt', 'penetrate']}
+                  label={t('pages.inbounds.form.penetrate')}
+                  valuePropName="checked"
+                >
+                  <Switch />
+                </Form.Item>
+                <Form.Item
+                  name={['streamSettings', 'sockopt', 'V6Only']}
+                  label={t('pages.inbounds.form.v6Only')}
+                  valuePropName="checked"
+                >
+                  <Switch />
+                </Form.Item>
+                <Form.Item
+                  name={['streamSettings', 'sockopt', 'domainStrategy']}
+                  label={t('pages.xray.wireguard.domainStrategy')}
+                >
+                  <Select
+                    style={{ width: '50%' }}
+                    options={Object.values(DOMAIN_STRATEGY_OPTION).map((d) => ({ value: d, label: d }))}
+                  />
+                </Form.Item>
+                <Form.Item
+                  name={['streamSettings', 'sockopt', 'tcpcongestion']}
+                  label={t('pages.inbounds.form.tcpCongestion')}
+                >
+                  <Select
+                    style={{ width: '50%' }}
+                    options={Object.values(TCP_CONGESTION_OPTION).map((c) => ({ value: c, label: c }))}
+                  />
+                </Form.Item>
+                <Form.Item name={['streamSettings', 'sockopt', 'tproxy']} label="TProxy">
+                  <Select
+                    style={{ width: '50%' }}
+                    options={[
+                      { value: 'off', label: 'Off' },
+                      { value: 'redirect', label: 'Redirect' },
+                      { value: 'tproxy', label: 'TProxy' },
+                    ]}
+                  />
+                </Form.Item>
+                <Form.Item name={['streamSettings', 'sockopt', 'dialerProxy']} label={t('pages.inbounds.form.dialerProxy')}>
+                  <Input />
+                </Form.Item>
+                <Form.Item
+                  name={['streamSettings', 'sockopt', 'interfaceName']}
+                  label={t('pages.inbounds.info.interfaceName')}
+                >
+                  <Input />
+                </Form.Item>
+                <Form.Item
+                  name={['streamSettings', 'sockopt', 'trustedXForwardedFor']}
+                  label={t('pages.inbounds.form.trustedXForwardedFor')}
+                >
+                  <Select
+                    mode="tags"
+                    style={{ width: '100%' }}
+                    tokenSeparators={[',']}
+                    options={[
+                      { value: 'CF-Connecting-IP', label: 'CF-Connecting-IP' },
+                      { value: 'X-Real-IP', label: 'X-Real-IP' },
+                      { value: 'True-Client-IP', label: 'True-Client-IP' },
+                      { value: 'X-Client-IP', label: 'X-Client-IP' },
+                    ]}
+                  />
+                </Form.Item>
+                <Form.Item
+                  name={['streamSettings', 'sockopt', 'addressPortStrategy']}
+                  label={t('pages.inbounds.form.addressPortStrategy')}
+                >
+                  <Select
+                    style={{ width: '50%' }}
+                    options={Object.values(Address_Port_Strategy).map((v) => ({ value: v, label: v }))}
+                  />
+                </Form.Item>
+                <Form.Item shouldUpdate noStyle>
+                  {({ getFieldValue, setFieldValue }) => {
+                    const he = getFieldValue(['streamSettings', 'sockopt', 'happyEyeballs']);
+                    const hasHe = he != null;
+                    return (
+                      <>
+                        <Form.Item label="Happy Eyeballs">
+                          <Switch
+                            checked={hasHe}
+                            onChange={(v) => {
+                              setFieldValue(
+                                ['streamSettings', 'sockopt', 'happyEyeballs'],
+                                v ? HappyEyeballsSchema.parse({}) : undefined,
+                              );
+                            }}
+                          />
+                        </Form.Item>
+                        {hasHe && (
+                          <>
+                            <Form.Item
+                              name={['streamSettings', 'sockopt', 'happyEyeballs', 'tryDelayMs']}
+                              label={t('pages.inbounds.form.tryDelayMs')}
+                            >
+                              <InputNumber min={0} placeholder="0 disabled — 250 recommended" />
+                            </Form.Item>
+                            <Form.Item
+                              name={['streamSettings', 'sockopt', 'happyEyeballs', 'prioritizeIPv6']}
+                              label={t('pages.inbounds.form.prioritizeIPv6')}
+                              valuePropName="checked"
+                            >
+                              <Switch />
+                            </Form.Item>
+                            <Form.Item
+                              name={['streamSettings', 'sockopt', 'happyEyeballs', 'interleave']}
+                              label={t('pages.inbounds.form.interleave')}
+                            >
+                              <InputNumber min={1} />
+                            </Form.Item>
+                            <Form.Item
+                              name={['streamSettings', 'sockopt', 'happyEyeballs', 'maxConcurrentTry']}
+                              label={t('pages.inbounds.form.maxConcurrentTry')}
+                            >
+                              <InputNumber min={0} />
+                            </Form.Item>
+                          </>
+                        )}
+                      </>
+                    );
+                  }}
+                </Form.Item>
+                <Form.List name={['streamSettings', 'sockopt', 'customSockopt']}>
+                  {(fields, { add, remove }) => (
+                    <>
+                      <Form.Item label={t('pages.inbounds.form.customSockopt')}>
+                        <Button
+                          type="dashed"
+                          size="small"
+                          onClick={() => add({ type: 'int', level: '6', opt: '', value: '' })}
+                        >
+                          + {t('pages.inbounds.form.addCustomOption')}
+                        </Button>
+                      </Form.Item>
+                      {fields.map((field) => (
+                        <Space.Compact key={field.key} style={{ display: 'flex', marginBottom: 8 }}>
+                          <Form.Item name={[field.name, 'system']} noStyle>
+                            <Select
+                              placeholder="all"
+                              allowClear
+                              style={{ width: 100 }}
+                              options={[
+                                { value: 'linux', label: 'linux' },
+                                { value: 'windows', label: 'windows' },
+                                { value: 'darwin', label: 'darwin' },
+                              ]}
+                            />
+                          </Form.Item>
+                          <Form.Item name={[field.name, 'type']} noStyle>
+                            <Select
+                              style={{ width: 80 }}
+                              options={[
+                                { value: 'int', label: 'int' },
+                                { value: 'str', label: 'str' },
+                              ]}
+                            />
+                          </Form.Item>
+                          <Form.Item name={[field.name, 'level']} noStyle>
+                            <Input placeholder="level (6=TCP)" style={{ width: 100 }} />
+                          </Form.Item>
+                          <Form.Item name={[field.name, 'opt']} noStyle>
+                            <Input placeholder="opt" style={{ width: 120 }} />
+                          </Form.Item>
+                          <Form.Item name={[field.name, 'value']} noStyle>
+                            <Input placeholder="value" style={{ flex: 1 }} />
+                          </Form.Item>
+                          <Button danger onClick={() => remove(field.name)}>−</Button>
+                        </Space.Compact>
+                      ))}
+                    </>
+                  )}
+                </Form.List>
+              </>
+            )}
+          </>
+        );
+      }}
+    </Form.Item>
+  );
+}

+ 37 - 0
frontend/src/pages/inbounds/form/transport/ws.tsx

@@ -0,0 +1,37 @@
+import { useTranslation } from 'react-i18next';
+import { Form, Input, InputNumber, Switch } from 'antd';
+
+import { HeaderMapEditor } from '@/components/form';
+
+export default function WsForm() {
+  const { t } = useTranslation();
+  return (
+    <>
+      <Form.Item
+        name={['streamSettings', 'wsSettings', 'acceptProxyProtocol']}
+        label={t('pages.inbounds.form.proxyProtocol')}
+        valuePropName="checked"
+      >
+        <Switch />
+      </Form.Item>
+      <Form.Item name={['streamSettings', 'wsSettings', 'host']} label={t('host')}>
+        <Input />
+      </Form.Item>
+      <Form.Item name={['streamSettings', 'wsSettings', 'path']} label={t('path')}>
+        <Input />
+      </Form.Item>
+      <Form.Item
+        name={['streamSettings', 'wsSettings', 'heartbeatPeriod']}
+        label={t('pages.inbounds.form.heartbeatPeriod')}
+      >
+        <InputNumber min={0} />
+      </Form.Item>
+      <Form.Item
+        label={t('pages.inbounds.form.headers')}
+        name={['streamSettings', 'wsSettings', 'headers']}
+      >
+        <HeaderMapEditor mode="v1" />
+      </Form.Item>
+    </>
+  );
+}

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

@@ -0,0 +1,218 @@
+import { useTranslation } from 'react-i18next';
+import { Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
+
+import { HeaderMapEditor } from '@/components/form';
+import type { InboundFormValues } from '@/schemas/forms/inbound-form';
+
+export default function XhttpForm({ form }: { form: FormInstance<InboundFormValues> }) {
+  const { t } = useTranslation();
+  const xhttpMode = Form.useWatch(['streamSettings', 'xhttpSettings', 'mode'], form);
+  const xhttpObfsMode = Form.useWatch(['streamSettings', 'xhttpSettings', 'xPaddingObfsMode'], form) ?? false;
+  const xhttpSessionPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'sessionPlacement'], form);
+  const xhttpSeqPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'seqPlacement'], form);
+  const xhttpUplinkPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'uplinkDataPlacement'], form);
+  return (
+    <>
+      <Form.Item name={['streamSettings', 'xhttpSettings', 'host']} label={t('host')}>
+        <Input />
+      </Form.Item>
+      <Form.Item name={['streamSettings', 'xhttpSettings', 'path']} label={t('path')}>
+        <Input />
+      </Form.Item>
+      <Form.Item name={['streamSettings', 'xhttpSettings', 'mode']} label={t('pages.inbounds.info.mode')}>
+        <Select
+          style={{ width: '50%' }}
+          options={(['auto', 'packet-up', 'stream-up', 'stream-one'] as const).map((m) => ({
+            value: m,
+            label: m,
+          }))}
+        />
+      </Form.Item>
+      {xhttpMode === 'packet-up' && (
+        <>
+          <Form.Item
+            name={['streamSettings', 'xhttpSettings', 'scMaxBufferedPosts']}
+            label={t('pages.inbounds.form.maxBufferedUpload')}
+          >
+            <InputNumber />
+          </Form.Item>
+          <Form.Item
+            name={['streamSettings', 'xhttpSettings', 'scMaxEachPostBytes']}
+            label={t('pages.inbounds.form.maxUploadSize')}
+          >
+            <Input />
+          </Form.Item>
+        </>
+      )}
+      {xhttpMode === 'stream-up' && (
+        <Form.Item
+          name={['streamSettings', 'xhttpSettings', 'scStreamUpServerSecs']}
+          label={t('pages.inbounds.form.streamUpServer')}
+        >
+          <Input />
+        </Form.Item>
+      )}
+      <Form.Item
+        name={['streamSettings', 'xhttpSettings', 'serverMaxHeaderBytes']}
+        label={t('pages.inbounds.form.serverMaxHeaderBytes')}
+      >
+        <InputNumber min={0} placeholder="0 (default)" />
+      </Form.Item>
+      <Form.Item
+        name={['streamSettings', 'xhttpSettings', 'xPaddingBytes']}
+        label={t('pages.inbounds.form.paddingBytes')}
+      >
+        <Input />
+      </Form.Item>
+      <Form.Item
+        name={['streamSettings', 'xhttpSettings', 'headers']}
+        label={t('pages.inbounds.form.headers')}
+      >
+        <HeaderMapEditor mode="v1" />
+      </Form.Item>
+      <Form.Item
+        name={['streamSettings', 'xhttpSettings', 'uplinkHTTPMethod']}
+        label={t('pages.inbounds.form.uplinkHttpMethod')}
+      >
+        <Select
+          options={[
+            { value: '', label: 'Default (POST)' },
+            { value: 'POST', label: 'POST' },
+            { value: 'PUT', label: 'PUT' },
+            {
+              value: 'GET',
+              label: 'GET (packet-up only)',
+              disabled: xhttpMode !== 'packet-up',
+            },
+          ]}
+        />
+      </Form.Item>
+      <Form.Item
+        name={['streamSettings', 'xhttpSettings', 'xPaddingObfsMode']}
+        label={t('pages.inbounds.form.paddingObfsMode')}
+        valuePropName="checked"
+      >
+        <Switch />
+      </Form.Item>
+      {xhttpObfsMode && (
+        <>
+          <Form.Item
+            name={['streamSettings', 'xhttpSettings', 'xPaddingKey']}
+            label={t('pages.inbounds.form.paddingKey')}
+          >
+            <Input placeholder="x_padding" />
+          </Form.Item>
+          <Form.Item
+            name={['streamSettings', 'xhttpSettings', 'xPaddingHeader']}
+            label={t('pages.inbounds.form.paddingHeader')}
+          >
+            <Input placeholder="X-Padding" />
+          </Form.Item>
+          <Form.Item
+            name={['streamSettings', 'xhttpSettings', 'xPaddingPlacement']}
+            label={t('pages.inbounds.form.paddingPlacement')}
+          >
+            <Select
+              options={[
+                { value: '', label: 'Default (queryInHeader)' },
+                { value: 'queryInHeader', label: 'queryInHeader' },
+                { value: 'header', label: 'header' },
+                { value: 'cookie', label: 'cookie' },
+                { value: 'query', label: 'query' },
+              ]}
+            />
+          </Form.Item>
+          <Form.Item
+            name={['streamSettings', 'xhttpSettings', 'xPaddingMethod']}
+            label={t('pages.inbounds.form.paddingMethod')}
+          >
+            <Select
+              options={[
+                { value: '', label: 'Default (repeat-x)' },
+                { value: 'repeat-x', label: 'repeat-x' },
+                { value: 'tokenish', label: 'tokenish' },
+              ]}
+            />
+          </Form.Item>
+        </>
+      )}
+      <Form.Item
+        name={['streamSettings', 'xhttpSettings', 'sessionPlacement']}
+        label={t('pages.inbounds.form.sessionPlacement')}
+      >
+        <Select
+          options={[
+            { value: '', label: 'Default (path)' },
+            { value: 'path', label: 'path' },
+            { value: 'header', label: 'header' },
+            { value: 'cookie', label: 'cookie' },
+            { value: 'query', label: 'query' },
+          ]}
+        />
+      </Form.Item>
+      {xhttpSessionPlacement && xhttpSessionPlacement !== 'path' && (
+        <Form.Item
+          name={['streamSettings', 'xhttpSettings', 'sessionKey']}
+          label={t('pages.inbounds.form.sessionKey')}
+        >
+          <Input placeholder="x_session" />
+        </Form.Item>
+      )}
+      <Form.Item
+        name={['streamSettings', 'xhttpSettings', 'seqPlacement']}
+        label={t('pages.inbounds.form.sequencePlacement')}
+      >
+        <Select
+          options={[
+            { value: '', label: 'Default (path)' },
+            { value: 'path', label: 'path' },
+            { value: 'header', label: 'header' },
+            { value: 'cookie', label: 'cookie' },
+            { value: 'query', label: 'query' },
+          ]}
+        />
+      </Form.Item>
+      {xhttpSeqPlacement && xhttpSeqPlacement !== 'path' && (
+        <Form.Item
+          name={['streamSettings', 'xhttpSettings', 'seqKey']}
+          label={t('pages.inbounds.form.sequenceKey')}
+        >
+          <Input placeholder="x_seq" />
+        </Form.Item>
+      )}
+      {xhttpMode === 'packet-up' && (
+        <>
+          <Form.Item
+            name={['streamSettings', 'xhttpSettings', 'uplinkDataPlacement']}
+            label={t('pages.inbounds.form.uplinkDataPlacement')}
+          >
+            <Select
+              options={[
+                { value: '', label: 'Default (body)' },
+                { value: 'body', label: 'body' },
+                { value: 'header', label: 'header' },
+                { value: 'cookie', label: 'cookie' },
+                { value: 'query', label: 'query' },
+              ]}
+            />
+          </Form.Item>
+          {xhttpUplinkPlacement && xhttpUplinkPlacement !== 'body' && (
+            <Form.Item
+              name={['streamSettings', 'xhttpSettings', 'uplinkDataKey']}
+              label={t('pages.inbounds.form.uplinkDataKey')}
+            >
+              <Input placeholder="x_data" />
+            </Form.Item>
+          )}
+        </>
+      )}
+      <Form.Item
+        name={['streamSettings', 'xhttpSettings', 'noSSEHeader']}
+        label={t('pages.inbounds.form.noSseHeader')}
+        valuePropName="checked"
+      >
+        <Switch />
+      </Form.Item>
+    </>
+  );
+}

+ 187 - 0
frontend/src/pages/inbounds/form/useInboundFallbacks.ts

@@ -0,0 +1,187 @@
+import { useRef, useState } from 'react';
+
+import { HttpUtil } from '@/utils';
+import type { FallbackRow } from '@/schemas/forms/inbound-form';
+import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
+
+// Fallback rows for VLESS/Trojan TLS inbounds: state + the load/save/derive
+// and add/update/remove/move handlers, plus the eligible-child option list.
+// Lifted out of InboundFormModal so the modal body stays focused on layout.
+export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBInbound[]) {
+  const fallbackKeyRef = useRef(0);
+  const [fallbacks, setFallbacks] = useState<FallbackRow[]>([]);
+
+  const fallbackChildOptions = (dbInbounds || [])
+    .filter((ib) => ib.id !== dbInbound?.id)
+    .map((ib) => ({
+      label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`,
+      value: ib.id,
+    }));
+
+  const loadFallbacks = async (masterId: number | null) => {
+    if (!masterId) {
+      setFallbacks([]);
+      return;
+    }
+    const msg = await HttpUtil.get(`/panel/api/inbounds/${masterId}/fallbacks`);
+    if (!msg?.success || !Array.isArray(msg.obj)) {
+      setFallbacks([]);
+      return;
+    }
+    setFallbacks(
+      (msg.obj as {
+        childId: number;
+        name?: string;
+        alpn?: string;
+        path?: string;
+        dest?: string;
+        xver?: number;
+      }[])
+        .map((r) => ({
+          rowKey: `fb-${++fallbackKeyRef.current}`,
+          childId: r.childId,
+          name: r.name || '',
+          alpn: r.alpn || '',
+          path: r.path || '',
+          dest: r.dest || '',
+          xver: r.xver || 0,
+        })),
+    );
+  };
+
+  const saveFallbacks = async (masterId: number) => {
+    if (!masterId) return true;
+    const payload = {
+      fallbacks: fallbacks.filter((c) => c.childId).map((c, i) => ({
+        childId: c.childId,
+        name: c.name,
+        alpn: c.alpn,
+        path: c.path,
+        dest: c.dest,
+        xver: Number(c.xver) || 0,
+        sortOrder: i,
+      })),
+    };
+    const msg = await HttpUtil.post(
+      `/panel/api/inbounds/${masterId}/fallbacks`,
+      payload,
+      { headers: { 'Content-Type': 'application/json' } },
+    );
+    return !!msg?.success;
+  };
+
+  // Derive a fallback row's SNI / ALPN / Path / xver from a child
+  // inbound's streamSettings — what the legacy panel auto-filled when an
+  // operator wired a fallback target. SNI/ALPN come straight off the
+  // child's TLS block; path depends on the child's transport (ws/grpc
+  // /httpupgrade carry an explicit path; tcp/kcp/xhttp have no path of
+  // their own). xver stays 0 unless the child explicitly opts in via
+  // PROXY-protocol sockopt.
+  const deriveFallbackDefaults = (childId: number): Partial<FallbackRow> => {
+    const child = (dbInbounds || []).find((ib) => ib.id === childId);
+    if (!child) return {};
+    const stream = coerceInboundJsonField(child.streamSettings);
+    const tls = (stream.tlsSettings as Record<string, unknown> | undefined) ?? {};
+    const network = typeof stream.network === 'string' ? stream.network : '';
+    const sni = typeof tls.serverName === 'string' ? tls.serverName : '';
+    const alpnArr = Array.isArray(tls.alpn) ? tls.alpn : [];
+    const alpn = alpnArr.filter((v) => typeof v === 'string').join(',');
+    let path = '';
+    if (network === 'ws') {
+      const ws = (stream.wsSettings as Record<string, unknown> | undefined) ?? {};
+      if (typeof ws.path === 'string') path = ws.path;
+    } else if (network === 'grpc') {
+      const grpc = (stream.grpcSettings as Record<string, unknown> | undefined) ?? {};
+      if (typeof grpc.serviceName === 'string') path = grpc.serviceName;
+    } else if (network === 'httpupgrade') {
+      const hu = (stream.httpupgradeSettings as Record<string, unknown> | undefined) ?? {};
+      if (typeof hu.path === 'string') path = hu.path;
+    } else if (network === 'xhttp') {
+      const xh = (stream.xhttpSettings as Record<string, unknown> | undefined) ?? {};
+      if (typeof xh.path === 'string') path = xh.path;
+    }
+    return { name: sni, alpn, path, xver: 0 };
+  };
+
+  const addFallback = () => {
+    setFallbacks((prev) => [...prev, {
+      rowKey: `fb-${++fallbackKeyRef.current}`,
+      childId: null,
+      name: '',
+      alpn: '',
+      path: '',
+      dest: '',
+      xver: 0,
+    }]);
+  };
+
+  const updateFallback = (rowKey: string, patch: Partial<FallbackRow>) => {
+    setFallbacks((prev) => prev.map((r) => {
+      if (r.rowKey !== rowKey) return r;
+      // When the picker selects a new child inbound and the row hasn't
+      // been hand-edited yet (sni/alpn/path/dest all blank, xver = 0),
+      // pull the SNI/ALPN/Path defaults off that child. Operators who
+      // intentionally typed values keep them — we only fill the empties.
+      if (typeof patch.childId === 'number' && patch.childId !== r.childId) {
+        const isPristine = !r.name && !r.alpn && !r.path && !r.dest && r.xver === 0;
+        if (isPristine) return { ...r, ...patch, ...deriveFallbackDefaults(patch.childId) };
+      }
+      return { ...r, ...patch };
+    }));
+  };
+
+  const removeFallback = (idx: number) => {
+    setFallbacks((prev) => prev.filter((_, i) => i !== idx));
+  };
+
+  // Move a fallback row up/down by swapping adjacent indices. The order
+  // is persisted via the fallback row's sortOrder (rebuilt by index on
+  // save), so reordering survives reloads.
+  const moveFallback = (idx: number, direction: -1 | 1) => {
+    setFallbacks((prev) => {
+      const target = idx + direction;
+      if (target < 0 || target >= prev.length) return prev;
+      const next = prev.slice();
+      [next[idx], next[target]] = [next[target], next[idx]];
+      return next;
+    });
+  };
+
+  // One-shot: add a fresh fallback row for every eligible inbound (i.e.
+  // every option in fallbackChildOptions) that is not already wired up.
+  // Convenient for operators who want catch-all routing to every host
+  // they manage on the panel.
+  const addAllFallbacks = () => {
+    setFallbacks((prev) => {
+      const alreadyHave = new Set(prev.map((r) => r.childId));
+      const additions = fallbackChildOptions
+        .filter((opt) => !alreadyHave.has(opt.value))
+        .map<FallbackRow>((opt) => {
+          const derived = deriveFallbackDefaults(opt.value);
+          return {
+            rowKey: `fb-${++fallbackKeyRef.current}`,
+            childId: opt.value,
+            name: derived.name ?? '',
+            alpn: derived.alpn ?? '',
+            path: derived.path ?? '',
+            dest: '',
+            xver: derived.xver ?? 0,
+          };
+        });
+      if (additions.length === 0) return prev;
+      return [...prev, ...additions];
+    });
+  };
+
+  return {
+    fallbacks,
+    fallbackChildOptions,
+    loadFallbacks,
+    saveFallbacks,
+    addFallback,
+    updateFallback,
+    removeFallback,
+    moveFallback,
+    addAllFallbacks,
+  };
+}

+ 205 - 0
frontend/src/pages/inbounds/form/useSecurityActions.ts

@@ -0,0 +1,205 @@
+import type { Dispatch, SetStateAction } from 'react';
+import { useTranslation } from 'react-i18next';
+import type { FormInstance } from 'antd';
+import type { MessageInstance } from 'antd/es/message/interface';
+
+import { HttpUtil, RandomUtil } from '@/utils';
+import { getRandomRealityTarget } from '@/models/reality-targets';
+import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls';
+import { RealityStreamSettingsSchema } from '@/schemas/protocols/security/reality';
+import type { InboundFormValues } from '@/schemas/forms/inbound-form';
+
+interface UseSecurityActionsArgs {
+  form: FormInstance<InboundFormValues>;
+  setSaving: Dispatch<SetStateAction<boolean>>;
+  messageApi: MessageInstance;
+}
+
+// Server-side TLS / Reality key + certificate generation handlers for the
+// inbound modal's security tab. Each talks to a /panel server endpoint and
+// writes the result back into the form. Lifted out of InboundFormModal so
+// the modal body stays focused on orchestration.
+export function useSecurityActions({ form, setSaving, messageApi }: UseSecurityActionsArgs) {
+  const { t } = useTranslation();
+
+  const genRealityKeypair = async () => {
+    setSaving(true);
+    try {
+      const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert');
+      if (msg?.success) {
+        const obj = msg.obj as { privateKey: string; publicKey: string };
+        form.setFieldValue(['streamSettings', 'realitySettings', 'privateKey'], obj.privateKey);
+        form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'publicKey'], obj.publicKey);
+      }
+    } finally {
+      setSaving(false);
+    }
+  };
+
+  const clearRealityKeypair = () => {
+    form.setFieldValue(['streamSettings', 'realitySettings', 'privateKey'], '');
+    form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'publicKey'], '');
+  };
+
+  const genMldsa65 = async () => {
+    setSaving(true);
+    try {
+      const msg = await HttpUtil.get('/panel/api/server/getNewmldsa65');
+      if (msg?.success) {
+        const obj = msg.obj as { seed: string; verify: string };
+        form.setFieldValue(['streamSettings', 'realitySettings', 'mldsa65Seed'], obj.seed);
+        form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'mldsa65Verify'], obj.verify);
+      }
+    } finally {
+      setSaving(false);
+    }
+  };
+
+  const clearMldsa65 = () => {
+    form.setFieldValue(['streamSettings', 'realitySettings', 'mldsa65Seed'], '');
+    form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'mldsa65Verify'], '');
+  };
+
+  const randomizeRealityTarget = () => {
+    const tgt = getRandomRealityTarget() as { target: string; sni: string };
+    form.setFieldValue(['streamSettings', 'realitySettings', 'target'], tgt.target);
+    form.setFieldValue(
+      ['streamSettings', 'realitySettings', 'serverNames'],
+      tgt.sni.split(',').map((s) => s.trim()).filter(Boolean),
+    );
+  };
+
+  const randomizeShortIds = () => {
+    form.setFieldValue(
+      ['streamSettings', 'realitySettings', 'shortIds'],
+      RandomUtil.randomShortIds().split(',').map((s) => s.trim()).filter(Boolean),
+    );
+  };
+
+  const getNewEchCert = async () => {
+    const sni = form.getFieldValue(['streamSettings', 'tlsSettings', 'serverName']);
+    setSaving(true);
+    try {
+      const msg = await HttpUtil.post('/panel/api/server/getNewEchCert', { sni });
+      if (msg?.success) {
+        const obj = msg.obj as { echServerKeys: string; echConfigList: string };
+        form.setFieldValue(['streamSettings', 'tlsSettings', 'echServerKeys'], obj.echServerKeys);
+        form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'echConfigList'], obj.echConfigList);
+      }
+    } finally {
+      setSaving(false);
+    }
+  };
+
+  const clearEchCert = () => {
+    form.setFieldValue(['streamSettings', 'tlsSettings', 'echServerKeys'], '');
+    form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'echConfigList'], '');
+  };
+
+  const generateRandomPinHash = () => {
+    const bytes = new Uint8Array(32);
+    crypto.getRandomValues(bytes);
+    let binary = '';
+    for (const b of bytes) binary += String.fromCharCode(b);
+    const hash = btoa(binary);
+    const current = (form.getFieldValue(
+      ['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'],
+    ) as string[] | undefined) ?? [];
+    form.setFieldValue(
+      ['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'],
+      [...current, hash],
+    );
+  };
+
+  const setCertFromPanel = async (certName: number) => {
+    setSaving(true);
+    try {
+      const msg = await HttpUtil.post('/panel/setting/all', undefined, { silent: true });
+      if (msg?.success) {
+        const obj = msg.obj as { webCertFile?: string; webKeyFile?: string };
+        if (!obj.webCertFile && !obj.webKeyFile) {
+          messageApi.warning(t('pages.inbounds.setDefaultCertEmpty'));
+          return;
+        }
+        form.setFieldValue(
+          ['streamSettings', 'tlsSettings', 'certificates', certName, 'certificateFile'],
+          obj.webCertFile ?? '',
+        );
+        form.setFieldValue(
+          ['streamSettings', 'tlsSettings', 'certificates', certName, 'keyFile'],
+          obj.webKeyFile ?? '',
+        );
+      }
+    } finally {
+      setSaving(false);
+    }
+  };
+
+  const clearCertFiles = (certName: number) => {
+    form.setFieldValue(
+      ['streamSettings', 'tlsSettings', 'certificates', certName, 'certificateFile'],
+      '',
+    );
+    form.setFieldValue(
+      ['streamSettings', 'tlsSettings', 'certificates', certName, 'keyFile'],
+      '',
+    );
+  };
+
+  const onSecurityChange = async (next: string) => {
+    const current = (form.getFieldValue('streamSettings') as Record<string, unknown>) ?? {};
+    const cleaned: Record<string, unknown> = { ...current, security: next };
+    delete cleaned.tlsSettings;
+    delete cleaned.realitySettings;
+    if (next === 'tls') {
+      const tls = TlsStreamSettingsSchema.parse({}) as Record<string, unknown>;
+      tls.certificates = [{
+        useFile: true,
+        certificateFile: '',
+        keyFile: '',
+        certificate: [],
+        key: [],
+        oneTimeLoading: false,
+        usage: 'encipherment',
+        buildChain: false,
+      }];
+      cleaned.tlsSettings = tls;
+    }
+    if (next === 'reality') {
+      const reality = RealityStreamSettingsSchema.parse({}) as Record<string, unknown>;
+      const tgt = getRandomRealityTarget() as { target: string; sni: string };
+      reality.target = tgt.target;
+      reality.serverNames = tgt.sni.split(',').map((s) => s.trim()).filter(Boolean);
+      reality.shortIds = RandomUtil.randomShortIds().split(',').map((s) => s.trim()).filter(Boolean);
+      cleaned.realitySettings = reality;
+    }
+    form.setFieldValue('streamSettings', cleaned);
+    if (next === 'reality') {
+      try {
+        const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert');
+        if (msg?.success) {
+          const obj = msg.obj as { privateKey: string; publicKey: string };
+          form.setFieldValue(['streamSettings', 'realitySettings', 'privateKey'], obj.privateKey);
+          form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'publicKey'], obj.publicKey);
+        }
+      } catch {
+        // best-effort: leave keypair fields empty if server call fails
+      }
+    }
+  };
+
+  return {
+    genRealityKeypair,
+    clearRealityKeypair,
+    genMldsa65,
+    clearMldsa65,
+    randomizeRealityTarget,
+    randomizeShortIds,
+    getNewEchCert,
+    clearEchCert,
+    generateRandomPinHash,
+    setCertFromPanel,
+    clearCertFiles,
+    onSecurityChange,
+  };
+}

+ 0 - 0
frontend/src/pages/inbounds/InboundInfoModal.css → frontend/src/pages/inbounds/info/InboundInfoModal.css


+ 12 - 262
frontend/src/pages/inbounds/InboundInfoModal.tsx → frontend/src/pages/inbounds/info/InboundInfoModal.tsx

@@ -1,279 +1,29 @@
 import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Button, Divider, Modal, Space, Tabs, Tag, Tooltip } from 'antd';
-import { getMessage } from '@/utils/messageBus';
 import { CopyOutlined, SyncOutlined, DeleteOutlined, DownloadOutlined } from '@ant-design/icons';
 
-import {
-  HttpUtil,
-  IntlUtil,
-  SizeFormatter,
-  ColorUtils,
-  ClipboardManager,
-  FileManager,
-} from '@/utils';
+import { HttpUtil, IntlUtil, SizeFormatter, ColorUtils } from '@/utils';
 import { Protocols } from '@/schemas/primitives';
-import InfinityIcon from '@/components/InfinityIcon';
+import { InfinityIcon } from '@/components/ui';
 import { useDatepicker } from '@/hooks/useDatepicker';
-import { coerceInboundJsonField } from '@/models/dbinbound';
-import {
-  canEnableTlsFlow,
-  isSS2022 as isSS2022Helper,
-  isSSMultiUser as isSSMultiUserHelper,
-} from '@/lib/xray/protocol-capabilities';
 import {
   genAllLinks,
   genWireguardConfigs,
   genWireguardLinks,
 } from '@/lib/xray/inbound-link';
 import { inboundFromDb } from '@/lib/xray/inbound-from-db';
-import type { SubSettings } from './useInbounds';
-import './InboundInfoModal.css';
-
-const LINK_PROTOCOLS: ReadonlySet<string> = new Set([
-  Protocols.VMESS,
-  Protocols.VLESS,
-  Protocols.TROJAN,
-  Protocols.SHADOWSOCKS,
-  Protocols.HYSTERIA,
-]);
-
-function hasShareLink(protocol: string): boolean {
-  return LINK_PROTOCOLS.has(protocol);
-}
-
-function readHeader(headers: unknown, name: string): string {
-  const needle = name.toLowerCase();
-  if (Array.isArray(headers)) {
-    for (const h of headers) {
-      if (h && typeof h === 'object' && String((h as { name?: string }).name ?? '').toLowerCase() === needle) {
-        return String((h as { value?: unknown }).value ?? '');
-      }
-    }
-    return '';
-  }
-  if (headers && typeof headers === 'object') {
-    for (const [k, v] of Object.entries(headers as Record<string, unknown>)) {
-      if (k.toLowerCase() === needle) {
-        return Array.isArray(v) ? String(v[0] ?? '') : String(v ?? '');
-      }
-    }
-  }
-  return '';
-}
-
-function readNetworkHost(stream: Record<string, unknown>, network: string): string | null {
-  switch (network) {
-    case 'tcp': {
-      const tcp = stream.tcpSettings as { header?: { request?: { headers?: unknown } } } | undefined;
-      return readHeader(tcp?.header?.request?.headers, 'host');
-    }
-    case 'ws': {
-      const ws = stream.wsSettings as { host?: string; headers?: unknown } | undefined;
-      return (ws?.host && ws.host.length > 0) ? ws.host : readHeader(ws?.headers, 'host');
-    }
-    case 'httpupgrade': {
-      const hu = stream.httpupgradeSettings as { host?: string; headers?: unknown } | undefined;
-      return (hu?.host && hu.host.length > 0) ? hu.host : readHeader(hu?.headers, 'host');
-    }
-    case 'xhttp': {
-      const xh = stream.xhttpSettings as { host?: string; headers?: unknown } | undefined;
-      return (xh?.host && xh.host.length > 0) ? xh.host : readHeader(xh?.headers, 'host');
-    }
-    default:
-      return null;
-  }
-}
-
-function readNetworkPath(stream: Record<string, unknown>, network: string): string | null {
-  switch (network) {
-    case 'tcp': {
-      const tcp = stream.tcpSettings as { header?: { request?: { path?: string[] } } } | undefined;
-      return tcp?.header?.request?.path?.[0] ?? null;
-    }
-    case 'ws':
-      return (stream.wsSettings as { path?: string } | undefined)?.path ?? null;
-    case 'httpupgrade':
-      return (stream.httpupgradeSettings as { path?: string } | undefined)?.path ?? null;
-    case 'xhttp':
-      return (stream.xhttpSettings as { path?: string } | undefined)?.path ?? null;
-    default:
-      return null;
-  }
-}
 
-interface ClientStats {
-  email: string;
-  up: number;
-  down: number;
-  total: number;
-  expiryTime: number;
-  enable?: boolean;
-}
-
-interface ClientSetting {
-  email?: string;
-  id?: string;
-  security?: string;
-  password?: string;
-  flow?: string;
-  subId?: string;
-  totalGB?: number;
-  expiryTime?: number;
-  comment?: string;
-  tgId?: string;
-  enable?: boolean;
-  limitIp?: number;
-  created_at?: number;
-  updated_at?: number;
-}
-
-interface InboundInfo {
-  protocol: string;
-  clients: ClientSetting[];
-  settings: Record<string, unknown>;
-  isTcp: boolean;
-  isWs: boolean;
-  isHttpupgrade: boolean;
-  isXHTTP: boolean;
-  isGrpc: boolean;
-  isSSMultiUser: boolean;
-  isSS2022: boolean;
-  isVlessTlsFlow: boolean;
-  host: string | null;
-  path: string | null;
-  serviceName: string;
-  serverName: string;
-  stream: {
-    network: string;
-    security: string;
-    xhttp?: { mode?: string };
-    grpc?: { multiMode?: boolean };
-  };
-}
-
-interface DBInboundLike {
-  id: number;
-  address: string;
-  port: number;
-  listen: string;
-  protocol: string;
-  remark: string;
-  enable?: boolean;
-  isVMess?: boolean;
-  isVLess?: boolean;
-  isTrojan?: boolean;
-  isSS?: boolean;
-  isMixed?: boolean;
-  isHTTP?: boolean;
-  isWireguard?: boolean;
-  settings: unknown;
-  streamSettings: unknown;
-  sniffing: unknown;
-  clientStats?: ClientStats[];
-}
-
-function buildInboundInfo(dbInbound: DBInboundLike): InboundInfo {
-  const settings = coerceInboundJsonField(dbInbound.settings) as Record<string, unknown>;
-  const stream = coerceInboundJsonField(dbInbound.streamSettings) as Record<string, unknown>;
-  const network = (stream.network as string | undefined) ?? '';
-  const security = (stream.security as string | undefined) ?? 'none';
-  const clients = Array.isArray(settings.clients) ? (settings.clients as ClientSetting[]) : [];
-  const xhttpSettings = stream.xhttpSettings as { mode?: string } | undefined;
-  const grpcSettings = stream.grpcSettings as { multiMode?: boolean; serviceName?: string } | undefined;
-  let serverName = '';
-  if (security === 'tls') {
-    const tls = stream.tlsSettings as { sni?: string; serverName?: string } | undefined;
-    serverName = tls?.sni ?? tls?.serverName ?? '';
-  } else if (security === 'reality') {
-    const reality = stream.realitySettings as { serverNames?: string[]; serverName?: string } | undefined;
-    if (Array.isArray(reality?.serverNames)) {
-      serverName = reality.serverNames.join(', ');
-    } else if (reality?.serverName) {
-      serverName = reality.serverName;
-    }
-  }
-  return {
-    protocol: dbInbound.protocol,
-    clients,
-    settings,
-    isTcp: network === 'tcp',
-    isWs: network === 'ws',
-    isHttpupgrade: network === 'httpupgrade',
-    isXHTTP: network === 'xhttp',
-    isGrpc: network === 'grpc',
-    isSSMultiUser: isSSMultiUserHelper({
-      protocol: dbInbound.protocol,
-      settings: settings as { method?: string },
-    }),
-    isSS2022: isSS2022Helper({
-      protocol: dbInbound.protocol,
-      settings: settings as { method?: string },
-    }),
-    isVlessTlsFlow: canEnableTlsFlow({
-      protocol: dbInbound.protocol,
-      streamSettings: { network, security },
-    }),
-    host: readNetworkHost(stream, network),
-    path: readNetworkPath(stream, network),
-    serviceName: grpcSettings?.serviceName ?? '',
-    serverName,
-    stream: {
-      network,
-      security,
-      xhttp: xhttpSettings ? { mode: xhttpSettings.mode } : undefined,
-      grpc: grpcSettings ? { multiMode: grpcSettings.multiMode } : undefined,
-    },
-  };
-}
-
-interface InboundInfoModalProps {
-  open: boolean;
-  onClose: () => void;
-  dbInbound: DBInboundLike | null;
-  clientIndex?: number;
-  remarkModel?: string;
-  expireDiff?: number;
-  trafficDiff?: number;
-  ipLimitEnable?: boolean;
-  tgBotEnable?: boolean;
-  nodeAddress?: string;
-  subSettings?: SubSettings;
-  lastOnlineMap?: Record<string, number>;
-}
-
-function copyText(value: unknown, t: (k: string) => string) {
-  ClipboardManager.copyText(String(value ?? '')).then((ok) => {
-    if (ok) getMessage().success(t('copied'));
-  });
-}
-
-function downloadText(content: string, filename: string) {
-  FileManager.downloadTextFile(content, filename);
-}
-
-function statsColor(stats: ClientStats, trafficDiff: number) {
-  return ColorUtils.usageColor(stats.up + stats.down, trafficDiff, stats.total);
-}
-
-function formatIpInfo(record: unknown) {
-  if (record == null) return '';
-  if (typeof record === 'string' || typeof record === 'number') return String(record);
-  const r = record as { ip?: string; IP?: string; timestamp?: number | string; Timestamp?: number | string };
-  const ip = r.ip || r.IP || '';
-  const ts = r.timestamp || r.Timestamp || 0;
-  if (!ip) return String(record);
-  if (!ts) return String(ip);
-  const date = new Date(Number(ts) * 1000);
-  const timeStr = date
-    .toLocaleString('en-GB', {
-      year: 'numeric', month: '2-digit', day: '2-digit',
-      hour: '2-digit', minute: '2-digit', second: '2-digit',
-      hour12: false,
-    })
-    .replace(',', '');
-  return `${ip} (${timeStr})`;
-}
+import {
+  buildInboundInfo,
+  copyText,
+  downloadText,
+  formatIpInfo,
+  hasShareLink,
+  statsColor,
+} from './helpers';
+import type { ClientSetting, ClientStats, InboundInfo, InboundInfoModalProps } from './types';
+import './InboundInfoModal.css';
 
 export default function InboundInfoModal({
   open,

+ 170 - 0
frontend/src/pages/inbounds/info/helpers.ts

@@ -0,0 +1,170 @@
+import { getMessage } from '@/utils/messageBus';
+import { ColorUtils, ClipboardManager, FileManager } from '@/utils';
+import { Protocols } from '@/schemas/primitives';
+import { coerceInboundJsonField } from '@/models/dbinbound';
+import {
+  canEnableTlsFlow,
+  isSS2022 as isSS2022Helper,
+  isSSMultiUser as isSSMultiUserHelper,
+} from '@/lib/xray/protocol-capabilities';
+
+import type { ClientSetting, ClientStats, DBInboundLike, InboundInfo } from './types';
+
+const LINK_PROTOCOLS: ReadonlySet<string> = new Set([
+  Protocols.VMESS,
+  Protocols.VLESS,
+  Protocols.TROJAN,
+  Protocols.SHADOWSOCKS,
+  Protocols.HYSTERIA,
+]);
+
+export function hasShareLink(protocol: string): boolean {
+  return LINK_PROTOCOLS.has(protocol);
+}
+
+function readHeader(headers: unknown, name: string): string {
+  const needle = name.toLowerCase();
+  if (Array.isArray(headers)) {
+    for (const h of headers) {
+      if (h && typeof h === 'object' && String((h as { name?: string }).name ?? '').toLowerCase() === needle) {
+        return String((h as { value?: unknown }).value ?? '');
+      }
+    }
+    return '';
+  }
+  if (headers && typeof headers === 'object') {
+    for (const [k, v] of Object.entries(headers as Record<string, unknown>)) {
+      if (k.toLowerCase() === needle) {
+        return Array.isArray(v) ? String(v[0] ?? '') : String(v ?? '');
+      }
+    }
+  }
+  return '';
+}
+
+function readNetworkHost(stream: Record<string, unknown>, network: string): string | null {
+  switch (network) {
+    case 'tcp': {
+      const tcp = stream.tcpSettings as { header?: { request?: { headers?: unknown } } } | undefined;
+      return readHeader(tcp?.header?.request?.headers, 'host');
+    }
+    case 'ws': {
+      const ws = stream.wsSettings as { host?: string; headers?: unknown } | undefined;
+      return (ws?.host && ws.host.length > 0) ? ws.host : readHeader(ws?.headers, 'host');
+    }
+    case 'httpupgrade': {
+      const hu = stream.httpupgradeSettings as { host?: string; headers?: unknown } | undefined;
+      return (hu?.host && hu.host.length > 0) ? hu.host : readHeader(hu?.headers, 'host');
+    }
+    case 'xhttp': {
+      const xh = stream.xhttpSettings as { host?: string; headers?: unknown } | undefined;
+      return (xh?.host && xh.host.length > 0) ? xh.host : readHeader(xh?.headers, 'host');
+    }
+    default:
+      return null;
+  }
+}
+
+function readNetworkPath(stream: Record<string, unknown>, network: string): string | null {
+  switch (network) {
+    case 'tcp': {
+      const tcp = stream.tcpSettings as { header?: { request?: { path?: string[] } } } | undefined;
+      return tcp?.header?.request?.path?.[0] ?? null;
+    }
+    case 'ws':
+      return (stream.wsSettings as { path?: string } | undefined)?.path ?? null;
+    case 'httpupgrade':
+      return (stream.httpupgradeSettings as { path?: string } | undefined)?.path ?? null;
+    case 'xhttp':
+      return (stream.xhttpSettings as { path?: string } | undefined)?.path ?? null;
+    default:
+      return null;
+  }
+}
+
+export function buildInboundInfo(dbInbound: DBInboundLike): InboundInfo {
+  const settings = coerceInboundJsonField(dbInbound.settings) as Record<string, unknown>;
+  const stream = coerceInboundJsonField(dbInbound.streamSettings) as Record<string, unknown>;
+  const network = (stream.network as string | undefined) ?? '';
+  const security = (stream.security as string | undefined) ?? 'none';
+  const clients = Array.isArray(settings.clients) ? (settings.clients as ClientSetting[]) : [];
+  const xhttpSettings = stream.xhttpSettings as { mode?: string } | undefined;
+  const grpcSettings = stream.grpcSettings as { multiMode?: boolean; serviceName?: string } | undefined;
+  let serverName = '';
+  if (security === 'tls') {
+    const tls = stream.tlsSettings as { sni?: string; serverName?: string } | undefined;
+    serverName = tls?.sni ?? tls?.serverName ?? '';
+  } else if (security === 'reality') {
+    const reality = stream.realitySettings as { serverNames?: string[]; serverName?: string } | undefined;
+    if (Array.isArray(reality?.serverNames)) {
+      serverName = reality.serverNames.join(', ');
+    } else if (reality?.serverName) {
+      serverName = reality.serverName;
+    }
+  }
+  return {
+    protocol: dbInbound.protocol,
+    clients,
+    settings,
+    isTcp: network === 'tcp',
+    isWs: network === 'ws',
+    isHttpupgrade: network === 'httpupgrade',
+    isXHTTP: network === 'xhttp',
+    isGrpc: network === 'grpc',
+    isSSMultiUser: isSSMultiUserHelper({
+      protocol: dbInbound.protocol,
+      settings: settings as { method?: string },
+    }),
+    isSS2022: isSS2022Helper({
+      protocol: dbInbound.protocol,
+      settings: settings as { method?: string },
+    }),
+    isVlessTlsFlow: canEnableTlsFlow({
+      protocol: dbInbound.protocol,
+      streamSettings: { network, security },
+    }),
+    host: readNetworkHost(stream, network),
+    path: readNetworkPath(stream, network),
+    serviceName: grpcSettings?.serviceName ?? '',
+    serverName,
+    stream: {
+      network,
+      security,
+      xhttp: xhttpSettings ? { mode: xhttpSettings.mode } : undefined,
+      grpc: grpcSettings ? { multiMode: grpcSettings.multiMode } : undefined,
+    },
+  };
+}
+
+export function copyText(value: unknown, t: (k: string) => string) {
+  ClipboardManager.copyText(String(value ?? '')).then((ok) => {
+    if (ok) getMessage().success(t('copied'));
+  });
+}
+
+export function downloadText(content: string, filename: string) {
+  FileManager.downloadTextFile(content, filename);
+}
+
+export function statsColor(stats: ClientStats, trafficDiff: number) {
+  return ColorUtils.usageColor(stats.up + stats.down, trafficDiff, stats.total);
+}
+
+export function formatIpInfo(record: unknown) {
+  if (record == null) return '';
+  if (typeof record === 'string' || typeof record === 'number') return String(record);
+  const r = record as { ip?: string; IP?: string; timestamp?: number | string; Timestamp?: number | string };
+  const ip = r.ip || r.IP || '';
+  const ts = r.timestamp || r.Timestamp || 0;
+  if (!ip) return String(record);
+  if (!ts) return String(ip);
+  const date = new Date(Number(ts) * 1000);
+  const timeStr = date
+    .toLocaleString('en-GB', {
+      year: 'numeric', month: '2-digit', day: '2-digit',
+      hour: '2-digit', minute: '2-digit', second: '2-digit',
+      hour12: false,
+    })
+    .replace(',', '');
+  return `${ip} (${timeStr})`;
+}

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

@@ -0,0 +1 @@
+export { default as InboundInfoModal } from './InboundInfoModal';

+ 87 - 0
frontend/src/pages/inbounds/info/types.ts

@@ -0,0 +1,87 @@
+import type { SubSettings } from '../useInbounds';
+
+export interface ClientStats {
+  email: string;
+  up: number;
+  down: number;
+  total: number;
+  expiryTime: number;
+  enable?: boolean;
+}
+
+export interface ClientSetting {
+  email?: string;
+  id?: string;
+  security?: string;
+  password?: string;
+  flow?: string;
+  subId?: string;
+  totalGB?: number;
+  expiryTime?: number;
+  comment?: string;
+  tgId?: string;
+  enable?: boolean;
+  limitIp?: number;
+  created_at?: number;
+  updated_at?: number;
+}
+
+export interface InboundInfo {
+  protocol: string;
+  clients: ClientSetting[];
+  settings: Record<string, unknown>;
+  isTcp: boolean;
+  isWs: boolean;
+  isHttpupgrade: boolean;
+  isXHTTP: boolean;
+  isGrpc: boolean;
+  isSSMultiUser: boolean;
+  isSS2022: boolean;
+  isVlessTlsFlow: boolean;
+  host: string | null;
+  path: string | null;
+  serviceName: string;
+  serverName: string;
+  stream: {
+    network: string;
+    security: string;
+    xhttp?: { mode?: string };
+    grpc?: { multiMode?: boolean };
+  };
+}
+
+export interface DBInboundLike {
+  id: number;
+  address: string;
+  port: number;
+  listen: string;
+  protocol: string;
+  remark: string;
+  enable?: boolean;
+  isVMess?: boolean;
+  isVLess?: boolean;
+  isTrojan?: boolean;
+  isSS?: boolean;
+  isMixed?: boolean;
+  isHTTP?: boolean;
+  isWireguard?: boolean;
+  settings: unknown;
+  streamSettings: unknown;
+  sniffing: unknown;
+  clientStats?: ClientStats[];
+}
+
+export interface InboundInfoModalProps {
+  open: boolean;
+  onClose: () => void;
+  dbInbound: DBInboundLike | null;
+  clientIndex?: number;
+  remarkModel?: string;
+  expireDiff?: number;
+  trafficDiff?: number;
+  ipLimitEnable?: boolean;
+  tgBotEnable?: boolean;
+  nodeAddress?: string;
+  subSettings?: SubSettings;
+  lastOnlineMap?: Record<string, number>;
+}

+ 20 - 0
frontend/src/pages/inbounds/InboundList.css → frontend/src/pages/inbounds/list/InboundList.css

@@ -75,6 +75,26 @@
   gap: 8px;
 }
 
+.inbound-card.is-selected {
+  border-color: var(--ant-color-primary);
+  background: color-mix(in srgb, var(--ant-color-primary) 6%, transparent);
+}
+
+.card-bulk-bar {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 4px 4px 8px;
+}
+
+.bulk-count {
+  font-size: 12px;
+  background: color-mix(in srgb, var(--ant-color-primary) 12%, transparent);
+  color: var(--ant-color-primary);
+  padding: 1px 8px;
+  border-radius: 10px;
+}
+
 .card-head {
   display: flex;
   align-items: center;

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

@@ -0,0 +1,260 @@
+import { useCallback, useMemo, useState, type Key } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+  Button,
+  Card,
+  Checkbox,
+  Dropdown,
+  Space,
+  Switch,
+  Table,
+  Tag,
+  Tooltip,
+  type MenuProps,
+} from 'antd';
+import {
+  PlusOutlined,
+  MenuOutlined,
+  MoreOutlined,
+  ExportOutlined,
+  ImportOutlined,
+  ReloadOutlined,
+  InfoCircleOutlined,
+  DeleteOutlined,
+} from '@ant-design/icons';
+
+import { HttpUtil } from '@/utils';
+
+import { SORT_FNS } from './helpers';
+import { buildRowActionsMenu } from './RowActions';
+import { useInboundColumns } from './useInboundColumns';
+import InboundStatsModal from './InboundStatsModal';
+import type { DBInboundRecord, GeneralAction, InboundListProps, RowAction, SortKey, SortOrder } from './types';
+import './InboundList.css';
+
+export default function InboundList({
+  dbInbounds,
+  clientCount,
+  lastOnlineMap: _lastOnlineMap,
+  expireDiff,
+  trafficDiff,
+  pageSize,
+  isMobile,
+  subEnable,
+  nodesById,
+  hasActiveNode,
+  onAddInbound,
+  onGeneralAction,
+  onRowAction,
+  onBulkDelete,
+}: InboundListProps) {
+  const { t } = useTranslation();
+  const [sortKey, setSortKey] = useState<SortKey | null>(null);
+  const [sortOrder, setSortOrder] = useState<SortOrder>(null);
+  const [statsRecord, setStatsRecord] = useState<DBInboundRecord | null>(null);
+  const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
+
+  const onSwitchEnable = useCallback(async (dbInbound: DBInboundRecord, next: boolean) => {
+    const previous = dbInbound.enable;
+    dbInbound.enable = next;
+    try {
+      const formData = new FormData();
+      formData.append('enable', String(next));
+      const msg = await HttpUtil.post(`/panel/api/inbounds/setEnable/${dbInbound.id}`, formData);
+      if (!msg?.success) dbInbound.enable = previous;
+    } catch {
+      dbInbound.enable = previous;
+    }
+  }, []);
+
+  const sortedInbounds = useMemo(() => {
+    if (!sortKey || !sortOrder) return dbInbounds;
+    const fn = SORT_FNS[sortKey];
+    if (!fn) return dbInbounds;
+    const sorted = [...dbInbounds].sort((a, b) => fn(a, b, { nodesById, clientCount }));
+    return sortOrder === 'descend' ? sorted.reverse() : sorted;
+  }, [dbInbounds, sortKey, sortOrder, nodesById, clientCount]);
+
+  const hasAnyRemark = useMemo(
+    () => dbInbounds.some((i) => typeof i.remark === 'string' && i.remark.trim() !== ''),
+    [dbInbounds],
+  );
+
+  const toggleSelect = useCallback((id: number, checked: boolean) => {
+    setSelectedRowKeys((prev) => {
+      const next = new Set(prev);
+      if (checked) next.add(id); else next.delete(id);
+      return Array.from(next);
+    });
+  }, []);
+
+  const selectAll = useCallback((checked: boolean) => {
+    setSelectedRowKeys(checked ? sortedInbounds.map((i) => i.id) : []);
+  }, [sortedInbounds]);
+
+  const allSelected = sortedInbounds.length > 0 && selectedRowKeys.length === sortedInbounds.length;
+  const someSelected = selectedRowKeys.length > 0 && selectedRowKeys.length < sortedInbounds.length;
+
+  const handleBulkDelete = useCallback(async () => {
+    const ok = await onBulkDelete(selectedRowKeys);
+    if (ok) setSelectedRowKeys([]);
+  }, [onBulkDelete, selectedRowKeys]);
+
+  const columns = useInboundColumns({
+    hasAnyRemark,
+    hasActiveNode,
+    nodesById,
+    clientCount,
+    subEnable,
+    expireDiff,
+    trafficDiff,
+    sortKey,
+    sortOrder,
+    onRowAction,
+    onSwitchEnable,
+  });
+
+  const paginationFor = (rows: DBInboundRecord[]) => {
+    const size = pageSize > 0 ? pageSize : rows.length || 1;
+    return { pageSize: size, showSizeChanger: false, hideOnSinglePage: true };
+  };
+
+  const generalActionsMenu: MenuProps = {
+    items: [
+      { key: 'import', icon: <ImportOutlined />, label: t('pages.inbounds.importInbound') },
+      { key: 'export', icon: <ExportOutlined />, label: t('pages.inbounds.export') },
+      ...(subEnable
+        ? [{ key: 'subs', icon: <ExportOutlined />, label: `${t('pages.inbounds.export')} — ${t('pages.settings.subSettings')}` }]
+        : []),
+      { key: 'resetInbounds', icon: <ReloadOutlined />, label: t('pages.inbounds.resetAllTraffic') },
+    ],
+    onClick: ({ key }) => onGeneralAction(key as GeneralAction),
+  };
+
+  return (
+    <Card
+      hoverable
+      title={(
+        <Space>
+          <Button type="primary" onClick={onAddInbound} icon={<PlusOutlined />}>
+            {!isMobile && t('pages.inbounds.addInbound')}
+          </Button>
+          <Dropdown trigger={['click']} menu={generalActionsMenu}>
+            <Button type="primary" icon={<MenuOutlined />}>
+              {!isMobile && t('pages.inbounds.generalActions')}
+            </Button>
+          </Dropdown>
+          {selectedRowKeys.length > 0 && (
+            <>
+              <Tag color="blue" closable onClose={() => setSelectedRowKeys([])} style={{ marginInlineEnd: 0 }}>
+                {t('pages.inbounds.selectedCount', { count: selectedRowKeys.length })}
+              </Tag>
+              <Button danger icon={<DeleteOutlined />} onClick={handleBulkDelete}>
+                {!isMobile && t('delete')}
+              </Button>
+            </>
+          )}
+        </Space>
+      )}
+    >
+      <Space orientation="vertical" style={{ width: '100%' }}>
+        {isMobile ? (
+          <div className="inbound-cards">
+            {sortedInbounds.length === 0 ? (
+              <div className="card-empty">
+                <ImportOutlined style={{ fontSize: 28, opacity: 0.5 }} />
+                <div>{t('noData')}</div>
+              </div>
+            ) : (
+              <>
+              <div className="card-bulk-bar">
+                <Checkbox
+                  checked={allSelected}
+                  indeterminate={someSelected}
+                  onChange={(e) => selectAll(e.target.checked)}
+                >
+                  {t('pages.inbounds.selectAll')}
+                </Checkbox>
+                {selectedRowKeys.length > 0 && (
+                  <span className="bulk-count">{selectedRowKeys.length}</span>
+                )}
+              </div>
+              {sortedInbounds.map((record) => (
+                <div key={record.id} className={`inbound-card${selectedRowKeys.includes(record.id) ? ' is-selected' : ''}`}>
+                  <div className="card-head">
+                    <Checkbox
+                      checked={selectedRowKeys.includes(record.id)}
+                      onChange={(e) => toggleSelect(record.id, e.target.checked)}
+                    />
+                    <span className="card-id">#{record.id}</span>
+                    <span className="tag-name">{record.remark}</span>
+                    <div className="card-actions" onClick={(e) => e.stopPropagation()}>
+                      <Tooltip title={t('pages.inbounds.inboundInfo')}>
+                        <InfoCircleOutlined className="row-action-trigger" onClick={() => setStatsRecord(record)} />
+                      </Tooltip>
+                      <Switch
+                        checked={record.enable}
+                        size="small"
+                        onChange={(next) => onSwitchEnable(record, next)}
+                      />
+                      <Dropdown
+                        trigger={['click']}
+                        placement="bottomRight"
+                        menu={{
+                          items: buildRowActionsMenu({ record, subEnable, t, isMobile: true, hasClients: (clientCount[record.id]?.clients || 0) > 0 }),
+                          onClick: ({ key }) => onRowAction({ key: key as RowAction, dbInbound: record }),
+                        }}
+                      >
+                        <MoreOutlined className="row-action-trigger" onClick={(e) => e.preventDefault()} />
+                      </Dropdown>
+                    </div>
+                  </div>
+                </div>
+              ))}
+              </>
+            )}
+          </div>
+        ) : (
+          <Table
+            columns={columns}
+            dataSource={sortedInbounds}
+            rowKey={(r) => r.id}
+            rowSelection={{
+              selectedRowKeys,
+              onChange: (keys: Key[]) => setSelectedRowKeys(keys as number[]),
+            }}
+            pagination={paginationFor(sortedInbounds)}
+            scroll={{ x: 1000 }}
+            style={{ marginTop: 10 }}
+            size="small"
+            locale={{
+              emptyText: (
+                <div className="card-empty">
+                  <ImportOutlined style={{ fontSize: 32, marginBottom: 8 }} />
+                  <div>{t('noData')}</div>
+                </div>
+              ),
+            }}
+            onChange={(_p, _f, sorter) => {
+              const single = Array.isArray(sorter) ? sorter[0] : sorter;
+              const colKey = (single?.columnKey || single?.field) as SortKey | undefined;
+              setSortKey(colKey || null);
+              setSortOrder((single?.order as SortOrder) || null);
+            }}
+          />
+        )}
+      </Space>
+
+      <InboundStatsModal
+        open={isMobile && !!statsRecord}
+        record={statsRecord}
+        hasActiveNode={hasActiveNode}
+        nodesById={nodesById}
+        clientCount={clientCount}
+        trafficDiff={trafficDiff}
+        expireDiff={expireDiff}
+        onClose={() => setStatsRecord(null)}
+      />
+    </Card>
+  );
+}

+ 141 - 0
frontend/src/pages/inbounds/list/InboundStatsModal.tsx

@@ -0,0 +1,141 @@
+import { useTranslation } from 'react-i18next';
+import { Modal, Tag } from 'antd';
+
+import { SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
+import { InfinityIcon } from '@/components/ui';
+import type { NodeRecord } from '@/api/queries/useNodesQuery';
+
+import {
+  readStreamHints,
+  networkLabel,
+  networkL4,
+  shadowsocksNetworkLabel,
+  tunnelNetworkLabel,
+  mixedNetworkLabel,
+} from './helpers';
+import type { ClientCountEntry, DBInboundRecord } from './types';
+
+interface InboundStatsModalProps {
+  open: boolean;
+  record: DBInboundRecord | null;
+  hasActiveNode: boolean;
+  nodesById: Map<number, NodeRecord>;
+  clientCount: Record<number, ClientCountEntry>;
+  trafficDiff: number;
+  expireDiff: number;
+  onClose: () => void;
+}
+
+export default function InboundStatsModal({
+  open,
+  record,
+  hasActiveNode,
+  nodesById,
+  clientCount,
+  trafficDiff,
+  expireDiff,
+  onClose,
+}: InboundStatsModalProps) {
+  const { t } = useTranslation();
+  return (
+    <Modal
+      open={open}
+      footer={null}
+      width={360}
+      centered
+      title={record ? `#${record.id} ${record.remark || ''}`.trim() : ''}
+      onCancel={onClose}
+      destroyOnHidden
+    >
+      {record && (
+        <div className="card-stats">
+          <div className="stat-row">
+            <span className="stat-label">{t('pages.inbounds.protocol')}</span>
+            <Tag color="purple">{record.protocol}</Tag>
+            {(record.isWireguard || record.isHysteria) && (
+              <Tag color="green">UDP</Tag>
+            )}
+            {record.isSS && (() => {
+              const stream = readStreamHints(record.streamSettings);
+              return (
+                <>
+                  <Tag color="green">{shadowsocksNetworkLabel(record.settings)}</Tag>
+                  {stream.isTls && <Tag color="blue">TLS</Tag>}
+                </>
+              );
+            })()}
+            {record.isTunnel && (
+              <Tag color="green">{tunnelNetworkLabel(record.settings)}</Tag>
+            )}
+            {record.isMixed && (
+              <Tag color="green">{mixedNetworkLabel(record.settings)}</Tag>
+            )}
+            {(record.isVMess || record.isVLess || record.isTrojan) && (() => {
+              const stream = readStreamHints(record.streamSettings);
+              const l4 = networkL4(stream.network);
+              return (
+                <>
+                  <Tag color="green">{networkLabel(stream.network)}</Tag>
+                  {l4 && <Tag color="green">{l4}</Tag>}
+                  {stream.isTls && <Tag color="blue">TLS</Tag>}
+                  {stream.isReality && <Tag color="blue">Reality</Tag>}
+                </>
+              );
+            })()}
+          </div>
+          <div className="stat-row">
+            <span className="stat-label">{t('pages.inbounds.port')}</span>
+            <Tag>{record.port}</Tag>
+          </div>
+          {hasActiveNode && (
+            <div className="stat-row">
+              <span className="stat-label">{t('pages.inbounds.node')}</span>
+              {record.nodeId == null ? (
+                <Tag color="default">{t('pages.inbounds.localPanel')}</Tag>
+              ) : nodesById.get(record.nodeId) ? (
+                <Tag color={nodesById.get(record.nodeId)!.status === 'online' ? 'blue' : 'red'}>
+                  {nodesById.get(record.nodeId)!.name}
+                </Tag>
+              ) : (
+                <Tag color="orange">#{record.nodeId}</Tag>
+              )}
+            </div>
+          )}
+          <div className="stat-row">
+            <span className="stat-label">{t('pages.inbounds.traffic')}</span>
+            <Tag color={ColorUtils.usageColor(record.up + record.down, trafficDiff, record.total)}>
+              {SizeFormatter.sizeFormat(record.up + record.down)} /
+              {' '}
+              {record.total > 0 ? SizeFormatter.sizeFormat(record.total) : <InfinityIcon />}
+            </Tag>
+          </div>
+          {clientCount[record.id] && (
+            <div className="stat-row">
+              <span className="stat-label">{t('clients')}</span>
+              <Tag color="green" className="client-count-tag">{clientCount[record.id].clients}</Tag>
+              {clientCount[record.id].online.length > 0 && (
+                <Tag color="blue">{clientCount[record.id].online.length} {t('online')}</Tag>
+              )}
+              {clientCount[record.id].depleted.length > 0 && (
+                <Tag color="red">{clientCount[record.id].depleted.length} {t('depleted')}</Tag>
+              )}
+              {clientCount[record.id].expiring.length > 0 && (
+                <Tag color="orange">{clientCount[record.id].expiring.length} {t('depletingSoon')}</Tag>
+              )}
+            </div>
+          )}
+          <div className="stat-row">
+            <span className="stat-label">{t('pages.inbounds.expireDate')}</span>
+            {record.expiryTime > 0 ? (
+              <Tag color={ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)}>
+                {IntlUtil.formatRelativeTime(record.expiryTime)}
+              </Tag>
+            ) : (
+              <Tag color="purple"><InfinityIcon /></Tag>
+            )}
+          </div>
+        </div>
+      )}
+    </Modal>
+  );
+}

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

@@ -0,0 +1,81 @@
+import { useTranslation } from 'react-i18next';
+import { Button, Dropdown, type MenuProps } from 'antd';
+import {
+  MoreOutlined,
+  EditOutlined,
+  QrcodeOutlined,
+  CopyOutlined,
+  ExportOutlined,
+  RetweetOutlined,
+  BlockOutlined,
+  DeleteOutlined,
+  InfoCircleOutlined,
+  TagsOutlined,
+  UsergroupAddOutlined,
+  UsergroupDeleteOutlined,
+} from '@ant-design/icons';
+
+import { isInboundMultiUser, showQrCodeMenu } from './helpers';
+import type { DBInboundRecord, RowAction } from './types';
+
+interface RowActionsMenuProps {
+  record: DBInboundRecord;
+  subEnable: boolean;
+  hasClients: boolean;
+  onClick: (key: RowAction) => void;
+  isMobile?: boolean;
+}
+
+export function buildRowActionsMenu({ record, subEnable, t, isMobile, hasClients }: { record: DBInboundRecord; subEnable: boolean; t: (k: string) => string; isMobile?: boolean; hasClients?: boolean }): MenuProps['items'] {
+  const items: MenuProps['items'] = [];
+  if (isMobile) {
+    items.push({ key: 'edit', icon: <EditOutlined />, label: t('edit') });
+  }
+  if (showQrCodeMenu(record)) {
+    items.push({ key: 'qrcode', icon: <QrcodeOutlined />, label: t('qrCode') });
+  }
+  if (isInboundMultiUser(record)) {
+    items.push({ key: 'export', icon: <ExportOutlined />, label: t('pages.inbounds.export') });
+    if (subEnable) {
+      items.push({
+        key: 'subs',
+        icon: <ExportOutlined />,
+        label: `${t('pages.inbounds.export')} — ${t('pages.settings.subSettings')}`,
+      });
+    }
+  } else {
+    items.push({ key: 'showInfo', icon: <InfoCircleOutlined />, label: t('pages.inbounds.inboundInfo') });
+  }
+  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) && hasClients) {
+    items.push({ key: 'attachClients', icon: <UsergroupAddOutlined />, label: t('pages.inbounds.attachClients') });
+    items.push({ key: 'detachClients', icon: <UsergroupDeleteOutlined />, label: t('pages.inbounds.detachClients') });
+    items.push({ key: 'addToGroup', icon: <TagsOutlined />, label: t('pages.inbounds.addClientsToGroup') });
+    items.push({ type: 'divider' });
+    items.push({ key: 'delAllClients', icon: <UsergroupDeleteOutlined />, danger: true, label: t('pages.inbounds.delAllClients') });
+  } else {
+    items.push({ type: 'divider' });
+  }
+  items.push({ key: 'delete', icon: <DeleteOutlined />, danger: true, label: t('delete') });
+  return items;
+}
+
+export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowActionsMenuProps) {
+  const { t } = useTranslation();
+  return (
+    <div className="action-buttons">
+      <Button type="text" size="small" icon={<EditOutlined />} onClick={() => onClick('edit')} />
+      <Dropdown
+        trigger={['click']}
+        menu={{
+          items: buildRowActionsMenu({ record, subEnable, t, hasClients }),
+          onClick: ({ key }) => onClick(key as RowAction),
+        }}
+      >
+        <Button type="text" size="small" icon={<MoreOutlined />} />
+      </Dropdown>
+    </div>
+  );
+}

+ 106 - 0
frontend/src/pages/inbounds/list/helpers.ts

@@ -0,0 +1,106 @@
+import type { NodeRecord } from '@/api/queries/useNodesQuery';
+import { isSSMultiUser } from '@/lib/xray/protocol-capabilities';
+import { coerceInboundJsonField } from '@/models/dbinbound';
+
+import type { ClientCountEntry, DBInboundRecord, SortKey, StreamHints } from './types';
+
+export function readStreamHints(streamSettings: unknown): StreamHints {
+  const stream = coerceInboundJsonField(streamSettings) as { network?: string; security?: string };
+  return {
+    network: stream.network ?? '',
+    isTls: stream.security === 'tls',
+    isReality: stream.security === 'reality',
+  };
+}
+
+// Display label for a network value. All known transports render in
+// upper-case for visual consistency with the TCP/UDP/TLS/Reality tags
+// already shown alongside; compound names (`httpupgrade`, `splithttp`,
+// `xhttp`) get a tiny touch of casing so they don't read as one word.
+export function networkLabel(network: string): string {
+  const n = (network || '').toLowerCase();
+  if (!n) return 'TCP';
+  switch (n) {
+    case 'httpupgrade': return 'HTTPUpgrade';
+    case 'splithttp': return 'SplitHTTP';
+    case 'xhttp': return 'XHTTP';
+  }
+  return n.toUpperCase();
+}
+
+// Returns the underlying L4 protocol for transports whose name isn't
+// already TCP/UDP. `kcp` and `quic` both ride on UDP; everything else
+// (`ws`, `grpc`, `http`, `httpupgrade`, `xhttp`) is TCP-based and gets
+// no extra tag (the transport name implies TCP).
+export function networkL4(network: string): 'UDP' | '' {
+  const n = (network || '').toLowerCase();
+  if (n === 'kcp' || n === 'quic') return 'UDP';
+  return '';
+}
+
+// Shadowsocks settings.network ("tcp" / "udp" / "tcp,udp") and Tunnel
+// settings.allowedNetwork (same shape, different field name) both carry
+// the L4 transport list independent of streamSettings. Returns a
+// comma-separated label.
+export function commaNetworkLabel(raw: string): string {
+  const parts = (raw || 'tcp').toLowerCase().split(',').map((p) => p.trim()).filter(Boolean);
+  if (parts.length === 0) return 'TCP';
+  return parts.map(networkLabel).join(',');
+}
+
+export function shadowsocksNetworkLabel(settings: unknown): string {
+  return commaNetworkLabel(readSettings(settings).network || '');
+}
+
+export function tunnelNetworkLabel(settings: unknown): string {
+  return commaNetworkLabel(readSettings(settings).allowedNetwork || '');
+}
+
+// Mixed (socks+http combo) is always TCP at L4; settings.udp=true adds
+// UDP-associate support on the same port (SOCKS5 UDP).
+export function mixedNetworkLabel(settings: unknown): string {
+  const st = coerceInboundJsonField(settings) as { udp?: boolean };
+  return st.udp ? 'TCP,UDP' : 'TCP';
+}
+
+export function readSettings(settings: unknown): { method?: string; network?: string; allowedNetwork?: string } {
+  return coerceInboundJsonField(settings) as { method?: string; network?: string; allowedNetwork?: string };
+}
+
+export function isInboundMultiUser(record: { protocol: string; settings: unknown }): boolean {
+  switch (record.protocol) {
+    case 'vmess':
+    case 'vless':
+    case 'trojan':
+    case 'hysteria':
+      return true;
+    case 'shadowsocks':
+      return isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(record.settings) });
+    default:
+      return false;
+  }
+}
+
+export function showQrCodeMenu(dbInbound: DBInboundRecord): boolean {
+  if (dbInbound.isWireguard) return true;
+  if (dbInbound.isSS) {
+    return !isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(dbInbound.settings) });
+  }
+  return false;
+}
+
+export const SORT_FNS: Record<SortKey, (a: DBInboundRecord, b: DBInboundRecord, ctx: { nodesById: Map<number, NodeRecord>; clientCount: Record<number, ClientCountEntry> }) => number> = {
+  id: (a, b) => a.id - b.id,
+  enable: (a, b) => Number(a.enable) - Number(b.enable),
+  remark: (a, b) => (a.remark || '').localeCompare(b.remark || ''),
+  port: (a, b) => a.port - b.port,
+  protocol: (a, b) => a.protocol.localeCompare(b.protocol),
+  traffic: (a, b) => (a.up + a.down) - (b.up + b.down),
+  expiryTime: (a, b) => (a.expiryTime || Infinity) - (b.expiryTime || Infinity),
+  node: (a, b, ctx) => {
+    const nameA = ctx.nodesById.get(a.nodeId ?? -1)?.name ?? (a.nodeId == null ? '￿' : `node #${a.nodeId}`);
+    const nameB = ctx.nodesById.get(b.nodeId ?? -1)?.name ?? (b.nodeId == null ? '￿' : `node #${b.nodeId}`);
+    return nameA.localeCompare(nameB);
+  },
+  clients: (a, b, ctx) => (ctx.clientCount[a.id]?.clients || 0) - (ctx.clientCount[b.id]?.clients || 0),
+};

+ 2 - 0
frontend/src/pages/inbounds/list/index.ts

@@ -0,0 +1,2 @@
+export { default as InboundList } from './InboundList';
+export { isInboundMultiUser } from './helpers';

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

@@ -0,0 +1,89 @@
+import type { NodeRecord } from '@/api/queries/useNodesQuery';
+
+export interface StreamHints {
+  network: string;
+  isTls: boolean;
+  isReality: boolean;
+}
+
+export type ProtocolFlags = {
+  isVMess?: boolean;
+  isVLess?: boolean;
+  isTrojan?: boolean;
+  isSS?: boolean;
+  isHysteria?: boolean;
+  isMixed?: boolean;
+  isHTTP?: boolean;
+  isWireguard?: boolean;
+  isTunnel?: boolean;
+};
+
+export interface DBInboundRecord extends ProtocolFlags {
+  id: number;
+  enable: boolean;
+  remark: string;
+  port: number;
+  protocol: string;
+  up: number;
+  down: number;
+  total: number;
+  expiryTime: number;
+  _expiryTime: { valueOf(): number } | null;
+  nodeId?: number | null;
+  settings: unknown;
+  streamSettings: unknown;
+}
+
+export interface ClientCountEntry {
+  clients: number;
+  active: string[];
+  deactive: string[];
+  depleted: string[];
+  expiring: string[];
+  online: string[];
+}
+
+export type RowAction =
+  | 'edit'
+  | 'showInfo'
+  | 'qrcode'
+  | 'export'
+  | 'subs'
+  | 'clipboard'
+  | 'delete'
+  | 'resetTraffic'
+  | 'delAllClients'
+  | 'clone';
+
+export type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
+
+export interface InboundListProps {
+  dbInbounds: DBInboundRecord[];
+  clientCount: Record<number, ClientCountEntry>;
+  onlineClients: string[];
+  lastOnlineMap: Record<string, number>;
+  expireDiff: number;
+  trafficDiff: number;
+  pageSize: number;
+  isMobile: boolean;
+  subEnable: boolean;
+  nodesById: Map<number, NodeRecord>;
+  hasActiveNode: boolean;
+  onAddInbound: () => void;
+  onGeneralAction: (key: GeneralAction) => void;
+  onRowAction: (action: { key: RowAction; dbInbound: DBInboundRecord }) => void;
+  onBulkDelete: (ids: number[]) => Promise<boolean>;
+}
+
+export type SortKey =
+  | 'id'
+  | 'enable'
+  | 'remark'
+  | 'port'
+  | 'protocol'
+  | 'traffic'
+  | 'expiryTime'
+  | 'node'
+  | 'clients';
+
+export type SortOrder = 'ascend' | 'descend' | null;

+ 290 - 0
frontend/src/pages/inbounds/list/useInboundColumns.tsx

@@ -0,0 +1,290 @@
+import { useCallback, useMemo, type ReactElement } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Popover, Switch, Tag, type TableColumnType } from 'antd';
+
+import { SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
+import { InfinityIcon } from '@/components/ui';
+import { useDatepicker } from '@/hooks/useDatepicker';
+import type { NodeRecord } from '@/api/queries/useNodesQuery';
+
+import { RowActionsCell } from './RowActions';
+import {
+  readStreamHints,
+  networkLabel,
+  networkL4,
+  shadowsocksNetworkLabel,
+  tunnelNetworkLabel,
+  mixedNetworkLabel,
+} from './helpers';
+import type { ClientCountEntry, DBInboundRecord, RowAction, SortKey, SortOrder } from './types';
+
+interface UseInboundColumnsParams {
+  hasAnyRemark: boolean;
+  hasActiveNode: boolean;
+  nodesById: Map<number, NodeRecord>;
+  clientCount: Record<number, ClientCountEntry>;
+  subEnable: boolean;
+  expireDiff: number;
+  trafficDiff: number;
+  sortKey: SortKey | null;
+  sortOrder: SortOrder;
+  onRowAction: (action: { key: RowAction; dbInbound: DBInboundRecord }) => void;
+  onSwitchEnable: (dbInbound: DBInboundRecord, next: boolean) => void;
+}
+
+export function useInboundColumns({
+  hasAnyRemark,
+  hasActiveNode,
+  nodesById,
+  clientCount,
+  subEnable,
+  expireDiff,
+  trafficDiff,
+  sortKey,
+  sortOrder,
+  onRowAction,
+  onSwitchEnable,
+}: UseInboundColumnsParams): TableColumnType<DBInboundRecord>[] {
+  const { t } = useTranslation();
+  const { datepicker } = useDatepicker();
+
+  const sorterFor = useCallback((key: SortKey) => ({
+    sorter: true as const,
+    showSorterTooltip: false,
+    sortOrder: sortKey === key ? sortOrder : null,
+    sortDirections: ['ascend' as const, 'descend' as const],
+  }), [sortKey, sortOrder]);
+
+  return useMemo(() => {
+    const cols: TableColumnType<DBInboundRecord>[] = [
+      {
+        title: 'ID',
+        dataIndex: 'id',
+        key: 'id',
+        align: 'right',
+        width: 30,
+        ...sorterFor('id'),
+      },
+      {
+        title: t('pages.inbounds.operate'),
+        key: 'action',
+        align: 'center',
+        width: 60,
+        render: (_, record) => (
+          <RowActionsCell
+            record={record}
+            subEnable={subEnable}
+            hasClients={(clientCount[record.id]?.clients || 0) > 0}
+            onClick={(key) => onRowAction({ key, dbInbound: record })}
+          />
+        ),
+      },
+      {
+        title: t('pages.inbounds.enable'),
+        key: 'enable',
+        align: 'center',
+        width: 35,
+        ...sorterFor('enable'),
+        render: (_, record) => (
+          <Switch
+            checked={record.enable}
+            onChange={(next) => onSwitchEnable(record, next)}
+          />
+        ),
+      },
+    ];
+
+    if (hasAnyRemark) {
+      cols.push({
+        title: t('pages.inbounds.remark'),
+        dataIndex: 'remark',
+        key: 'remark',
+        align: 'center',
+        width: 60,
+        ...sorterFor('remark'),
+      });
+    }
+
+    if (hasActiveNode) {
+      cols.push({
+        title: t('pages.inbounds.node'),
+        key: 'node',
+        align: 'center',
+        width: 60,
+        ...sorterFor('node'),
+        render: (_, record) => {
+          if (record.nodeId == null) {
+            return <Tag color="default">{t('pages.inbounds.localPanel')}</Tag>;
+          }
+          const node = nodesById.get(record.nodeId);
+          if (!node) {
+            return <Tag color="orange">node #{record.nodeId}</Tag>;
+          }
+          return (
+            <Tag color={node.status === 'online' ? 'blue' : 'red'}>{node.name}</Tag>
+          );
+        },
+      });
+    }
+
+    cols.push(
+      {
+        title: t('pages.inbounds.port'),
+        dataIndex: 'port',
+        key: 'port',
+        align: 'center',
+        width: 40,
+        ...sorterFor('port'),
+      },
+      {
+        title: t('pages.inbounds.protocol'),
+        key: 'protocol',
+        align: 'left',
+        width: 130,
+        ...sorterFor('protocol'),
+        render: (_, record) => {
+          const tags: ReactElement[] = [<Tag key="p" color="purple">{record.protocol}</Tag>];
+          if (record.isWireguard || record.isHysteria) {
+            tags.push(<Tag key="n" color="green">UDP</Tag>);
+          } else if (record.isSS) {
+            const stream = readStreamHints(record.streamSettings);
+            tags.push(<Tag key="n" color="green">{shadowsocksNetworkLabel(record.settings)}</Tag>);
+            if (stream.isTls) tags.push(<Tag key="tls" color="blue">TLS</Tag>);
+          } else if (record.isTunnel) {
+            tags.push(<Tag key="n" color="green">{tunnelNetworkLabel(record.settings)}</Tag>);
+          } else if (record.isMixed) {
+            tags.push(<Tag key="n" color="green">{mixedNetworkLabel(record.settings)}</Tag>);
+          } else if (record.isVMess || record.isVLess || record.isTrojan) {
+            const stream = readStreamHints(record.streamSettings);
+            tags.push(<Tag key="n" color="green">{networkLabel(stream.network)}</Tag>);
+            const l4 = networkL4(stream.network);
+            if (l4) tags.push(<Tag key="l4" color="green">{l4}</Tag>);
+            if (stream.isTls) tags.push(<Tag key="tls" color="blue">TLS</Tag>);
+            if (stream.isReality) tags.push(<Tag key="reality" color="blue">Reality</Tag>);
+          }
+          return <div className="protocol-tags">{tags}</div>;
+        },
+      },
+      {
+        title: t('clients'),
+        key: 'clients',
+        align: 'left',
+        width: 50,
+        ...sorterFor('clients'),
+        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>
+              {cc.deactive.length > 0 && (
+                <Popover
+                  title={t('disabled')}
+                  content={(
+                    <div className="client-email-list">
+                      {cc.deactive.map((e) => <div key={e}>{e}</div>)}
+                    </div>
+                  )}
+                >
+                  <Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.deactive.length}</Tag>
+                </Popover>
+              )}
+              {cc.depleted.length > 0 && (
+                <Popover
+                  title={t('depleted')}
+                  content={(
+                    <div className="client-email-list">
+                      {cc.depleted.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>
+                </Popover>
+              )}
+              {cc.expiring.length > 0 && (
+                <Popover
+                  title={t('depletingSoon')}
+                  content={(
+                    <div className="client-email-list">
+                      {cc.expiring.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>
+                </Popover>
+              )}
+              {cc.online.length > 0 && (
+                <Popover
+                  title={t('online')}
+                  content={(
+                    <div className="client-email-list">
+                      {cc.online.map((e) => <div key={e}>{e}</div>)}
+                    </div>
+                  )}
+                >
+                  <Tag color="blue" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.online.length}</Tag>
+                </Popover>
+              )}
+            </>
+          );
+        },
+      },
+      {
+        title: t('pages.inbounds.traffic'),
+        key: 'traffic',
+        align: 'center',
+        width: 90,
+        ...sorterFor('traffic'),
+        render: (_, record) => (
+          <Popover
+            content={(
+              <table cellPadding={2}>
+                <tbody>
+                  <tr>
+                    <td>↑ {SizeFormatter.sizeFormat(record.up)}</td>
+                    <td>↓ {SizeFormatter.sizeFormat(record.down)}</td>
+                  </tr>
+                  {record.total > 0 && record.up + record.down < record.total && (
+                    <tr>
+                      <td>{t('remained')}</td>
+                      <td>{SizeFormatter.sizeFormat(record.total - record.up - record.down)}</td>
+                    </tr>
+                  )}
+                </tbody>
+              </table>
+            )}
+          >
+            <Tag color={ColorUtils.usageColor(record.up + record.down, trafficDiff, record.total)}>
+              {SizeFormatter.sizeFormat(record.up + record.down)} /
+              {' '}
+              {record.total > 0 ? SizeFormatter.sizeFormat(record.total) : <InfinityIcon />}
+            </Tag>
+          </Popover>
+        ),
+      },
+      {
+        title: t('pages.inbounds.expireDate'),
+        key: 'expiryTime',
+        align: 'center',
+        width: 40,
+        ...sorterFor('expiryTime'),
+        render: (_, record) => {
+          if (record.expiryTime > 0) {
+            return (
+              <Popover content={IntlUtil.formatDate(record.expiryTime, datepicker)}>
+                <Tag color={ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)} style={{ minWidth: 50 }}>
+                  {IntlUtil.formatRelativeTime(record.expiryTime)}
+                </Tag>
+              </Popover>
+            );
+          }
+          return <Tag color="purple"><InfinityIcon /></Tag>;
+        },
+      },
+    );
+
+    return cols;
+  }, [t, hasAnyRemark, hasActiveNode, nodesById, clientCount, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable, sorterFor]);
+}

+ 1 - 1
frontend/src/pages/inbounds/QrCodeModal.tsx → frontend/src/pages/inbounds/qr/QrCodeModal.tsx

@@ -12,7 +12,7 @@ import {
 } from '@/lib/xray/inbound-link';
 import { inboundFromDb, type DbInboundLike } from '@/lib/xray/inbound-from-db';
 import QrPanel from './QrPanel';
-import type { SubSettings } from './useInbounds';
+import type { SubSettings } from '../useInbounds';
 
 interface ClientSetting {
   email?: string;

+ 0 - 0
frontend/src/pages/inbounds/QrPanel.css → frontend/src/pages/inbounds/qr/QrPanel.css


+ 0 - 0
frontend/src/pages/inbounds/QrPanel.tsx → frontend/src/pages/inbounds/qr/QrPanel.tsx


+ 2 - 0
frontend/src/pages/inbounds/qr/index.ts

@@ -0,0 +1,2 @@
+export { default as QrPanel } from './QrPanel';
+export { default as QrCodeModal } from './QrCodeModal';

+ 3 - 3
frontend/src/pages/index/IndexPage.tsx

@@ -39,13 +39,13 @@ import { HttpUtil, SizeFormatter, TimeFormatter, ClipboardManager, FileManager }
 import { useTheme } from '@/hooks/useTheme';
 import { useStatusQuery } from '@/api/queries/useStatusQuery';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
-import AppSidebar from '@/components/AppSidebar';
-import LazyMount from '@/components/LazyMount';
+import AppSidebar from '@/layouts/AppSidebar';
+import { LazyMount } from '@/components/utility';
 import { setMessageInstance } from '@/utils/messageBus';
 import StatusCard from './StatusCard';
 import XrayStatusCard from './XrayStatusCard';
 import type { PanelUpdateInfo } from './PanelUpdateModal';
-const JsonEditor = lazy(() => import('@/components/JsonEditor'));
+const JsonEditor = lazy(() => import('@/components/form/JsonEditor'));
 const PanelUpdateModal = lazy(() => import('./PanelUpdateModal'));
 const LogModal = lazy(() => import('./LogModal'));
 const BackupModal = lazy(() => import('./BackupModal'));

+ 1 - 1
frontend/src/pages/index/SystemHistoryModal.tsx

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
 import { Modal, Select, Tabs } from 'antd';
 
 import { HttpUtil, SizeFormatter } from '@/utils';
-import Sparkline from '@/components/Sparkline';
+import { Sparkline } from '@/components/viz';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import type { Status } from '@/models/status';
 import './SystemHistoryModal.css';

+ 1 - 1
frontend/src/pages/index/XrayMetricsModal.tsx

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
 import { Alert, Modal, Select, Tabs, Tag } from 'antd';
 
 import { HttpUtil, Msg, SizeFormatter } from '@/utils';
-import Sparkline from '@/components/Sparkline';
+import { Sparkline } from '@/components/viz';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import './XrayMetricsModal.css';
 

+ 1 - 1
frontend/src/pages/nodes/NodeHistoryPanel.tsx

@@ -1,7 +1,7 @@
 import { useEffect, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { HttpUtil } from '@/utils';
-import Sparkline from '@/components/Sparkline';
+import { Sparkline } from '@/components/viz';
 import './NodeHistoryPanel.css';
 
 interface NodeRef {

+ 1 - 1
frontend/src/pages/nodes/NodesPage.tsx

@@ -13,7 +13,7 @@ import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { useNodesQuery } from '@/api/queries/useNodesQuery';
 import type { NodeRecord } from '@/api/queries/useNodesQuery';
 import { useNodeMutations } from '@/api/queries/useNodeMutations';
-import AppSidebar from '@/components/AppSidebar';
+import AppSidebar from '@/layouts/AppSidebar';
 import NodeList from './NodeList';
 import NodeFormModal from './NodeFormModal';
 import { setMessageInstance } from '@/utils/messageBus';

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff