Compare commits

..

6 Commits

Author SHA1 Message Date
MHSanaei 44f2f426d8 feat(frontend): split wireguard inbound export into config and links tabs
The per-inbound export modal only showed the joined .conf blocks for wireguard, with no way to grab the wireguard:// share links the QR modal already generates. TextModal gains an optional tabs prop (copy and download follow the active tab), and the wireguard export now offers a Config tab with the .conf blocks alongside a Links tab with the per-client wireguard:// URLs. Tab labels reuse the existing pages.clients.config / pages.clients.tabLinks locale keys. Other protocols keep the single untabbed view.
2026-07-12 15:39:00 +02:00
MHSanaei c4a1139d3f feat(frontend): treat wireguard inbounds as multi-user in client actions
WireGuard has been first-class multi-client on the backend for a while (key generation, tunnel address allocation, attach/detach/delete all flow through the shared client apply path), but isInboundMultiUser still excluded it, so wireguard rows only offered Export Inbound / Reset Traffic / Clone / Delete. Adding it to the multi-user set surfaces Export All URLs (per-client .conf blocks), the subscription export, and the attach/detach/group/delete-all client actions, and makes wireguard inbounds valid targets in the attach-clients picker. The now-dead isWireguard guard on the inbound-info branch is dropped. The clients-page bulk attach/detach modals carried the same stale protocol set, also missing mtproto, so both now match the single-client form's inbound picker.
2026-07-12 15:31:27 +02:00
MHSanaei 476bec451d fix(frontend): show zero client count for mtproto and wireguard inbounds 2026-07-12 15:27:28 +02:00
MHSanaei f905c2dcec chore(frontend): bump version and deps
Update the frontend package version from 0.4.1 to 0.4.3 and refresh key dependencies. This includes i18next/react-i18next, Storybook packages (10.5.0), and ESLint (10.7.0), with corresponding lockfile updates to keep dependency resolution in sync.
2026-07-12 15:10:27 +02:00
isultanov99 30f6bc1833 feat: Add outbound egress metadata (IP + country) (#5886)
* Add outbound egress metadata

Show egress IP and country information for outbound HTTP tests. The probe reuses the temporary SOCKS route from the existing HTTP test and fetches Cloudflare trace metadata after the reachability check succeeds.

The outbound list now adds separate Egress and Country columns, hides egress IPs until the user reveals them, and marks Cloudflare WARP results with an orange cloud pill. Mobile cards keep the same data compact by placing the country and IPv4/IPv6 values on separate lines.

Validation: npm run typecheck; npm run lint; npm run build; go test ./internal/web/service/outbound

* Use context-aware DNS lookup for egress trace

* Address outbound egress review feedback

Restore the Real Delay selector and TCP default so the egress metadata change does not remove an existing test mode.

Keep HTTP probe tests hermetic by stubbing egress trace lookups, run IPv4 and IPv6 trace fetches concurrently with a shorter diagnostic timeout, scope mobile IP reveal state per row, support keyboard activation for reveal toggles, and treat WARP+ trace values as WARP-like.
2026-07-12 15:09:52 +02:00
Sangeeth Thilakarathna 2c95e29297 fix(api): preserve 64-bit integer schema formats (#5908)
Co-authored-by: sanmaxdev <sanmaxdev@users.noreply.github.com>
2026-07-12 10:58:02 +02:00
37 changed files with 813 additions and 153 deletions
+141 -120
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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": {
@@ -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;
@@ -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
View File
@@ -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}>
@@ -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') });
@@ -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) });
@@ -60,6 +60,8 @@ const TRACKED_PROTOCOLS: readonly string[] = [
Protocols.TROJAN,
Protocols.SHADOWSOCKS,
Protocols.HYSTERIA,
Protocols.WIREGUARD,
Protocols.MTPROTO,
];
async function fetchSlimInbounds(): Promise<unknown[]> {
@@ -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>
);
}
@@ -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" />
@@ -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;
@@ -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;
}
@@ -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
View File
@@ -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.
@@ -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
View File
@@ -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
View File
@@ -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
@@ -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
View File
@@ -1622,6 +1622,8 @@
"tag": "الوسم",
"tagDesc": "تاج فريد",
"address": "العنوان",
"egress": "Egress",
"egressHint": "Run an HTTP test to show egress IP and country.",
"reverse": "عكسي",
"domain": "النطاق",
"type": "النوع",
+2
View File
@@ -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
View File
@@ -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
View File
@@ -1622,6 +1622,8 @@
"tag": "تگ",
"tagDesc": "برچسب یگانه",
"address": "آدرس",
"egress": "Egress",
"egressHint": "Run an HTTP test to show egress IP and country.",
"reverse": "معکوس",
"domain": "دامنه",
"type": "نوع",
+2
View File
@@ -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
View File
@@ -1622,6 +1622,8 @@
"tag": "タグ",
"tagDesc": "一意のタグ",
"address": "アドレス",
"egress": "Egress",
"egressHint": "Run an HTTP test to show egress IP and country.",
"reverse": "リバース",
"domain": "ドメイン",
"type": "タイプ",
+2
View File
@@ -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
View File
@@ -1622,6 +1622,8 @@
"tag": "Тег",
"tagDesc": "Уникальный тег",
"address": "Адрес",
"egress": "Выход",
"egressHint": "Запустите HTTP-тест, чтобы показать выходной IP и страну.",
"reverse": "Реверс-прокси",
"domain": "Домен",
"type": "Тип",
+2
View File
@@ -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
View File
@@ -1622,6 +1622,8 @@
"tag": "Тег",
"tagDesc": "Унікальний тег",
"address": "Адреса",
"egress": "Egress",
"egressHint": "Run an HTTP test to show egress IP and country.",
"reverse": "Зворотний",
"domain": "Домен",
"type": "Тип",
+2
View File
@@ -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
View File
@@ -1622,6 +1622,8 @@
"tag": "标签",
"tagDesc": "唯一标签",
"address": "地址",
"egress": "Egress",
"egressHint": "Run an HTTP test to show egress IP and country.",
"reverse": "反向",
"domain": "域名",
"type": "类型",
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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}