6 Commits 814cda3fb4 ... 44f2f426d8

Autor SHA1 Mensagem Data
  MHSanaei 44f2f426d8 feat(frontend): split wireguard inbound export into config and links tabs há 8 horas atrás
  MHSanaei c4a1139d3f feat(frontend): treat wireguard inbounds as multi-user in client actions há 8 horas atrás
  MHSanaei 476bec451d fix(frontend): show zero client count for mtproto and wireguard inbounds há 8 horas atrás
  MHSanaei f905c2dcec chore(frontend): bump version and deps há 8 horas atrás
  isultanov99 30f6bc1833 feat: Add outbound egress metadata (IP + country) (#5886) há 8 horas atrás
  Sangeeth Thilakarathna 2c95e29297 fix(api): preserve 64-bit integer schema formats (#5908) há 13 horas atrás
37 ficheiros alterados com 813 adições e 153 exclusões
  1. 141 120
      frontend/package-lock.json
  2. 8 8
      frontend/package.json
  3. 37 0
      frontend/public/openapi.json
  4. 30 6
      frontend/src/components/feedback/TextModal.tsx
  5. 37 0
      frontend/src/generated/schemas.ts
  6. 1 1
      frontend/src/pages/clients/BulkAttachInboundsModal.tsx
  7. 1 1
      frontend/src/pages/clients/BulkDetachInboundsModal.tsx
  8. 21 8
      frontend/src/pages/inbounds/InboundsPage.tsx
  9. 1 1
      frontend/src/pages/inbounds/list/RowActions.tsx
  10. 1 0
      frontend/src/pages/inbounds/list/helpers.ts
  11. 2 0
      frontend/src/pages/inbounds/useInbounds.ts
  12. 18 0
      frontend/src/pages/xray/outbounds/CountryPill.tsx
  13. 57 1
      frontend/src/pages/xray/outbounds/OutboundCardList.tsx
  14. 92 0
      frontend/src/pages/xray/outbounds/OutboundsTab.css
  15. 16 0
      frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts
  16. 76 3
      frontend/src/pages/xray/outbounds/useOutboundColumns.tsx
  17. 9 0
      frontend/src/schemas/xray.ts
  18. 17 0
      internal/web/service/outbound/egress_trace_test.go
  19. 10 0
      internal/web/service/outbound/outbound.go
  20. 153 1
      internal/web/service/outbound/probe_http.go
  21. 20 0
      internal/web/service/outbound/probe_http_test.go
  22. 2 0
      internal/web/translation/ar-EG.json
  23. 2 0
      internal/web/translation/en-US.json
  24. 2 0
      internal/web/translation/es-ES.json
  25. 2 0
      internal/web/translation/fa-IR.json
  26. 2 0
      internal/web/translation/id-ID.json
  27. 2 0
      internal/web/translation/ja-JP.json
  28. 2 0
      internal/web/translation/pt-BR.json
  29. 2 0
      internal/web/translation/ru-RU.json
  30. 2 0
      internal/web/translation/tr-TR.json
  31. 2 0
      internal/web/translation/uk-UA.json
  32. 2 0
      internal/web/translation/vi-VN.json
  33. 2 0
      internal/web/translation/zh-CN.json
  34. 2 0
      internal/web/translation/zh-TW.json
  35. 5 1
      tools/openapigen/emit_jsonschema.go
  36. 30 0
      tools/openapigen/emit_jsonschema_test.go
  37. 4 2
      tools/openapigen/walker.go

+ 141 - 120
frontend/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "3x-ui-frontend",
-  "version": "0.4.1",
+  "version": "0.4.3",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "3x-ui-frontend",
-      "version": "0.4.1",
+      "version": "0.4.3",
       "dependencies": {
         "@ant-design/icons": "^6.3.2",
         "@codemirror/lang-json": "^6.0.2",
@@ -18,13 +18,13 @@
         "antd": "^6.5.0",
         "codemirror": "^6.0.2",
         "dayjs": "^1.11.21",
-        "i18next": "^26.3.5",
+        "i18next": "^26.3.6",
         "otpauth": "^9.5.1",
         "persian-calendar-suite": "^1.5.5",
         "react": "^19.2.7",
         "react-dom": "^19.2.7",
         "react-hook-form": "^7.81.0",
-        "react-i18next": "^17.0.8",
+        "react-i18next": "^17.0.9",
         "react-router-dom": "^7.18.1",
         "swagger-ui-react": "^5.32.8",
         "uplot": "^1.6.32",
@@ -32,9 +32,9 @@
       },
       "devDependencies": {
         "@eslint/js": "^10.0.1",
-        "@storybook/addon-a11y": "^10.4.6",
-        "@storybook/addon-docs": "^10.4.6",
-        "@storybook/react-vite": "^10.4.6",
+        "@storybook/addon-a11y": "^10.5.0",
+        "@storybook/addon-docs": "^10.5.0",
+        "@storybook/react-vite": "^10.5.0",
         "@testing-library/dom": "^10.4.1",
         "@testing-library/react": "^16.3.2",
         "@types/react": "^19.2.17",
@@ -42,7 +42,7 @@
         "@types/swagger-ui-react": "^5.18.0",
         "@vitejs/plugin-react": "^6.0.3",
         "@vitest/coverage-v8": "^4.1.10",
-        "eslint": "^10.6.0",
+        "eslint": "^10.7.0",
         "eslint-plugin-jsx-a11y": "^6.10.2",
         "eslint-plugin-react-hooks": "^7.1.1",
         "globals": "^17.7.0",
@@ -50,7 +50,7 @@
         "jsdom": "^29.1.1",
         "lint-staged": "^17.0.8",
         "msw": "^2.15.0",
-        "storybook": "^10.4.6",
+        "storybook": "^10.5.0",
         "typescript": "^6.0.3",
         "typescript-eslint": "^8.63.0",
         "vite": "8.1.4",
@@ -2572,13 +2572,13 @@
       }
     },
     "node_modules/@rc-component/dropdown": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/@rc-component/dropdown/-/dropdown-1.0.2.tgz",
-      "integrity": "sha512-6PY2ecUSYhDPhkNHHb4wfeAya04WhpmUSKzdR60G+kMNVUCX2vjT/AgTS0Lz0I/K6xrPMJ3enQbwVpeN3sHCgg==",
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/@rc-component/dropdown/-/dropdown-1.0.3.tgz",
+      "integrity": "sha512-YTST/N6kpqpDz3IMuM/PSSZnrDpSOA6dgHv12gPA90ZTSLv2CoqkZ0+9NtwTY6BeO7dstPblSic2QJg7dSFy/g==",
       "license": "MIT",
       "dependencies": {
         "@rc-component/trigger": "^3.0.0",
-        "@rc-component/util": "^1.2.1",
+        "@rc-component/util": "^1.11.1",
         "clsx": "^2.1.1"
       },
       "peerDependencies": {
@@ -3084,15 +3084,15 @@
       }
     },
     "node_modules/@rc-component/trigger": {
-      "version": "3.9.1",
-      "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-3.9.1.tgz",
-      "integrity": "sha512-LNsYvz60mrLJ/kRvKcHE7boUvcQfVMCfRqZ71x3Fo9AOiZ1KKIEqkzMA8DNvz2V3Bcvir/vwQNn7JF1NPODQ7Q==",
+      "version": "3.10.0",
+      "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-3.10.0.tgz",
+      "integrity": "sha512-uC3QSG7Ax3qLOE5Q2jLqJCJc4iBtJEHzNTPhqGvlRvRcU8x8CT5moIavRVe24YSQKCp2/D1GSq7y76SCSheuVA==",
       "license": "MIT",
       "dependencies": {
-        "@rc-component/motion": "^1.1.4",
-        "@rc-component/portal": "^2.2.0",
-        "@rc-component/resize-observer": "^1.1.1",
-        "@rc-component/util": "^1.2.1",
+        "@rc-component/motion": "^1.3.3",
+        "@rc-component/portal": "^2.2.1",
+        "@rc-component/resize-observer": "^1.1.2",
+        "@rc-component/util": "^1.11.1",
         "clsx": "^2.1.1"
       },
       "engines": {
@@ -3132,12 +3132,12 @@
       }
     },
     "node_modules/@rc-component/virtual-list": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/@rc-component/virtual-list/-/virtual-list-1.2.0.tgz",
-      "integrity": "sha512-iavRm1Jo4GDbASQwdGa7jFyk93RvSOo9xHyBT4QL1pgFJj/Fdf1G+3RErH7/7BmAMvx2AkF62mjGYxDbXsK9TQ==",
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/@rc-component/virtual-list/-/virtual-list-1.3.2.tgz",
+      "integrity": "sha512-/smuvWBFdP/Is9QuNDKynD0+T3XTXWFyNXXNKJ4sno8CE3bTOK8sfgYmQJtYwLUNX+lv0Ytd+PMshgpmdReq5g==",
       "license": "MIT",
       "dependencies": {
-        "@babel/runtime": "^7.20.0",
+        "@babel/runtime": "^8.0.0",
         "@rc-component/resize-observer": "^1.0.1",
         "@rc-component/util": "^1.4.0",
         "clsx": "^2.1.1"
@@ -3150,6 +3150,12 @@
         "react-dom": ">=18.0.0"
       }
     },
+    "node_modules/@rc-component/virtual-list/node_modules/@babel/runtime": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-8.0.0.tgz",
+      "integrity": "sha512-sL6cvO2IfkSu/iU+zs2S/w01B7A8V7suXSIKEN4hPFFdZoiPGxrj5pAG0lCaqLWiEIrjKzdznIWuaLcxPR53qw==",
+      "license": "MIT"
+    },
     "node_modules/@rolldown/binding-android-arm64": {
       "version": "1.1.5",
       "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
@@ -3510,9 +3516,9 @@
       "license": "MIT"
     },
     "node_modules/@storybook/addon-a11y": {
-      "version": "10.4.6",
-      "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.4.6.tgz",
-      "integrity": "sha512-XCJy+f0DFOiCgUU9knRDlLDxVFI+AAQ3/wE/NF85zB9iDPPS2DwkSN+mas3zDgHt66zhN8Cq3/UiyCDUweV9Zw==",
+      "version": "10.5.0",
+      "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.5.0.tgz",
+      "integrity": "sha512-AguqTsYS2NX20AF1vWt6IonbkpMXur30/dAOOYltbEW7uKmPxBNtjuiBmYB2WI4aFN4r8jje4bbDGk/Yfq58hQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -3524,20 +3530,20 @@
         "url": "https://opencollective.com/storybook"
       },
       "peerDependencies": {
-        "storybook": "^10.4.6"
+        "storybook": "^10.5.0"
       }
     },
     "node_modules/@storybook/addon-docs": {
-      "version": "10.4.6",
-      "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.4.6.tgz",
-      "integrity": "sha512-aWAfP5JMiT5a3zBJizwroCRzOCqZwDTJmvsYvwMD3ilIEa/kT1vhf6Xrbk4XIPhDwbh8Hpb/Gfnka1xBYEISWg==",
+      "version": "10.5.0",
+      "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.0.tgz",
+      "integrity": "sha512-kbZGdX+mysiY4xezhpcFEwzQ1zSXcMhSS3u09Qt8+w5XetCAMMSv/yAxzpi6z+YoH+EdQ53e1q3MFtPUkzkfUA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
         "@mdx-js/react": "^3.0.0",
-        "@storybook/csf-plugin": "10.4.6",
+        "@storybook/csf-plugin": "10.5.0",
         "@storybook/icons": "^2.0.2",
-        "@storybook/react-dom-shim": "10.4.6",
+        "@storybook/react-dom-shim": "10.5.0",
         "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
         "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
         "ts-dedent": "^2.0.0"
@@ -3548,7 +3554,7 @@
       },
       "peerDependencies": {
         "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
-        "storybook": "^10.4.6"
+        "storybook": "^10.5.0"
       },
       "peerDependenciesMeta": {
         "@types/react": {
@@ -3557,13 +3563,13 @@
       }
     },
     "node_modules/@storybook/builder-vite": {
-      "version": "10.4.6",
-      "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.4.6.tgz",
-      "integrity": "sha512-BHBtD81HiXUiDQz/CaFynLtWmm7AFUQn8VnXuHipZ8KlnUANopa4yqdVuy/Gwz8ub254uFI5NMZsW/KlgWNgNg==",
+      "version": "10.5.0",
+      "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.5.0.tgz",
+      "integrity": "sha512-KXlifNIThDgS84KqVAJXyilool8OLTWp6DGoO9h5bHM2IPLe7UcdKfOzMUBkQ807mWBk4aW1yGEekj7kC2dvmg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@storybook/csf-plugin": "10.4.6",
+        "@storybook/csf-plugin": "10.5.0",
         "ts-dedent": "^2.0.0"
       },
       "funding": {
@@ -3571,14 +3577,14 @@
         "url": "https://opencollective.com/storybook"
       },
       "peerDependencies": {
-        "storybook": "^10.4.6",
+        "storybook": "^10.5.0",
         "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
       }
     },
     "node_modules/@storybook/csf-plugin": {
-      "version": "10.4.6",
-      "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.4.6.tgz",
-      "integrity": "sha512-NILLxDqpA/JR/AazGWpsz+4fadJwRU4uhHephGtYpVOWnQA/DkJfKT6zpcJVq8+QA8A2zKMLX3GVKsXIrxjuDA==",
+      "version": "10.5.0",
+      "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.0.tgz",
+      "integrity": "sha512-R7VFw6FnDZTWct0ekOcFnTfDgdHnnAuQW+AbGG9A9IZPvyH9uLJivK7L86+r9MITRrO2+v81HaFDsT/OFk6ZkQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -3591,7 +3597,7 @@
       "peerDependencies": {
         "esbuild": "*",
         "rollup": "*",
-        "storybook": "^10.4.6",
+        "storybook": "^10.5.0",
         "vite": "*",
         "webpack": "*"
       },
@@ -3628,14 +3634,14 @@
       }
     },
     "node_modules/@storybook/react": {
-      "version": "10.4.6",
-      "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.4.6.tgz",
-      "integrity": "sha512-9Y7YecrVFe1/01KYjfOLxVqTg2Aq+IO6TEv6sC2U0PfD0AWCSCmQ91QqgBpN/XW4aFFWoiZNinyXMUlU8zxy2w==",
+      "version": "10.5.0",
+      "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.0.tgz",
+      "integrity": "sha512-2IhddiREy7NqAqnFsEOfW6HBG7azIcLxhRQYploSsaemOncR29xhzvAZsG+xlWgrtvxv4h3fYQTTR4TNn8CgPQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
         "@storybook/global": "^5.0.0",
-        "@storybook/react-dom-shim": "10.4.6",
+        "@storybook/react-dom-shim": "10.5.0",
         "react-docgen": "^8.0.2",
         "react-docgen-typescript": "^2.2.2"
       },
@@ -3648,7 +3654,7 @@
         "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
         "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
         "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
-        "storybook": "^10.4.6",
+        "storybook": "^10.5.0",
         "typescript": ">= 4.9.x"
       },
       "peerDependenciesMeta": {
@@ -3664,9 +3670,9 @@
       }
     },
     "node_modules/@storybook/react-dom-shim": {
-      "version": "10.4.6",
-      "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.4.6.tgz",
-      "integrity": "sha512-iGNmKzrq9vgl2PDrYAnZKI+yvac3Ym+lJXXuQaqlFRS23zA5MNm4EBX+rAG7WulqchoK6NaZ0KQOs2mAgEpTMg==",
+      "version": "10.5.0",
+      "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.0.tgz",
+      "integrity": "sha512-GwGA6zDj4Cfw6vGsdfPKfF39L0W6solNbbnjdjGa57m2ooASkidLhrXo2wgC9wh3o/jcfonmYPDRGY6sEhGDtg==",
       "dev": true,
       "license": "MIT",
       "funding": {
@@ -3678,7 +3684,7 @@
         "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
         "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
         "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
-        "storybook": "^10.4.6"
+        "storybook": "^10.5.0"
       },
       "peerDependenciesMeta": {
         "@types/react": {
@@ -3690,19 +3696,19 @@
       }
     },
     "node_modules/@storybook/react-vite": {
-      "version": "10.4.6",
-      "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.4.6.tgz",
-      "integrity": "sha512-0arEQtybqGYXHbXpTot+Wv9YtG+V5Vp43QayXavPKQ20M8mpEzhyCPKd0EhqMGSC1Z1UEt0hm365WUBhI9LfKA==",
+      "version": "10.5.0",
+      "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.5.0.tgz",
+      "integrity": "sha512-d9n/I3pViscXpJYT9JHxcpxVSam14HsHOr2wBqTQTiRtaiH4xSsT00HTEGm6CEZTyq/npTJyV0Qday9XhrtiDQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
         "@joshwooding/vite-plugin-react-docgen-typescript": "^0.7.0",
         "@rollup/pluginutils": "^5.0.2",
-        "@storybook/builder-vite": "10.4.6",
-        "@storybook/react": "10.4.6",
+        "@storybook/builder-vite": "10.5.0",
+        "@storybook/react": "10.5.0",
         "empathic": "^2.0.0",
         "magic-string": "^0.30.0",
-        "react-docgen": "^8.0.0",
+        "react-docgen": "^8.0.2",
         "resolve": "^1.22.8",
         "tsconfig-paths": "^4.2.0"
       },
@@ -3713,8 +3719,14 @@
       "peerDependencies": {
         "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
         "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
-        "storybook": "^10.4.6",
+        "storybook": "^10.5.0",
+        "typescript": ">= 4.9.x",
         "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
       }
     },
     "node_modules/@swagger-api/apidom-ast": {
@@ -4621,9 +4633,9 @@
       "license": "MIT"
     },
     "node_modules/@types/hast": {
-      "version": "3.0.4",
-      "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
-      "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
+      "version": "3.0.5",
+      "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz",
+      "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==",
       "license": "MIT",
       "dependencies": {
         "@types/unist": "*"
@@ -4771,9 +4783,9 @@
       }
     },
     "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
-      "version": "7.0.5",
-      "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
-      "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+      "version": "7.0.6",
+      "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz",
+      "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -5658,9 +5670,9 @@
       "license": "MIT"
     },
     "node_modules/baseline-browser-mapping": {
-      "version": "2.10.42",
-      "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz",
-      "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==",
+      "version": "2.10.43",
+      "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz",
+      "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==",
       "dev": true,
       "license": "Apache-2.0",
       "bin": {
@@ -5693,9 +5705,9 @@
       }
     },
     "node_modules/browserslist": {
-      "version": "4.28.5",
-      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz",
-      "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==",
+      "version": "4.28.6",
+      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz",
+      "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==",
       "dev": true,
       "funding": [
         {
@@ -5714,9 +5726,9 @@
       "license": "MIT",
       "dependencies": {
         "baseline-browser-mapping": "^2.10.42",
-        "caniuse-lite": "^1.0.30001800",
-        "electron-to-chromium": "^1.5.387",
-        "node-releases": "^2.0.50",
+        "caniuse-lite": "^1.0.30001803",
+        "electron-to-chromium": "^1.5.389",
+        "node-releases": "^2.0.51",
         "update-browserslist-db": "^1.2.3"
       },
       "bin": {
@@ -5814,9 +5826,9 @@
       }
     },
     "node_modules/caniuse-lite": {
-      "version": "1.0.30001803",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz",
-      "integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==",
+      "version": "1.0.30001805",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001805.tgz",
+      "integrity": "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==",
       "dev": true,
       "funding": [
         {
@@ -6481,9 +6493,9 @@
       "license": "MIT"
     },
     "node_modules/dompurify": {
-      "version": "3.4.11",
-      "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
-      "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
+      "version": "3.4.12",
+      "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
+      "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
       "license": "(MPL-2.0 OR Apache-2.0)",
       "optionalDependencies": {
         "@types/trusted-types": "^2.0.7"
@@ -6669,9 +6681,9 @@
       }
     },
     "node_modules/es-module-lexer": {
-      "version": "2.3.0",
-      "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz",
-      "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==",
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
+      "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
       "dev": true,
       "license": "MIT"
     },
@@ -6802,9 +6814,9 @@
       }
     },
     "node_modules/eslint": {
-      "version": "10.6.0",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz",
-      "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==",
+      "version": "10.7.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz",
+      "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==",
       "dev": true,
       "license": "MIT",
       "workspaces": [
@@ -7741,9 +7753,9 @@
       }
     },
     "node_modules/i18next": {
-      "version": "26.3.5",
-      "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.5.tgz",
-      "integrity": "sha512-cd5lgkvjYQAPDfP3bbrAIE1wJmjTIlsrsjDqlvsHn4wFAerP5O0NfAe8RiDcXRfukN5ETe7Tlmfp6DkyuqDn6Q==",
+      "version": "26.3.6",
+      "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
+      "integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
       "funding": [
         {
           "type": "individual",
@@ -7760,7 +7772,7 @@
       ],
       "license": "MIT",
       "peerDependencies": {
-        "typescript": "^5 || ^6"
+        "typescript": "^5 || ^6 || ^7"
       },
       "peerDependenciesMeta": {
         "typescript": {
@@ -8577,6 +8589,13 @@
         "node": ">=6"
       }
     },
+    "node_modules/jsonc-parser": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
+      "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/jsx-ast-utils": {
       "version": "3.3.5",
       "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -9346,9 +9365,9 @@
       }
     },
     "node_modules/nanoid": {
-      "version": "3.3.15",
-      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
-      "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
+      "version": "3.3.16",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+      "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
       "dev": true,
       "funding": [
         {
@@ -9409,9 +9428,9 @@
       }
     },
     "node_modules/node-releases": {
-      "version": "2.0.50",
-      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz",
-      "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==",
+      "version": "2.0.51",
+      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
+      "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -9893,9 +9912,9 @@
       }
     },
     "node_modules/postcss": {
-      "version": "8.5.16",
-      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
-      "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
+      "version": "8.5.17",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.17.tgz",
+      "integrity": "sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==",
       "dev": true,
       "funding": [
         {
@@ -10158,9 +10177,9 @@
       }
     },
     "node_modules/react-i18next": {
-      "version": "17.0.8",
-      "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
-      "integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
+      "version": "17.0.9",
+      "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.9.tgz",
+      "integrity": "sha512-buLzOSqHtXxjf+qgSrLWNTXVZ1jSwO6kUv3uJqSP1roGBPgNnbhFm7OmdVwWcgf2gIbUyP0J333uPyx+Btsi3w==",
       "license": "MIT",
       "dependencies": {
         "@babel/runtime": "^7.29.2",
@@ -10170,7 +10189,7 @@
       "peerDependencies": {
         "i18next": ">= 26.2.0",
         "react": ">= 16.8.0",
-        "typescript": "^5 || ^6"
+        "typescript": "^5 || ^6 || ^7"
       },
       "peerDependenciesMeta": {
         "react-dom": {
@@ -10754,9 +10773,9 @@
       }
     },
     "node_modules/set-cookie-parser": {
-      "version": "3.1.1",
-      "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.1.tgz",
-      "integrity": "sha512-vM9SUhjsUYs6UeJUmygc5Ofm5eQGe85riob5ju6XCgFGJI5PLV4nrDAQpQjd+LkFBpAkADn5BQQpZ9EUNkyLuA==",
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz",
+      "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==",
       "dev": true,
       "license": "MIT"
     },
@@ -11062,20 +11081,22 @@
       }
     },
     "node_modules/storybook": {
-      "version": "10.4.6",
-      "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.4.6.tgz",
-      "integrity": "sha512-6wkA6LxfDSSilloITsrFOJfsnw0mDUP2h8Ls+lRt8oRsudtz2RWFhLv+Toiwg6NW7hUpdTDc2hzR7DztJid6+A==",
+      "version": "10.5.0",
+      "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.0.tgz",
+      "integrity": "sha512-dRhM/kSSvHQR8DmZO41v5sJuz9U6zDjjR2gRBTgZN2RBSXbmF0Brvgszrvvxyx2VfxuYKzhB+xumKwWkwlBtig==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
         "@storybook/global": "^5.0.0",
         "@storybook/icons": "^2.0.2",
+        "@testing-library/dom": "^10.4.1",
         "@testing-library/jest-dom": "^6.9.1",
         "@testing-library/user-event": "^14.6.1",
         "@vitest/expect": "3.2.4",
         "@vitest/spy": "3.2.4",
         "@webcontainer/env": "^1.1.1",
         "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0",
+        "jsonc-parser": "^3.3.1",
         "open": "^10.2.0",
         "oxc-parser": "^0.127.0",
         "oxc-resolver": "^11.19.1",
@@ -11094,7 +11115,7 @@
       "peerDependencies": {
         "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
         "prettier": "^2 || ^3",
-        "vite-plus": "^0.1.15"
+        "vite-plus": "^0.1.15 || ^0.2.0"
       },
       "peerDependenciesMeta": {
         "@types/react": {
@@ -11145,9 +11166,9 @@
       "license": "MIT"
     },
     "node_modules/string-width": {
-      "version": "8.2.1",
-      "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz",
-      "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==",
+      "version": "8.2.2",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
+      "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -11492,22 +11513,22 @@
       }
     },
     "node_modules/tldts": {
-      "version": "7.4.7",
-      "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.7.tgz",
-      "integrity": "sha512-56L0/9HELHSsG1bFCzay8UoLxzRL7kpFf7Wl5q/kSYwiSJGACvro61xnKzPNM+SadxllzdtXsKDSXE7HPeqIAw==",
+      "version": "7.4.8",
+      "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.8.tgz",
+      "integrity": "sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "tldts-core": "^7.4.7"
+        "tldts-core": "^7.4.8"
       },
       "bin": {
         "tldts": "bin/cli.js"
       }
     },
     "node_modules/tldts-core": {
-      "version": "7.4.7",
-      "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.7.tgz",
-      "integrity": "sha512-rNlAI8fKn/JckBMUSbNL/ES2kmDiurWaE49l+ikwEc9A6lFR7gMx9AhgQMQKBK4H5w4pKLH64JzZfB99uRsGNQ==",
+      "version": "7.4.8",
+      "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.8.tgz",
+      "integrity": "sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==",
       "dev": true,
       "license": "MIT"
     },

+ 8 - 8
frontend/package.json

@@ -1,7 +1,7 @@
 {
   "name": "3x-ui-frontend",
   "private": true,
-  "version": "0.4.1",
+  "version": "0.4.3",
   "type": "module",
   "description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
   "engines": {
@@ -37,13 +37,13 @@
     "antd": "^6.5.0",
     "codemirror": "^6.0.2",
     "dayjs": "^1.11.21",
-    "i18next": "^26.3.5",
+    "i18next": "^26.3.6",
     "otpauth": "^9.5.1",
     "persian-calendar-suite": "^1.5.5",
     "react": "^19.2.7",
     "react-dom": "^19.2.7",
     "react-hook-form": "^7.81.0",
-    "react-i18next": "^17.0.8",
+    "react-i18next": "^17.0.9",
     "react-router-dom": "^7.18.1",
     "swagger-ui-react": "^5.32.8",
     "uplot": "^1.6.32",
@@ -51,9 +51,9 @@
   },
   "devDependencies": {
     "@eslint/js": "^10.0.1",
-    "@storybook/addon-a11y": "^10.4.6",
-    "@storybook/addon-docs": "^10.4.6",
-    "@storybook/react-vite": "^10.4.6",
+    "@storybook/addon-a11y": "^10.5.0",
+    "@storybook/addon-docs": "^10.5.0",
+    "@storybook/react-vite": "^10.5.0",
     "@testing-library/dom": "^10.4.1",
     "@testing-library/react": "^16.3.2",
     "@types/react": "^19.2.17",
@@ -61,7 +61,7 @@
     "@types/swagger-ui-react": "^5.18.0",
     "@vitejs/plugin-react": "^6.0.3",
     "@vitest/coverage-v8": "^4.1.10",
-    "eslint": "^10.6.0",
+    "eslint": "^10.7.0",
     "eslint-plugin-jsx-a11y": "^6.10.2",
     "eslint-plugin-react-hooks": "^7.1.1",
     "globals": "^17.7.0",
@@ -69,7 +69,7 @@
     "jsdom": "^29.1.1",
     "lint-staged": "^17.0.8",
     "msw": "^2.15.0",
-    "storybook": "^10.4.6",
+    "storybook": "^10.5.0",
     "typescript": "^6.0.3",
     "typescript-eslint": "^8.63.0",
     "vite": "8.1.4",

+ 37 - 0
frontend/public/openapi.json

@@ -881,6 +881,7 @@
       "ApiToken": {
         "properties": {
           "createdAt": {
+            "format": "int64",
             "type": "integer"
           },
           "enabled": {
@@ -910,6 +911,7 @@
         "properties": {
           "createdAt": {
             "example": 1736000000,
+            "format": "int64",
             "type": "integer"
           },
           "enabled": {
@@ -960,6 +962,7 @@
           },
           "created_at": {
             "description": "Creation timestamp",
+            "format": "int64",
             "type": "integer"
           },
           "email": {
@@ -972,6 +975,7 @@
           },
           "expiryTime": {
             "description": "Expiration timestamp",
+            "format": "int64",
             "type": "integer"
           },
           "flow": {
@@ -1033,14 +1037,17 @@
           },
           "tgId": {
             "description": "Telegram user ID for notifications",
+            "format": "int64",
             "type": "integer"
           },
           "totalGB": {
             "description": "Total traffic limit in GB",
+            "format": "int64",
             "type": "integer"
           },
           "updated_at": {
             "description": "Last update timestamp",
+            "format": "int64",
             "type": "integer"
           }
         },
@@ -1064,6 +1071,7 @@
             "type": "integer"
           },
           "createdAt": {
+            "format": "int64",
             "type": "integer"
           },
           "flowOverride": {
@@ -1096,6 +1104,7 @@
             "type": "string"
           },
           "createdAt": {
+            "format": "int64",
             "type": "integer"
           },
           "email": {
@@ -1105,6 +1114,7 @@
             "type": "boolean"
           },
           "expiryTime": {
+            "format": "int64",
             "type": "integer"
           },
           "flow": {
@@ -1148,12 +1158,15 @@
             "type": "string"
           },
           "tgId": {
+            "format": "int64",
             "type": "integer"
           },
           "totalGB": {
+            "format": "int64",
             "type": "integer"
           },
           "updatedAt": {
+            "format": "int64",
             "type": "integer"
           },
           "uuid": {
@@ -1206,6 +1219,7 @@
         "properties": {
           "down": {
             "example": 2097152,
+            "format": "int64",
             "type": "integer"
           },
           "email": {
@@ -1218,6 +1232,7 @@
           },
           "expiryTime": {
             "example": 1735689600000,
+            "format": "int64",
             "type": "integer"
           },
           "id": {
@@ -1230,6 +1245,7 @@
           },
           "lastOnline": {
             "example": 1735680000000,
+            "format": "int64",
             "type": "integer"
           },
           "reset": {
@@ -1242,10 +1258,12 @@
           },
           "total": {
             "example": 10737418240,
+            "format": "int64",
             "type": "integer"
           },
           "up": {
             "example": 1048576,
+            "format": "int64",
             "type": "integer"
           },
           "uuid": {
@@ -1316,6 +1334,7 @@
             "type": "array"
           },
           "createdAt": {
+            "format": "int64",
             "type": "integer"
           },
           "echConfigList": {
@@ -1431,6 +1450,7 @@
             "type": "array"
           },
           "updatedAt": {
+            "format": "int64",
             "type": "integer"
           },
           "verifyPeerCertByName": {
@@ -1660,6 +1680,7 @@
           },
           "down": {
             "description": "Download traffic in bytes",
+            "format": "int64",
             "type": "integer"
           },
           "enable": {
@@ -1669,6 +1690,7 @@
           },
           "expiryTime": {
             "description": "Expiration timestamp",
+            "format": "int64",
             "type": "integer"
           },
           "fallbackParent": {
@@ -1687,6 +1709,7 @@
           },
           "lastTrafficResetTime": {
             "description": "Last traffic reset timestamp",
+            "format": "int64",
             "type": "integer"
           },
           "listen": {
@@ -1755,6 +1778,7 @@
           },
           "total": {
             "description": "Total traffic limit in bytes",
+            "format": "int64",
             "type": "integer"
           },
           "trafficReset": {
@@ -1770,6 +1794,7 @@
           },
           "up": {
             "description": "Upload traffic in bytes",
+            "format": "int64",
             "type": "integer"
           }
         },
@@ -1981,6 +2006,7 @@
             "type": "boolean"
           },
           "configDirtyAt": {
+            "format": "int64",
             "type": "integer"
           },
           "cpuPct": {
@@ -1989,6 +2015,7 @@
           },
           "createdAt": {
             "example": 1700000000,
+            "format": "int64",
             "type": "integer"
           },
           "depletedCount": {
@@ -2034,6 +2061,7 @@
           "lastHeartbeat": {
             "description": "unix seconds, 0 = never",
             "example": 1700000000,
+            "format": "int64",
             "type": "integer"
           },
           "latencyMs": {
@@ -2050,10 +2078,12 @@
           },
           "netDown": {
             "example": 2097152,
+            "format": "int64",
             "type": "integer"
           },
           "netUp": {
             "example": 1048576,
+            "format": "int64",
             "type": "integer"
           },
           "onlineCount": {
@@ -2110,10 +2140,12 @@
           },
           "updatedAt": {
             "example": 1700000000,
+            "format": "int64",
             "type": "integer"
           },
           "uptimeSecs": {
             "example": 86400,
+            "format": "int64",
             "type": "integer"
           },
           "xrayError": {
@@ -2175,6 +2207,7 @@
         "description": "OutboundTraffics tracks traffic statistics for Xray outbound connections.",
         "properties": {
           "down": {
+            "format": "int64",
             "type": "integer"
           },
           "id": {
@@ -2184,9 +2217,11 @@
             "type": "string"
           },
           "total": {
+            "format": "int64",
             "type": "integer"
           },
           "up": {
+            "format": "int64",
             "type": "integer"
           }
         },
@@ -2208,6 +2243,7 @@
           },
           "finishedAt": {
             "example": 1735689612,
+            "format": "int64",
             "type": "integer"
           },
           "runId": {
@@ -2254,6 +2290,7 @@
           },
           "uptimeSecs": {
             "example": 86400,
+            "format": "int64",
             "type": "integer"
           },
           "xrayError": {

+ 30 - 6
frontend/src/components/feedback/TextModal.tsx

@@ -1,10 +1,17 @@
-import { Button, Input, Modal, message } from 'antd';
+import { useEffect, useState } from 'react';
+import { Button, Input, Modal, Tabs, message } from 'antd';
 import { CopyOutlined, DownloadOutlined } from '@ant-design/icons';
 import { useTranslation } from 'react-i18next';
 
 import JsonEditor from '@/components/form/JsonEditor';
 import { ClipboardManager, FileManager } from '@/utils';
 
+export interface TextModalTab {
+  key: string;
+  label: string;
+  content: string;
+}
+
 interface TextModalProps {
   open: boolean;
   onClose: () => void;
@@ -12,13 +19,23 @@ interface TextModalProps {
   content: string;
   fileName?: string;
   json?: boolean;
+  tabs?: TextModalTab[];
 }
 
-export default function TextModal({ open, onClose, title, content, fileName = '', json = false }: TextModalProps) {
+export default function TextModal({ open, onClose, title, content, fileName = '', json = false, tabs }: TextModalProps) {
   const { t } = useTranslation();
   const [messageApi, messageContextHolder] = message.useMessage();
+  const [activeKey, setActiveKey] = useState('');
+
+  useEffect(() => {
+    if (open && tabs && tabs.length > 0) setActiveKey(tabs[0].key);
+  }, [open, tabs]);
+
+  const activeTab = tabs?.find((tab) => tab.key === activeKey) ?? tabs?.[0];
+  const activeContent = activeTab ? activeTab.content : content;
+
   async function copy() {
-    const ok = await ClipboardManager.copyText(content || '');
+    const ok = await ClipboardManager.copyText(activeContent || '');
     if (ok) {
       messageApi.success(t('copied'));
       onClose();
@@ -27,7 +44,7 @@ export default function TextModal({ open, onClose, title, content, fileName = ''
 
   function download() {
     if (!fileName) return;
-    FileManager.downloadTextFile(content, fileName);
+    FileManager.downloadTextFile(activeContent, fileName);
   }
 
   return (
@@ -47,11 +64,18 @@ export default function TextModal({ open, onClose, title, content, fileName = ''
         </>
       )}
     >
+      {tabs && tabs.length > 0 && (
+        <Tabs
+          activeKey={activeTab?.key}
+          onChange={setActiveKey}
+          items={tabs.map((tab) => ({ key: tab.key, label: tab.label }))}
+        />
+      )}
       {json ? (
-        <JsonEditor value={content} readOnly minHeight="240px" maxHeight="60vh" />
+        <JsonEditor value={activeContent} readOnly minHeight="240px" maxHeight="60vh" />
       ) : (
         <Input.TextArea
-          value={content}
+          value={activeContent}
           readOnly
           autoSize={{ minRows: 10, maxRows: 20 }}
           style={{

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

@@ -855,6 +855,7 @@ export const SCHEMAS: Record<string, unknown> = {
   "ApiToken": {
     "properties": {
       "createdAt": {
+        "format": "int64",
         "type": "integer"
       },
       "enabled": {
@@ -884,6 +885,7 @@ export const SCHEMAS: Record<string, unknown> = {
     "properties": {
       "createdAt": {
         "example": 1736000000,
+        "format": "int64",
         "type": "integer"
       },
       "enabled": {
@@ -934,6 +936,7 @@ export const SCHEMAS: Record<string, unknown> = {
       },
       "created_at": {
         "description": "Creation timestamp",
+        "format": "int64",
         "type": "integer"
       },
       "email": {
@@ -946,6 +949,7 @@ export const SCHEMAS: Record<string, unknown> = {
       },
       "expiryTime": {
         "description": "Expiration timestamp",
+        "format": "int64",
         "type": "integer"
       },
       "flow": {
@@ -1007,14 +1011,17 @@ export const SCHEMAS: Record<string, unknown> = {
       },
       "tgId": {
         "description": "Telegram user ID for notifications",
+        "format": "int64",
         "type": "integer"
       },
       "totalGB": {
         "description": "Total traffic limit in GB",
+        "format": "int64",
         "type": "integer"
       },
       "updated_at": {
         "description": "Last update timestamp",
+        "format": "int64",
         "type": "integer"
       }
     },
@@ -1038,6 +1045,7 @@ export const SCHEMAS: Record<string, unknown> = {
         "type": "integer"
       },
       "createdAt": {
+        "format": "int64",
         "type": "integer"
       },
       "flowOverride": {
@@ -1070,6 +1078,7 @@ export const SCHEMAS: Record<string, unknown> = {
         "type": "string"
       },
       "createdAt": {
+        "format": "int64",
         "type": "integer"
       },
       "email": {
@@ -1079,6 +1088,7 @@ export const SCHEMAS: Record<string, unknown> = {
         "type": "boolean"
       },
       "expiryTime": {
+        "format": "int64",
         "type": "integer"
       },
       "flow": {
@@ -1122,12 +1132,15 @@ export const SCHEMAS: Record<string, unknown> = {
         "type": "string"
       },
       "tgId": {
+        "format": "int64",
         "type": "integer"
       },
       "totalGB": {
+        "format": "int64",
         "type": "integer"
       },
       "updatedAt": {
+        "format": "int64",
         "type": "integer"
       },
       "uuid": {
@@ -1180,6 +1193,7 @@ export const SCHEMAS: Record<string, unknown> = {
     "properties": {
       "down": {
         "example": 2097152,
+        "format": "int64",
         "type": "integer"
       },
       "email": {
@@ -1192,6 +1206,7 @@ export const SCHEMAS: Record<string, unknown> = {
       },
       "expiryTime": {
         "example": 1735689600000,
+        "format": "int64",
         "type": "integer"
       },
       "id": {
@@ -1204,6 +1219,7 @@ export const SCHEMAS: Record<string, unknown> = {
       },
       "lastOnline": {
         "example": 1735680000000,
+        "format": "int64",
         "type": "integer"
       },
       "reset": {
@@ -1216,10 +1232,12 @@ export const SCHEMAS: Record<string, unknown> = {
       },
       "total": {
         "example": 10737418240,
+        "format": "int64",
         "type": "integer"
       },
       "up": {
         "example": 1048576,
+        "format": "int64",
         "type": "integer"
       },
       "uuid": {
@@ -1290,6 +1308,7 @@ export const SCHEMAS: Record<string, unknown> = {
         "type": "array"
       },
       "createdAt": {
+        "format": "int64",
         "type": "integer"
       },
       "echConfigList": {
@@ -1405,6 +1424,7 @@ export const SCHEMAS: Record<string, unknown> = {
         "type": "array"
       },
       "updatedAt": {
+        "format": "int64",
         "type": "integer"
       },
       "verifyPeerCertByName": {
@@ -1634,6 +1654,7 @@ export const SCHEMAS: Record<string, unknown> = {
       },
       "down": {
         "description": "Download traffic in bytes",
+        "format": "int64",
         "type": "integer"
       },
       "enable": {
@@ -1643,6 +1664,7 @@ export const SCHEMAS: Record<string, unknown> = {
       },
       "expiryTime": {
         "description": "Expiration timestamp",
+        "format": "int64",
         "type": "integer"
       },
       "fallbackParent": {
@@ -1661,6 +1683,7 @@ export const SCHEMAS: Record<string, unknown> = {
       },
       "lastTrafficResetTime": {
         "description": "Last traffic reset timestamp",
+        "format": "int64",
         "type": "integer"
       },
       "listen": {
@@ -1729,6 +1752,7 @@ export const SCHEMAS: Record<string, unknown> = {
       },
       "total": {
         "description": "Total traffic limit in bytes",
+        "format": "int64",
         "type": "integer"
       },
       "trafficReset": {
@@ -1744,6 +1768,7 @@ export const SCHEMAS: Record<string, unknown> = {
       },
       "up": {
         "description": "Upload traffic in bytes",
+        "format": "int64",
         "type": "integer"
       }
     },
@@ -1955,6 +1980,7 @@ export const SCHEMAS: Record<string, unknown> = {
         "type": "boolean"
       },
       "configDirtyAt": {
+        "format": "int64",
         "type": "integer"
       },
       "cpuPct": {
@@ -1963,6 +1989,7 @@ export const SCHEMAS: Record<string, unknown> = {
       },
       "createdAt": {
         "example": 1700000000,
+        "format": "int64",
         "type": "integer"
       },
       "depletedCount": {
@@ -2008,6 +2035,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "lastHeartbeat": {
         "description": "unix seconds, 0 = never",
         "example": 1700000000,
+        "format": "int64",
         "type": "integer"
       },
       "latencyMs": {
@@ -2024,10 +2052,12 @@ export const SCHEMAS: Record<string, unknown> = {
       },
       "netDown": {
         "example": 2097152,
+        "format": "int64",
         "type": "integer"
       },
       "netUp": {
         "example": 1048576,
+        "format": "int64",
         "type": "integer"
       },
       "onlineCount": {
@@ -2084,10 +2114,12 @@ export const SCHEMAS: Record<string, unknown> = {
       },
       "updatedAt": {
         "example": 1700000000,
+        "format": "int64",
         "type": "integer"
       },
       "uptimeSecs": {
         "example": 86400,
+        "format": "int64",
         "type": "integer"
       },
       "xrayError": {
@@ -2149,6 +2181,7 @@ export const SCHEMAS: Record<string, unknown> = {
     "description": "OutboundTraffics tracks traffic statistics for Xray outbound connections.",
     "properties": {
       "down": {
+        "format": "int64",
         "type": "integer"
       },
       "id": {
@@ -2158,9 +2191,11 @@ export const SCHEMAS: Record<string, unknown> = {
         "type": "string"
       },
       "total": {
+        "format": "int64",
         "type": "integer"
       },
       "up": {
+        "format": "int64",
         "type": "integer"
       }
     },
@@ -2182,6 +2217,7 @@ export const SCHEMAS: Record<string, unknown> = {
       },
       "finishedAt": {
         "example": 1735689612,
+        "format": "int64",
         "type": "integer"
       },
       "runId": {
@@ -2228,6 +2264,7 @@ export const SCHEMAS: Record<string, unknown> = {
       },
       "uptimeSecs": {
         "example": 86400,
+        "format": "int64",
         "type": "integer"
       },
       "xrayError": {

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

@@ -7,7 +7,7 @@ import type { InboundOption } from '@/hooks/useClients';
 import { formatInboundLabel } from '@/lib/inbounds/label';
 import type { BulkAttachResult } from '@/schemas/client';
 
-const MULTI_USER_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'hysteria', 'shadowsocks']);
+const MULTI_USER_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'hysteria', 'shadowsocks', 'wireguard', 'mtproto']);
 
 interface BulkAttachInboundsModalProps {
   open: boolean;

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

@@ -7,7 +7,7 @@ import type { InboundOption } from '@/hooks/useClients';
 import { formatInboundLabel } from '@/lib/inbounds/label';
 import type { BulkDetachResult } from '@/schemas/client';
 
-const MULTI_USER_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'hysteria', 'shadowsocks']);
+const MULTI_USER_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'hysteria', 'shadowsocks', 'wireguard', 'mtproto']);
 
 interface BulkDetachInboundsModalProps {
   open: boolean;

+ 21 - 8
frontend/src/pages/inbounds/InboundsPage.tsx

@@ -24,7 +24,7 @@ import {
 
 import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
 import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
-import { genInboundLinks, preferPublicHost } from '@/lib/xray/inbound-link';
+import { genInboundLinks, genWireguardLinks, preferPublicHost } from '@/lib/xray/inbound-link';
 import { inboundFromDb } from '@/lib/xray/inbound-from-db';
 import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
 import { useTheme } from '@/hooks/useTheme';
@@ -33,6 +33,7 @@ import { useWebSocket } from '@/hooks/useWebSocket';
 import { useNodesQuery } from '@/api/queries/useNodesQuery';
 import AppSidebar from '@/layouts/AppSidebar';
 const TextModal = lazy(() => import('@/components/feedback/TextModal'));
+import type { TextModalTab } from '@/components/feedback/TextModal';
 const PromptModal = lazy(() => import('@/components/feedback/PromptModal'));
 
 import { useInbounds } from './useInbounds';
@@ -148,6 +149,7 @@ export default function InboundsPage() {
   const [textContent, setTextContent] = useState('');
   const [textFileName, setTextFileName] = useState('');
   const [textJson, setTextJson] = useState(false);
+  const [textTabs, setTextTabs] = useState<TextModalTab[] | undefined>(undefined);
 
   const [promptOpen, setPromptOpen] = useState(false);
   const [promptTitle, setPromptTitle] = useState('');
@@ -166,11 +168,12 @@ export default function InboundsPage() {
   const infoNodeAddress = useMemo(() => hostOverrideFor(infoDbInbound), [infoDbInbound, hostOverrideFor]);
   const qrNodeAddress = useMemo(() => hostOverrideFor(qrDbInbound), [qrDbInbound, hostOverrideFor]);
 
-  const openText = useCallback((opts: { title: string; content: string; fileName?: string; json?: boolean }) => {
+  const openText = useCallback((opts: { title: string; content: string; fileName?: string; json?: boolean; tabs?: TextModalTab[] }) => {
     setTextTitle(opts.title);
     setTextContent(opts.content);
     setTextFileName(opts.fileName || '');
     setTextJson(opts.json || false);
+    setTextTabs(opts.tabs);
     setTextOpen(true);
   }, []);
 
@@ -259,15 +262,24 @@ export default function InboundsPage() {
 
   const exportInboundLinks = useCallback((dbInbound: DBInbound) => {
     const projected = checkFallback(dbInbound);
+    const genInput = {
+      inbound: inboundFromDb(projected),
+      remark: projected.remark,
+      hostOverride: hostOverrideFor(dbInbound),
+      fallbackHostname: preferPublicHost(window.location.hostname, subSettings.publicHost),
+    };
+    const content = genInboundLinks(genInput);
+    const tabs: TextModalTab[] | undefined = projected.isWireguard
+      ? [
+        { key: 'config', label: t('pages.clients.config'), content },
+        { key: 'links', label: t('pages.clients.tabLinks'), content: genWireguardLinks(genInput) },
+      ]
+      : undefined;
     openText({
       title: t('pages.inbounds.exportLinksTitle'),
-      content: genInboundLinks({
-        inbound: inboundFromDb(projected),
-        remark: projected.remark,
-        hostOverride: hostOverrideFor(dbInbound),
-        fallbackHostname: preferPublicHost(window.location.hostname, subSettings.publicHost),
-      }),
+      content,
       fileName: projected.remark || 'inbound',
+      tabs,
     });
   }, [checkFallback, hostOverrideFor, subSettings.publicHost, openText, t]);
 
@@ -706,6 +718,7 @@ export default function InboundsPage() {
             content={textContent}
             fileName={textFileName}
             json={textJson}
+            tabs={textTabs}
           />
         </LazyMount>
         <LazyMount when={promptOpen}>

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

@@ -43,7 +43,7 @@ export function buildRowActionsMenu({ record, subEnable, t, isMobile, hasClients
         label: `${t('pages.inbounds.export')} — ${t('pages.settings.subSettings')}`,
       });
     }
-  } else if (!record.isWireguard) {
+  } else {
     items.push({ key: 'showInfo', icon: <InfoCircleOutlined />, label: t('pages.inbounds.inboundInfo') });
   }
   items.push({ key: 'clipboard', icon: <CopyOutlined />, label: t('pages.inbounds.exportInbound') });

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

@@ -73,6 +73,7 @@ export function isInboundMultiUser(record: { protocol: string; settings: unknown
     case 'trojan':
     case 'hysteria':
     case 'mtproto':
+    case 'wireguard':
       return true;
     case 'shadowsocks':
       return isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(record.settings) });

+ 2 - 0
frontend/src/pages/inbounds/useInbounds.ts

@@ -60,6 +60,8 @@ const TRACKED_PROTOCOLS: readonly string[] = [
   Protocols.TROJAN,
   Protocols.SHADOWSOCKS,
   Protocols.HYSTERIA,
+  Protocols.WIREGUARD,
+  Protocols.MTPROTO,
 ];
 
 async function fetchSlimInbounds(): Promise<unknown[]> {

+ 18 - 0
frontend/src/pages/xray/outbounds/CountryPill.tsx

@@ -0,0 +1,18 @@
+import { CloudOutlined } from '@ant-design/icons';
+
+interface CountryPillProps {
+  flag: string;
+  name: string;
+  warp?: string;
+}
+
+export default function CountryPill({ flag, name, warp }: CountryPillProps) {
+  const isWarp = !!warp && warp.toLowerCase() !== 'off';
+  return (
+    <span className={isWarp ? 'country-pill warp-on' : 'country-pill'}>
+      {isWarp && <CloudOutlined className="warp-cloud-icon" />}
+      {flag && <span>{flag}</span>}
+      <span>{name}</span>
+    </span>
+  );
+}

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

@@ -1,3 +1,4 @@
+import { useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Button, Dropdown, Tag, Tooltip } from 'antd';
 import {
@@ -9,15 +10,21 @@ import {
   ThunderboltOutlined,
   LoadingOutlined,
   ExportOutlined,
+  EyeInvisibleOutlined,
+  EyeOutlined,
 } from '@ant-design/icons';
 
 import { SizeFormatter } from '@/utils';
+import { activateOnKey } from '@/utils/a11y';
 import { OutboundProtocols as Protocols } from '@/schemas/primitives';
 import type { OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
 
 import type { OutboundRow } from './outbounds-tab-types';
+import CountryPill from './CountryPill';
 import TestResultPopover from './TestResultPopover';
 import {
+  countryFlag,
+  countryName,
   isTesting,
   isUntestable,
   outboundAddresses,
@@ -49,7 +56,55 @@ export default function OutboundCardList({
   confirmDelete,
   onTest,
 }: OutboundCardListProps) {
-  const { t } = useTranslation();
+  const { t, i18n } = useTranslation();
+  const [showEgressIp, setShowEgressIp] = useState<Record<string, boolean>>({});
+
+  const setCardEgressVisible = (key: string, visible: boolean) => {
+    setShowEgressIp((prev) => ({ ...prev, [key]: visible }));
+  };
+
+  const renderEgress = (index: number, rowKey: string) => {
+    const result = testResult(outboundTestStates, index);
+    const egress = result?.egress;
+    const isEgressVisible = !!showEgressIp[rowKey];
+    const flag = countryFlag(egress?.country);
+    const name = countryName(egress?.country, i18n.language);
+    const addresses = [
+      egress?.ipv4 ? { label: 'v4', value: egress.ipv4 } : null,
+      egress?.ipv6 ? { label: 'v6', value: egress.ipv6 } : null,
+    ].filter((item): item is { label: string; value: string } => Boolean(item));
+
+    if (!egress || (addresses.length === 0 && !egress.country)) {
+      return null;
+    }
+
+    return (
+      <div className="card-egress">
+        <div className="card-egress-row">
+          <span>{t('pages.xray.outbound.egress')}:</span>
+          <Tooltip title={t('pages.index.toggleIpVisibility')}>
+            {isEgressVisible ? (
+              <EyeOutlined className="ip-toggle-icon" role="button" tabIndex={0} aria-label={t('pages.index.toggleIpVisibility')} onClick={() => setCardEgressVisible(rowKey, false)} onKeyDown={activateOnKey(() => setCardEgressVisible(rowKey, false))} />
+            ) : (
+              <EyeInvisibleOutlined className="ip-toggle-icon" role="button" tabIndex={0} aria-label={t('pages.index.toggleIpVisibility')} onClick={() => setCardEgressVisible(rowKey, true)} onKeyDown={activateOnKey(() => setCardEgressVisible(rowKey, true))} />
+            )}
+          </Tooltip>
+          {egress.country && (
+            <CountryPill flag={flag} name={name || egress.country} warp={egress.warp} />
+          )}
+        </div>
+        {addresses.map((addr) => (
+          <Tooltip key={addr.label} title={addr.value}>
+            <div className="card-egress-row">
+              <span className="egress-family">{addr.label}:</span>
+              <span className={isEgressVisible ? 'address-visible egress-ip' : 'address-hidden egress-ip'}>{addr.value}</span>
+            </div>
+          </Tooltip>
+        ))}
+      </div>
+    );
+  };
+
   if (rows.length === 0) {
     return (
       <div className="card-empty">
@@ -101,6 +156,7 @@ export default function OutboundCardList({
               ))}
             </div>
           )}
+          {renderEgress(index, String(record.key))}
           <div className="card-foot">
             <span className="traffic-up">↑ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).up)}</span>
             <span className="traffic-sep" />

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

@@ -75,6 +75,81 @@
   background: var(--ant-color-fill-tertiary);
 }
 
+.egress-header {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+}
+
+.egress-ip {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  font-size: 12px;
+}
+
+.egress-stack {
+  display: inline-flex;
+  flex-direction: column;
+  gap: 3px;
+}
+
+.egress-address {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  min-width: 0;
+}
+
+.egress-family {
+  color: var(--ant-color-text-secondary);
+  font-size: 10px;
+  text-transform: uppercase;
+}
+
+.country-pill {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  border: 1px solid var(--ant-color-border);
+  border-radius: 4px;
+  padding: 1px 6px;
+  line-height: 20px;
+  font-size: 12px;
+  white-space: nowrap;
+}
+
+.country-pill.warp-on {
+  border-color: #f48120;
+}
+
+.warp-cloud-icon {
+  width: 1em;
+  height: 1em;
+  color: #f48120;
+  flex: 0 0 auto;
+}
+
+.warp-cloud-icon svg {
+  fill: #f48120;
+}
+
+.ip-toggle-icon {
+  cursor: pointer;
+  opacity: 0.65;
+}
+
+.ip-toggle-icon:hover {
+  opacity: 1;
+}
+
+.address-hidden {
+  filter: blur(5px);
+  transition: filter 0.2s ease;
+}
+
+.address-visible {
+  filter: none;
+}
+
 .action-cell {
   display: flex;
   align-items: center;
@@ -110,6 +185,23 @@
   flex-wrap: wrap;
 }
 
+.card-egress {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  gap: 3px;
+  font-size: 12px;
+  opacity: 0.85;
+}
+
+.card-egress-row {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  min-width: 0;
+  max-width: 100%;
+}
+
 .card-test {
   margin-left: auto;
   display: inline-flex;

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

@@ -61,6 +61,22 @@ export function trafficFor(outboundsTraffic: OutboundTrafficRow[], o: OutboundRo
   return { up: tr?.up || 0, down: tr?.down || 0 };
 }
 
+export function countryFlag(country?: string): string {
+  const code = (country || '').trim().toUpperCase();
+  if (!/^[A-Z]{2}$/.test(code)) return '';
+  return String.fromCodePoint(...[...code].map((ch) => 0x1f1e6 + ch.charCodeAt(0) - 65));
+}
+
+export function countryName(country?: string, locale?: string): string {
+  const code = (country || '').trim().toUpperCase();
+  if (!/^[A-Z]{2}$/.test(code)) return '';
+  try {
+    return new Intl.DisplayNames(locale ? [locale] : undefined, { type: 'region' }).of(code) || code;
+  } catch {
+    return code;
+  }
+}
+
 export function isTesting<K extends string | number>(states: Record<K, OutboundTestState>, idx: K): boolean {
   return !!states?.[idx]?.testing;
 }

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

@@ -1,4 +1,4 @@
-import { useMemo } from 'react';
+import { useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Button, Dropdown, Tag, Tooltip } from 'antd';
 import {
@@ -11,17 +11,23 @@ import {
   LoadingOutlined,
   ArrowUpOutlined,
   ArrowDownOutlined,
+  EyeInvisibleOutlined,
+  EyeOutlined,
 } from '@ant-design/icons';
 import type { ColumnsType } from 'antd/es/table';
 
 import { SizeFormatter } from '@/utils';
+import { activateOnKey } from '@/utils/a11y';
 import { OutboundProtocols as Protocols } from '@/schemas/primitives';
 import type { OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
 
 import type { OutboundRow } from './outbounds-tab-types';
+import CountryPill from './CountryPill';
 import TestResultPopover from './TestResultPopover';
 import {
   effectiveTestMode,
+  countryFlag,
+  countryName,
   isTesting,
   isUntestable,
   outboundAddresses,
@@ -58,7 +64,8 @@ export function useOutboundColumns({
   onResetTraffic,
   onTest,
 }: OutboundColumnsParams): ColumnsType<OutboundRow> {
-  const { t } = useTranslation();
+  const { t, i18n } = useTranslation();
+  const [showEgressIp, setShowEgressIp] = useState(false);
   return useMemo(
     () => [
       {
@@ -135,6 +142,72 @@ export function useOutboundColumns({
           );
         },
       },
+      {
+        title: (
+          <span className="egress-header">
+            {t('pages.xray.outbound.egress')}
+            <Tooltip title={t('pages.index.toggleIpVisibility')}>
+              {showEgressIp ? (
+                <EyeOutlined className="ip-toggle-icon" role="button" tabIndex={0} aria-label={t('pages.index.toggleIpVisibility')} onClick={() => setShowEgressIp(false)} onKeyDown={activateOnKey(() => setShowEgressIp(false))} />
+              ) : (
+                <EyeInvisibleOutlined className="ip-toggle-icon" role="button" tabIndex={0} aria-label={t('pages.index.toggleIpVisibility')} onClick={() => setShowEgressIp(true)} onKeyDown={activateOnKey(() => setShowEgressIp(true))} />
+              )}
+            </Tooltip>
+          </span>
+        ),
+        key: 'egress',
+        align: 'left',
+        width: 210,
+        render: (_v, _record, index) => {
+          const egress = testResult(outboundTestStates, index)?.egress;
+          const addresses = [
+            egress?.ipv4 ? { label: 'v4', value: egress.ipv4 } : null,
+            egress?.ipv6 ? { label: 'v6', value: egress.ipv6 } : null,
+          ].filter((item): item is { label: string; value: string } => Boolean(item));
+          if (addresses.length === 0) {
+            return (
+              <Tooltip title={t('pages.xray.outbound.egressHint')}>
+                <span className="empty">—</span>
+              </Tooltip>
+            );
+          }
+          return (
+            <div className="egress-stack">
+              {addresses.map((addr) => (
+                <Tooltip key={addr.label} title={addr.value}>
+                  <span className="egress-address">
+                    <span className="egress-family">{addr.label}</span>
+                    <span className={showEgressIp ? 'address-visible egress-ip' : 'address-hidden egress-ip'}>{addr.value}</span>
+                  </span>
+                </Tooltip>
+              ))}
+            </div>
+          );
+        },
+      },
+      {
+        title: t('pages.xray.outbound.country'),
+        key: 'egressCountry',
+        align: 'left',
+        width: 160,
+        render: (_v, _record, index) => {
+          const egress = testResult(outboundTestStates, index)?.egress;
+          if (!egress?.country) {
+            return (
+              <Tooltip title={t('pages.xray.outbound.egressHint')}>
+                <span className="empty">—</span>
+              </Tooltip>
+            );
+          }
+          const flag = countryFlag(egress.country);
+          const name = countryName(egress.country, i18n.language);
+          return (
+            <Tooltip title={egress.warp ? `Cloudflare trace · WARP ${egress.warp}` : 'Cloudflare trace'}>
+              <CountryPill flag={flag} name={name || egress.country} warp={egress.warp} />
+            </Tooltip>
+          );
+        },
+      },
       {
         title: t('pages.inbounds.traffic'),
         key: 'traffic',
@@ -183,6 +256,6 @@ export function useOutboundColumns({
       },
     ],
     // eslint-disable-next-line react-hooks/exhaustive-deps
-    [t, testMode, rows, outboundTestStates, outboundsTraffic],
+    [t, i18n.language, testMode, rows, outboundTestStates, outboundsTraffic, showEgressIp],
   );
 }

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

@@ -78,6 +78,15 @@ export const OutboundTestResultSchema = z.object({
       }).loose(),
     )
     .optional(),
+  egress: z
+    .object({
+      ipv4: z.string().optional(),
+      ipv6: z.string().optional(),
+      country: z.string().optional(),
+      warp: z.string().optional(),
+    })
+    .loose()
+    .optional(),
 }).loose();
 
 // Batch results from /xray/testOutbounds, aligned with the request order.

+ 17 - 0
internal/web/service/outbound/egress_trace_test.go

@@ -0,0 +1,17 @@
+package outbound
+
+import "testing"
+
+func TestParseCloudflareTrace(t *testing.T) {
+	values := parseCloudflareTrace("ip=104.28.1.2\nloc=NL\nwarp=on\n")
+
+	if values["ip"] != "104.28.1.2" {
+		t.Fatalf("ip = %q", values["ip"])
+	}
+	if values["loc"] != "NL" {
+		t.Fatalf("loc = %q", values["loc"])
+	}
+	if values["warp"] != "on" {
+		t.Fatalf("warp = %q", values["warp"])
+	}
+}

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

@@ -142,6 +142,7 @@ type TestOutboundResult struct {
 	TTFBMs     int64 `json:"ttfbMs,omitempty"`
 
 	Endpoints []TestEndpointResult `json:"endpoints,omitempty"`
+	Egress    *TestEgressResult    `json:"egress,omitempty"`
 }
 
 // TestEndpointResult is one entry in a TCP-mode probe — the per-endpoint
@@ -153,6 +154,15 @@ type TestEndpointResult struct {
 	Error   string `json:"error,omitempty"`
 }
 
+// TestEgressResult is populated by HTTP-mode probes from Cloudflare's trace
+// endpoint. It reports what an external service sees after the outbound chain.
+type TestEgressResult struct {
+	IPv4    string `json:"ipv4,omitempty"`
+	IPv6    string `json:"ipv6,omitempty"`
+	Country string `json:"country,omitempty"`
+	Warp    string `json:"warp,omitempty"`
+}
+
 func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundResult, error) {
 	var ob map[string]any
 	if err := json.Unmarshal([]byte(outboundJSON), &ob); err != nil {

+ 153 - 1
internal/web/service/outbound/probe_http.go

@@ -14,6 +14,7 @@ import (
 	"net/url"
 	"os"
 	"strconv"
+	"strings"
 	"sync"
 	"time"
 
@@ -54,8 +55,13 @@ const (
 	// tcpBatchConcurrency caps parallel TCP-mode items in a batch (each item
 	// already dials its endpoints concurrently).
 	tcpBatchConcurrency = 8
+	// egressTraceTimeout keeps diagnostic trace metadata from extending a
+	// successful HTTP probe by the full reachability timeout.
+	egressTraceTimeout = 3 * time.Second
 
-	defaultTestURL = "https://www.google.com/generate_204"
+	defaultTestURL  = "https://www.google.com/generate_204"
+	egressTraceHost = "cloudflare.com"
+	egressTracePath = "/cdn-cgi/trace"
 )
 
 // httpTestSemaphore serialises HTTP-mode batches (each spawns a temp xray
@@ -76,6 +82,8 @@ var newBatchProcess = func(cfg *xray.Config, configPath string) batchProcess {
 	return xray.NewTestProcess(cfg, configPath)
 }
 
+var egressTraceProbe = probeEgressTrace
+
 // httpBatchItem is one outbound inside an HTTP-mode batch. result is the
 // pre-allocated entry in the caller's result slice, filled in place.
 type httpBatchItem struct {
@@ -542,6 +550,150 @@ func probeThroughSocks(port int, testURL string, timeout time.Duration, realDela
 		}
 	}
 	result.Delay = max(delay, 1)
+	if !realDelay {
+		result.Egress = egressTraceProbe(proxyURL)
+	}
+}
+
+// probeEgressTrace fetches Cloudflare's plain-text trace endpoint through the
+// same SOCKS route used by the HTTP probe. It asks one IPv4 and one IPv6
+// Cloudflare address directly, when available, while keeping the TLS SNI as
+// cloudflare.com. Failures are intentionally ignored by the caller: egress
+// metadata is diagnostic, not reachability.
+func probeEgressTrace(proxyURL *url.URL) *TestEgressResult {
+	ipv4, ipv6 := cloudflareTraceTargets()
+	if ipv4 == nil && ipv6 == nil {
+		return nil
+	}
+
+	tr := &http.Transport{
+		Proxy: http.ProxyURL(proxyURL),
+		TLSClientConfig: &tls.Config{
+			ServerName: egressTraceHost,
+		},
+	}
+	defer tr.CloseIdleConnections()
+
+	client := &http.Client{
+		Transport: tr,
+		Timeout:   egressTraceTimeout,
+	}
+
+	egress := &TestEgressResult{}
+	targets := make([]net.IP, 0, 2)
+	if ipv4 != nil {
+		targets = append(targets, ipv4)
+	}
+	if ipv6 != nil {
+		targets = append(targets, ipv6)
+	}
+	results := make(chan map[string]string, len(targets))
+	for _, target := range targets {
+		go func(ip net.IP) {
+			results <- fetchCloudflareTrace(client, ip)
+		}(target)
+	}
+	for range targets {
+		applyEgressTrace(egress, <-results)
+	}
+	if egress.IPv4 == "" && egress.IPv6 == "" && egress.Country == "" && egress.Warp == "" {
+		return nil
+	}
+
+	return egress
+}
+
+func cloudflareTraceTargets() (net.IP, net.IP) {
+	ctx, cancel := context.WithTimeout(context.Background(), egressTraceTimeout)
+	defer cancel()
+
+	addrs, err := net.DefaultResolver.LookupIPAddr(ctx, egressTraceHost)
+	if err != nil {
+		return nil, nil
+	}
+	var ipv4, ipv6 net.IP
+	for _, addr := range addrs {
+		ip := addr.IP
+		if ipv4 == nil {
+			if v4 := ip.To4(); v4 != nil {
+				ipv4 = v4
+				continue
+			}
+		}
+		if ipv6 == nil && ip.To4() == nil && ip.To16() != nil {
+			ipv6 = ip
+		}
+		if ipv4 != nil && ipv6 != nil {
+			break
+		}
+	}
+	return ipv4, ipv6
+}
+
+func fetchCloudflareTrace(client *http.Client, ip net.IP) map[string]string {
+	if ip == nil {
+		return nil
+	}
+	traceURL := (&url.URL{
+		Scheme: "https",
+		Host:   net.JoinHostPort(ip.String(), "443"),
+		Path:   egressTracePath,
+	}).String()
+	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, traceURL, nil)
+	if err != nil {
+		return nil
+	}
+	req.Host = egressTraceHost
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		return nil
+	}
+	body, err := io.ReadAll(io.LimitReader(resp.Body, 16<<10))
+	if err != nil {
+		return nil
+	}
+	return parseCloudflareTrace(string(body))
+}
+
+func applyEgressTrace(egress *TestEgressResult, values map[string]string) {
+	if len(values) == 0 {
+		return
+	}
+	if ip := net.ParseIP(values["ip"]); ip != nil {
+		if ip.To4() != nil {
+			if egress.IPv4 == "" {
+				egress.IPv4 = values["ip"]
+			}
+		} else if egress.IPv6 == "" {
+			egress.IPv6 = values["ip"]
+		}
+	}
+	if egress.Country == "" {
+		egress.Country = values["loc"]
+	}
+	if values["warp"] == "on" || egress.Warp == "" {
+		egress.Warp = values["warp"]
+	}
+}
+
+func parseCloudflareTrace(body string) map[string]string {
+	values := make(map[string]string)
+	for line := range strings.SplitSeq(body, "\n") {
+		line = strings.TrimSpace(line)
+		if line == "" {
+			continue
+		}
+		key, value, ok := strings.Cut(line, "=")
+		if !ok {
+			continue
+		}
+		values[strings.TrimSpace(key)] = strings.TrimSpace(value)
+	}
+	return values
 }
 
 // timedWarmGet re-issues the probe request over the transport's kept-alive

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

@@ -9,6 +9,7 @@ import (
 	"net"
 	"net/http"
 	"net/http/httptest"
+	"net/url"
 	"strconv"
 	"strings"
 	"sync"
@@ -135,6 +136,13 @@ func withStubProcess(t *testing.T, factory func(cfg *xray.Config, configPath str
 	t.Cleanup(func() { newBatchProcess = orig })
 }
 
+func withEgressTraceProbe(t *testing.T, probe func(*url.URL) *TestEgressResult) {
+	t.Helper()
+	orig := egressTraceProbe
+	egressTraceProbe = probe
+	t.Cleanup(func() { egressTraceProbe = orig })
+}
+
 func mustJSON(t *testing.T, v any) string {
 	t.Helper()
 	b, err := json.Marshal(v)
@@ -417,6 +425,9 @@ func TestTestOutboundsHTTPBatchThroughStubSocks(t *testing.T) {
 		proc = &stubProcess{cfg: cfg, serveSocks: true}
 		return proc
 	})
+	withEgressTraceProbe(t, func(*url.URL) *TestEgressResult {
+		return &TestEgressResult{IPv4: "198.51.100.1", Country: "ZZ", Warp: "off"}
+	})
 
 	batch := mustJSON(t, []any{
 		map[string]any{"tag": "a", "protocol": "vless"},
@@ -445,6 +456,9 @@ func TestTestOutboundsHTTPBatchThroughStubSocks(t *testing.T) {
 		if r.Mode != "http" {
 			t.Errorf("result %d mode = %q", i, r.Mode)
 		}
+		if r.Egress == nil || r.Egress.IPv4 != "198.51.100.1" {
+			t.Errorf("result %d egress = %+v", i, r.Egress)
+		}
 	}
 	if proc.IsRunning() {
 		t.Error("temp process not stopped after batch")
@@ -525,6 +539,9 @@ func TestTestOutboundsTCPModeForcesUDPToHTTPProbe(t *testing.T) {
 	withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
 		return &stubProcess{cfg: cfg, serveSocks: true}
 	})
+	withEgressTraceProbe(t, func(*url.URL) *TestEgressResult {
+		return &TestEgressResult{IPv4: "198.51.100.2", Country: "ZZ", Warp: "off"}
+	})
 
 	batch := mustJSON(t, []any{map[string]any{"tag": "wg", "protocol": "wireguard"}})
 	results, err := (&OutboundService{}).TestOutbounds(batch, srv.URL, "", "tcp")
@@ -535,6 +552,9 @@ func TestTestOutboundsTCPModeForcesUDPToHTTPProbe(t *testing.T) {
 	if !r.Success || r.Mode != "http" {
 		t.Errorf("UDP outbound in tcp mode = %+v, want success with mode %q", r, "http")
 	}
+	if r.Egress == nil || r.Egress.IPv4 != "198.51.100.2" {
+		t.Errorf("UDP outbound egress = %+v", r.Egress)
+	}
 }
 
 func TestProbeModeLabel(t *testing.T) {

+ 2 - 0
internal/web/translation/ar-EG.json

@@ -1622,6 +1622,8 @@
         "tag": "الوسم",
         "tagDesc": "تاج فريد",
         "address": "العنوان",
+        "egress": "Egress",
+        "egressHint": "Run an HTTP test to show egress IP and country.",
         "reverse": "عكسي",
         "domain": "النطاق",
         "type": "النوع",

+ 2 - 0
internal/web/translation/en-US.json

@@ -1739,6 +1739,8 @@
         "tag": "Tag",
         "tagDesc": "Unique Tag",
         "address": "Address",
+        "egress": "Egress",
+        "egressHint": "Run an HTTP test to show egress IP and country.",
         "reverse": "Reverse",
         "domain": "Domain",
         "type": "Type",

+ 2 - 0
internal/web/translation/es-ES.json

@@ -1622,6 +1622,8 @@
         "tag": "Etiqueta",
         "tagDesc": "etiqueta única",
         "address": "Dirección",
+        "egress": "Egress",
+        "egressHint": "Run an HTTP test to show egress IP and country.",
         "reverse": "Reverso",
         "domain": "Dominio",
         "type": "Tipo",

+ 2 - 0
internal/web/translation/fa-IR.json

@@ -1622,6 +1622,8 @@
         "tag": "تگ",
         "tagDesc": "برچسب یگانه",
         "address": "آدرس",
+        "egress": "Egress",
+        "egressHint": "Run an HTTP test to show egress IP and country.",
         "reverse": "معکوس",
         "domain": "دامنه",
         "type": "نوع",

+ 2 - 0
internal/web/translation/id-ID.json

@@ -1622,6 +1622,8 @@
         "tag": "Tag",
         "tagDesc": "Tag Unik",
         "address": "Alamat",
+        "egress": "Egress",
+        "egressHint": "Run an HTTP test to show egress IP and country.",
         "reverse": "Revers",
         "domain": "Domain",
         "type": "Tipe",

+ 2 - 0
internal/web/translation/ja-JP.json

@@ -1622,6 +1622,8 @@
         "tag": "タグ",
         "tagDesc": "一意のタグ",
         "address": "アドレス",
+        "egress": "Egress",
+        "egressHint": "Run an HTTP test to show egress IP and country.",
         "reverse": "リバース",
         "domain": "ドメイン",
         "type": "タイプ",

+ 2 - 0
internal/web/translation/pt-BR.json

@@ -1622,6 +1622,8 @@
         "tag": "Tag",
         "tagDesc": "Tag Única",
         "address": "Endereço",
+        "egress": "Egress",
+        "egressHint": "Run an HTTP test to show egress IP and country.",
         "reverse": "Reverso",
         "domain": "Domínio",
         "type": "Tipo",

+ 2 - 0
internal/web/translation/ru-RU.json

@@ -1622,6 +1622,8 @@
         "tag": "Тег",
         "tagDesc": "Уникальный тег",
         "address": "Адрес",
+        "egress": "Выход",
+        "egressHint": "Запустите HTTP-тест, чтобы показать выходной IP и страну.",
         "reverse": "Реверс-прокси",
         "domain": "Домен",
         "type": "Тип",

+ 2 - 0
internal/web/translation/tr-TR.json

@@ -1622,6 +1622,8 @@
         "tag": "Etiket",
         "tagDesc": "Benzersiz Etiket",
         "address": "Adres",
+        "egress": "Egress",
+        "egressHint": "Run an HTTP test to show egress IP and country.",
         "reverse": "Ters",
         "domain": "Alan Adı",
         "type": "Tür",

+ 2 - 0
internal/web/translation/uk-UA.json

@@ -1622,6 +1622,8 @@
         "tag": "Тег",
         "tagDesc": "Унікальний тег",
         "address": "Адреса",
+        "egress": "Egress",
+        "egressHint": "Run an HTTP test to show egress IP and country.",
         "reverse": "Зворотний",
         "domain": "Домен",
         "type": "Тип",

+ 2 - 0
internal/web/translation/vi-VN.json

@@ -1622,6 +1622,8 @@
         "tag": "Tag",
         "tagDesc": "thẻ duy nhất",
         "address": "Địa chỉ",
+        "egress": "Egress",
+        "egressHint": "Run an HTTP test to show egress IP and country.",
         "reverse": "Đảo ngược",
         "domain": "Tên miền",
         "type": "Loại",

+ 2 - 0
internal/web/translation/zh-CN.json

@@ -1622,6 +1622,8 @@
         "tag": "标签",
         "tagDesc": "唯一标签",
         "address": "地址",
+        "egress": "Egress",
+        "egressHint": "Run an HTTP test to show egress IP and country.",
         "reverse": "反向",
         "domain": "域名",
         "type": "类型",

+ 2 - 0
internal/web/translation/zh-TW.json

@@ -1622,6 +1622,8 @@
         "tag": "標籤",
         "tagDesc": "唯一標籤",
         "address": "地址",
+        "egress": "Egress",
+        "egressHint": "Run an HTTP test to show egress IP and country.",
         "reverse": "反向",
         "domain": "網域",
         "type": "類型",

+ 5 - 1
tools/openapigen/emit_jsonschema.go

@@ -97,7 +97,11 @@ func (g *schemaGen) typeSchema(t TypeRef) map[string]any {
 		}
 		return map[string]any{"type": "string"}
 	case KindInt:
-		return map[string]any{"type": "integer"}
+		sch := map[string]any{"type": "integer"}
+		if t.Name == "int64" {
+			sch["format"] = "int64"
+		}
+		return sch
 	case KindNumber:
 		return map[string]any{"type": "number"}
 	case KindBool:

+ 30 - 0
tools/openapigen/emit_jsonschema_test.go

@@ -0,0 +1,30 @@
+package main
+
+import "testing"
+
+func TestIntegerSchemaFormats(t *testing.T) {
+	tests := []struct {
+		name       string
+		goType     string
+		wantFormat string
+	}{
+		{name: "int", goType: "int"},
+		{name: "int32", goType: "int32"},
+		{name: "int64", goType: "int64", wantFormat: "int64"},
+		{name: "uint64", goType: "uint64", wantFormat: "int64"},
+	}
+
+	gen := &schemaGen{}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			schema := gen.typeSchema(identType(tt.goType))
+			if got := schema["type"]; got != "integer" {
+				t.Fatalf("type = %v, want integer", got)
+			}
+			got, _ := schema["format"].(string)
+			if got != tt.wantFormat {
+				t.Fatalf("format = %q, want %q", got, tt.wantFormat)
+			}
+		})
+	}
+}

+ 4 - 2
tools/openapigen/walker.go

@@ -197,8 +197,10 @@ func identType(name string) TypeRef {
 		return TypeRef{Kind: KindString}
 	case "bool":
 		return TypeRef{Kind: KindBool}
-	case "int", "int8", "int16", "int32", "int64",
-		"uint", "uint8", "uint16", "uint32", "uint64":
+	case "int64", "uint64":
+		return TypeRef{Kind: KindInt, Name: "int64"}
+	case "int", "int8", "int16", "int32",
+		"uint", "uint8", "uint16", "uint32":
 		return TypeRef{Kind: KindInt}
 	case "float32", "float64":
 		return TypeRef{Kind: KindNumber}