mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-15 09:35:00 +03:00
Compare commits
50 Commits
7bcc5830c6
..
v3.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
| b5ef412b8d | |||
| 41cb0b8ae7 | |||
| cd46730bb9 | |||
| 4eab37b66c | |||
| 08bc481ae3 | |||
| 0f7da02a07 | |||
| 0c73862bbe | |||
| c7a0188772 | |||
| 90e6217749 | |||
| 6e20588236 | |||
| 5eec178483 | |||
| 5716ae5987 | |||
| 85983eec1a | |||
| 5af02265ec | |||
| 1c5cb84492 | |||
| 7c698c4bcf | |||
| 80e168787e | |||
| 3af1afc53b | |||
| 0cefadd166 | |||
| 0766e16684 | |||
| 63a6d40457 | |||
| f1a4286e2f | |||
| 7ae3ea66d1 | |||
| 253063b785 | |||
| d04cb10971 | |||
| d1a13844b2 | |||
| bade1fcef6 | |||
| 0e0e41197f | |||
| 5c29851be1 | |||
| 60da6bed15 | |||
| 7e87b7dc60 | |||
| dbee150b33 | |||
| 1a525b4cb4 | |||
| b062cb5a14 | |||
| a27d57b2ff | |||
| 10a0c9131c | |||
| a5e5640804 | |||
| 0711d3077b | |||
| 8578b229ce | |||
| c47a905ad2 | |||
| 825778144c | |||
| 1b0dbf8e6d | |||
| 09a887f95c | |||
| cc65f37164 | |||
| 21143a6d72 | |||
| 1508666e52 | |||
| 2db48174b0 | |||
| 554d85c2f7 | |||
| 2a7342baa9 | |||
| ec45d3491a |
+3
-2
@@ -1,5 +1,6 @@
|
|||||||
# Shell scripts must stay LF so the Docker build works when the repo is
|
|
||||||
# checked out on Windows (CRLF breaks the script shebang -> exit 127).
|
|
||||||
*.sh text eol=lf
|
*.sh text eol=lf
|
||||||
DockerInit.sh text eol=lf
|
DockerInit.sh text eol=lf
|
||||||
DockerEntrypoint.sh text eol=lf
|
DockerEntrypoint.sh text eol=lf
|
||||||
|
frontend/src/generated/** text eol=lf
|
||||||
|
frontend/public/openapi.json text eol=lf
|
||||||
|
frontend/src/test/__snapshots__/** text eol=lf
|
||||||
@@ -36,6 +36,7 @@ Thumbs.db
|
|||||||
x-ui.db
|
x-ui.db
|
||||||
x-ui.db-shm
|
x-ui.db-shm
|
||||||
x-ui.db-wal
|
x-ui.db-wal
|
||||||
|
system_metrics.gob
|
||||||
*.dump
|
*.dump
|
||||||
|
|
||||||
# Ignore Docker specific files
|
# Ignore Docker specific files
|
||||||
|
|||||||
Generated
+98
-95
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "3x-ui-frontend",
|
"name": "3x-ui-frontend",
|
||||||
"version": "0.3.0",
|
"version": "0.3.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "3x-ui-frontend",
|
"name": "3x-ui-frontend",
|
||||||
"version": "0.3.0",
|
"version": "0.3.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^6.2.5",
|
"@ant-design/icons": "^6.2.5",
|
||||||
"@codemirror/lang-json": "^6.0.2",
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
@@ -518,9 +518,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@codemirror/lint": {
|
"node_modules/@codemirror/lint": {
|
||||||
"version": "6.9.6",
|
"version": "6.9.7",
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.6.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
|
||||||
"integrity": "sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==",
|
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/state": "^6.0.0",
|
"@codemirror/state": "^6.0.0",
|
||||||
@@ -561,9 +561,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@codemirror/view": {
|
"node_modules/@codemirror/view": {
|
||||||
"version": "6.43.0",
|
"version": "6.43.1",
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.1.tgz",
|
||||||
"integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==",
|
"integrity": "sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/state": "^6.6.0",
|
"@codemirror/state": "^6.6.0",
|
||||||
@@ -617,9 +617,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@csstools/css-color-parser": {
|
"node_modules/@csstools/css-color-parser": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.3.tgz",
|
||||||
"integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==",
|
"integrity": "sha512-DOgvIPkikIOixQRlD4YF31VN6fLLUTdrzhfRbis8vm0kMTgIbEPX0Ip/YX9fOeV9iywAS4sUUbTclpan7yYP8Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1062,14 +1062,14 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||||
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
|
"integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tybys/wasm-util": "^0.10.1"
|
"@tybys/wasm-util": "^0.10.2"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -1103,9 +1103,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rc-component/async-validator": {
|
"node_modules/@rc-component/async-validator": {
|
||||||
"version": "5.1.1",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-6.0.0.tgz",
|
||||||
"integrity": "sha512-T03+Wk31Kz/28OC+rLlHtSNwD5Io3OWw6rPFPAp898sqALB/XnTrr3trB3mPoj379v0aRaW6t09HUG6dUyHR3g==",
|
"integrity": "sha512-D3AGQwdyE58gmvx6waVSXJ80JGO+IY5L2O8HDnSOex7JNlzB3GuN/4hyHNTdhy2qtOhkpbIjmeAN3tL993wKbA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.24.4"
|
"@babel/runtime": "^7.24.4"
|
||||||
@@ -1115,14 +1115,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rc-component/cascader": {
|
"node_modules/@rc-component/cascader": {
|
||||||
"version": "1.15.0",
|
"version": "1.16.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rc-component/cascader/-/cascader-1.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rc-component/cascader/-/cascader-1.16.1.tgz",
|
||||||
"integrity": "sha512-ZzpMtwFCRo3fbXHuDnncARJMZQjdqA2w7aDuPofNQt+aDx39st1hgfIpEwTBLhe2Hqsvs/zOr8RTtgxTkCPySw==",
|
"integrity": "sha512-wxLopwM+EBed0zNNGdnGE4coYoqcO+XD42fHgn+pDvO+XzhNFbdgSlSNXdKocIYqccvqgWvoxDPNb0OVRdi59A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rc-component/select": "~1.6.0",
|
"@rc-component/select": "~1.7.1",
|
||||||
"@rc-component/tree": "~1.3.0",
|
"@rc-component/tree": "~1.3.2",
|
||||||
"@rc-component/util": "^1.4.0",
|
"@rc-component/util": "^1.11.1",
|
||||||
"clsx": "^2.1.1"
|
"clsx": "^2.1.1"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -1236,12 +1236,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rc-component/form": {
|
"node_modules/@rc-component/form": {
|
||||||
"version": "1.8.2",
|
"version": "1.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rc-component/form/-/form-1.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rc-component/form/-/form-1.8.3.tgz",
|
||||||
"integrity": "sha512-ZidCvOLmM9Xr+3vzk4UAoR7Aj1W/5IHyrzlBB7sNkygpTeRVrohQSo4TN7W/nARTH+nt8zSAPsn4BEl4zLEO2g==",
|
"integrity": "sha512-jNkat3uxZ246ELudKwnjQhnDI8+rSxgLxjztvQU3Mrb0G+LwDyOrPu9RNfekOjqU5GQ5QJepi225x+9LhCizJw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rc-component/async-validator": "^5.1.0",
|
"@rc-component/async-validator": "^6.0.0",
|
||||||
"@rc-component/util": "^1.11.1",
|
"@rc-component/util": "^1.11.1",
|
||||||
"clsx": "^2.1.1"
|
"clsx": "^2.1.1"
|
||||||
},
|
},
|
||||||
@@ -1410,12 +1410,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rc-component/pagination": {
|
"node_modules/@rc-component/pagination": {
|
||||||
"version": "1.2.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rc-component/pagination/-/pagination-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rc-component/pagination/-/pagination-1.3.0.tgz",
|
||||||
"integrity": "sha512-YcpUFE8dMLfSo6OARJlK6DbHHvrxz7pMGPGmC/caZSJJz6HRKHC1RPP001PRHCvG9Z/veD039uOQmazVuLJzlw==",
|
"integrity": "sha512-12ahTY+HPITg1L2bjWKXUqBJe/oOnpA2QsChdCjthqLVf/e19StiCsv8OLKpWoHbc+8PFEkNjRqRqrLoRBHjFw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rc-component/util": "^1.3.0",
|
"@rc-component/util": "^1.11.1",
|
||||||
"clsx": "^2.1.1"
|
"clsx": "^2.1.1"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -1493,9 +1493,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rc-component/qrcode": {
|
"node_modules/@rc-component/qrcode": {
|
||||||
"version": "1.1.2",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-2.0.0.tgz",
|
||||||
"integrity": "sha512-CTXG18eP3sO3gc+96ep9HyVI/RzMup7L59apM/D0wWo1SHRdwOb7xyD4bMbmpu4dPlTch59Kxb8lU7U9ME60fg==",
|
"integrity": "sha512-aAv3QhPP1xyafuTZOxub6a54pCeBnN3IwQkpETrBtthq4BL5IgxnCbuoBWPDpdLw1y1j6BgBUCAKV92+yX06Dw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.24.7"
|
"@babel/runtime": "^7.24.7"
|
||||||
@@ -1555,15 +1555,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rc-component/select": {
|
"node_modules/@rc-component/select": {
|
||||||
"version": "1.6.15",
|
"version": "1.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rc-component/select/-/select-1.6.15.tgz",
|
"resolved": "https://registry.npmjs.org/@rc-component/select/-/select-1.7.1.tgz",
|
||||||
"integrity": "sha512-SyVCWnqxCQZZcQvQJ/CxSjx2bGma6ds/HtnpkIfZVnt6RoEgbqUmHgD6vrzNarNXwbLXerwVzWwq8F3d1sst7g==",
|
"integrity": "sha512-GZ1cMJk2xQh0VHyOQjjG8drYL4iu24NcbkXioUcReQOCUr+ub/3fmRonZe6cRPEZhWMbJdeHsqnEltogDaZ5Tg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rc-component/overflow": "^1.0.0",
|
"@rc-component/overflow": "^1.0.0",
|
||||||
"@rc-component/trigger": "^3.0.0",
|
"@rc-component/trigger": "^3.0.0",
|
||||||
"@rc-component/util": "^1.3.0",
|
"@rc-component/util": "^1.11.1",
|
||||||
"@rc-component/virtual-list": "^1.0.1",
|
"@rc-component/virtual-list": "^1.2.0",
|
||||||
"clsx": "^2.1.1"
|
"clsx": "^2.1.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1717,12 +1717,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rc-component/tree-select": {
|
"node_modules/@rc-component/tree-select": {
|
||||||
"version": "1.9.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rc-component/tree-select/-/tree-select-1.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rc-component/tree-select/-/tree-select-1.10.0.tgz",
|
||||||
"integrity": "sha512-GXcFe15a+trUl1/J3OHWQhsVWFpwFpGFK2cqYWZ1sK22Zs3KZTvMwDpzr75PIo1s6QVioVxpE/pRwRopkeDQ6w==",
|
"integrity": "sha512-E1U4pn2LAbXEhLJdzIzid7WYbIuFbkTIctuFoeC6weppf8UbPR3+YYB6/ay0c0ksand4gXMRQpa1Z60Auo7VJA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rc-component/select": "~1.6.0",
|
"@rc-component/select": "~1.7.0",
|
||||||
"@rc-component/tree": "~1.3.0",
|
"@rc-component/tree": "~1.3.0",
|
||||||
"@rc-component/util": "^1.4.0",
|
"@rc-component/util": "^1.4.0",
|
||||||
"clsx": "^2.1.1"
|
"clsx": "^2.1.1"
|
||||||
@@ -3284,9 +3284,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
|
||||||
"version": "7.8.3",
|
"version": "7.8.4",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
|
||||||
"integrity": "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg==",
|
"integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -3478,9 +3478,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.16.0",
|
"version": "8.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -3553,54 +3553,54 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/antd": {
|
"node_modules/antd": {
|
||||||
"version": "6.4.3",
|
"version": "6.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/antd/-/antd-6.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/antd/-/antd-6.4.4.tgz",
|
||||||
"integrity": "sha512-6H2avkxCGfxcF67r3J2mwm9Ck50el1pks/73vfM1wDsPL/tPtj5vHuauMgJFnrqmq7CH3g8aoZ0VBQbt+jpAsw==",
|
"integrity": "sha512-lgPz4KhfhiYddV/qPYo0ieqWimCVgV2OQF72mbeGNixE753JWNnmEc7UNGy08wBS/zZ7hxrmX0pc5aX7EUaIIg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/colors": "^8.0.1",
|
"@ant-design/colors": "^8.0.1",
|
||||||
"@ant-design/cssinjs": "^2.1.2",
|
"@ant-design/cssinjs": "^2.1.2",
|
||||||
"@ant-design/cssinjs-utils": "^2.1.2",
|
"@ant-design/cssinjs-utils": "^2.1.2",
|
||||||
"@ant-design/fast-color": "^3.0.1",
|
"@ant-design/fast-color": "^3.0.1",
|
||||||
"@ant-design/icons": "^6.2.3",
|
"@ant-design/icons": "^6.2.5",
|
||||||
"@ant-design/react-slick": "~2.0.0",
|
"@ant-design/react-slick": "~2.0.0",
|
||||||
"@babel/runtime": "^7.29.2",
|
"@babel/runtime": "^7.29.2",
|
||||||
"@rc-component/cascader": "~1.15.0",
|
"@rc-component/cascader": "~1.16.1",
|
||||||
"@rc-component/checkbox": "~2.0.0",
|
"@rc-component/checkbox": "~2.0.0",
|
||||||
"@rc-component/collapse": "~1.2.0",
|
"@rc-component/collapse": "~1.2.0",
|
||||||
"@rc-component/color-picker": "~3.1.1",
|
"@rc-component/color-picker": "~3.1.1",
|
||||||
"@rc-component/dialog": "~1.9.0",
|
"@rc-component/dialog": "~1.9.0",
|
||||||
"@rc-component/drawer": "~1.4.2",
|
"@rc-component/drawer": "~1.4.2",
|
||||||
"@rc-component/dropdown": "~1.0.2",
|
"@rc-component/dropdown": "~1.0.2",
|
||||||
"@rc-component/form": "~1.8.1",
|
"@rc-component/form": "~1.8.3",
|
||||||
"@rc-component/image": "~1.9.0",
|
"@rc-component/image": "~1.9.0",
|
||||||
"@rc-component/input": "~1.3.0",
|
"@rc-component/input": "~1.3.1",
|
||||||
"@rc-component/input-number": "~1.6.2",
|
"@rc-component/input-number": "~1.6.2",
|
||||||
"@rc-component/mentions": "~1.9.0",
|
"@rc-component/mentions": "~1.9.0",
|
||||||
"@rc-component/menu": "~1.3.0",
|
"@rc-component/menu": "~1.3.1",
|
||||||
"@rc-component/motion": "^1.3.2",
|
"@rc-component/motion": "^1.3.3",
|
||||||
"@rc-component/mutate-observer": "^2.0.1",
|
"@rc-component/mutate-observer": "^2.0.1",
|
||||||
"@rc-component/notification": "~2.0.7",
|
"@rc-component/notification": "~2.0.7",
|
||||||
"@rc-component/pagination": "~1.2.0",
|
"@rc-component/pagination": "~1.3.0",
|
||||||
"@rc-component/picker": "~1.10.0",
|
"@rc-component/picker": "~1.10.0",
|
||||||
"@rc-component/progress": "~1.0.2",
|
"@rc-component/progress": "~1.0.2",
|
||||||
"@rc-component/qrcode": "~1.1.1",
|
"@rc-component/qrcode": "~2.0.0",
|
||||||
"@rc-component/rate": "~1.0.1",
|
"@rc-component/rate": "~1.0.1",
|
||||||
"@rc-component/resize-observer": "^1.1.2",
|
"@rc-component/resize-observer": "^1.1.2",
|
||||||
"@rc-component/segmented": "~1.3.0",
|
"@rc-component/segmented": "~1.3.0",
|
||||||
"@rc-component/select": "~1.6.15",
|
"@rc-component/select": "~1.7.1",
|
||||||
"@rc-component/slider": "~1.0.1",
|
"@rc-component/slider": "~1.0.1",
|
||||||
"@rc-component/steps": "~1.2.2",
|
"@rc-component/steps": "~1.2.2",
|
||||||
"@rc-component/switch": "~1.0.3",
|
"@rc-component/switch": "~1.0.3",
|
||||||
"@rc-component/table": "~1.10.0",
|
"@rc-component/table": "~1.10.2",
|
||||||
"@rc-component/tabs": "~1.9.0",
|
"@rc-component/tabs": "~1.9.1",
|
||||||
"@rc-component/tooltip": "~1.4.0",
|
"@rc-component/tooltip": "~1.4.0",
|
||||||
"@rc-component/tour": "~2.4.0",
|
"@rc-component/tour": "~2.4.0",
|
||||||
"@rc-component/tree": "~1.3.1",
|
"@rc-component/tree": "~1.3.2",
|
||||||
"@rc-component/tree-select": "~1.9.0",
|
"@rc-component/tree-select": "~1.10.0",
|
||||||
"@rc-component/trigger": "^3.9.0",
|
"@rc-component/trigger": "^3.9.1",
|
||||||
"@rc-component/upload": "~1.1.0",
|
"@rc-component/upload": "~1.1.1",
|
||||||
"@rc-component/util": "^1.11.0",
|
"@rc-component/util": "^1.11.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
"scroll-into-view-if-needed": "^3.1.0",
|
"scroll-into-view-if-needed": "^3.1.0",
|
||||||
@@ -3719,9 +3719,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.34",
|
"version": "2.10.37",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz",
|
||||||
"integrity": "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==",
|
"integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -3859,9 +3859,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001797",
|
"version": "1.0.30001799",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
|
||||||
"integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==",
|
"integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -4327,9 +4327,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/dompurify": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.4.8",
|
"version": "3.4.10",
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.10.tgz",
|
||||||
"integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==",
|
"integrity": "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==",
|
||||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@types/trusted-types": "^2.0.7"
|
"@types/trusted-types": "^2.0.7"
|
||||||
@@ -4359,9 +4359,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.368",
|
"version": "1.5.372",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz",
|
||||||
"integrity": "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==",
|
"integrity": "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
@@ -4431,9 +4431,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/es-toolkit": {
|
"node_modules/es-toolkit": {
|
||||||
"version": "1.47.0",
|
"version": "1.47.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.1.tgz",
|
||||||
"integrity": "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==",
|
"integrity": "sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"docs",
|
"docs",
|
||||||
@@ -4464,11 +4464,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint": {
|
"node_modules/eslint": {
|
||||||
"version": "10.4.1",
|
"version": "10.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz",
|
||||||
"integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==",
|
"integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"packages/*"
|
||||||
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.2",
|
"@eslint-community/regexpp": "^4.12.2",
|
||||||
@@ -4806,16 +4809,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/form-data": {
|
"node_modules/form-data": {
|
||||||
"version": "4.0.5",
|
"version": "4.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"asynckit": "^0.4.0",
|
"asynckit": "^0.4.0",
|
||||||
"combined-stream": "^1.0.8",
|
"combined-stream": "^1.0.8",
|
||||||
"es-set-tostringtag": "^2.1.0",
|
"es-set-tostringtag": "^2.1.0",
|
||||||
"hasown": "^2.0.2",
|
"hasown": "^2.0.4",
|
||||||
"mime-types": "^2.1.12"
|
"mime-types": "^2.1.35"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
@@ -5994,9 +5997,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/obug": {
|
"node_modules/obug": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
|
||||||
"integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==",
|
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
"https://github.com/sponsors/sxzz",
|
"https://github.com/sponsors/sxzz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "3x-ui-frontend",
|
"name": "3x-ui-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.3.0",
|
"version": "0.3.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
|
"description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -73,4 +73,4 @@
|
|||||||
"core-js-pure": false,
|
"core-js-pure": false,
|
||||||
"tree-sitter-json": false
|
"tree-sitter-json": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1332,8 +1332,25 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"settings": {},
|
"settings": {},
|
||||||
|
"shareAddr": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"shareAddrStrategy": {
|
||||||
|
"enum": [
|
||||||
|
"node",
|
||||||
|
"listen",
|
||||||
|
"custom"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"sniffing": {},
|
"sniffing": {},
|
||||||
"streamSettings": {},
|
"streamSettings": {},
|
||||||
|
"subSortIndex": {
|
||||||
|
"description": "1-based sort order of this inbound's links in subscription output only (lower first; ties by id)",
|
||||||
|
"example": 1,
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"tag": {
|
"tag": {
|
||||||
"example": "in-443-tcp",
|
"example": "in-443-tcp",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -1370,8 +1387,11 @@
|
|||||||
"protocol",
|
"protocol",
|
||||||
"remark",
|
"remark",
|
||||||
"settings",
|
"settings",
|
||||||
|
"shareAddr",
|
||||||
|
"shareAddrStrategy",
|
||||||
"sniffing",
|
"sniffing",
|
||||||
"streamSettings",
|
"streamSettings",
|
||||||
|
"subSortIndex",
|
||||||
"tag",
|
"tag",
|
||||||
"total",
|
"total",
|
||||||
"trafficReset",
|
"trafficReset",
|
||||||
@@ -1446,6 +1466,11 @@
|
|||||||
"example": 1,
|
"example": 1,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"nodeId": {
|
||||||
|
"description": "Hosting node; nil for this panel's own inbounds. Lets the clients\npage map a node filter onto inbound IDs (#4997).",
|
||||||
|
"nullable": true,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"port": {
|
"port": {
|
||||||
"example": 443,
|
"example": 443,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
@@ -1559,6 +1584,19 @@
|
|||||||
"example": 5,
|
"example": 5,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"inboundSyncMode": {
|
||||||
|
"enum": [
|
||||||
|
"all",
|
||||||
|
"selected"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"inboundTags": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"lastError": {
|
"lastError": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -1662,6 +1700,8 @@
|
|||||||
"guid",
|
"guid",
|
||||||
"id",
|
"id",
|
||||||
"inboundCount",
|
"inboundCount",
|
||||||
|
"inboundSyncMode",
|
||||||
|
"inboundTags",
|
||||||
"lastError",
|
"lastError",
|
||||||
"lastHeartbeat",
|
"lastHeartbeat",
|
||||||
"latencyMs",
|
"latencyMs",
|
||||||
@@ -2116,8 +2156,11 @@
|
|||||||
"protocol": "vless",
|
"protocol": "vless",
|
||||||
"remark": "VLESS-443",
|
"remark": "VLESS-443",
|
||||||
"settings": null,
|
"settings": null,
|
||||||
|
"shareAddr": "",
|
||||||
|
"shareAddrStrategy": "node",
|
||||||
"sniffing": null,
|
"sniffing": null,
|
||||||
"streamSettings": null,
|
"streamSettings": null,
|
||||||
|
"subSortIndex": 1,
|
||||||
"tag": "in-443-tcp",
|
"tag": "in-443-tcp",
|
||||||
"total": 0,
|
"total": 0,
|
||||||
"trafficReset": "never",
|
"trafficReset": "never",
|
||||||
@@ -2214,6 +2257,7 @@
|
|||||||
"obj": [
|
"obj": [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
|
"nodeId": null,
|
||||||
"port": 443,
|
"port": 443,
|
||||||
"protocol": "vless",
|
"protocol": "vless",
|
||||||
"remark": "VLESS-443",
|
"remark": "VLESS-443",
|
||||||
@@ -5996,6 +6040,10 @@
|
|||||||
"guid": "",
|
"guid": "",
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"inboundCount": 5,
|
"inboundCount": 5,
|
||||||
|
"inboundSyncMode": "all",
|
||||||
|
"inboundTags": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"lastError": "",
|
"lastError": "",
|
||||||
"lastHeartbeat": 1700000000,
|
"lastHeartbeat": 1700000000,
|
||||||
"latencyMs": 42,
|
"latencyMs": 42,
|
||||||
@@ -6436,6 +6484,65 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/panel/api/nodes/inbounds": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Nodes"
|
||||||
|
],
|
||||||
|
"summary": "Use unsaved node connection details to list the remote inbounds available for selective import.",
|
||||||
|
"operationId": "post_panel_api_nodes_inbounds",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"name": "de-fra-1",
|
||||||
|
"scheme": "https",
|
||||||
|
"address": "node1.example.com",
|
||||||
|
"port": 2053,
|
||||||
|
"basePath": "/",
|
||||||
|
"apiToken": "abcdef..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"success": true,
|
||||||
|
"obj": [
|
||||||
|
{
|
||||||
|
"tag": "inbound-443",
|
||||||
|
"remark": "VLESS",
|
||||||
|
"protocol": "vless",
|
||||||
|
"port": 443
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/panel/api/nodes/probe/{id}": {
|
"/panel/api/nodes/probe/{id}": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -7417,6 +7524,46 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/panel/api/xray/testOutbounds": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Xray Settings"
|
||||||
|
],
|
||||||
|
"summary": "Test a batch of outbounds (max 50) through one shared temp xray instance. Returns an array of results in input order, each with the outbound tag, delay, HTTP status and a connect/TLS/TTFB timing breakdown.",
|
||||||
|
"operationId": "post_panel_api_xray_testOutbounds",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/panel/api/xray/balancerStatus": {
|
"/panel/api/xray/balancerStatus": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ export interface NodeUpdateResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RemoteInboundOption {
|
||||||
|
tag: string;
|
||||||
|
remark?: string;
|
||||||
|
protocol?: string;
|
||||||
|
port?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function useNodeMutations() {
|
export function useNodeMutations() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.nodes.root() });
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.nodes.root() });
|
||||||
@@ -72,5 +79,7 @@ export function useNodeMutations() {
|
|||||||
},
|
},
|
||||||
fetchFingerprint: (payload: Partial<NodeRecord>): Promise<Msg<string>> =>
|
fetchFingerprint: (payload: Partial<NodeRecord>): Promise<Msg<string>> =>
|
||||||
HttpUtil.post<string>('/panel/api/nodes/certFingerprint', payload),
|
HttpUtil.post<string>('/panel/api/nodes/certFingerprint', payload),
|
||||||
|
fetchInbounds: (payload: Partial<NodeRecord>): Promise<Msg<RemoteInboundOption[]>> =>
|
||||||
|
HttpUtil.post<RemoteInboundOption[]>('/panel/api/nodes/inbounds', payload),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { keys } from '@/api/queryKeys';
|
||||||
|
import { fetchXrayConfig } from '@/hooks/useXraySetting';
|
||||||
|
|
||||||
|
// Available outbound (and balancer-eligible) tags the user can route an mtproto
|
||||||
|
// inbound's Telegram traffic to. Shares the cached xray config query so opening
|
||||||
|
// the inbound form costs no extra request when the Xray page was already
|
||||||
|
// visited; `select` derives just the tag list without disturbing other readers.
|
||||||
|
export function useOutboundTags() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: keys.xray.config(),
|
||||||
|
queryFn: fetchXrayConfig,
|
||||||
|
staleTime: Infinity,
|
||||||
|
select: (data): string[] => {
|
||||||
|
const tags = new Set<string>();
|
||||||
|
for (const o of data?.xraySetting?.outbounds ?? []) {
|
||||||
|
const tag = (o as { tag?: string } | null)?.tag;
|
||||||
|
if (tag) tags.add(tag);
|
||||||
|
}
|
||||||
|
for (const t of data?.subscriptionOutboundTags ?? []) {
|
||||||
|
if (t) tags.add(t);
|
||||||
|
}
|
||||||
|
// Balancers are valid routing targets too — injectMtprotoEgress emits a
|
||||||
|
// balancerTag rule when the chosen tag names a balancer.
|
||||||
|
const balancers = (data?.xraySetting?.routing as { balancers?: Array<{ tag?: string }> } | undefined)?.balancers;
|
||||||
|
for (const b of balancers ?? []) {
|
||||||
|
if (b?.tag) tags.add(b.tag);
|
||||||
|
}
|
||||||
|
return [...tags];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import { Input, Modal } from 'antd';
|
|||||||
import type { InputRef } from 'antd';
|
import type { InputRef } from 'antd';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import JsonEditor from '@/components/form/JsonEditor';
|
||||||
|
|
||||||
interface PromptModalProps {
|
interface PromptModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -11,6 +13,7 @@ interface PromptModalProps {
|
|||||||
type?: 'input' | 'textarea';
|
type?: 'input' | 'textarea';
|
||||||
initialValue?: string;
|
initialValue?: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
json?: boolean;
|
||||||
onConfirm: (value: string) => void;
|
onConfirm: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,6 +25,7 @@ export default function PromptModal({
|
|||||||
type = 'input',
|
type = 'input',
|
||||||
initialValue = '',
|
initialValue = '',
|
||||||
loading = false,
|
loading = false,
|
||||||
|
json = false,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: PromptModalProps) {
|
}: PromptModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -63,7 +67,9 @@ export default function PromptModal({
|
|||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
{type === 'textarea' ? (
|
{json ? (
|
||||||
|
<JsonEditor value={value} onChange={setValue} minHeight="240px" maxHeight="60vh" />
|
||||||
|
) : type === 'textarea' ? (
|
||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
ref={(el) => { textareaRef.current = (el as unknown as { resizableTextArea?: { textArea: HTMLTextAreaElement } })?.resizableTextArea?.textArea ?? null; }}
|
ref={(el) => { textareaRef.current = (el as unknown as { resizableTextArea?: { textArea: HTMLTextAreaElement } })?.resizableTextArea?.textArea ?? null; }}
|
||||||
value={value}
|
value={value}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Button, Input, Modal, message } from 'antd';
|
|||||||
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons';
|
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import JsonEditor from '@/components/form/JsonEditor';
|
||||||
import { ClipboardManager, FileManager } from '@/utils';
|
import { ClipboardManager, FileManager } from '@/utils';
|
||||||
|
|
||||||
interface TextModalProps {
|
interface TextModalProps {
|
||||||
@@ -10,9 +11,10 @@ interface TextModalProps {
|
|||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
|
json?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TextModal({ open, onClose, title, content, fileName = '' }: TextModalProps) {
|
export default function TextModal({ open, onClose, title, content, fileName = '', json = false }: TextModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [messageApi, messageContextHolder] = message.useMessage();
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
async function copy() {
|
async function copy() {
|
||||||
@@ -45,16 +47,20 @@ export default function TextModal({ open, onClose, title, content, fileName = ''
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Input.TextArea
|
{json ? (
|
||||||
value={content}
|
<JsonEditor value={content} readOnly minHeight="240px" maxHeight="60vh" />
|
||||||
readOnly
|
) : (
|
||||||
autoSize={{ minRows: 10, maxRows: 20 }}
|
<Input.TextArea
|
||||||
style={{
|
value={content}
|
||||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
readOnly
|
||||||
fontSize: 12,
|
autoSize={{ minRows: 10, maxRows: 20 }}
|
||||||
overflowY: 'auto',
|
style={{
|
||||||
}}
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||||
/>
|
fontSize: 12,
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -288,8 +288,11 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"protocol": "vless",
|
"protocol": "vless",
|
||||||
"remark": "VLESS-443",
|
"remark": "VLESS-443",
|
||||||
"settings": null,
|
"settings": null,
|
||||||
|
"shareAddr": "",
|
||||||
|
"shareAddrStrategy": "node",
|
||||||
"sniffing": null,
|
"sniffing": null,
|
||||||
"streamSettings": null,
|
"streamSettings": null,
|
||||||
|
"subSortIndex": 1,
|
||||||
"tag": "in-443-tcp",
|
"tag": "in-443-tcp",
|
||||||
"total": 0,
|
"total": 0,
|
||||||
"trafficReset": "never",
|
"trafficReset": "never",
|
||||||
@@ -313,6 +316,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
},
|
},
|
||||||
"InboundOption": {
|
"InboundOption": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
|
"nodeId": null,
|
||||||
"port": 443,
|
"port": 443,
|
||||||
"protocol": "vless",
|
"protocol": "vless",
|
||||||
"remark": "VLESS-443",
|
"remark": "VLESS-443",
|
||||||
@@ -340,6 +344,10 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"guid": "",
|
"guid": "",
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"inboundCount": 5,
|
"inboundCount": 5,
|
||||||
|
"inboundSyncMode": "all",
|
||||||
|
"inboundTags": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"lastError": "",
|
"lastError": "",
|
||||||
"lastHeartbeat": 1700000000,
|
"lastHeartbeat": 1700000000,
|
||||||
"latencyMs": 42,
|
"latencyMs": 42,
|
||||||
|
|||||||
@@ -1306,8 +1306,25 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"settings": {},
|
"settings": {},
|
||||||
|
"shareAddr": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"shareAddrStrategy": {
|
||||||
|
"enum": [
|
||||||
|
"node",
|
||||||
|
"listen",
|
||||||
|
"custom"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"sniffing": {},
|
"sniffing": {},
|
||||||
"streamSettings": {},
|
"streamSettings": {},
|
||||||
|
"subSortIndex": {
|
||||||
|
"description": "1-based sort order of this inbound's links in subscription output only (lower first; ties by id)",
|
||||||
|
"example": 1,
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"tag": {
|
"tag": {
|
||||||
"example": "in-443-tcp",
|
"example": "in-443-tcp",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -1344,8 +1361,11 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"protocol",
|
"protocol",
|
||||||
"remark",
|
"remark",
|
||||||
"settings",
|
"settings",
|
||||||
|
"shareAddr",
|
||||||
|
"shareAddrStrategy",
|
||||||
"sniffing",
|
"sniffing",
|
||||||
"streamSettings",
|
"streamSettings",
|
||||||
|
"subSortIndex",
|
||||||
"tag",
|
"tag",
|
||||||
"total",
|
"total",
|
||||||
"trafficReset",
|
"trafficReset",
|
||||||
@@ -1420,6 +1440,11 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"example": 1,
|
"example": 1,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"nodeId": {
|
||||||
|
"description": "Hosting node; nil for this panel's own inbounds. Lets the clients\npage map a node filter onto inbound IDs (#4997).",
|
||||||
|
"nullable": true,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"port": {
|
"port": {
|
||||||
"example": 443,
|
"example": 443,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
@@ -1533,6 +1558,19 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"example": 5,
|
"example": 5,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"inboundSyncMode": {
|
||||||
|
"enum": [
|
||||||
|
"all",
|
||||||
|
"selected"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"inboundTags": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"lastError": {
|
"lastError": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -1636,6 +1674,8 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"guid",
|
"guid",
|
||||||
"id",
|
"id",
|
||||||
"inboundCount",
|
"inboundCount",
|
||||||
|
"inboundSyncMode",
|
||||||
|
"inboundTags",
|
||||||
"lastError",
|
"lastError",
|
||||||
"lastHeartbeat",
|
"lastHeartbeat",
|
||||||
"latencyMs",
|
"latencyMs",
|
||||||
|
|||||||
@@ -289,8 +289,11 @@ export interface Inbound {
|
|||||||
protocol: Protocol;
|
protocol: Protocol;
|
||||||
remark: string;
|
remark: string;
|
||||||
settings: unknown;
|
settings: unknown;
|
||||||
|
shareAddr: string;
|
||||||
|
shareAddrStrategy: string;
|
||||||
sniffing: unknown;
|
sniffing: unknown;
|
||||||
streamSettings: unknown;
|
streamSettings: unknown;
|
||||||
|
subSortIndex: number;
|
||||||
tag: string;
|
tag: string;
|
||||||
total: number;
|
total: number;
|
||||||
trafficReset: string;
|
trafficReset: string;
|
||||||
@@ -317,6 +320,7 @@ export interface InboundFallback {
|
|||||||
|
|
||||||
export interface InboundOption {
|
export interface InboundOption {
|
||||||
id: number;
|
id: number;
|
||||||
|
nodeId?: number | null;
|
||||||
port: number;
|
port: number;
|
||||||
protocol: string;
|
protocol: string;
|
||||||
remark: string;
|
remark: string;
|
||||||
@@ -346,6 +350,8 @@ export interface Node {
|
|||||||
guid: string;
|
guid: string;
|
||||||
id: number;
|
id: number;
|
||||||
inboundCount: number;
|
inboundCount: number;
|
||||||
|
inboundSyncMode: string;
|
||||||
|
inboundTags: string[];
|
||||||
lastError: string;
|
lastError: string;
|
||||||
lastHeartbeat: number;
|
lastHeartbeat: number;
|
||||||
latencyMs: number;
|
latencyMs: number;
|
||||||
|
|||||||
@@ -310,8 +310,11 @@ export const InboundSchema = z.object({
|
|||||||
protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun', 'mtproto']),
|
protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun', 'mtproto']),
|
||||||
remark: z.string(),
|
remark: z.string(),
|
||||||
settings: z.unknown(),
|
settings: z.unknown(),
|
||||||
|
shareAddr: z.string(),
|
||||||
|
shareAddrStrategy: z.enum(['node', 'listen', 'custom']),
|
||||||
sniffing: z.unknown(),
|
sniffing: z.unknown(),
|
||||||
streamSettings: z.unknown(),
|
streamSettings: z.unknown(),
|
||||||
|
subSortIndex: z.number().int().min(1),
|
||||||
tag: z.string(),
|
tag: z.string(),
|
||||||
total: z.number().int(),
|
total: z.number().int(),
|
||||||
trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']),
|
trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']),
|
||||||
@@ -341,6 +344,7 @@ export type InboundFallback = z.infer<typeof InboundFallbackSchema>;
|
|||||||
|
|
||||||
export const InboundOptionSchema = z.object({
|
export const InboundOptionSchema = z.object({
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
|
nodeId: z.number().int().nullable().optional(),
|
||||||
port: z.number().int(),
|
port: z.number().int(),
|
||||||
protocol: z.string(),
|
protocol: z.string(),
|
||||||
remark: z.string(),
|
remark: z.string(),
|
||||||
@@ -372,6 +376,8 @@ export const NodeSchema = z.object({
|
|||||||
guid: z.string(),
|
guid: z.string(),
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
inboundCount: z.number().int(),
|
inboundCount: z.number().int(),
|
||||||
|
inboundSyncMode: z.enum(['all', 'selected']),
|
||||||
|
inboundTags: z.array(z.string()),
|
||||||
lastError: z.string(),
|
lastError: z.string(),
|
||||||
lastHeartbeat: z.number().int(),
|
lastHeartbeat: z.number().int(),
|
||||||
latencyMs: z.number().int(),
|
latencyMs: z.number().int(),
|
||||||
|
|||||||
@@ -255,12 +255,6 @@ export function useClients() {
|
|||||||
return { ...live, total: serverSummary.total || live.total };
|
return { ...live, total: serverSummary.total || live.total };
|
||||||
}, [allClientStats, onlines, expireDiff, trafficDiff, listQuery.data?.summary]);
|
}, [allClientStats, onlines, expireDiff, trafficDiff, listQuery.data?.summary]);
|
||||||
|
|
||||||
// Client mutations (add/update/remove/attach/detach/resetTraffic/…) all
|
|
||||||
// mutate inbound rows server-side too — adding a client appends to
|
|
||||||
// settings.clients on each attached inbound, the slim list's per-inbound
|
|
||||||
// client count is derived from that. Invalidate both buckets so the
|
|
||||||
// Inbounds page and any open edit modal pick up the new shape without
|
|
||||||
// a manual reload.
|
|
||||||
const invalidateAll = useCallback(
|
const invalidateAll = useCallback(
|
||||||
() => {
|
() => {
|
||||||
markLocalInvalidate();
|
markLocalInvalidate();
|
||||||
@@ -268,6 +262,7 @@ export function useClients() {
|
|||||||
return Promise.all([
|
return Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: keys.clients.root() }),
|
queryClient.invalidateQueries({ queryKey: keys.clients.root() }),
|
||||||
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
|
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: keys.xray.config() }),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
[queryClient],
|
[queryClient],
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { parseMsg } from '@/utils/zodValidate';
|
|||||||
import { keys } from '@/api/queryKeys';
|
import { keys } from '@/api/queryKeys';
|
||||||
import {
|
import {
|
||||||
OutboundTrafficListSchema,
|
OutboundTrafficListSchema,
|
||||||
OutboundTestResultSchema,
|
OutboundTestResultListSchema,
|
||||||
XrayConfigPayloadSchema,
|
XrayConfigPayloadSchema,
|
||||||
XraySettingsValueSchema,
|
XraySettingsValueSchema,
|
||||||
type OutboundTestResult,
|
type OutboundTestResult,
|
||||||
@@ -16,6 +16,10 @@ import {
|
|||||||
|
|
||||||
const DIRTY_POLL_MS = 1000;
|
const DIRTY_POLL_MS = 1000;
|
||||||
const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
|
const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
|
||||||
|
// One HTTP-mode batch request tests this many outbounds through a single
|
||||||
|
// shared temp xray instance; chunking keeps responses bounded (~15s worst
|
||||||
|
// case) and lands Test All results progressively.
|
||||||
|
const HTTP_BATCH_CHUNK = 16;
|
||||||
|
|
||||||
export function isUdpOutbound(outbound: unknown): boolean {
|
export function isUdpOutbound(outbound: unknown): boolean {
|
||||||
const o = outbound as { protocol?: string; streamSettings?: { network?: string } } | null | undefined;
|
const o = outbound as { protocol?: string; streamSettings?: { network?: string } } | null | undefined;
|
||||||
@@ -77,7 +81,7 @@ export interface UseXraySettingResult {
|
|||||||
|
|
||||||
type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
|
type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
|
||||||
|
|
||||||
async function fetchXrayConfig(): Promise<XrayConfigPayload> {
|
export async function fetchXrayConfig(): Promise<XrayConfigPayload> {
|
||||||
const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true });
|
const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true });
|
||||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to load xray config');
|
if (!msg?.success) throw new Error(msg?.msg || 'Failed to load xray config');
|
||||||
if (typeof msg.obj !== 'string') throw new Error('Malformed xray config response: expected string');
|
if (typeof msg.obj !== 'string') throw new Error('Malformed xray config response: expected string');
|
||||||
@@ -254,21 +258,26 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
|
|
||||||
const spinning = saveMut.isPending || resetDefaultMut.isPending;
|
const spinning = saveMut.isPending || resetDefaultMut.isPending;
|
||||||
|
|
||||||
// Shared POST + parse for a single outbound test. Returns an OutboundTestResult
|
// Shared POST + parse for a batch of outbound tests. The backend probes the
|
||||||
// (success or a failure-shaped result); callers store it under their own key.
|
// whole batch through one shared temp xray instance and returns results in
|
||||||
const postOutboundTest = useCallback(
|
// request order; this aligns them by index and shapes failures so every
|
||||||
async (outbound: unknown, effMode: string): Promise<OutboundTestResult> => {
|
// input gets an OutboundTestResult.
|
||||||
|
const postOutboundTestBatch = useCallback(
|
||||||
|
async (outbounds: unknown[], effMode: string): Promise<OutboundTestResult[]> => {
|
||||||
|
const failAll = (error: string): OutboundTestResult[] =>
|
||||||
|
outbounds.map(() => ({ success: false, error, mode: effMode }));
|
||||||
try {
|
try {
|
||||||
const raw = await HttpUtil.post('/panel/api/xray/testOutbound', {
|
const raw = await HttpUtil.post('/panel/api/xray/testOutbounds', {
|
||||||
outbound: JSON.stringify(outbound),
|
outbounds: JSON.stringify(outbounds),
|
||||||
allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
|
allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
|
||||||
mode: effMode,
|
mode: effMode,
|
||||||
});
|
});
|
||||||
const msg = parseMsg(raw, OutboundTestResultSchema, 'xray/testOutbound');
|
const msg = parseMsg(raw, OutboundTestResultListSchema, 'xray/testOutbounds');
|
||||||
if (msg?.success && msg.obj) return msg.obj;
|
if (!msg?.success || !Array.isArray(msg.obj)) return failAll(msg?.msg || 'Unknown error');
|
||||||
return { success: false, error: msg?.msg || 'Unknown error', mode: effMode };
|
const list = msg.obj;
|
||||||
|
return outbounds.map((_ob, i) => list[i] ?? { success: false, error: 'Missing result', mode: effMode });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { success: false, error: String(e), mode: effMode };
|
return failAll(String(e));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
@@ -282,11 +291,11 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
...prev,
|
...prev,
|
||||||
[index]: { testing: true, result: null, mode: effMode },
|
[index]: { testing: true, result: null, mode: effMode },
|
||||||
}));
|
}));
|
||||||
const result = await postOutboundTest(outbound, effMode);
|
const [result] = await postOutboundTestBatch([outbound], effMode);
|
||||||
setOutboundTestStates((prev) => ({ ...prev, [index]: { testing: false, result } }));
|
setOutboundTestStates((prev) => ({ ...prev, [index]: { testing: false, result } }));
|
||||||
return result.success ? result : null;
|
return result.success ? result : null;
|
||||||
},
|
},
|
||||||
[postOutboundTest],
|
[postOutboundTestBatch],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Test a subscription outbound (not present in templateSettings.outbounds);
|
// Test a subscription outbound (not present in templateSettings.outbounds);
|
||||||
@@ -299,11 +308,11 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
...prev,
|
...prev,
|
||||||
[tag]: { testing: true, result: null, mode: effMode },
|
[tag]: { testing: true, result: null, mode: effMode },
|
||||||
}));
|
}));
|
||||||
const result = await postOutboundTest(outbound, effMode);
|
const [result] = await postOutboundTestBatch([outbound], effMode);
|
||||||
setSubscriptionTestStates((prev) => ({ ...prev, [tag]: { testing: false, result } }));
|
setSubscriptionTestStates((prev) => ({ ...prev, [tag]: { testing: false, result } }));
|
||||||
return result.success ? result : null;
|
return result.success ? result : null;
|
||||||
},
|
},
|
||||||
[postOutboundTest],
|
[postOutboundTestBatch],
|
||||||
);
|
);
|
||||||
|
|
||||||
const testAllOutbounds = useCallback(async (mode = 'tcp') => {
|
const testAllOutbounds = useCallback(async (mode = 'tcp') => {
|
||||||
@@ -324,7 +333,10 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
tcpQueue.push({ index: i, outbound: ob });
|
tcpQueue.push({ index: i, outbound: ob });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const runLane = async (queue: { index: number; outbound: unknown }[], concurrency: number) => {
|
// TCP probes are dial-only and cheap server-side; per-item requests
|
||||||
|
// keep results landing one by one.
|
||||||
|
const runTcpLane = async () => {
|
||||||
|
const queue = [...tcpQueue];
|
||||||
const worker = async () => {
|
const worker = async () => {
|
||||||
while (queue.length > 0) {
|
while (queue.length > 0) {
|
||||||
const item = queue.shift();
|
const item = queue.shift();
|
||||||
@@ -332,14 +344,33 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
await testOutbound(item.index, item.outbound, mode);
|
await testOutbound(item.index, item.outbound, mode);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, () => worker());
|
await Promise.all(Array.from({ length: Math.min(8, queue.length) }, () => worker()));
|
||||||
await Promise.all(workers);
|
|
||||||
};
|
};
|
||||||
await Promise.all([runLane(tcpQueue, 8), runLane(httpQueue, 1)]);
|
// HTTP probes go out as chunked batches — one temp xray spawn per
|
||||||
|
// chunk instead of one per outbound, with results landing per chunk.
|
||||||
|
const runHttpLane = async () => {
|
||||||
|
for (let at = 0; at < httpQueue.length; at += HTTP_BATCH_CHUNK) {
|
||||||
|
const chunk = httpQueue.slice(at, at + HTTP_BATCH_CHUNK);
|
||||||
|
setOutboundTestStates((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const item of chunk) next[item.index] = { testing: true, result: null, mode: 'http' };
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
const results = await postOutboundTestBatch(chunk.map((c) => c.outbound), 'http');
|
||||||
|
setOutboundTestStates((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
chunk.forEach((item, i) => {
|
||||||
|
next[item.index] = { testing: false, result: results[i] };
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await Promise.all([runTcpLane(), runHttpLane()]);
|
||||||
} finally {
|
} finally {
|
||||||
setTestingAll(false);
|
setTestingAll(false);
|
||||||
}
|
}
|
||||||
}, [testingAll, testOutbound]);
|
}, [testingAll, testOutbound, postOutboundTestBatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Display label for an inbound: `tag (remark)` when a distinct remark exists,
|
* Display label for an inbound: the remark when one is set, otherwise the
|
||||||
* otherwise just the tag. Falls back to the remark when no tag is set, and to an
|
* inbound tag. Falls back to an empty string when neither is present.
|
||||||
* empty string when neither is present.
|
|
||||||
*/
|
*/
|
||||||
export function formatInboundLabel(tag?: string, remark?: string): string {
|
export function formatInboundLabel(tag?: string, remark?: string): string {
|
||||||
const tagText = (tag || '').trim();
|
|
||||||
const remarkText = (remark || '').trim();
|
const remarkText = (remark || '').trim();
|
||||||
if (!tagText) return remarkText;
|
if (remarkText) return remarkText;
|
||||||
if (!remarkText || remarkText === tagText) return tagText;
|
return (tag || '').trim();
|
||||||
return `${tagText} (${remarkText})`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
import { AutoComplete, Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
||||||
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
import type { FormInstance } from 'antd/es/form';
|
import type { FormInstance } from 'antd/es/form';
|
||||||
import type { NamePath } from 'antd/es/form/interface';
|
import type { NamePath } from 'antd/es/form/interface';
|
||||||
@@ -205,13 +205,18 @@ function TcpMaskItem({
|
|||||||
if (type === 'fragment') {
|
if (type === 'fragment') {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Form.Item label="Packets" name={[fieldName, 'settings', 'packets']}>
|
<Form.Item
|
||||||
<Select
|
label="Packets"
|
||||||
|
name={[fieldName, 'settings', 'packets']}
|
||||||
|
rules={[{ validator: validateFragmentPackets }]}
|
||||||
|
>
|
||||||
|
<AutoComplete
|
||||||
options={[
|
options={[
|
||||||
{ value: 'tlshello', label: 'tlshello' },
|
{ value: 'tlshello', label: 'tlshello' },
|
||||||
{ value: '1-3', label: '1-3' },
|
{ value: '1-3', label: '1-3' },
|
||||||
{ value: '1-5', label: '1-5' },
|
{ value: '1-5', label: '1-5' },
|
||||||
]}
|
]}
|
||||||
|
placeholder="tlshello or n-m, e.g. 1-3"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
@@ -264,6 +269,16 @@ function TcpMaskItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// xray's fragment `packets` accepts "tlshello" or an arbitrary packet-number
|
||||||
|
// range like "1-3" (#5075 — presets only covered the common cases).
|
||||||
|
function validateFragmentPackets(_rule: unknown, value: unknown): Promise<void> {
|
||||||
|
const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
|
||||||
|
if (str.length === 0 || str === 'tlshello' || /^\d+-\d+$/.test(str)) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error('Use "tlshello" or a packet range like 1-3'));
|
||||||
|
}
|
||||||
|
|
||||||
// Walks a deep object path safely. Used inside shouldUpdate which gets
|
// Walks a deep object path safely. Used inside shouldUpdate which gets
|
||||||
// the whole form values blob; we need to compare a deep field across
|
// the whole form values blob; we need to compare a deep field across
|
||||||
// prev/curr without crashing on missing intermediates.
|
// prev/curr without crashing on missing intermediates.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { InboundFormValues, TrafficReset } from '@/schemas/forms/inbound-form';
|
import type { InboundFormValues, ShareAddrStrategy, TrafficReset } from '@/schemas/forms/inbound-form';
|
||||||
import type { InboundSettings } from '@/schemas/protocols/inbound';
|
import type { InboundSettings } from '@/schemas/protocols/inbound';
|
||||||
import {
|
import {
|
||||||
HysteriaClientSchema,
|
HysteriaClientSchema,
|
||||||
@@ -12,6 +12,9 @@ import type { Sniffing } from '@/schemas/primitives';
|
|||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
|
import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
|
||||||
import { canEnableSniffing } from '@/lib/xray/protocol-capabilities';
|
import { canEnableSniffing } from '@/lib/xray/protocol-capabilities';
|
||||||
|
import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
|
||||||
|
|
||||||
|
const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
|
||||||
|
|
||||||
// Plain-data adapter between the panel's stored inbound row shape and
|
// Plain-data adapter between the panel's stored inbound row shape and
|
||||||
// the typed InboundFormValues that Form.useForm<T> carries inside
|
// the typed InboundFormValues that Form.useForm<T> carries inside
|
||||||
@@ -37,6 +40,9 @@ export interface RawInboundRow {
|
|||||||
trafficReset?: string;
|
trafficReset?: string;
|
||||||
lastTrafficResetTime?: number;
|
lastTrafficResetTime?: number;
|
||||||
nodeId?: number | null;
|
nodeId?: number | null;
|
||||||
|
shareAddrStrategy?: string;
|
||||||
|
shareAddr?: string;
|
||||||
|
subSortIndex?: number;
|
||||||
clientStats?: unknown;
|
clientStats?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +67,9 @@ export interface WireInboundPayload {
|
|||||||
tag: string;
|
tag: string;
|
||||||
clientStats?: unknown;
|
clientStats?: unknown;
|
||||||
nodeId?: number;
|
nodeId?: number;
|
||||||
|
shareAddrStrategy: ShareAddrStrategy;
|
||||||
|
shareAddr: string;
|
||||||
|
subSortIndex: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function coerceJsonObject(value: unknown): Record<string, unknown> {
|
function coerceJsonObject(value: unknown): Record<string, unknown> {
|
||||||
@@ -82,6 +91,7 @@ function coerceJsonObject(value: unknown): Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TRAFFIC_RESETS: TrafficReset[] = ['never', 'hourly', 'daily', 'weekly', 'monthly'];
|
const TRAFFIC_RESETS: TrafficReset[] = ['never', 'hourly', 'daily', 'weekly', 'monthly'];
|
||||||
|
const SHARE_ADDR_STRATEGIES: ShareAddrStrategy[] = ['node', 'listen', 'custom'];
|
||||||
|
|
||||||
function coerceTrafficReset(v: unknown): TrafficReset {
|
function coerceTrafficReset(v: unknown): TrafficReset {
|
||||||
return typeof v === 'string' && (TRAFFIC_RESETS as string[]).includes(v)
|
return typeof v === 'string' && (TRAFFIC_RESETS as string[]).includes(v)
|
||||||
@@ -89,6 +99,12 @@ function coerceTrafficReset(v: unknown): TrafficReset {
|
|||||||
: 'never';
|
: 'never';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function coerceShareAddrStrategy(v: unknown): ShareAddrStrategy {
|
||||||
|
return typeof v === 'string' && (SHARE_ADDR_STRATEGIES as string[]).includes(v)
|
||||||
|
? (v as ShareAddrStrategy)
|
||||||
|
: 'node';
|
||||||
|
}
|
||||||
|
|
||||||
// Network values that map to a required `${network}Settings` key in
|
// Network values that map to a required `${network}Settings` key in
|
||||||
// NetworkSettingsSchema. Older saved inbounds may be missing the per-
|
// NetworkSettingsSchema. Older saved inbounds may be missing the per-
|
||||||
// network sub-object (the legacy panel sometimes emitted streamSettings
|
// network sub-object (the legacy panel sometimes emitted streamSettings
|
||||||
@@ -144,6 +160,16 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
|
|||||||
if (streamSettings) {
|
if (streamSettings) {
|
||||||
healStreamNetworkKey(streamSettings as unknown as Record<string, unknown>);
|
healStreamNetworkKey(streamSettings as unknown as Record<string, unknown>);
|
||||||
synthesizeTlsCertUseFile(streamSettings as unknown as Record<string, unknown>);
|
synthesizeTlsCertUseFile(streamSettings as unknown as Record<string, unknown>);
|
||||||
|
const streamRecord = streamSettings as unknown as Record<string, unknown>;
|
||||||
|
const xh = streamRecord.xhttpSettings;
|
||||||
|
if (xh && typeof xh === 'object' && !Array.isArray(xh)) {
|
||||||
|
const xhttp = xh as Record<string, unknown>;
|
||||||
|
const xmux = xhttp.xmux;
|
||||||
|
if (xmux && typeof xmux === 'object' && !Array.isArray(xmux)) {
|
||||||
|
xhttp.enableXmux = true;
|
||||||
|
xhttp.xmux = { ...XMUX_DEFAULTS, ...(xmux as Record<string, unknown>) };
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const sniffing = coerceJsonObject(row.sniffing) as unknown as Sniffing;
|
const sniffing = coerceJsonObject(row.sniffing) as unknown as Sniffing;
|
||||||
|
|
||||||
@@ -162,6 +188,9 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
|
|||||||
trafficReset: coerceTrafficReset(row.trafficReset),
|
trafficReset: coerceTrafficReset(row.trafficReset),
|
||||||
lastTrafficResetTime: row.lastTrafficResetTime ?? 0,
|
lastTrafficResetTime: row.lastTrafficResetTime ?? 0,
|
||||||
nodeId: row.nodeId ?? null,
|
nodeId: row.nodeId ?? null,
|
||||||
|
shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy),
|
||||||
|
shareAddr: row.shareAddr ?? '',
|
||||||
|
subSortIndex: Math.max(1, row.subSortIndex ?? 1),
|
||||||
protocol,
|
protocol,
|
||||||
settings,
|
settings,
|
||||||
} as InboundFormValues;
|
} as InboundFormValues;
|
||||||
@@ -307,6 +336,9 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
|
|||||||
// rather than the default { enabled: false } so the row carries no sniffing.
|
// rather than the default { enabled: false } so the row carries no sniffing.
|
||||||
sniffing: canEnableSniffing({ protocol: values.protocol }) ? JSON.stringify(normalizeSniffing(values.sniffing)) : '',
|
sniffing: canEnableSniffing({ protocol: values.protocol }) ? JSON.stringify(normalizeSniffing(values.sniffing)) : '',
|
||||||
tag: values.tag,
|
tag: values.tag,
|
||||||
|
shareAddrStrategy: values.shareAddrStrategy,
|
||||||
|
shareAddr: values.shareAddr,
|
||||||
|
subSortIndex: values.subSortIndex,
|
||||||
};
|
};
|
||||||
if (values.nodeId != null) payload.nodeId = values.nodeId;
|
if (values.nodeId != null) payload.nodeId = values.nodeId;
|
||||||
return payload;
|
return payload;
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export interface DbInboundLike {
|
|||||||
up?: number;
|
up?: number;
|
||||||
down?: number;
|
down?: number;
|
||||||
total?: number;
|
total?: number;
|
||||||
|
shareAddrStrategy?: string;
|
||||||
|
shareAddr?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillProtocolSettingsDefaults(protocol: string, settings: Record<string, unknown>): Record<string, unknown> {
|
function fillProtocolSettingsDefaults(protocol: string, settings: Record<string, unknown>): Record<string, unknown> {
|
||||||
@@ -48,6 +50,8 @@ export function inboundFromDb(raw: DbInboundLike): Inbound {
|
|||||||
up: raw.up ?? 0,
|
up: raw.up ?? 0,
|
||||||
down: raw.down ?? 0,
|
down: raw.down ?? 0,
|
||||||
total: raw.total ?? 0,
|
total: raw.total ?? 0,
|
||||||
|
shareAddrStrategy: raw.shareAddrStrategy ?? 'node',
|
||||||
|
shareAddr: raw.shareAddr ?? '',
|
||||||
settings,
|
settings,
|
||||||
streamSettings,
|
streamSettings,
|
||||||
sniffing,
|
sniffing,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { getHeaderValue } from './headers';
|
|||||||
// directly.
|
// directly.
|
||||||
|
|
||||||
type ForceTls = 'same' | 'tls' | 'none';
|
type ForceTls = 'same' | 'tls' | 'none';
|
||||||
|
const SHARE_HOSTNAME_RE = /^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/;
|
||||||
|
|
||||||
// xHTTP headers ship as Record<string, string> on the wire (Zod schema)
|
// xHTTP headers ship as Record<string, string> on the wire (Zod schema)
|
||||||
// rather than the legacy class's HeaderEntry[]. Lookup by case-folded key.
|
// rather than the legacy class's HeaderEntry[]. Lookup by case-folded key.
|
||||||
@@ -58,9 +59,15 @@ function buildXhttpExtra(xhttp: XHttpStreamSettings | undefined): Record<string,
|
|||||||
'uplinkDataKey',
|
'uplinkDataKey',
|
||||||
'scMaxEachPostBytes',
|
'scMaxEachPostBytes',
|
||||||
] as const;
|
] as const;
|
||||||
|
// Values matching xray-core's own defaults stay off the wire — old panels
|
||||||
|
// seeded them into every config and the literal values are a DPI
|
||||||
|
// fingerprint (#5141). Mirrors the sub service's filter.
|
||||||
|
const coreDefaults: Partial<Record<(typeof stringFields)[number], string>> = {
|
||||||
|
scMaxEachPostBytes: '1000000',
|
||||||
|
};
|
||||||
for (const k of stringFields) {
|
for (const k of stringFields) {
|
||||||
const v = xhttp[k];
|
const v = xhttp[k];
|
||||||
if (typeof v === 'string' && v.length > 0) extra[k] = v;
|
if (typeof v === 'string' && v.length > 0 && v !== coreDefaults[k]) extra[k] = v;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Headers on the wire are a record; emit them as a map upstream's
|
// Headers on the wire are a record; emit them as a map upstream's
|
||||||
@@ -777,19 +784,76 @@ function isUnixSocketListen(listen: string): boolean {
|
|||||||
return listen.startsWith('/') || listen.startsWith('@');
|
return listen.startsWith('/') || listen.startsWith('@');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeShareHost(host: string): string {
|
||||||
|
const h = host.trim();
|
||||||
|
if (
|
||||||
|
h.length === 0
|
||||||
|
|| h.includes('://')
|
||||||
|
|| h.startsWith('//')
|
||||||
|
|| /[/?#@]/.test(h)
|
||||||
|
) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (h.startsWith('[')) {
|
||||||
|
if (!h.endsWith(']')) return '';
|
||||||
|
try {
|
||||||
|
return new URL(`http://${h}`).hostname;
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (h.includes(':')) {
|
||||||
|
try {
|
||||||
|
return new URL(`http://[${h}]`).hostname;
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SHARE_HOSTNAME_RE.test(h) ? h : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isShareableHost(host: string): boolean {
|
||||||
|
const h = normalizeShareHost(host).replace(/^\[|\]$/g, '').toLowerCase();
|
||||||
|
if (h.length === 0) return false;
|
||||||
|
if (h === '0.0.0.0' || h === '::' || h === '::0') return false;
|
||||||
|
if (h === 'localhost' || h === '::1' || h.startsWith('127.')) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shareableListen(inbound: Inbound): string {
|
||||||
|
const listen = inbound.listen.trim();
|
||||||
|
return listen.length > 0 && !isUnixSocketListen(listen) && isShareableHost(listen)
|
||||||
|
? normalizeShareHost(listen)
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShareAddrStrategy = 'node' | 'listen' | 'custom';
|
||||||
|
|
||||||
|
function shareAddrStrategy(inbound: Inbound): ShareAddrStrategy {
|
||||||
|
const strategy = inbound.shareAddrStrategy;
|
||||||
|
return strategy === 'listen' || strategy === 'custom'
|
||||||
|
? strategy
|
||||||
|
: 'node';
|
||||||
|
}
|
||||||
|
|
||||||
// Orchestrators.
|
// Orchestrators.
|
||||||
// resolveAddr picks the host that goes into share/sub links. Order:
|
// resolveAddr picks the host that goes into share/QR links. The default
|
||||||
// 1. hostOverride (caller supplies node address for node-managed inbounds)
|
// `node` strategy keeps the previous node-address-first behavior for
|
||||||
// 2. inbound's bind listen (when it's an explicit reachable address —
|
// node-managed inbounds; other strategies let a row prefer its listen address
|
||||||
// not 0.0.0.0 and not a unix domain socket path)
|
// or a custom endpoint.
|
||||||
// 3. fallbackHostname (caller-supplied — typically window.location.hostname
|
|
||||||
// in the browser; tests pass a fixed value)
|
|
||||||
export function resolveAddr(inbound: Inbound, hostOverride: string, fallbackHostname: string): string {
|
export function resolveAddr(inbound: Inbound, hostOverride: string, fallbackHostname: string): string {
|
||||||
if (hostOverride.length > 0) return hostOverride;
|
const nodeAddr = normalizeShareHost(hostOverride);
|
||||||
if (inbound.listen.length > 0 && inbound.listen !== '0.0.0.0' && !isUnixSocketListen(inbound.listen)) {
|
const listenAddr = shareableListen(inbound);
|
||||||
return inbound.listen;
|
const customAddr = normalizeShareHost(inbound.shareAddr ?? '');
|
||||||
|
const fallbackAddr = normalizeShareHost(fallbackHostname);
|
||||||
|
switch (shareAddrStrategy(inbound)) {
|
||||||
|
case 'listen':
|
||||||
|
return listenAddr || nodeAddr || fallbackAddr;
|
||||||
|
case 'custom':
|
||||||
|
return customAddr || nodeAddr || listenAddr || fallbackAddr;
|
||||||
|
default:
|
||||||
|
return nodeAddr || listenAddr || fallbackAddr;
|
||||||
}
|
}
|
||||||
return fallbackHostname;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A loopback browser host means the panel was reached through a tunnel (e.g.
|
// A loopback browser host means the panel was reached through a tunnel (e.g.
|
||||||
@@ -801,10 +865,9 @@ function isLoopbackHost(host: string): boolean {
|
|||||||
|
|
||||||
// preferPublicHost is the browser-side analog of the backend's
|
// preferPublicHost is the browser-side analog of the backend's
|
||||||
// configuredPublicHost: when the panel is reached on a loopback host, prefer a
|
// configuredPublicHost: when the panel is reached on a loopback host, prefer a
|
||||||
// configured public host (Sub/Web Domain) for share/QR links so they match the
|
// configured public host (Sub/Web Domain) for share/QR links instead of leaking
|
||||||
// subscription links instead of leaking localhost. An explicit per-inbound
|
// localhost. An explicit per-inbound listen or node override still wins, since
|
||||||
// listen or node override still wins, since resolveAddr only reaches the
|
// resolveAddr only reaches the fallbackHostname after those.
|
||||||
// fallbackHostname after those.
|
|
||||||
export function preferPublicHost(browserHost: string, publicHost: string): string {
|
export function preferPublicHost(browserHost: string, publicHost: string): string {
|
||||||
return publicHost && isLoopbackHost(browserHost) ? publicHost : browserHost;
|
return publicHost && isLoopbackHost(browserHost) ? publicHost : browserHost;
|
||||||
}
|
}
|
||||||
@@ -1016,11 +1079,11 @@ export function genWireguardLinks(input: GenWireguardFanoutInput): string {
|
|||||||
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
|
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
|
||||||
const sep = remarkModel.charAt(0);
|
const sep = remarkModel.charAt(0);
|
||||||
return inbound.settings.peers
|
return inbound.settings.peers
|
||||||
.map((_p, i) => genWireguardLink({
|
.map((p, i) => genWireguardLink({
|
||||||
settings: inbound.settings as WireguardInboundSettings,
|
settings: inbound.settings as WireguardInboundSettings,
|
||||||
address: addr,
|
address: addr,
|
||||||
port: inbound.port,
|
port: inbound.port,
|
||||||
remark: `${remark}${sep}${i + 1}`,
|
remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(p)}`,
|
||||||
peerIndex: i,
|
peerIndex: i,
|
||||||
}))
|
}))
|
||||||
.join('\r\n');
|
.join('\r\n');
|
||||||
@@ -1032,16 +1095,23 @@ export function genWireguardConfigs(input: GenWireguardFanoutInput): string {
|
|||||||
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
|
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
|
||||||
const sep = remarkModel.charAt(0);
|
const sep = remarkModel.charAt(0);
|
||||||
return inbound.settings.peers
|
return inbound.settings.peers
|
||||||
.map((_p, i) => genWireguardConfig({
|
.map((p, i) => genWireguardConfig({
|
||||||
settings: inbound.settings as WireguardInboundSettings,
|
settings: inbound.settings as WireguardInboundSettings,
|
||||||
address: addr,
|
address: addr,
|
||||||
port: inbound.port,
|
port: inbound.port,
|
||||||
remark: `${remark}${sep}${i + 1}`,
|
remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(p)}`,
|
||||||
peerIndex: i,
|
peerIndex: i,
|
||||||
}))
|
}))
|
||||||
.join('\r\n');
|
.join('\r\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Peer comments (#5168) are panel-side annotations; when present they ride
|
||||||
|
// along in the share remark so the device is identifiable in client apps.
|
||||||
|
function wgPeerCommentSuffix(peer: unknown): string {
|
||||||
|
const comment = (peer as { comment?: unknown })?.comment;
|
||||||
|
return typeof comment === 'string' && comment.trim() !== '' ? ` (${comment.trim()})` : '';
|
||||||
|
}
|
||||||
|
|
||||||
export function isPostQuantumLink(link: string): boolean {
|
export function isPostQuantumLink(link: string): boolean {
|
||||||
if (/[?&]pqv=/.test(link)) return true;
|
if (/[?&]pqv=/.test(link)) return true;
|
||||||
if (link.includes('mlkem768') || link.includes('mldsa65')) return true;
|
if (link.includes('mlkem768') || link.includes('mldsa65')) return true;
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ function buildStream(network: string, security: string): Raw {
|
|||||||
case 'xhttp':
|
case 'xhttp':
|
||||||
stream.xhttpSettings = {
|
stream.xhttpSettings = {
|
||||||
path: '/', host: '', mode: 'auto', headers: {},
|
path: '/', host: '', mode: 'auto', headers: {},
|
||||||
xPaddingBytes: '100-1000', scMaxEachPostBytes: '1000000',
|
xPaddingBytes: '100-1000',
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -41,6 +41,36 @@ function hasMeaningfulHeaders(headers: unknown): boolean {
|
|||||||
return isRecord(headers) && Object.keys(headers).length > 0;
|
return isRecord(headers) && Object.keys(headers).length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Upper bound of an xray-core Int32Range value: "16-32" -> 32, "4" -> 4,
|
||||||
|
// 4 -> 4, "" / null -> 0. xmux fields are ranges, and xray-core keys its
|
||||||
|
// mutual-exclusivity check on the `.To` (upper) side.
|
||||||
|
function int32RangeUpper(v: unknown): number {
|
||||||
|
if (typeof v === 'number') return Number.isFinite(v) ? v : 0;
|
||||||
|
if (typeof v !== 'string') return 0;
|
||||||
|
const trimmed = v.trim();
|
||||||
|
if (trimmed === '') return 0;
|
||||||
|
const parts = trimmed.split('-');
|
||||||
|
const n = Number(parts[parts.length - 1]);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// xray-core's XmuxConfig rejects a config that sets BOTH maxConnections
|
||||||
|
// and maxConcurrency ("maxConnections cannot be specified together with
|
||||||
|
// maxConcurrency"). The panel pre-fills maxConcurrency ("16-32") whenever
|
||||||
|
// XMUX is enabled, so any explicit maxConnections would otherwise always
|
||||||
|
// collide and make xray refuse the config. maxConnections defaults to 0
|
||||||
|
// (off), so a positive value is an explicit opt-in to connection-pool
|
||||||
|
// mode — honor it and drop the leftover default maxConcurrency, matching
|
||||||
|
// core's "one strategy at a time" semantics.
|
||||||
|
function resolveXmuxExclusivity(xmux: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
if (int32RangeUpper(xmux.maxConnections) > 0 && int32RangeUpper(xmux.maxConcurrency) > 0) {
|
||||||
|
const out = { ...xmux };
|
||||||
|
delete out.maxConcurrency;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return xmux;
|
||||||
|
}
|
||||||
|
|
||||||
/** Validates REALITY inbound `target` / `dest` (must include a port). */
|
/** Validates REALITY inbound `target` / `dest` (must include a port). */
|
||||||
export function validateRealityTarget(target: string): string | undefined {
|
export function validateRealityTarget(target: string): string | undefined {
|
||||||
const trimmed = target.trim();
|
const trimmed = target.trim();
|
||||||
@@ -115,16 +145,22 @@ export function normalizeXhttpForWire(
|
|||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
const out: Record<string, unknown> = { ...raw };
|
const out: Record<string, unknown> = { ...raw };
|
||||||
const mode = typeof out.mode === 'string' && out.mode !== '' ? out.mode : 'auto';
|
const mode = typeof out.mode === 'string' && out.mode !== '' ? out.mode : 'auto';
|
||||||
|
const enableXmux = out.enableXmux === true;
|
||||||
delete out.enableXmux;
|
delete out.enableXmux;
|
||||||
|
|
||||||
if (side === 'inbound') {
|
if (side === 'inbound') {
|
||||||
delete out.xmux;
|
if (!enableXmux) delete out.xmux;
|
||||||
delete out.scMinPostsIntervalMs;
|
delete out.scMinPostsIntervalMs;
|
||||||
delete out.uplinkChunkSize;
|
delete out.uplinkChunkSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isRecord(out.xmux)) {
|
||||||
|
out.xmux = resolveXmuxExclusivity(out.xmux);
|
||||||
|
}
|
||||||
|
|
||||||
dropEmptyStrings(out, PLACEMENT_STRING_FIELDS);
|
dropEmptyStrings(out, PLACEMENT_STRING_FIELDS);
|
||||||
|
// Empty tuning fields mean "use xray-core's default" — never emit them.
|
||||||
|
dropEmptyStrings(out, ['scMaxEachPostBytes', 'scMinPostsIntervalMs', 'scStreamUpServerSecs']);
|
||||||
|
|
||||||
if (!hasMeaningfulHeaders(out.headers)) {
|
if (!hasMeaningfulHeaders(out.headers)) {
|
||||||
delete out.headers;
|
delete out.headers;
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ export type DBInboundInit = Partial<{
|
|||||||
sniffing: RawJsonField;
|
sniffing: RawJsonField;
|
||||||
clientStats: ClientStats[];
|
clientStats: ClientStats[];
|
||||||
nodeId: number | null;
|
nodeId: number | null;
|
||||||
|
shareAddrStrategy: string;
|
||||||
|
shareAddr: string;
|
||||||
|
subSortIndex: number;
|
||||||
originNodeGuid: string;
|
originNodeGuid: string;
|
||||||
fallbackParent: FallbackParentRef | null;
|
fallbackParent: FallbackParentRef | null;
|
||||||
}>;
|
}>;
|
||||||
@@ -84,6 +87,9 @@ export class DBInbound {
|
|||||||
sniffing: RawJsonField;
|
sniffing: RawJsonField;
|
||||||
clientStats: ClientStats[];
|
clientStats: ClientStats[];
|
||||||
nodeId: number | null;
|
nodeId: number | null;
|
||||||
|
shareAddrStrategy: string;
|
||||||
|
shareAddr: string;
|
||||||
|
subSortIndex: number;
|
||||||
originNodeGuid: string;
|
originNodeGuid: string;
|
||||||
fallbackParent: FallbackParentRef | null;
|
fallbackParent: FallbackParentRef | null;
|
||||||
|
|
||||||
@@ -110,6 +116,9 @@ export class DBInbound {
|
|||||||
this.sniffing = "";
|
this.sniffing = "";
|
||||||
this.clientStats = [];
|
this.clientStats = [];
|
||||||
this.nodeId = null;
|
this.nodeId = null;
|
||||||
|
this.shareAddrStrategy = "node";
|
||||||
|
this.shareAddr = "";
|
||||||
|
this.subSortIndex = 1;
|
||||||
this.originNodeGuid = "";
|
this.originNodeGuid = "";
|
||||||
this.fallbackParent = null;
|
this.fallbackParent = null;
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ export class AllSetting {
|
|||||||
if (data != null) {
|
if (data != null) {
|
||||||
ObjectUtil.cloneProps(this, data);
|
ObjectUtil.cloneProps(this, data);
|
||||||
}
|
}
|
||||||
|
const cpu = Math.round(Number(this.tgCpu));
|
||||||
|
this.tgCpu = Number.isFinite(cpu) ? Math.min(100, Math.max(0, cpu)) : 80;
|
||||||
}
|
}
|
||||||
|
|
||||||
equals(other: AllSetting): boolean {
|
equals(other: AllSetting): boolean {
|
||||||
|
|||||||
@@ -843,6 +843,13 @@ export const sections: readonly Section[] = [
|
|||||||
body: '{\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/"\n}',
|
body: '{\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/"\n}',
|
||||||
response: '{\n "success": true,\n "obj": "k3b1...base64-sha256...="\n}',
|
response: '{\n "success": true,\n "obj": "k3b1...base64-sha256...="\n}',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/nodes/inbounds',
|
||||||
|
summary: 'Use unsaved node connection details to list the remote inbounds available for selective import.',
|
||||||
|
body: '{\n "name": "de-fra-1",\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}',
|
||||||
|
response: '{\n "success": true,\n "obj": [\n { "tag": "inbound-443", "remark": "VLESS", "protocol": "vless", "port": 443 }\n ]\n}',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/api/nodes/probe/:id',
|
path: '/panel/api/nodes/probe/:id',
|
||||||
@@ -1056,6 +1063,17 @@ export const sections: readonly Section[] = [
|
|||||||
],
|
],
|
||||||
body: 'outbound={"protocol":"freedom","settings":{}}&mode=tcp',
|
body: 'outbound={"protocol":"freedom","settings":{}}&mode=tcp',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/xray/testOutbounds',
|
||||||
|
summary: 'Test a batch of outbounds (max 50) through one shared temp xray instance. Returns an array of results in input order, each with the outbound tag, delay, HTTP status and a connect/TLS/TTFB timing breakdown.',
|
||||||
|
params: [
|
||||||
|
{ name: 'outbounds', in: 'body (form)', type: 'string', desc: 'JSON array of outbound configs to test (required).' },
|
||||||
|
{ name: 'allOutbounds', in: 'body (form)', type: 'string', desc: 'JSON array of all outbounds — used to resolve dialerProxy chains.' },
|
||||||
|
{ name: 'mode', in: 'body (form)', type: 'string', desc: '"tcp" for fast dial-only probes (UDP-transport outbounds are still probed over HTTP). Default/empty routes a real HTTP request through each outbound.' },
|
||||||
|
],
|
||||||
|
body: 'outbounds=[{"tag":"direct","protocol":"freedom","settings":{}}]&mode=http',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/api/xray/balancerStatus',
|
path: '/panel/api/xray/balancerStatus',
|
||||||
|
|||||||
@@ -66,11 +66,7 @@ export default function BulkAddToGroupModal({
|
|||||||
placeholder={t('pages.clients.groupName')}
|
placeholder={t('pages.clients.groupName')}
|
||||||
options={groups.map((g) => ({ value: g }))}
|
options={groups.map((g) => ({ value: g }))}
|
||||||
onChange={(v) => setValue(v ?? '')}
|
onChange={(v) => setValue(v ?? '')}
|
||||||
filterOption={(input, option) =>
|
|
||||||
String(option?.value ?? '').toLowerCase().includes((input || '').toLowerCase())
|
|
||||||
}
|
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: '100%' }}
|
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -288,11 +288,7 @@ export default function ClientBulkAddModal({
|
|||||||
placeholder={t('pages.clients.groupPlaceholder')}
|
placeholder={t('pages.clients.groupPlaceholder')}
|
||||||
options={groups.map((g) => ({ value: g }))}
|
options={groups.map((g) => ({ value: g }))}
|
||||||
onChange={(v) => update('group', v ?? '')}
|
onChange={(v) => update('group', v ?? '')}
|
||||||
filterOption={(input, option) =>
|
|
||||||
String(option?.value ?? '').toLowerCase().includes((input || '').toLowerCase())
|
|
||||||
}
|
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
|||||||
@@ -8,15 +8,17 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
|
Popconfirm,
|
||||||
Row,
|
Row,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Switch,
|
Switch,
|
||||||
Tabs,
|
Tabs,
|
||||||
Tag,
|
Tag,
|
||||||
|
Tooltip,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { EyeOutlined, ReloadOutlined } from '@ant-design/icons';
|
import { EyeOutlined, ReloadOutlined, RetweetOutlined } from '@ant-design/icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
import { HttpUtil, RandomUtil } from '@/utils';
|
import { HttpUtil, RandomUtil } from '@/utils';
|
||||||
@@ -38,6 +40,7 @@ const CLIENT_IP_LOG_MODAL_Z_INDEX = CLIENT_FORM_MODAL_Z_INDEX + 1;
|
|||||||
|
|
||||||
interface ApiMsg<T = unknown> {
|
interface ApiMsg<T = unknown> {
|
||||||
success?: boolean;
|
success?: boolean;
|
||||||
|
msg?: string;
|
||||||
obj?: T;
|
obj?: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +74,7 @@ interface ClientFormModalProps {
|
|||||||
payload: Record<string, unknown> | SaveCreatePayload,
|
payload: Record<string, unknown> | SaveCreatePayload,
|
||||||
meta: SaveMetaEdit | SaveMetaCreate,
|
meta: SaveMetaEdit | SaveMetaCreate,
|
||||||
) => Promise<ApiMsg | null>;
|
) => Promise<ApiMsg | null>;
|
||||||
|
resetTraffic?: (client: ClientRecord) => Promise<ApiMsg | null>;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +143,7 @@ export default function ClientFormModal({
|
|||||||
tgBotEnable = false,
|
tgBotEnable = false,
|
||||||
groups = [],
|
groups = [],
|
||||||
save,
|
save,
|
||||||
|
resetTraffic,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: ClientFormModalProps) {
|
}: ClientFormModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -147,6 +152,7 @@ export default function ClientFormModal({
|
|||||||
|
|
||||||
const [form, setForm] = useState<FormState>(emptyForm);
|
const [form, setForm] = useState<FormState>(emptyForm);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [resetting, setResetting] = useState(false);
|
||||||
const [clientIps, setClientIps] = useState<string[]>([]);
|
const [clientIps, setClientIps] = useState<string[]>([]);
|
||||||
const [ipsLoading, setIpsLoading] = useState(false);
|
const [ipsLoading, setIpsLoading] = useState(false);
|
||||||
const [ipsClearing, setIpsClearing] = useState(false);
|
const [ipsClearing, setIpsClearing] = useState(false);
|
||||||
@@ -327,6 +333,21 @@ export default function ClientFormModal({
|
|||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onResetTraffic() {
|
||||||
|
if (!isEdit || !client?.email || !resetTraffic) return;
|
||||||
|
setResetting(true);
|
||||||
|
try {
|
||||||
|
const msg = await resetTraffic(client);
|
||||||
|
if (msg?.success) {
|
||||||
|
messageApi.success(t('pages.clients.toasts.trafficReset'));
|
||||||
|
} else {
|
||||||
|
messageApi.error(msg?.msg || t('somethingWentWrong'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setResetting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
const schema = isEdit ? ClientFormSchema : ClientCreateFormSchema;
|
const schema = isEdit ? ClientFormSchema : ClientCreateFormSchema;
|
||||||
const validated = schema.safeParse({
|
const validated = schema.safeParse({
|
||||||
@@ -412,15 +433,35 @@ export default function ClientFormModal({
|
|||||||
open={open}
|
open={open}
|
||||||
title={isEdit ? t('pages.clients.editClient') : t('pages.clients.addClient')}
|
title={isEdit ? t('pages.clients.editClient') : t('pages.clients.addClient')}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
okText={isEdit ? t('save') : t('create')}
|
|
||||||
cancelText={t('cancel')}
|
|
||||||
okButtonProps={{ loading: submitting }}
|
|
||||||
width={720}
|
width={720}
|
||||||
zIndex={CLIENT_FORM_MODAL_Z_INDEX}
|
zIndex={CLIENT_FORM_MODAL_Z_INDEX}
|
||||||
style={{ top: 20 }}
|
style={{ top: 20 }}
|
||||||
styles={{ body: { maxHeight: 'calc(100vh - 160px)', overflowY: 'auto', overflowX: 'hidden' } }}
|
styles={{ body: { maxHeight: 'calc(100vh - 160px)', overflowY: 'auto', overflowX: 'hidden' } }}
|
||||||
onOk={onSubmit}
|
|
||||||
onCancel={close}
|
onCancel={close}
|
||||||
|
footer={
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
{isEdit && resetTraffic && (
|
||||||
|
<Popconfirm
|
||||||
|
title={t('pages.inbounds.resetTraffic')}
|
||||||
|
description={t('pages.inbounds.resetTrafficContent')}
|
||||||
|
okText={t('reset')}
|
||||||
|
cancelText={t('cancel')}
|
||||||
|
zIndex={CLIENT_IP_LOG_MODAL_Z_INDEX}
|
||||||
|
onConfirm={onResetTraffic}
|
||||||
|
>
|
||||||
|
<Button color="danger" variant="filled" icon={<RetweetOutlined />} loading={resetting}>
|
||||||
|
{t('pages.inbounds.resetTraffic')}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
<div style={{ marginInlineStart: 'auto', display: 'flex', gap: 8 }}>
|
||||||
|
<Button onClick={close}>{t('cancel')}</Button>
|
||||||
|
<Button type="primary" loading={submitting} onClick={onSubmit}>
|
||||||
|
{isEdit ? t('save') : t('create')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Form layout="vertical">
|
<Form layout="vertical">
|
||||||
<Tabs
|
<Tabs
|
||||||
@@ -428,7 +469,7 @@ export default function ClientFormModal({
|
|||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
key: 'basic',
|
key: 'basic',
|
||||||
label: t('pages.clients.tabBasic'),
|
label: t('pages.clients.tabBasics'),
|
||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
@@ -441,20 +482,31 @@ export default function ClientFormModal({
|
|||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
onChange={(e) => update('email', e.target.value)}
|
onChange={(e) => update('email', e.target.value)}
|
||||||
/>
|
/>
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => update('email', RandomUtil.randomLowerAndNum(12))} />
|
{!isEdit && (
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={() => update('email', RandomUtil.randomLowerAndNum(12))} />
|
||||||
|
)}
|
||||||
</Space.Compact>
|
</Space.Compact>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} md={8}>
|
<Col xs={24} md={6}>
|
||||||
<Form.Item label={t('pages.clients.totalGB')}>
|
<Form.Item label={t('pages.clients.totalGB')} tooltip={t('pages.clients.totalGBDesc')}>
|
||||||
<InputNumber value={form.totalGB} min={0} step={1} style={{ width: '100%' }}
|
<InputNumber value={form.totalGB} min={0} step={1} style={{ width: '100%' }}
|
||||||
onChange={(v) => update('totalGB', Number(v) || 0)} />
|
onChange={(v) => update('totalGB', Number(v) || 0)} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} md={4}>
|
<Col xs={24} md={6}>
|
||||||
<Form.Item label={t('pages.clients.limitIp')}>
|
<Form.Item label={t('pages.clients.limitIp')} tooltip={t('pages.clients.limitIpDesc')}>
|
||||||
<InputNumber value={form.limitIp} min={0} style={{ width: '100%' }}
|
<Space.Compact style={{ display: 'flex' }}>
|
||||||
onChange={(v) => update('limitIp', Number(v) || 0)} />
|
<InputNumber value={form.limitIp} min={0} style={{ flex: 1 }}
|
||||||
|
onChange={(v) => update('limitIp', Number(v) || 0)} />
|
||||||
|
{isEdit && (
|
||||||
|
<Tooltip title={t('pages.clients.ipLog')}>
|
||||||
|
<Button icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
|
||||||
|
{clientIps.length > 0 ? clientIps.length : ''}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Space.Compact>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
@@ -489,7 +541,7 @@ export default function ClientFormModal({
|
|||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} md={6}>
|
<Col xs={12} md={6}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t('pages.clients.renew')}
|
label={t('pages.clients.renewDays')}
|
||||||
tooltip={t('pages.clients.renewDesc')}
|
tooltip={t('pages.clients.renewDesc')}
|
||||||
>
|
>
|
||||||
<InputNumber value={form.reset} min={0} style={{ width: '100%' }}
|
<InputNumber value={form.reset} min={0} style={{ width: '100%' }}
|
||||||
@@ -499,16 +551,7 @@ export default function ClientFormModal({
|
|||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
{tgBotEnable && (
|
<Col xs={24} md={12}>
|
||||||
<Col xs={24} md={12}>
|
|
||||||
<Form.Item label={t('pages.clients.telegramId')}>
|
|
||||||
<InputNumber value={form.tgId} min={0} controls={false}
|
|
||||||
placeholder={t('pages.clients.telegramIdPlaceholder')} style={{ width: '100%' }}
|
|
||||||
onChange={(v) => update('tgId', Number(v) || 0)} />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
)}
|
|
||||||
<Col xs={24} md={tgBotEnable ? 12 : 24}>
|
|
||||||
<Form.Item label={t('pages.clients.comment')}>
|
<Form.Item label={t('pages.clients.comment')}>
|
||||||
<Input value={form.comment} onChange={(e) => update('comment', e.target.value)} />
|
<Input value={form.comment} onChange={(e) => update('comment', e.target.value)} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -520,16 +563,34 @@ export default function ClientFormModal({
|
|||||||
placeholder={t('pages.clients.groupPlaceholder')}
|
placeholder={t('pages.clients.groupPlaceholder')}
|
||||||
options={groups.map((g) => ({ value: g }))}
|
options={groups.map((g) => ({ value: g }))}
|
||||||
onChange={(v) => update('group', v ?? '')}
|
onChange={(v) => update('group', v ?? '')}
|
||||||
filterOption={(input, option) =>
|
|
||||||
String(option?.value ?? '').toLowerCase().includes((input || '').toLowerCase())
|
|
||||||
}
|
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
{(tgBotEnable || showReverseTag) && (
|
||||||
|
<Row gutter={16}>
|
||||||
|
{tgBotEnable && (
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item label={t('pages.clients.telegramId')}>
|
||||||
|
<InputNumber value={form.tgId} min={0} controls={false}
|
||||||
|
placeholder={t('pages.clients.telegramIdPlaceholder')} style={{ width: '100%' }}
|
||||||
|
onChange={(v) => update('tgId', Number(v) || 0)} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
{showReverseTag && (
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item label={t('pages.clients.reverseTag')}>
|
||||||
|
<Input value={form.reverseTag} placeholder={t('pages.clients.reverseTagPlaceholder')}
|
||||||
|
onChange={(e) => update('reverseTag', e.target.value)} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
<Form.Item label={t('pages.clients.attachedInbounds')} required={!isEdit}>
|
<Form.Item label={t('pages.clients.attachedInbounds')} required={!isEdit}>
|
||||||
<SelectAllClearButtons
|
<SelectAllClearButtons
|
||||||
options={inboundOptions}
|
options={inboundOptions}
|
||||||
@@ -555,20 +616,12 @@ export default function ClientFormModal({
|
|||||||
<Switch checked={form.enable} onChange={(v) => update('enable', v)} />
|
<Switch checked={form.enable} onChange={(v) => update('enable', v)} />
|
||||||
<span style={{ marginLeft: 8 }}>{t('enable')}</span>
|
<span style={{ marginLeft: 8 }}>{t('enable')}</span>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
{isEdit && (
|
|
||||||
<Form.Item label={t('pages.clients.ipLog')}>
|
|
||||||
<Button icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
|
|
||||||
{clientIps.length > 0 ? clientIps.length : ''}
|
|
||||||
</Button>
|
|
||||||
</Form.Item>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'config',
|
key: 'config',
|
||||||
label: t('pages.clients.tabConfig'),
|
label: t('pages.clients.tabCredentials'),
|
||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
@@ -635,14 +688,6 @@ export default function ClientFormModal({
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
)}
|
)}
|
||||||
{showReverseTag && (
|
|
||||||
<Col xs={24} md={12}>
|
|
||||||
<Form.Item label={t('pages.clients.reverseTag')}>
|
|
||||||
<Input value={form.reverseTag} placeholder={t('pages.clients.reverseTagPlaceholder')}
|
|
||||||
onChange={(e) => update('reverseTag', e.target.value)} />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
)}
|
|
||||||
</Row>
|
</Row>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import { formatInboundLabel } from '@/lib/inbounds/label';
|
|||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { useWebSocket } from '@/hooks/useWebSocket';
|
import { useWebSocket } from '@/hooks/useWebSocket';
|
||||||
import { useClients } from '@/hooks/useClients';
|
import { useClients } from '@/hooks/useClients';
|
||||||
|
import { useNodesQuery } from '@/api/queries/useNodesQuery';
|
||||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||||
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
||||||
import ClientTrafficCell from '@/components/clients/ClientTrafficCell';
|
import ClientTrafficCell from '@/components/clients/ClientTrafficCell';
|
||||||
@@ -148,6 +149,7 @@ function readFilterState(): PersistedFilterState {
|
|||||||
buckets: Array.isArray(fromRaw.buckets) ? fromRaw.buckets : [],
|
buckets: Array.isArray(fromRaw.buckets) ? fromRaw.buckets : [],
|
||||||
protocols: Array.isArray(fromRaw.protocols) ? fromRaw.protocols : [],
|
protocols: Array.isArray(fromRaw.protocols) ? fromRaw.protocols : [],
|
||||||
inboundIds: Array.isArray(fromRaw.inboundIds) ? fromRaw.inboundIds : [],
|
inboundIds: Array.isArray(fromRaw.inboundIds) ? fromRaw.inboundIds : [],
|
||||||
|
nodeIds: Array.isArray(fromRaw.nodeIds) ? fromRaw.nodeIds : [],
|
||||||
groups: Array.isArray(fromRaw.groups) ? fromRaw.groups : [],
|
groups: Array.isArray(fromRaw.groups) ? fromRaw.groups : [],
|
||||||
},
|
},
|
||||||
sort: typeof raw.sort === 'string' ? raw.sort : '',
|
sort: typeof raw.sort === 'string' ? raw.sort : '',
|
||||||
@@ -163,15 +165,15 @@ function gbToBytes(gb: number | undefined): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SORT_OPTIONS: { value: string; column: string; order: 'ascend' | 'descend'; labelKey: string }[] = [
|
const SORT_OPTIONS: { value: string; column: string; order: 'ascend' | 'descend'; labelKey: string }[] = [
|
||||||
{ value: 'createdAt:ascend', column: 'createdAt', order: 'ascend', labelKey: 'pages.clients.sortOldest' },
|
{ value: 'createdAt:ascend', column: 'createdAt', order: 'ascend', labelKey: 'pages.clients.sortOldest' },
|
||||||
{ value: 'createdAt:descend', column: 'createdAt', order: 'descend', labelKey: 'pages.clients.sortNewest' },
|
{ value: 'createdAt:descend', column: 'createdAt', order: 'descend', labelKey: 'pages.clients.sortNewest' },
|
||||||
{ value: 'updatedAt:descend', column: 'updatedAt', order: 'descend', labelKey: 'pages.clients.sortRecentlyUpdated' },
|
{ value: 'updatedAt:descend', column: 'updatedAt', order: 'descend', labelKey: 'pages.clients.sortRecentlyUpdated' },
|
||||||
{ value: 'lastOnline:descend', column: 'lastOnline', order: 'descend', labelKey: 'pages.clients.sortRecentlyOnline' },
|
{ value: 'lastOnline:descend', column: 'lastOnline', order: 'descend', labelKey: 'pages.clients.sortRecentlyOnline' },
|
||||||
{ value: 'email:ascend', column: 'email', order: 'ascend', labelKey: 'pages.clients.sortEmailAZ' },
|
{ value: 'email:ascend', column: 'email', order: 'ascend', labelKey: 'pages.clients.sortEmailAZ' },
|
||||||
{ value: 'email:descend', column: 'email', order: 'descend', labelKey: 'pages.clients.sortEmailZA' },
|
{ value: 'email:descend', column: 'email', order: 'descend', labelKey: 'pages.clients.sortEmailZA' },
|
||||||
{ value: 'traffic:descend', column: 'traffic', order: 'descend', labelKey: 'pages.clients.sortMostTraffic' },
|
{ value: 'traffic:descend', column: 'traffic', order: 'descend', labelKey: 'pages.clients.sortMostTraffic' },
|
||||||
{ value: 'remaining:descend', column: 'remaining', order: 'descend', labelKey: 'pages.clients.sortHighestRemaining' },
|
{ value: 'remaining:descend', column: 'remaining', order: 'descend', labelKey: 'pages.clients.sortHighestRemaining' },
|
||||||
{ value: 'expiryTime:ascend', column: 'expiryTime', order: 'ascend', labelKey: 'pages.clients.sortExpiringSoonest' },
|
{ value: 'expiryTime:ascend', column: 'expiryTime', order: 'ascend', labelKey: 'pages.clients.sortExpiringSoonest' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_SORT = SORT_OPTIONS[0];
|
const DEFAULT_SORT = SORT_OPTIONS[0];
|
||||||
@@ -209,6 +211,10 @@ export default function ClientsPage() {
|
|||||||
client_stats: applyClientStatsEvent,
|
client_stats: applyClientStatsEvent,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Node list for the Nodes filter; the section only renders when the panel
|
||||||
|
// actually manages nodes (#4997).
|
||||||
|
const { nodes } = useNodesQuery();
|
||||||
|
|
||||||
const [togglingEmail, setTogglingEmail] = useState<string | null>(null);
|
const [togglingEmail, setTogglingEmail] = useState<string | null>(null);
|
||||||
const [formOpen, setFormOpen] = useState(false);
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
|
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
|
||||||
@@ -255,6 +261,23 @@ export default function ClientsPage() {
|
|||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}, [debouncedSearch, filters, sortColumn, sortOrder]);
|
}, [debouncedSearch, filters, sortColumn, sortOrder]);
|
||||||
|
|
||||||
|
// The node filter maps onto inbound ids client-side (#4997): the paging API
|
||||||
|
// already accepts an inbound CSV, so nodes never have to reach the backend.
|
||||||
|
// Sentinel 0 = "local panel" (inbounds without a nodeId).
|
||||||
|
const effectiveInboundCsv = useMemo(() => {
|
||||||
|
if (!filters.nodeIds.length) return filters.inboundIds.join(',');
|
||||||
|
const nodeSet = new Set(filters.nodeIds);
|
||||||
|
const nodeInboundIds = inbounds
|
||||||
|
.filter((ib) => nodeSet.has(ib.nodeId ?? 0))
|
||||||
|
.map((ib) => ib.id);
|
||||||
|
const pool = filters.inboundIds.length
|
||||||
|
? nodeInboundIds.filter((id) => filters.inboundIds.includes(id))
|
||||||
|
: nodeInboundIds;
|
||||||
|
// Nothing matches the selected nodes: send an impossible id so the filter
|
||||||
|
// yields an honest empty result instead of being silently ignored.
|
||||||
|
return pool.length ? pool.join(',') : '-1';
|
||||||
|
}, [filters.nodeIds, filters.inboundIds, inbounds]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setQuery({
|
setQuery({
|
||||||
page: currentPage,
|
page: currentPage,
|
||||||
@@ -262,7 +285,7 @@ export default function ClientsPage() {
|
|||||||
search: debouncedSearch,
|
search: debouncedSearch,
|
||||||
filter: filters.buckets.join(','),
|
filter: filters.buckets.join(','),
|
||||||
protocol: filters.protocols.join(','),
|
protocol: filters.protocols.join(','),
|
||||||
inbound: filters.inboundIds.join(','),
|
inbound: effectiveInboundCsv,
|
||||||
expiryFrom: filters.expiryFrom,
|
expiryFrom: filters.expiryFrom,
|
||||||
expiryTo: filters.expiryTo,
|
expiryTo: filters.expiryTo,
|
||||||
usageFrom: gbToBytes(filters.usageFromGB),
|
usageFrom: gbToBytes(filters.usageFromGB),
|
||||||
@@ -274,7 +297,7 @@ export default function ClientsPage() {
|
|||||||
sort: sortColumn || undefined,
|
sort: sortColumn || undefined,
|
||||||
order: sortOrder || undefined,
|
order: sortOrder || undefined,
|
||||||
});
|
});
|
||||||
}, [setQuery, currentPage, tablePageSize, debouncedSearch, filters, sortColumn, sortOrder]);
|
}, [setQuery, currentPage, tablePageSize, debouncedSearch, filters, effectiveInboundCsv, sortColumn, sortOrder]);
|
||||||
|
|
||||||
const activeCount = activeFilterCount(filters);
|
const activeCount = activeFilterCount(filters);
|
||||||
|
|
||||||
@@ -582,19 +605,19 @@ export default function ClientsPage() {
|
|||||||
render: (_v, record) => (
|
render: (_v, record) => (
|
||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
<Tooltip title={t('pages.clients.qrCode')}>
|
<Tooltip title={t('pages.clients.qrCode')}>
|
||||||
<Button size="small" type="text" icon={<QrcodeOutlined />} onClick={() => onShowQr(record)} />
|
<Button size="small" type="text" style={{ fontSize: 18 }} icon={<QrcodeOutlined />} onClick={() => onShowQr(record)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title={t('pages.clients.clientInfo')}>
|
<Tooltip title={t('pages.clients.clientInfo')}>
|
||||||
<Button size="small" type="text" icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} />
|
<Button size="small" type="text" style={{ fontSize: 18 }} icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title={t('pages.inbounds.resetTraffic')}>
|
<Tooltip title={t('pages.inbounds.resetTraffic')}>
|
||||||
<Button size="small" type="text" icon={<RetweetOutlined />} onClick={() => onResetTraffic(record)} />
|
<Button size="small" type="text" style={{ fontSize: 18 }} icon={<RetweetOutlined />} onClick={() => onResetTraffic(record)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title={t('edit')}>
|
<Tooltip title={t('edit')}>
|
||||||
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => onEdit(record)} />
|
<Button size="small" type="text" style={{ fontSize: 18 }} icon={<EditOutlined />} onClick={() => onEdit(record)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title={t('delete')}>
|
<Tooltip title={t('delete')}>
|
||||||
<Button size="small" type="text" danger icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
|
<Button size="small" type="text" danger style={{ fontSize: 18 }} icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
@@ -640,6 +663,7 @@ export default function ClientsPage() {
|
|||||||
{
|
{
|
||||||
title: t('pages.clients.client'),
|
title: t('pages.clients.client'),
|
||||||
key: 'email',
|
key: 'email',
|
||||||
|
width: 220,
|
||||||
render: (_v, record) => (
|
render: (_v, record) => (
|
||||||
<div className="email-cell">
|
<div className="email-cell">
|
||||||
<span className="email">{record.email}</span>
|
<span className="email">{record.email}</span>
|
||||||
@@ -719,7 +743,7 @@ export default function ClientsPage() {
|
|||||||
{
|
{
|
||||||
title: t('pages.clients.traffic'),
|
title: t('pages.clients.traffic'),
|
||||||
key: 'traffic',
|
key: 'traffic',
|
||||||
width: 240,
|
width: 300,
|
||||||
render: (_v, record) => (
|
render: (_v, record) => (
|
||||||
<ClientTrafficCell
|
<ClientTrafficCell
|
||||||
up={record.traffic?.up}
|
up={record.traffic?.up}
|
||||||
@@ -739,6 +763,7 @@ export default function ClientsPage() {
|
|||||||
{
|
{
|
||||||
title: t('pages.clients.duration'),
|
title: t('pages.clients.duration'),
|
||||||
key: 'expiryTime',
|
key: 'expiryTime',
|
||||||
|
width: 130,
|
||||||
render: (_v, record) => (
|
render: (_v, record) => (
|
||||||
<Tooltip title={expiryLabel(record)}>
|
<Tooltip title={expiryLabel(record)}>
|
||||||
<Tag color={expiryColor(record)}>{record.expiryTime ? expiryRelative(record) : '∞'}</Tag>
|
<Tag color={expiryColor(record)}>{record.expiryTime ? expiryRelative(record) : '∞'}</Tag>
|
||||||
@@ -900,40 +925,40 @@ export default function ClientsPage() {
|
|||||||
menu={{
|
menu={{
|
||||||
items: selectedRowKeys.length > 0
|
items: selectedRowKeys.length > 0
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
key: 'adjust',
|
key: 'adjust',
|
||||||
icon: <ClockCircleOutlined />,
|
icon: <ClockCircleOutlined />,
|
||||||
label: t('pages.clients.adjust'),
|
label: t('pages.clients.adjust'),
|
||||||
onClick: () => setBulkAdjustOpen(true),
|
onClick: () => setBulkAdjustOpen(true),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'subLinks',
|
key: 'subLinks',
|
||||||
icon: <LinkOutlined />,
|
icon: <LinkOutlined />,
|
||||||
label: t('pages.clients.subLinks'),
|
label: t('pages.clients.subLinks'),
|
||||||
onClick: () => setSubLinksOpen(true),
|
onClick: () => setSubLinksOpen(true),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
key: 'bulk',
|
key: 'bulk',
|
||||||
icon: <UsergroupAddOutlined />,
|
icon: <UsergroupAddOutlined />,
|
||||||
label: t('pages.clients.bulk'),
|
label: t('pages.clients.bulk'),
|
||||||
onClick: () => setBulkAddOpen(true),
|
onClick: () => setBulkAddOpen(true),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'resetAll',
|
key: 'resetAll',
|
||||||
icon: <RetweetOutlined />,
|
icon: <RetweetOutlined />,
|
||||||
label: t('pages.clients.resetAllTraffics'),
|
label: t('pages.clients.resetAllTraffics'),
|
||||||
onClick: onResetAllTraffics,
|
onClick: onResetAllTraffics,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'delDepleted',
|
key: 'delDepleted',
|
||||||
icon: <RestOutlined />,
|
icon: <RestOutlined />,
|
||||||
label: t('pages.clients.delDepleted'),
|
label: t('pages.clients.delDepleted'),
|
||||||
danger: true,
|
danger: true,
|
||||||
onClick: onDelDepleted,
|
onClick: onDelDepleted,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button icon={<MoreOutlined />}>
|
<Button icon={<MoreOutlined />}>
|
||||||
@@ -1222,6 +1247,7 @@ export default function ClientsPage() {
|
|||||||
tgBotEnable={tgBotEnable}
|
tgBotEnable={tgBotEnable}
|
||||||
groups={allGroups}
|
groups={allGroups}
|
||||||
save={onSave}
|
save={onSave}
|
||||||
|
resetTraffic={resetTraffic}
|
||||||
onOpenChange={setFormOpen}
|
onOpenChange={setFormOpen}
|
||||||
/>
|
/>
|
||||||
</LazyMount>
|
</LazyMount>
|
||||||
@@ -1333,6 +1359,7 @@ export default function ClientsPage() {
|
|||||||
inbounds={inbounds}
|
inbounds={inbounds}
|
||||||
protocols={protocolOptions}
|
protocols={protocolOptions}
|
||||||
groups={groupOptions}
|
groups={groupOptions}
|
||||||
|
nodes={nodes}
|
||||||
/>
|
/>
|
||||||
</LazyMount>
|
</LazyMount>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import dayjs from 'dayjs';
|
|||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
|
|
||||||
import type { InboundOption } from '@/hooks/useClients';
|
import type { InboundOption } from '@/hooks/useClients';
|
||||||
|
import type { NodeRecord } from '@/schemas/node';
|
||||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||||
import { emptyFilters, type ClientFilters } from './filters';
|
import { emptyFilters, type ClientFilters } from './filters';
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ interface FilterDrawerProps {
|
|||||||
inbounds: InboundOption[];
|
inbounds: InboundOption[];
|
||||||
protocols: string[];
|
protocols: string[];
|
||||||
groups: string[];
|
groups: string[];
|
||||||
|
nodes: NodeRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const BUCKET_KEYS = ['active', 'expiring', 'depleted', 'deactive', 'online'] as const;
|
const BUCKET_KEYS = ['active', 'expiring', 'depleted', 'deactive', 'online'] as const;
|
||||||
@@ -41,6 +43,7 @@ export default function FilterDrawer({
|
|||||||
inbounds,
|
inbounds,
|
||||||
protocols,
|
protocols,
|
||||||
groups,
|
groups,
|
||||||
|
nodes,
|
||||||
}: FilterDrawerProps) {
|
}: FilterDrawerProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -66,6 +69,16 @@ export default function FilterDrawer({
|
|||||||
[groups],
|
[groups],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 0 is the "local panel" sentinel (inbounds without a nodeId) — see
|
||||||
|
// ClientFilters.nodeIds (#4997).
|
||||||
|
const nodeOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: 0, label: t('pages.clients.filters.localPanel') },
|
||||||
|
...nodes.map((n) => ({ value: n.id, label: n.name || `#${n.id}` })),
|
||||||
|
],
|
||||||
|
[nodes, t],
|
||||||
|
);
|
||||||
|
|
||||||
const dateRange: [Dayjs | null, Dayjs | null] = [
|
const dateRange: [Dayjs | null, Dayjs | null] = [
|
||||||
filters.expiryFrom ? dayjs(filters.expiryFrom) : null,
|
filters.expiryFrom ? dayjs(filters.expiryFrom) : null,
|
||||||
filters.expiryTo ? dayjs(filters.expiryTo) : null,
|
filters.expiryTo ? dayjs(filters.expiryTo) : null,
|
||||||
@@ -132,6 +145,23 @@ export default function FilterDrawer({
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
{nodes.length > 0 && (
|
||||||
|
<Form.Item label={t('pages.clients.filters.nodes')}>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
value={filters.nodeIds}
|
||||||
|
onChange={(v) => patch('nodeIds', v as number[])}
|
||||||
|
options={nodeOptions}
|
||||||
|
placeholder={t('pages.clients.filters.nodes')}
|
||||||
|
maxTagCount="responsive"
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
listHeight={220}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
<Form.Item label={t('pages.clients.group')}>
|
<Form.Item label={t('pages.clients.group')}>
|
||||||
<Select
|
<Select
|
||||||
mode="multiple"
|
mode="multiple"
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ export interface ClientFilters {
|
|||||||
buckets: string[];
|
buckets: string[];
|
||||||
protocols: string[];
|
protocols: string[];
|
||||||
inboundIds: number[];
|
inboundIds: number[];
|
||||||
|
// Node ids to filter by; 0 is the "local panel" sentinel (inbounds with
|
||||||
|
// no nodeId). Mapped onto inbound ids client-side — see ClientsPage.
|
||||||
|
nodeIds: number[];
|
||||||
groups: string[];
|
groups: string[];
|
||||||
expiryFrom?: number;
|
expiryFrom?: number;
|
||||||
expiryTo?: number;
|
expiryTo?: number;
|
||||||
@@ -17,6 +20,7 @@ export function emptyFilters(): ClientFilters {
|
|||||||
buckets: [],
|
buckets: [],
|
||||||
protocols: [],
|
protocols: [],
|
||||||
inboundIds: [],
|
inboundIds: [],
|
||||||
|
nodeIds: [],
|
||||||
groups: [],
|
groups: [],
|
||||||
autoRenew: '',
|
autoRenew: '',
|
||||||
hasTgId: '',
|
hasTgId: '',
|
||||||
@@ -29,6 +33,7 @@ export function activeFilterCount(f: ClientFilters): number {
|
|||||||
if (f.buckets.length) n++;
|
if (f.buckets.length) n++;
|
||||||
if (f.protocols.length) n++;
|
if (f.protocols.length) n++;
|
||||||
if (f.inboundIds.length) n++;
|
if (f.inboundIds.length) n++;
|
||||||
|
if (f.nodeIds.length) n++;
|
||||||
if (f.groups.length) n++;
|
if (f.groups.length) n++;
|
||||||
if (f.expiryFrom || f.expiryTo) n++;
|
if (f.expiryFrom || f.expiryTo) n++;
|
||||||
if (f.usageFromGB || f.usageToGB) n++;
|
if (f.usageFromGB || f.usageToGB) n++;
|
||||||
|
|||||||
@@ -22,11 +22,14 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { MenuProps, TableColumnsType } from 'antd';
|
import type { MenuProps, TableColumnsType } from 'antd';
|
||||||
import {
|
import {
|
||||||
|
ArrowDownOutlined,
|
||||||
|
ArrowUpOutlined,
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
LinkOutlined,
|
LinkOutlined,
|
||||||
MoreOutlined,
|
MoreOutlined,
|
||||||
|
PieChartOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
RetweetOutlined,
|
RetweetOutlined,
|
||||||
TagsOutlined,
|
TagsOutlined,
|
||||||
@@ -165,6 +168,14 @@ export default function GroupsPage() {
|
|||||||
() => groups.reduce((acc, g) => acc + (g.trafficUsed || 0), 0),
|
() => groups.reduce((acc, g) => acc + (g.trafficUsed || 0), 0),
|
||||||
[groups],
|
[groups],
|
||||||
);
|
);
|
||||||
|
const totalUpload = useMemo(
|
||||||
|
() => groups.reduce((acc, g) => acc + (g.up || 0), 0),
|
||||||
|
[groups],
|
||||||
|
);
|
||||||
|
const totalDownload = useMemo(
|
||||||
|
() => groups.reduce((acc, g) => acc + (g.down || 0), 0),
|
||||||
|
[groups],
|
||||||
|
);
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
setCreateName('');
|
setCreateName('');
|
||||||
@@ -396,10 +407,10 @@ export default function GroupsPage() {
|
|||||||
render: (_v, row) => (
|
render: (_v, row) => (
|
||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
<Dropdown trigger={['click']} menu={{ items: rowActions(row) }}>
|
<Dropdown trigger={['click']} menu={{ items: rowActions(row) }}>
|
||||||
<Button size="small" type="text" icon={<MoreOutlined />} />
|
<Button size="small" type="text" style={{ fontSize: 18 }} icon={<MoreOutlined />} />
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
<Tooltip title={t('pages.groups.rename')}>
|
<Tooltip title={t('pages.groups.rename')}>
|
||||||
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => openRename(row)} />
|
<Button size="small" type="text" style={{ fontSize: 18 }} icon={<EditOutlined />} onClick={() => openRename(row)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
@@ -417,6 +428,20 @@ export default function GroupsPage() {
|
|||||||
width: 180,
|
width: 180,
|
||||||
render: (count: number) => <span>{count || 0}</span>,
|
render: (count: number) => <span>{count || 0}</span>,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t('pages.groups.upload'),
|
||||||
|
dataIndex: 'up',
|
||||||
|
key: 'up',
|
||||||
|
width: 140,
|
||||||
|
render: (bytes: number) => <span>{SizeFormatter.sizeFormat(bytes || 0)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('pages.groups.download'),
|
||||||
|
dataIndex: 'down',
|
||||||
|
key: 'down',
|
||||||
|
width: 140,
|
||||||
|
render: (bytes: number) => <span>{SizeFormatter.sizeFormat(bytes || 0)}</span>,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t('pages.groups.trafficUsed'),
|
title: t('pages.groups.trafficUsed'),
|
||||||
dataIndex: 'trafficUsed',
|
dataIndex: 'trafficUsed',
|
||||||
@@ -456,25 +481,38 @@ export default function GroupsPage() {
|
|||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<Card size="small" hoverable className="summary-card">
|
<Card size="small" hoverable className="summary-card">
|
||||||
<Row gutter={[16, isMobile ? 16 : 12]}>
|
<Row gutter={[16, isMobile ? 16 : 12]}>
|
||||||
<Col xs={12} sm={8} md={6}>
|
<Col xs={12} sm={12} md={6}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title={t('pages.groups.totalGroups')}
|
title={t('pages.groups.totalGroups')}
|
||||||
value={String(totalGroups)}
|
value={String(totalGroups)}
|
||||||
prefix={<TagsOutlined />}
|
prefix={<TagsOutlined />}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={8} md={6}>
|
<Col xs={12} sm={12} md={6}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title={t('pages.groups.totalGroupedClients')}
|
title={t('pages.groups.totalGroupedClients')}
|
||||||
value={String(totalClients)}
|
value={String(totalClients)}
|
||||||
prefix={<TeamOutlined />}
|
prefix={<TeamOutlined />}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={8} md={6}>
|
<Col xs={12} sm={12} md={6}>
|
||||||
|
<Statistic
|
||||||
|
title={t('pages.groups.totalUpDown')}
|
||||||
|
value={0}
|
||||||
|
formatter={() => (
|
||||||
|
<span>
|
||||||
|
<ArrowUpOutlined /> {SizeFormatter.sizeFormat(totalUpload)}
|
||||||
|
{' / '}
|
||||||
|
<ArrowDownOutlined /> {SizeFormatter.sizeFormat(totalDownload)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={12} md={6}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title={t('pages.groups.totalTraffic')}
|
title={t('pages.groups.totalTraffic')}
|
||||||
value={SizeFormatter.sizeFormat(totalTraffic)}
|
value={SizeFormatter.sizeFormat(totalTraffic)}
|
||||||
prefix={<RetweetOutlined />}
|
prefix={<PieChartOutlined />}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import {
|
|||||||
|
|
||||||
import { setMessageInstance } from '@/utils/messageBus';
|
import { setMessageInstance } from '@/utils/messageBus';
|
||||||
import {
|
import {
|
||||||
SwapOutlined,
|
ArrowUpOutlined,
|
||||||
|
ArrowDownOutlined,
|
||||||
PieChartOutlined,
|
PieChartOutlined,
|
||||||
BarsOutlined,
|
BarsOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
@@ -146,12 +147,14 @@ export default function InboundsPage() {
|
|||||||
const [textTitle, setTextTitle] = useState('');
|
const [textTitle, setTextTitle] = useState('');
|
||||||
const [textContent, setTextContent] = useState('');
|
const [textContent, setTextContent] = useState('');
|
||||||
const [textFileName, setTextFileName] = useState('');
|
const [textFileName, setTextFileName] = useState('');
|
||||||
|
const [textJson, setTextJson] = useState(false);
|
||||||
|
|
||||||
const [promptOpen, setPromptOpen] = useState(false);
|
const [promptOpen, setPromptOpen] = useState(false);
|
||||||
const [promptTitle, setPromptTitle] = useState('');
|
const [promptTitle, setPromptTitle] = useState('');
|
||||||
const [promptOkText, setPromptOkText] = useState('OK');
|
const [promptOkText, setPromptOkText] = useState('OK');
|
||||||
const [promptType, setPromptType] = useState<'textarea' | 'input'>('textarea');
|
const [promptType, setPromptType] = useState<'textarea' | 'input'>('textarea');
|
||||||
const [promptInitial, setPromptInitial] = useState('');
|
const [promptInitial, setPromptInitial] = useState('');
|
||||||
|
const [promptJson, setPromptJson] = useState(false);
|
||||||
const [promptLoading, setPromptLoading] = useState(false);
|
const [promptLoading, setPromptLoading] = useState(false);
|
||||||
const [promptHandler, setPromptHandler] = useState<((value: string) => Promise<boolean | void> | boolean | void) | null>(null);
|
const [promptHandler, setPromptHandler] = useState<((value: string) => Promise<boolean | void> | boolean | void) | null>(null);
|
||||||
|
|
||||||
@@ -163,10 +166,11 @@ export default function InboundsPage() {
|
|||||||
const infoNodeAddress = useMemo(() => hostOverrideFor(infoDbInbound), [infoDbInbound, hostOverrideFor]);
|
const infoNodeAddress = useMemo(() => hostOverrideFor(infoDbInbound), [infoDbInbound, hostOverrideFor]);
|
||||||
const qrNodeAddress = useMemo(() => hostOverrideFor(qrDbInbound), [qrDbInbound, hostOverrideFor]);
|
const qrNodeAddress = useMemo(() => hostOverrideFor(qrDbInbound), [qrDbInbound, hostOverrideFor]);
|
||||||
|
|
||||||
const openText = useCallback((opts: { title: string; content: string; fileName?: string }) => {
|
const openText = useCallback((opts: { title: string; content: string; fileName?: string; json?: boolean }) => {
|
||||||
setTextTitle(opts.title);
|
setTextTitle(opts.title);
|
||||||
setTextContent(opts.content);
|
setTextContent(opts.content);
|
||||||
setTextFileName(opts.fileName || '');
|
setTextFileName(opts.fileName || '');
|
||||||
|
setTextJson(opts.json || false);
|
||||||
setTextOpen(true);
|
setTextOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -175,12 +179,14 @@ export default function InboundsPage() {
|
|||||||
okText?: string;
|
okText?: string;
|
||||||
type?: 'textarea' | 'input';
|
type?: 'textarea' | 'input';
|
||||||
value?: string;
|
value?: string;
|
||||||
|
json?: boolean;
|
||||||
confirm: (value: string) => Promise<boolean | void> | boolean | void;
|
confirm: (value: string) => Promise<boolean | void> | boolean | void;
|
||||||
}) => {
|
}) => {
|
||||||
setPromptTitle(opts.title);
|
setPromptTitle(opts.title);
|
||||||
setPromptOkText(opts.okText || t('confirm'));
|
setPromptOkText(opts.okText || t('confirm'));
|
||||||
setPromptType(opts.type || 'textarea');
|
setPromptType(opts.type || 'textarea');
|
||||||
setPromptInitial(opts.value || '');
|
setPromptInitial(opts.value || '');
|
||||||
|
setPromptJson(opts.json || false);
|
||||||
setPromptHandler(() => opts.confirm);
|
setPromptHandler(() => opts.confirm);
|
||||||
setPromptOpen(true);
|
setPromptOpen(true);
|
||||||
}, [t]);
|
}, [t]);
|
||||||
@@ -267,7 +273,7 @@ export default function InboundsPage() {
|
|||||||
}, [checkFallback, remarkModel, hostOverrideFor, subSettings.publicHost, openText, t]);
|
}, [checkFallback, remarkModel, hostOverrideFor, subSettings.publicHost, openText, t]);
|
||||||
|
|
||||||
const exportInboundClipboard = useCallback((dbInbound: DBInbound) => {
|
const exportInboundClipboard = useCallback((dbInbound: DBInbound) => {
|
||||||
openText({ title: t('pages.inbounds.inboundJsonTitle'), content: JSON.stringify(dbInbound, null, 2) });
|
openText({ title: t('pages.inbounds.inboundJsonTitle'), content: JSON.stringify(dbInbound, null, 2), json: true });
|
||||||
}, [openText, t]);
|
}, [openText, t]);
|
||||||
|
|
||||||
const exportInboundSubs = useCallback((dbInbound: DBInbound) => {
|
const exportInboundSubs = useCallback((dbInbound: DBInbound) => {
|
||||||
@@ -327,6 +333,7 @@ export default function InboundsPage() {
|
|||||||
okText: t('pages.inbounds.import'),
|
okText: t('pages.inbounds.import'),
|
||||||
type: 'textarea',
|
type: 'textarea',
|
||||||
value: '',
|
value: '',
|
||||||
|
json: true,
|
||||||
confirm: async (value) => {
|
confirm: async (value) => {
|
||||||
const msg = await HttpUtil.post('/panel/api/inbounds/import', { data: value });
|
const msg = await HttpUtil.post('/panel/api/inbounds/import', { data: value });
|
||||||
if (msg?.success) {
|
if (msg?.success) {
|
||||||
@@ -457,6 +464,8 @@ export default function InboundsPage() {
|
|||||||
settings: clonedSettings,
|
settings: clonedSettings,
|
||||||
streamSettings: streamSettingsString,
|
streamSettings: streamSettingsString,
|
||||||
sniffing: sniffingString,
|
sniffing: sniffingString,
|
||||||
|
shareAddrStrategy: dbInbound.shareAddrStrategy,
|
||||||
|
shareAddr: dbInbound.shareAddr,
|
||||||
};
|
};
|
||||||
const msg = await HttpUtil.post('/panel/api/inbounds/add', data);
|
const msg = await HttpUtil.post('/panel/api/inbounds/add', data);
|
||||||
if (msg?.success) await refresh();
|
if (msg?.success) await refresh();
|
||||||
@@ -577,8 +586,14 @@ export default function InboundsPage() {
|
|||||||
<Col xs={12} sm={12} md={8}>
|
<Col xs={12} sm={12} md={8}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title={t('pages.inbounds.totalDownUp')}
|
title={t('pages.inbounds.totalDownUp')}
|
||||||
value={`${SizeFormatter.sizeFormat(totals.up)} / ${SizeFormatter.sizeFormat(totals.down)}`}
|
value={0}
|
||||||
prefix={<SwapOutlined />}
|
formatter={() => (
|
||||||
|
<span>
|
||||||
|
<ArrowUpOutlined /> {SizeFormatter.sizeFormat(totals.up)}
|
||||||
|
{' / '}
|
||||||
|
<ArrowDownOutlined /> {SizeFormatter.sizeFormat(totals.down)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={12} md={8}>
|
<Col xs={12} sm={12} md={8}>
|
||||||
@@ -703,6 +718,7 @@ export default function InboundsPage() {
|
|||||||
title={textTitle}
|
title={textTitle}
|
||||||
content={textContent}
|
content={textContent}
|
||||||
fileName={textFileName}
|
fileName={textFileName}
|
||||||
|
json={textJson}
|
||||||
/>
|
/>
|
||||||
</LazyMount>
|
</LazyMount>
|
||||||
<LazyMount when={promptOpen}>
|
<LazyMount when={promptOpen}>
|
||||||
@@ -714,6 +730,7 @@ export default function InboundsPage() {
|
|||||||
type={promptType}
|
type={promptType}
|
||||||
initialValue={promptInitial}
|
initialValue={promptInitial}
|
||||||
loading={promptLoading}
|
loading={promptLoading}
|
||||||
|
json={promptJson}
|
||||||
onConfirm={onPromptConfirm}
|
onConfirm={onPromptConfirm}
|
||||||
/>
|
/>
|
||||||
</LazyMount>
|
</LazyMount>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
@@ -84,6 +85,8 @@ import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
|||||||
|
|
||||||
const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p }));
|
const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p }));
|
||||||
const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'] as const;
|
const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'] as const;
|
||||||
|
const SHARE_ADDR_STRATEGIES = ['node', 'listen', 'custom'] as const;
|
||||||
|
const SHARE_ADDR_HOSTNAME_RE = /^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/;
|
||||||
const NODE_ELIGIBLE_PROTOCOLS = new Set<string>([
|
const NODE_ELIGIBLE_PROTOCOLS = new Set<string>([
|
||||||
Protocols.VLESS,
|
Protocols.VLESS,
|
||||||
Protocols.VMESS,
|
Protocols.VMESS,
|
||||||
@@ -93,6 +96,30 @@ const NODE_ELIGIBLE_PROTOCOLS = new Set<string>([
|
|||||||
Protocols.WIREGUARD,
|
Protocols.WIREGUARD,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
function isValidShareAddrInput(value: string): boolean {
|
||||||
|
const v = value.trim();
|
||||||
|
if (v.length === 0) return true;
|
||||||
|
if (v.includes('://') || v.startsWith('//') || /[/?#@]/.test(v)) return false;
|
||||||
|
if (v.startsWith('[')) {
|
||||||
|
if (!v.endsWith(']')) return false;
|
||||||
|
try {
|
||||||
|
new URL(`http://${v}`);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (v.includes(':')) {
|
||||||
|
try {
|
||||||
|
new URL(`http://[${v}]`);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SHARE_ADDR_HOSTNAME_RE.test(v);
|
||||||
|
}
|
||||||
|
|
||||||
interface InboundFormModalProps {
|
interface InboundFormModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -150,6 +177,10 @@ export default function InboundFormModal({
|
|||||||
const selectableNodes = (availableNodes || []).filter((n) => n.enable);
|
const selectableNodes = (availableNodes || []).filter((n) => n.enable);
|
||||||
const protocol = (Form.useWatch('protocol', form) ?? '') as string;
|
const protocol = (Form.useWatch('protocol', form) ?? '') as string;
|
||||||
const isNodeEligible = NODE_ELIGIBLE_PROTOCOLS.has(protocol);
|
const isNodeEligible = NODE_ELIGIBLE_PROTOCOLS.has(protocol);
|
||||||
|
// The `node` share-address strategy only means something when the inbound can
|
||||||
|
// actually live on a node — otherwise the node address it would resolve to is
|
||||||
|
// always empty. Offer it only then; `listen`/`custom` work for local inbounds.
|
||||||
|
const nodeShareOptionAvailable = selectableNodes.length > 0 && isNodeEligible;
|
||||||
const sniffingEnabled = Form.useWatch(['sniffing', 'enabled'], form) ?? false;
|
const sniffingEnabled = Form.useWatch(['sniffing', 'enabled'], form) ?? false;
|
||||||
const vlessEncryption = Form.useWatch(['settings', 'encryption'], form) ?? '';
|
const vlessEncryption = Form.useWatch(['settings', 'encryption'], form) ?? '';
|
||||||
const ssMethod = Form.useWatch(['settings', 'method'], form);
|
const ssMethod = Form.useWatch(['settings', 'method'], form);
|
||||||
@@ -174,8 +205,9 @@ export default function InboundFormModal({
|
|||||||
|
|
||||||
const wPort = Form.useWatch('port', form);
|
const wPort = Form.useWatch('port', form);
|
||||||
const wListen = (Form.useWatch('listen', form) ?? '') as string;
|
const wListen = (Form.useWatch('listen', form) ?? '') as string;
|
||||||
const isUdsListen = wListen.startsWith('/');
|
const isUdsListen = wListen.startsWith('/') || wListen.startsWith('@');
|
||||||
const wNodeId = Form.useWatch('nodeId', form) ?? null;
|
const wNodeId = Form.useWatch('nodeId', form) ?? null;
|
||||||
|
const shareAddrStrategy = Form.useWatch('shareAddrStrategy', form) ?? 'node';
|
||||||
const wTag = Form.useWatch('tag', form) ?? '';
|
const wTag = Form.useWatch('tag', form) ?? '';
|
||||||
const wSsNetwork = Form.useWatch(['settings', 'network'], form);
|
const wSsNetwork = Form.useWatch(['settings', 'network'], form);
|
||||||
const wTunnelNetwork = Form.useWatch(['settings', 'allowedNetwork'], form);
|
const wTunnelNetwork = Form.useWatch(['settings', 'allowedNetwork'], form);
|
||||||
@@ -343,6 +375,19 @@ export default function InboundFormModal({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open, wPort, wNodeId, protocol, network, mixedUdpOn, wSsNetwork, wTunnelNetwork]);
|
}, [open, wPort, wNodeId, protocol, network, mixedUdpOn, wSsNetwork, wTunnelNetwork]);
|
||||||
|
|
||||||
|
// Keep the strategy value inside the visible option set: when `node` isn't
|
||||||
|
// offered (no node, or a protocol that can't deploy to one) fall back to
|
||||||
|
// `listen`, which yields the same link for a local inbound. Mirrors how the
|
||||||
|
// protocol reset drops a nodeId that no longer applies.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const current = form.getFieldValue('shareAddrStrategy') as InboundFormValues['shareAddrStrategy'] | undefined;
|
||||||
|
if (!nodeShareOptionAvailable && (current ?? 'node') === 'node') {
|
||||||
|
form.setFieldValue('shareAddrStrategy', 'listen');
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, nodeShareOptionAvailable, shareAddrStrategy]);
|
||||||
|
|
||||||
// Why: protocol picker reset cascades through the form — clearing the
|
// Why: protocol picker reset cascades through the form — clearing the
|
||||||
// settings DU branch and dropping a nodeId that no longer applies. The
|
// settings DU branch and dropping a nodeId that no longer applies. The
|
||||||
// legacy modal did this imperatively in onProtocolChange; here we hook
|
// legacy modal did this imperatively in onProtocolChange; here we hook
|
||||||
@@ -499,6 +544,46 @@ export default function InboundFormModal({
|
|||||||
<Input placeholder={t('pages.inbounds.monitorDesc')} />
|
<Input placeholder={t('pages.inbounds.monitorDesc')} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="shareAddrStrategy"
|
||||||
|
label={t('pages.inbounds.form.shareAddrStrategy')}
|
||||||
|
extra={t('pages.inbounds.form.shareAddrStrategyHelp')}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
options={SHARE_ADDR_STRATEGIES
|
||||||
|
.filter((strategy) => strategy !== 'node' || nodeShareOptionAvailable)
|
||||||
|
.map((strategy) => ({
|
||||||
|
value: strategy,
|
||||||
|
label: t(`pages.inbounds.form.shareAddrStrategyOptions.${strategy}`),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{shareAddrStrategy === 'custom' && (
|
||||||
|
<Form.Item
|
||||||
|
name="shareAddr"
|
||||||
|
label={t('pages.inbounds.form.shareAddr')}
|
||||||
|
extra={t('pages.inbounds.form.shareAddrHelp')}
|
||||||
|
rules={[{
|
||||||
|
validator: (_, value) => (
|
||||||
|
isValidShareAddrInput(String(value ?? ''))
|
||||||
|
? Promise.resolve()
|
||||||
|
: Promise.reject(new Error(t('pages.inbounds.form.shareAddrHelp')))
|
||||||
|
),
|
||||||
|
}]}
|
||||||
|
>
|
||||||
|
<Input placeholder="edge.example.com" />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="subSortIndex"
|
||||||
|
label={t('pages.inbounds.form.subSortIndex')}
|
||||||
|
extra={t('pages.inbounds.form.subSortIndexHelp')}
|
||||||
|
>
|
||||||
|
<InputNumber min={1} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="port"
|
name="port"
|
||||||
label={t('pages.inbounds.port')}
|
label={t('pages.inbounds.port')}
|
||||||
@@ -605,6 +690,15 @@ export default function InboundFormModal({
|
|||||||
{protocol === Protocols.VLESS && <VlessFields saving={saving} selectedVlessAuth={selectedVlessAuth} network={network} security={security} getNewVlessEnc={getNewVlessEnc} clearVlessEnc={clearVlessEnc} />}
|
{protocol === Protocols.VLESS && <VlessFields saving={saving} selectedVlessAuth={selectedVlessAuth} network={network} security={security} getNewVlessEnc={getNewVlessEnc} clearVlessEnc={clearVlessEnc} />}
|
||||||
|
|
||||||
{isFallbackHost && fallbacksCard}
|
{isFallbackHost && fallbacksCard}
|
||||||
|
{(protocol === Protocols.VLESS || protocol === Protocols.TROJAN)
|
||||||
|
&& network === 'tcp' && !isFallbackHost && (
|
||||||
|
<Alert
|
||||||
|
className="mt-12"
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message={t('pages.inbounds.fallbacks.needsTls')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -656,6 +750,12 @@ export default function InboundFormModal({
|
|||||||
udp: [...udp, { type: 'mkcp-legacy', settings: { header: '', value: '' } }],
|
udp: [...udp, { type: 'mkcp-legacy', settings: { header: '', value: '' } }],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
const fm = cleaned.finalmask as Record<string, unknown> | undefined;
|
||||||
|
if (fm && Array.isArray(fm.udp)) {
|
||||||
|
const udp = (fm.udp as unknown[]).filter((m) => (m as { type?: string })?.type !== 'mkcp-legacy');
|
||||||
|
cleaned.finalmask = { ...fm, udp };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
form.setFieldValue('streamSettings', cleaned);
|
form.setFieldValue('streamSettings', cleaned);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function HysteriaFields({ form }: { form: FormInstance }) {
|
|||||||
label={t('pages.inbounds.form.udpIdleTimeout')}
|
label={t('pages.inbounds.form.udpIdleTimeout')}
|
||||||
name={['streamSettings', 'hysteriaSettings', 'udpIdleTimeout']}
|
name={['streamSettings', 'hysteriaSettings', 'udpIdleTimeout']}
|
||||||
>
|
>
|
||||||
<InputNumber min={1} style={{ width: '100%' }} />
|
<InputNumber min={2} max={600} style={{ width: '100%' }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label={t('pages.inbounds.form.masquerade')}>
|
<Form.Item label={t('pages.inbounds.form.masquerade')}>
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
|||||||
import { ReloadOutlined } from '@ant-design/icons';
|
import { ReloadOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { generateMtprotoSecret, mtprotoSecretForDomain } from '@/lib/xray/inbound-defaults';
|
import { generateMtprotoSecret, mtprotoSecretForDomain } from '@/lib/xray/inbound-defaults';
|
||||||
|
import { useOutboundTags } from '@/api/queries/useOutboundTags';
|
||||||
|
|
||||||
export default function MtprotoFields() {
|
export default function MtprotoFields() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const form = Form.useFormInstance();
|
const form = Form.useFormInstance();
|
||||||
|
const routeThroughXray = Form.useWatch(['settings', 'routeThroughXray'], form) as boolean | undefined;
|
||||||
|
const { data: outboundTags } = useOutboundTags();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Form.Item name={['settings', 'fakeTlsDomain']} label={t('pages.inbounds.form.fakeTlsDomain')}>
|
<Form.Item name={['settings', 'fakeTlsDomain']} label={t('pages.inbounds.form.fakeTlsDomain')}>
|
||||||
@@ -71,6 +74,28 @@ export default function MtprotoFields() {
|
|||||||
<Form.Item name={['settings', 'debug']} label={t('pages.inbounds.form.mtgDebug')} valuePropName="checked">
|
<Form.Item name={['settings', 'debug']} label={t('pages.inbounds.form.mtgDebug')} valuePropName="checked">
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name={['settings', 'routeThroughXray']}
|
||||||
|
label={t('pages.inbounds.form.mtgRouteThroughXray')}
|
||||||
|
tooltip={t('pages.inbounds.form.mtgRouteThroughXrayHint')}
|
||||||
|
valuePropName="checked"
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
{routeThroughXray && (
|
||||||
|
<Form.Item
|
||||||
|
name={['settings', 'outboundTag']}
|
||||||
|
label={t('pages.inbounds.form.mtgRouteOutbound')}
|
||||||
|
tooltip={t('pages.inbounds.form.mtgRouteOutboundHint')}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
placeholder={t('pages.inbounds.form.mtgRouteOutboundPlaceholder')}
|
||||||
|
options={(outboundTags ?? []).map((tag) => ({ value: tag, label: tag }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,12 @@ export default function WireguardFields({ wgPubKey, regenInboundWg, regenWgPeerK
|
|||||||
<Divider titlePlacement="center">
|
<Divider titlePlacement="center">
|
||||||
<Space>
|
<Space>
|
||||||
<span>{t('pages.inbounds.info.peerNumber', { n: idx + 1 })}</span>
|
<span>{t('pages.inbounds.info.peerNumber', { n: idx + 1 })}</span>
|
||||||
|
<Form.Item noStyle shouldUpdate>
|
||||||
|
{() => {
|
||||||
|
const comment = form.getFieldValue(['settings', 'peers', field.name, 'comment']) as string | undefined;
|
||||||
|
return comment ? <span style={{ opacity: 0.65 }}>— {comment}</span> : null;
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
{fields.length > 1 && (
|
{fields.length > 1 && (
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
@@ -112,6 +118,9 @@ export default function WireguardFields({ wgPubKey, regenInboundWg, regenWgPeerK
|
|||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
</Divider>
|
</Divider>
|
||||||
|
<Form.Item name={[field.name, 'comment']} label={t('comment')}>
|
||||||
|
<Input placeholder="e.g. Alice's laptop" />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item label={t('pages.xray.wireguard.secretKey')}>
|
<Form.Item label={t('pages.xray.wireguard.secretKey')}>
|
||||||
<Space.Compact block>
|
<Space.Compact block>
|
||||||
<Form.Item name={[field.name, 'privateKey']} noStyle>
|
<Form.Item name={[field.name, 'privateKey']} noStyle>
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { Form, Input, InputNumber, Select, Switch, type FormInstance } from 'ant
|
|||||||
|
|
||||||
import { HeaderMapEditor } from '@/components/form';
|
import { HeaderMapEditor } from '@/components/form';
|
||||||
import type { InboundFormValues } from '@/schemas/forms/inbound-form';
|
import type { InboundFormValues } from '@/schemas/forms/inbound-form';
|
||||||
|
import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
|
||||||
|
|
||||||
|
const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
|
||||||
|
|
||||||
export default function XhttpForm({ form }: { form: FormInstance<InboundFormValues> }) {
|
export default function XhttpForm({ form }: { form: FormInstance<InboundFormValues> }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -11,6 +14,15 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
|
|||||||
const xhttpSessionPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'sessionPlacement'], form);
|
const xhttpSessionPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'sessionPlacement'], form);
|
||||||
const xhttpSeqPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'seqPlacement'], form);
|
const xhttpSeqPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'seqPlacement'], form);
|
||||||
const xhttpUplinkPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'uplinkDataPlacement'], form);
|
const xhttpUplinkPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'uplinkDataPlacement'], form);
|
||||||
|
|
||||||
|
function onXmuxToggle(checked: boolean) {
|
||||||
|
if (!checked) return;
|
||||||
|
const existing = form.getFieldValue(['streamSettings', 'xhttpSettings', 'xmux']);
|
||||||
|
const hasValues = existing && typeof existing === 'object' && Object.keys(existing).length > 0;
|
||||||
|
if (hasValues) return;
|
||||||
|
form.setFieldValue(['streamSettings', 'xhttpSettings', 'xmux'], { ...XMUX_DEFAULTS });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Form.Item name={['streamSettings', 'xhttpSettings', 'host']} label={t('host')}>
|
<Form.Item name={['streamSettings', 'xhttpSettings', 'host']} label={t('host')}>
|
||||||
@@ -213,6 +225,65 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
|
|||||||
>
|
>
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
{/* XMUX is the connection-multiplexing layer
|
||||||
|
xHTTP uses to fan out parallel requests over
|
||||||
|
a small pool of upstream connections. UI-only
|
||||||
|
toggle (enableXmux) hides the 6 nested knobs
|
||||||
|
when off. */}
|
||||||
|
<Form.Item
|
||||||
|
label="XMUX"
|
||||||
|
name={['streamSettings', 'xhttpSettings', 'enableXmux']}
|
||||||
|
valuePropName="checked"
|
||||||
|
>
|
||||||
|
<Switch onChange={onXmuxToggle} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item shouldUpdate noStyle>
|
||||||
|
{() => {
|
||||||
|
if (!form.getFieldValue([
|
||||||
|
'streamSettings', 'xhttpSettings', 'enableXmux',
|
||||||
|
])) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form.Item
|
||||||
|
label={t('pages.xray.outboundForm.maxConcurrency')}
|
||||||
|
name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConcurrency']}
|
||||||
|
>
|
||||||
|
<Input placeholder="16-32" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t('pages.xray.outboundForm.maxConnections')}
|
||||||
|
name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConnections']}
|
||||||
|
>
|
||||||
|
<Input placeholder="0" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t('pages.xray.outboundForm.maxReuseTimes')}
|
||||||
|
name={['streamSettings', 'xhttpSettings', 'xmux', 'cMaxReuseTimes']}
|
||||||
|
>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t('pages.xray.outboundForm.maxRequestTimes')}
|
||||||
|
name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxRequestTimes']}
|
||||||
|
>
|
||||||
|
<Input placeholder="600-900" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t('pages.xray.outboundForm.maxReusableSecs')}
|
||||||
|
name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxReusableSecs']}
|
||||||
|
>
|
||||||
|
<Input placeholder="1800-3000" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t('pages.xray.outboundForm.keepAlivePeriod')}
|
||||||
|
name={['streamSettings', 'xhttpSettings', 'xmux', 'hKeepAlivePeriod']}
|
||||||
|
>
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Switch,
|
Switch,
|
||||||
Table,
|
Table,
|
||||||
@@ -50,6 +51,29 @@ export default function InboundList({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [statsRecord, setStatsRecord] = useState<DBInboundRecord | null>(null);
|
const [statsRecord, setStatsRecord] = useState<DBInboundRecord | null>(null);
|
||||||
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
||||||
|
// Node filter (#4997): 'all' shows everything, 0 is the local-panel
|
||||||
|
// sentinel (inbounds without a nodeId), otherwise a node id. Session-only.
|
||||||
|
const [nodeFilter, setNodeFilter] = useState<number | 'all'>('all');
|
||||||
|
|
||||||
|
const showNodeFilter = useMemo(
|
||||||
|
() => nodesById.size > 0 || dbInbounds.some((ib) => ib.nodeId != null),
|
||||||
|
[nodesById, dbInbounds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const nodeFilterOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: 'all' as const, label: t('pages.clients.filters.nodes') },
|
||||||
|
{ value: 0, label: t('pages.clients.filters.localPanel') },
|
||||||
|
...Array.from(nodesById.values()).map((n) => ({ value: n.id, label: n.name || `#${n.id}` })),
|
||||||
|
],
|
||||||
|
[nodesById, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const visibleInbounds = useMemo(() => {
|
||||||
|
if (nodeFilter === 'all') return dbInbounds;
|
||||||
|
if (nodeFilter === 0) return dbInbounds.filter((ib) => ib.nodeId == null);
|
||||||
|
return dbInbounds.filter((ib) => ib.nodeId === nodeFilter);
|
||||||
|
}, [dbInbounds, nodeFilter]);
|
||||||
|
|
||||||
const onSwitchEnable = useCallback(async (dbInbound: DBInboundRecord, next: boolean) => {
|
const onSwitchEnable = useCallback(async (dbInbound: DBInboundRecord, next: boolean) => {
|
||||||
const previous = dbInbound.enable;
|
const previous = dbInbound.enable;
|
||||||
@@ -69,6 +93,11 @@ export default function InboundList({
|
|||||||
[dbInbounds],
|
[dbInbounds],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasAnySubSortIndex = useMemo(
|
||||||
|
() => dbInbounds.some((i) => (i.subSortIndex ?? 1) > 1),
|
||||||
|
[dbInbounds],
|
||||||
|
);
|
||||||
|
|
||||||
const toggleSelect = useCallback((id: number, checked: boolean) => {
|
const toggleSelect = useCallback((id: number, checked: boolean) => {
|
||||||
setSelectedRowKeys((prev) => {
|
setSelectedRowKeys((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -78,11 +107,11 @@ export default function InboundList({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const selectAll = useCallback((checked: boolean) => {
|
const selectAll = useCallback((checked: boolean) => {
|
||||||
setSelectedRowKeys(checked ? dbInbounds.map((i) => i.id) : []);
|
setSelectedRowKeys(checked ? visibleInbounds.map((i) => i.id) : []);
|
||||||
}, [dbInbounds]);
|
}, [visibleInbounds]);
|
||||||
|
|
||||||
const allSelected = dbInbounds.length > 0 && selectedRowKeys.length === dbInbounds.length;
|
const allSelected = visibleInbounds.length > 0 && selectedRowKeys.length === visibleInbounds.length;
|
||||||
const someSelected = selectedRowKeys.length > 0 && selectedRowKeys.length < dbInbounds.length;
|
const someSelected = selectedRowKeys.length > 0 && selectedRowKeys.length < visibleInbounds.length;
|
||||||
|
|
||||||
const handleBulkDelete = useCallback(async () => {
|
const handleBulkDelete = useCallback(async () => {
|
||||||
const ok = await onBulkDelete(selectedRowKeys);
|
const ok = await onBulkDelete(selectedRowKeys);
|
||||||
@@ -91,6 +120,7 @@ export default function InboundList({
|
|||||||
|
|
||||||
const columns = useInboundColumns({
|
const columns = useInboundColumns({
|
||||||
hasAnyRemark,
|
hasAnyRemark,
|
||||||
|
hasAnySubSortIndex,
|
||||||
hasActiveNode,
|
hasActiveNode,
|
||||||
nodesById,
|
nodesById,
|
||||||
clientCount,
|
clientCount,
|
||||||
@@ -131,6 +161,15 @@ export default function InboundList({
|
|||||||
{!isMobile && t('pages.inbounds.generalActions')}
|
{!isMobile && t('pages.inbounds.generalActions')}
|
||||||
</Button>
|
</Button>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
{showNodeFilter && (
|
||||||
|
<Select
|
||||||
|
value={nodeFilter}
|
||||||
|
onChange={(v) => setNodeFilter(v)}
|
||||||
|
options={nodeFilterOptions}
|
||||||
|
popupMatchSelectWidth={false}
|
||||||
|
style={{ minWidth: isMobile ? 90 : 140 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{selectedRowKeys.length > 0 && (
|
{selectedRowKeys.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Tag color="blue" closable onClose={() => setSelectedRowKeys([])} style={{ marginInlineEnd: 0 }}>
|
<Tag color="blue" closable onClose={() => setSelectedRowKeys([])} style={{ marginInlineEnd: 0 }}>
|
||||||
@@ -147,7 +186,7 @@ export default function InboundList({
|
|||||||
<Space orientation="vertical" style={{ width: '100%' }}>
|
<Space orientation="vertical" style={{ width: '100%' }}>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<div className="inbound-cards">
|
<div className="inbound-cards">
|
||||||
{dbInbounds.length === 0 ? (
|
{visibleInbounds.length === 0 ? (
|
||||||
<div className="card-empty">
|
<div className="card-empty">
|
||||||
<ImportOutlined style={{ fontSize: 28, opacity: 0.5 }} />
|
<ImportOutlined style={{ fontSize: 28, opacity: 0.5 }} />
|
||||||
<div>{t('noData')}</div>
|
<div>{t('noData')}</div>
|
||||||
@@ -166,7 +205,7 @@ export default function InboundList({
|
|||||||
<span className="bulk-count">{selectedRowKeys.length}</span>
|
<span className="bulk-count">{selectedRowKeys.length}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{dbInbounds.map((record) => (
|
{visibleInbounds.map((record) => (
|
||||||
<div key={record.id} className={`inbound-card${selectedRowKeys.includes(record.id) ? ' is-selected' : ''}`}>
|
<div key={record.id} className={`inbound-card${selectedRowKeys.includes(record.id) ? ' is-selected' : ''}`}>
|
||||||
<div className="card-head">
|
<div className="card-head">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -204,13 +243,13 @@ export default function InboundList({
|
|||||||
) : (
|
) : (
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={dbInbounds}
|
dataSource={visibleInbounds}
|
||||||
rowKey={(r) => r.id}
|
rowKey={(r) => r.id}
|
||||||
rowSelection={{
|
rowSelection={{
|
||||||
selectedRowKeys,
|
selectedRowKeys,
|
||||||
onChange: (keys: Key[]) => setSelectedRowKeys(keys as number[]),
|
onChange: (keys: Key[]) => setSelectedRowKeys(keys as number[]),
|
||||||
}}
|
}}
|
||||||
pagination={paginationFor(dbInbounds)}
|
pagination={paginationFor(visibleInbounds)}
|
||||||
scroll={{ x: 1000 }}
|
scroll={{ x: 1000 }}
|
||||||
style={{ marginTop: 10 }}
|
style={{ marginTop: 10 }}
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowAc
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="action-buttons">
|
<div className="action-buttons">
|
||||||
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => onClick('edit')} />
|
<Button type="text" size="small" style={{ fontSize: 18 }} icon={<EditOutlined />} onClick={() => onClick('edit')} />
|
||||||
<Dropdown
|
<Dropdown
|
||||||
trigger={['click']}
|
trigger={['click']}
|
||||||
menu={{
|
menu={{
|
||||||
@@ -77,7 +77,7 @@ export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowAc
|
|||||||
onClick: ({ key }) => onClick(key as RowAction),
|
onClick: ({ key }) => onClick(key as RowAction),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button type="text" size="small" icon={<MoreOutlined />} />
|
<Button type="text" size="small" style={{ fontSize: 18 }} icon={<MoreOutlined />} />
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface DBInboundRecord extends ProtocolFlags {
|
|||||||
id: number;
|
id: number;
|
||||||
enable: boolean;
|
enable: boolean;
|
||||||
remark: string;
|
remark: string;
|
||||||
|
subSortIndex: number;
|
||||||
port: number;
|
port: number;
|
||||||
protocol: string;
|
protocol: string;
|
||||||
up: number;
|
up: number;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, type ReactElement } from 'react';
|
import { useMemo, type ReactElement } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Popover, Switch, Tag, type TableColumnType } from 'antd';
|
import { Popover, Switch, Tag, Tooltip, type TableColumnType } from 'antd';
|
||||||
import { TeamOutlined } from '@ant-design/icons';
|
import { TeamOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
|
import { SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
|
||||||
@@ -21,6 +21,7 @@ import type { ClientCountEntry, DBInboundRecord, RowAction } from './types';
|
|||||||
|
|
||||||
interface UseInboundColumnsParams {
|
interface UseInboundColumnsParams {
|
||||||
hasAnyRemark: boolean;
|
hasAnyRemark: boolean;
|
||||||
|
hasAnySubSortIndex: boolean;
|
||||||
hasActiveNode: boolean;
|
hasActiveNode: boolean;
|
||||||
nodesById: Map<number, NodeRecord>;
|
nodesById: Map<number, NodeRecord>;
|
||||||
clientCount: Record<number, ClientCountEntry>;
|
clientCount: Record<number, ClientCountEntry>;
|
||||||
@@ -33,6 +34,7 @@ interface UseInboundColumnsParams {
|
|||||||
|
|
||||||
export function useInboundColumns({
|
export function useInboundColumns({
|
||||||
hasAnyRemark,
|
hasAnyRemark,
|
||||||
|
hasAnySubSortIndex,
|
||||||
hasActiveNode,
|
hasActiveNode,
|
||||||
nodesById,
|
nodesById,
|
||||||
clientCount,
|
clientCount,
|
||||||
@@ -113,6 +115,20 @@ export function useInboundColumns({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasAnySubSortIndex) {
|
||||||
|
cols.push({
|
||||||
|
title: (
|
||||||
|
<Tooltip title={t('pages.inbounds.form.subSortIndex')}>
|
||||||
|
{t('pages.inbounds.subSortIndex')}
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
dataIndex: 'subSortIndex',
|
||||||
|
key: 'subSortIndex',
|
||||||
|
align: 'right',
|
||||||
|
width: 70,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
cols.push(
|
cols.push(
|
||||||
{
|
{
|
||||||
title: t('pages.inbounds.port'),
|
title: t('pages.inbounds.port'),
|
||||||
@@ -267,5 +283,5 @@ export function useInboundColumns({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return cols;
|
return cols;
|
||||||
}, [t, hasAnyRemark, hasActiveNode, nodesById, clientCount, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable]);
|
}, [t, hasAnyRemark, hasAnySubSortIndex, hasActiveNode, nodesById, clientCount, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||||
|
import type { RemoteInboundOption } from '@/api/queries/useNodeMutations';
|
||||||
import type { Msg } from '@/utils';
|
import type { Msg } from '@/utils';
|
||||||
import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node';
|
import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node';
|
||||||
import { antdRule } from '@/utils/zodForm';
|
import { antdRule } from '@/utils/zodForm';
|
||||||
@@ -27,6 +28,7 @@ interface NodeFormModalProps {
|
|||||||
node: NodeRecord | null;
|
node: NodeRecord | null;
|
||||||
testConnection: (payload: Partial<NodeRecord>) => Promise<Msg<ProbeResult>>;
|
testConnection: (payload: Partial<NodeRecord>) => Promise<Msg<ProbeResult>>;
|
||||||
fetchFingerprint: (payload: Partial<NodeRecord>) => Promise<Msg<string>>;
|
fetchFingerprint: (payload: Partial<NodeRecord>) => Promise<Msg<string>>;
|
||||||
|
fetchInbounds: (payload: Partial<NodeRecord>) => Promise<Msg<RemoteInboundOption[]>>;
|
||||||
save: (payload: Partial<NodeRecord>) => Promise<Msg<unknown>>;
|
save: (payload: Partial<NodeRecord>) => Promise<Msg<unknown>>;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
@@ -45,6 +47,8 @@ function defaultValues(): NodeFormValues {
|
|||||||
allowPrivateAddress: false,
|
allowPrivateAddress: false,
|
||||||
tlsVerifyMode: 'verify',
|
tlsVerifyMode: 'verify',
|
||||||
pinnedCertSha256: '',
|
pinnedCertSha256: '',
|
||||||
|
inboundSyncMode: 'all',
|
||||||
|
inboundTags: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +58,7 @@ export default function NodeFormModal({
|
|||||||
node,
|
node,
|
||||||
testConnection,
|
testConnection,
|
||||||
fetchFingerprint,
|
fetchFingerprint,
|
||||||
|
fetchInbounds,
|
||||||
save,
|
save,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: NodeFormModalProps) {
|
}: NodeFormModalProps) {
|
||||||
@@ -64,9 +69,12 @@ export default function NodeFormModal({
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [fetchingPin, setFetchingPin] = useState(false);
|
const [fetchingPin, setFetchingPin] = useState(false);
|
||||||
|
const [fetchingInbounds, setFetchingInbounds] = useState(false);
|
||||||
|
const [inboundOptions, setInboundOptions] = useState<RemoteInboundOption[]>([]);
|
||||||
const [testResult, setTestResult] = useState<ProbeResult | null>(null);
|
const [testResult, setTestResult] = useState<ProbeResult | null>(null);
|
||||||
const scheme = Form.useWatch('scheme', form) ?? 'https';
|
const scheme = Form.useWatch('scheme', form) ?? 'https';
|
||||||
const tlsVerifyMode = Form.useWatch('tlsVerifyMode', form) ?? 'verify';
|
const tlsVerifyMode = Form.useWatch('tlsVerifyMode', form) ?? 'verify';
|
||||||
|
const inboundSyncMode = Form.useWatch('inboundSyncMode', form) ?? 'all';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -77,11 +85,14 @@ export default function NodeFormModal({
|
|||||||
...(node as unknown as Partial<NodeFormValues>),
|
...(node as unknown as Partial<NodeFormValues>),
|
||||||
id: node.id,
|
id: node.id,
|
||||||
scheme: (node.scheme as 'http' | 'https') || base.scheme,
|
scheme: (node.scheme as 'http' | 'https') || base.scheme,
|
||||||
|
inboundSyncMode: (node.inboundSyncMode as 'all' | 'selected') || base.inboundSyncMode,
|
||||||
|
inboundTags: node.inboundTags ?? [],
|
||||||
}
|
}
|
||||||
: base;
|
: base;
|
||||||
if (next.scheme === 'http') next.tlsVerifyMode = 'skip';
|
if (next.scheme === 'http') next.tlsVerifyMode = 'skip';
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
form.setFieldsValue(next);
|
form.setFieldsValue(next);
|
||||||
|
setInboundOptions((next.inboundTags || []).map((tag) => ({ tag })));
|
||||||
setTestResult(null);
|
setTestResult(null);
|
||||||
}, [open, mode, node, form]);
|
}, [open, mode, node, form]);
|
||||||
|
|
||||||
@@ -104,6 +115,8 @@ export default function NodeFormModal({
|
|||||||
allowPrivateAddress: values.allowPrivateAddress,
|
allowPrivateAddress: values.allowPrivateAddress,
|
||||||
tlsVerifyMode: values.tlsVerifyMode,
|
tlsVerifyMode: values.tlsVerifyMode,
|
||||||
pinnedCertSha256: values.tlsVerifyMode === 'pin' ? values.pinnedCertSha256.trim() : '',
|
pinnedCertSha256: values.tlsVerifyMode === 'pin' ? values.pinnedCertSha256.trim() : '',
|
||||||
|
inboundSyncMode: values.inboundSyncMode,
|
||||||
|
inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +162,26 @@ export default function NodeFormModal({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onFetchInbounds() {
|
||||||
|
try {
|
||||||
|
await form.validateFields(['name', 'address', 'port', 'apiToken']);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFetchingInbounds(true);
|
||||||
|
try {
|
||||||
|
const msg = await fetchInbounds(buildPayload(form.getFieldsValue(true)));
|
||||||
|
if (msg?.success && Array.isArray(msg.obj)) {
|
||||||
|
setInboundOptions(msg.obj);
|
||||||
|
messageApi.success(t('pages.nodes.inboundsLoaded', { count: msg.obj.length }));
|
||||||
|
} else {
|
||||||
|
messageApi.error(msg?.msg || t('pages.nodes.inboundsLoadFailed'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setFetchingInbounds(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onFinish(values: NodeFormValues) {
|
async function onFinish(values: NodeFormValues) {
|
||||||
const result = NodeFormSchema.safeParse(values);
|
const result = NodeFormSchema.safeParse(values);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -323,6 +356,46 @@ export default function NodeFormModal({
|
|||||||
<Input.Password placeholder={t('pages.nodes.apiTokenPlaceholder')} />
|
<Input.Password placeholder={t('pages.nodes.apiTokenPlaceholder')} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={t('pages.nodes.inboundSyncMode')}
|
||||||
|
name="inboundSyncMode"
|
||||||
|
extra={t('pages.nodes.inboundSyncModeHint')}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: 'all', label: t('pages.nodes.allInbounds') },
|
||||||
|
{ value: 'selected', label: t('pages.nodes.selectedInbounds') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{inboundSyncMode === 'selected' && (
|
||||||
|
<Form.Item
|
||||||
|
label={t('pages.nodes.inboundTags')}
|
||||||
|
name="inboundTags"
|
||||||
|
extra={t('pages.nodes.inboundTagsHint')}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
allowClear
|
||||||
|
loading={fetchingInbounds}
|
||||||
|
placeholder={t('pages.nodes.inboundTagsPlaceholder')}
|
||||||
|
popupRender={(menu) => (
|
||||||
|
<>
|
||||||
|
<Button type="text" block loading={fetchingInbounds} onClick={onFetchInbounds}>
|
||||||
|
{t('pages.nodes.loadInbounds')}
|
||||||
|
</Button>
|
||||||
|
{menu}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
options={inboundOptions.map((inbound) => ({
|
||||||
|
value: inbound.tag,
|
||||||
|
label: `${inbound.remark || inbound.tag}${inbound.protocol ? ` (${inbound.protocol}:${inbound.port || 0})` : ''}`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="test-row">
|
<div className="test-row">
|
||||||
<Button type="default" loading={testing} onClick={onTest}>
|
<Button type="default" loading={testing} onClick={onTest}>
|
||||||
{t('pages.nodes.testConnection')}
|
{t('pages.nodes.testConnection')}
|
||||||
|
|||||||
@@ -241,18 +241,18 @@ export default function NodeList({
|
|||||||
) : (
|
) : (
|
||||||
<Space>
|
<Space>
|
||||||
<Tooltip title={t('pages.nodes.probe')}>
|
<Tooltip title={t('pages.nodes.probe')}>
|
||||||
<Button type="text" size="small" icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
|
<Button type="text" size="small" style={{ fontSize: 18 }} icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{isUpdateEligible(record) && (
|
{isUpdateEligible(record) && (
|
||||||
<Tooltip title={t('pages.nodes.updatePanel')}>
|
<Tooltip title={t('pages.nodes.updatePanel')}>
|
||||||
<Button type="text" size="small" icon={<CloudDownloadOutlined />} onClick={() => onUpdateNode(record)} />
|
<Button type="text" size="small" style={{ fontSize: 18 }} icon={<CloudDownloadOutlined />} onClick={() => onUpdateNode(record)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
<Tooltip title={t('edit')}>
|
<Tooltip title={t('edit')}>
|
||||||
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => onEdit(record)} />
|
<Button type="text" size="small" style={{ fontSize: 18 }} icon={<EditOutlined />} onClick={() => onEdit(record)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title={t('delete')}>
|
<Tooltip title={t('delete')}>
|
||||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
|
<Button type="text" size="small" danger style={{ fontSize: 18 }} icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default function NodesPage() {
|
|||||||
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
||||||
|
|
||||||
const { nodes, loading, fetched, fetchError, refetch, totals } = useNodesQuery();
|
const { nodes, loading, fetched, fetchError, refetch, totals } = useNodesQuery();
|
||||||
const { create, update, remove, setEnable, testConnection, fetchFingerprint, probe, updatePanels } = useNodeMutations();
|
const { create, update, remove, setEnable, testConnection, fetchFingerprint, fetchInbounds, probe, updatePanels } = useNodeMutations();
|
||||||
|
|
||||||
const { data: latestVersion = '' } = useQuery({
|
const { data: latestVersion = '' } = useQuery({
|
||||||
queryKey: ['server', 'panelUpdateInfo'],
|
queryKey: ['server', 'panelUpdateInfo'],
|
||||||
@@ -235,6 +235,7 @@ export default function NodesPage() {
|
|||||||
node={formNode}
|
node={formNode}
|
||||||
testConnection={testConnection}
|
testConnection={testConnection}
|
||||||
fetchFingerprint={fetchFingerprint}
|
fetchFingerprint={fetchFingerprint}
|
||||||
|
fetchInbounds={fetchInbounds}
|
||||||
save={onSave}
|
save={onSave}
|
||||||
onOpenChange={setFormOpen}
|
onOpenChange={setFormOpen}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
|||||||
|
|
||||||
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
|
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
|
||||||
const [inboundOptions, setInboundOptions] = useState<{ label: string; value: string }[]>([]);
|
const [inboundOptions, setInboundOptions] = useState<{ label: string; value: string }[]>([]);
|
||||||
const [outboundOptions, setOutboundOptions] = useState<{ label: string; value: string }[]>([]);
|
const [outboundTagList, setOutboundTagList] = useState<string[]>([]);
|
||||||
|
const [balancerTagList, setBalancerTagList] = useState<string[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -69,9 +70,11 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
// Outbound tags for the panel egress picker: template outbounds plus
|
// Candidates for the panel egress picker: template outbounds plus
|
||||||
// subscription-derived outbounds, same candidate set as the geodata
|
// subscription-derived outbounds, and routing balancers. The panel egress
|
||||||
// download picker.
|
// is injected as a routing rule, so a balancer tag is a valid target
|
||||||
|
// (it load-balances the panel's own traffic). The geodata picker, by
|
||||||
|
// contrast, dials a forced tag and can only use a concrete outbound.
|
||||||
const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true }) as ApiMsg<string>;
|
const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true }) as ApiMsg<string>;
|
||||||
if (cancelled || !msg?.success || typeof msg.obj !== 'string') return;
|
if (cancelled || !msg?.success || typeof msg.obj !== 'string') return;
|
||||||
try {
|
try {
|
||||||
@@ -90,14 +93,38 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
|||||||
for (const tag of subTags) {
|
for (const tag of subTags) {
|
||||||
if (typeof tag === 'string' && tag) tags.add(tag);
|
if (typeof tag === 'string' && tag) tags.add(tag);
|
||||||
}
|
}
|
||||||
setOutboundOptions([...tags].map((tag) => ({ label: tag, value: tag })));
|
const balancerTags: string[] = [];
|
||||||
|
const routing = (template.routing || {}) as Record<string, unknown>;
|
||||||
|
const balancers = Array.isArray(routing.balancers) ? routing.balancers : [];
|
||||||
|
for (const b of balancers) {
|
||||||
|
if (!b || typeof b !== 'object') continue;
|
||||||
|
const tag = (b as Record<string, unknown>).tag;
|
||||||
|
if (typeof tag === 'string' && tag && !tags.has(tag)) balancerTags.push(tag);
|
||||||
|
}
|
||||||
|
setOutboundTagList([...tags]);
|
||||||
|
setBalancerTagList(balancerTags);
|
||||||
} catch {
|
} catch {
|
||||||
setOutboundOptions([]);
|
setOutboundTagList([]);
|
||||||
|
setBalancerTagList([]);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Outbound tags and balancer tags share one picker. When balancers exist they
|
||||||
|
// get their own labeled group so it's clear the selection routes through a
|
||||||
|
// balancer rather than a single outbound.
|
||||||
|
const outboundOptions = useMemo<
|
||||||
|
({ label: string; value: string } | { label: string; options: { label: string; value: string }[] })[]
|
||||||
|
>(() => {
|
||||||
|
const outOpts = outboundTagList.map((tag) => ({ label: tag, value: tag }));
|
||||||
|
if (balancerTagList.length === 0) return outOpts;
|
||||||
|
return [
|
||||||
|
{ label: t('pages.xray.Outbounds'), options: outOpts },
|
||||||
|
{ label: t('pages.xray.Balancers'), options: balancerTagList.map((tag) => ({ label: tag, value: tag })) },
|
||||||
|
];
|
||||||
|
}, [outboundTagList, balancerTagList, t]);
|
||||||
|
|
||||||
const ldapInboundTagList = useMemo(() => {
|
const ldapInboundTagList = useMemo(() => {
|
||||||
const csv = allSetting.ldapInboundTags || '';
|
const csv = allSetting.ldapInboundTags || '';
|
||||||
return csv.length ? csv.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
return csv.length ? csv.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
||||||
@@ -183,6 +210,11 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
|||||||
onChange={(v) => updateSetting({ pageSize: Number(v) || 0 })} />
|
onChange={(v) => updateSetting({ pageSize: Number(v) || 0 })} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
|
||||||
|
<SettingListItem paddings="small" title={t('pages.settings.restartXrayOnClientDisable')} description={t('pages.settings.restartXrayOnClientDisableDesc')}>
|
||||||
|
<Switch checked={allSetting.restartXrayOnClientDisable}
|
||||||
|
onChange={(v) => updateSetting({ restartXrayOnClientDisable: v })} />
|
||||||
|
</SettingListItem>
|
||||||
|
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.language')}>
|
<SettingListItem paddings="small" title={t('pages.settings.language')}>
|
||||||
<Select
|
<Select
|
||||||
value={lang}
|
value={lang}
|
||||||
@@ -240,10 +272,6 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
|||||||
onChange={(e) => updateSetting({ externalTrafficInformURI: e.target.value })}
|
onChange={(e) => updateSetting({ externalTrafficInformURI: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.restartXrayOnClientDisable')} description={t('pages.settings.restartXrayOnClientDisableDesc')}>
|
|
||||||
<Switch checked={allSetting.restartXrayOnClientDisable}
|
|
||||||
onChange={(v) => updateSetting({ restartXrayOnClientDisable: v })} />
|
|
||||||
</SettingListItem>
|
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Divider, Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
|
import { Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
|
||||||
import { ClockCircleOutlined, InfoCircleOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons';
|
import { BranchesOutlined, IdcardOutlined, InfoCircleOutlined, NodeIndexOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { AllSetting } from '@/models/setting';
|
import type { AllSetting } from '@/models/setting';
|
||||||
import { SettingListItem } from '@/components/ui';
|
import { SettingListItem } from '@/components/ui';
|
||||||
@@ -139,8 +139,18 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
|
|||||||
</Space.Compact>
|
</Space.Compact>
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
|
||||||
<Divider>{t('pages.settings.subTitle')}</Divider>
|
<SettingListItem paddings="small" title={t('pages.settings.subUpdates')} description={t('pages.settings.subUpdatesDesc')}>
|
||||||
|
<InputNumber value={allSetting.subUpdates} min={1} style={{ width: '100%' }}
|
||||||
|
onChange={(v) => updateSetting({ subUpdates: Number(v) || 0 })} />
|
||||||
|
</SettingListItem>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '3',
|
||||||
|
label: catTabLabel(<IdcardOutlined />, t('pages.settings.profile'), isMobile),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.subTitle')} description={t('pages.settings.subTitleDesc')}>
|
<SettingListItem paddings="small" title={t('pages.settings.subTitle')} description={t('pages.settings.subTitleDesc')}>
|
||||||
<Input value={allSetting.subTitle} onChange={(e) => updateSetting({ subTitle: e.target.value })} />
|
<Input value={allSetting.subTitle} onChange={(e) => updateSetting({ subTitle: e.target.value })} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
@@ -156,14 +166,47 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
|
|||||||
<Input.TextArea value={allSetting.subAnnounce}
|
<Input.TextArea value={allSetting.subAnnounce}
|
||||||
onChange={(e) => updateSetting({ subAnnounce: e.target.value })} />
|
onChange={(e) => updateSetting({ subAnnounce: e.target.value })} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
<SettingListItem
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.subThemeDir')} description={t('pages.settings.subThemeDirDesc')}>
|
paddings="small"
|
||||||
|
title={t('pages.settings.subThemeDir')}
|
||||||
|
description={(
|
||||||
|
<>
|
||||||
|
{t('pages.settings.subThemeDirDesc')}{' '}
|
||||||
|
<a
|
||||||
|
href="https://github.com/MHSanaei/3x-ui/blob/main/docs/custom-subscription-templates.md"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{t('pages.settings.subThemeDirDocs')}
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Input value={allSetting.subThemeDir} placeholder="/etc/3x-ui/sub_templates/my-theme/"
|
<Input value={allSetting.subThemeDir} placeholder="/etc/3x-ui/sub_templates/my-theme/"
|
||||||
onChange={(e) => updateSetting({ subThemeDir: e.target.value })} />
|
onChange={(e) => updateSetting({ subThemeDir: e.target.value })} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
</>
|
||||||
<Divider>Happ</Divider>
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '4',
|
||||||
|
label: catTabLabel(<SafetyCertificateOutlined />, t('pages.settings.certs'), isMobile),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<SettingListItem paddings="small" title={t('pages.settings.subCertPath')} description={t('pages.settings.subCertPathDesc')}>
|
||||||
|
<Input value={allSetting.subCertFile} onChange={(e) => updateSetting({ subCertFile: e.target.value })} />
|
||||||
|
</SettingListItem>
|
||||||
|
<SettingListItem paddings="small" title={t('pages.settings.subKeyPath')} description={t('pages.settings.subKeyPathDesc')}>
|
||||||
|
<Input value={allSetting.subKeyFile} onChange={(e) => updateSetting({ subKeyFile: e.target.value })} />
|
||||||
|
</SettingListItem>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '5',
|
||||||
|
label: catTabLabel(<BranchesOutlined />, 'Happ', isMobile),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.subEnableRouting')} description={t('pages.settings.subEnableRoutingDesc')}>
|
<SettingListItem paddings="small" title={t('pages.settings.subEnableRouting')} description={t('pages.settings.subEnableRoutingDesc')}>
|
||||||
<Switch checked={allSetting.subEnableRouting} onChange={(v) => updateSetting({ subEnableRouting: v })} />
|
<Switch checked={allSetting.subEnableRouting} onChange={(v) => updateSetting({ subEnableRouting: v })} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
@@ -171,9 +214,14 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
|
|||||||
<Input.TextArea value={allSetting.subRoutingRules} placeholder="happ://routing/add/..."
|
<Input.TextArea value={allSetting.subRoutingRules} placeholder="happ://routing/add/..."
|
||||||
onChange={(e) => updateSetting({ subRoutingRules: e.target.value })} />
|
onChange={(e) => updateSetting({ subRoutingRules: e.target.value })} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
</>
|
||||||
<Divider>Clash / Mihomo</Divider>
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '6',
|
||||||
|
label: catTabLabel(<NodeIndexOutlined />, 'Clash / Mihomo', isMobile),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.subClashEnableRouting')} description={t('pages.settings.subClashEnableRoutingDesc')}>
|
<SettingListItem paddings="small" title={t('pages.settings.subClashEnableRouting')} description={t('pages.settings.subClashEnableRoutingDesc')}>
|
||||||
<Switch checked={allSetting.subClashEnableRouting} onChange={(v) => updateSetting({ subClashEnableRouting: v })} />
|
<Switch checked={allSetting.subClashEnableRouting} onChange={(v) => updateSetting({ subClashEnableRouting: v })} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
@@ -188,32 +236,6 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
|
|||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: '3',
|
|
||||||
label: catTabLabel(<SafetyCertificateOutlined />, t('pages.settings.certs'), isMobile),
|
|
||||||
children: (
|
|
||||||
<>
|
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.subCertPath')} description={t('pages.settings.subCertPathDesc')}>
|
|
||||||
<Input value={allSetting.subCertFile} onChange={(e) => updateSetting({ subCertFile: e.target.value })} />
|
|
||||||
</SettingListItem>
|
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.subKeyPath')} description={t('pages.settings.subKeyPathDesc')}>
|
|
||||||
<Input value={allSetting.subKeyFile} onChange={(e) => updateSetting({ subKeyFile: e.target.value })} />
|
|
||||||
</SettingListItem>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '4',
|
|
||||||
label: catTabLabel(<ClockCircleOutlined />, t('pages.settings.intervals'), isMobile),
|
|
||||||
children: (
|
|
||||||
<>
|
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.subUpdates')} description={t('pages.settings.subUpdatesDesc')}>
|
|
||||||
<InputNumber value={allSetting.subUpdates} min={1} style={{ width: '100%' }}
|
|
||||||
onChange={(v) => updateSetting({ subUpdates: Number(v) || 0 })} />
|
|
||||||
</SettingListItem>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]} />
|
]} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Input, InputNumber, Select, Switch, Tabs } from 'antd';
|
import { Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
|
||||||
import { BellOutlined, SettingOutlined } from '@ant-design/icons';
|
import { BellOutlined, SettingOutlined } from '@ant-design/icons';
|
||||||
import { LanguageManager } from '@/utils';
|
import { LanguageManager } from '@/utils';
|
||||||
import type { AllSetting } from '@/models/setting';
|
import type { AllSetting } from '@/models/setting';
|
||||||
@@ -13,6 +13,134 @@ interface TelegramTabProps {
|
|||||||
updateSetting: (patch: Partial<AllSetting>) => void;
|
updateSetting: (patch: Partial<AllSetting>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The notification schedule is fed straight to robfig/cron's AddJob (see
|
||||||
|
// web.go startTask), which accepts @every <duration>, the @hourly/@daily/...
|
||||||
|
// macros, and full crontab expressions. This builder covers the common cases
|
||||||
|
// with dropdowns so users don't have to memorise the syntax, while "Custom"
|
||||||
|
// preserves the raw crontab escape hatch.
|
||||||
|
type Unit = 's' | 'm' | 'h';
|
||||||
|
type Macro = '@hourly' | '@daily' | '@weekly' | '@monthly';
|
||||||
|
type Mode = 'every' | Macro | 'custom';
|
||||||
|
const MACROS: Macro[] = ['@hourly', '@daily', '@weekly', '@monthly'];
|
||||||
|
const EVERY_RE = /^@every\s+(\d+)\s*([smh])$/i;
|
||||||
|
|
||||||
|
interface RunTime {
|
||||||
|
mode: Mode;
|
||||||
|
num: number;
|
||||||
|
unit: Unit;
|
||||||
|
custom: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRunTime(raw: string): RunTime {
|
||||||
|
const v = (raw ?? '').trim();
|
||||||
|
const m = v.match(EVERY_RE);
|
||||||
|
if (m) {
|
||||||
|
return { mode: 'every', num: Math.max(1, Number(m[1]) || 1), unit: m[2].toLowerCase() as Unit, custom: '' };
|
||||||
|
}
|
||||||
|
if ((MACROS as string[]).includes(v)) {
|
||||||
|
return { mode: v as Macro, num: 1, unit: 'h', custom: '' };
|
||||||
|
}
|
||||||
|
return { mode: 'custom', num: 1, unit: 'h', custom: v };
|
||||||
|
}
|
||||||
|
|
||||||
|
function composeRunTime(s: RunTime): string {
|
||||||
|
if (s.mode === 'every') return `@every ${Math.max(1, s.num || 1)}${s.unit}`;
|
||||||
|
if (s.mode === 'custom') return s.custom;
|
||||||
|
return s.mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The panel's cron runs with seconds enabled (cron.WithSeconds() in web.go), so
|
||||||
|
// crontab expressions are 6-field: "second minute hour day month weekday". When
|
||||||
|
// the user drops into Custom we seed the box with the crontab equivalent of the
|
||||||
|
// current selection rather than a bare @macro, so they get a real expression to
|
||||||
|
// edit (and one that the 6-field parser accepts).
|
||||||
|
function toCrontab(s: RunTime): string {
|
||||||
|
switch (s.mode) {
|
||||||
|
case '@hourly': return '0 0 * * * *';
|
||||||
|
case '@daily': return '0 0 0 * * *';
|
||||||
|
case '@weekly': return '0 0 0 * * 0';
|
||||||
|
case '@monthly': return '0 0 0 1 * *';
|
||||||
|
case 'every': {
|
||||||
|
const n = Math.max(1, s.num || 1);
|
||||||
|
if (s.unit === 's') return `*/${n} * * * * *`;
|
||||||
|
if (s.unit === 'm') return `0 */${n} * * * *`;
|
||||||
|
return `0 0 */${n} * * *`;
|
||||||
|
}
|
||||||
|
default: return s.custom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotifyTimeField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// Init once: the Settings tabs only mount after settings are fetched, so the
|
||||||
|
// incoming value is already the persisted one.
|
||||||
|
const [state, setState] = useState<RunTime>(() => parseRunTime(value));
|
||||||
|
|
||||||
|
function update(patch: Partial<RunTime>) {
|
||||||
|
const next = { ...state, ...patch };
|
||||||
|
setState(next);
|
||||||
|
onChange(composeRunTime(next));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onModeChange(mode: Mode) {
|
||||||
|
// Seed Custom with the crontab equivalent of the current selection so the
|
||||||
|
// box starts from a real expression (e.g. "0 0 0 * * *", not "@daily").
|
||||||
|
if (mode === 'custom' && !state.custom.trim()) {
|
||||||
|
update({ mode, custom: toCrontab(state) });
|
||||||
|
} else {
|
||||||
|
update({ mode });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const modeOptions = [
|
||||||
|
{ value: 'every', label: t('pages.settings.notifyTime.every') },
|
||||||
|
{ value: '@hourly', label: t('pages.settings.notifyTime.hourly') },
|
||||||
|
{ value: '@daily', label: t('pages.settings.notifyTime.daily') },
|
||||||
|
{ value: '@weekly', label: t('pages.settings.notifyTime.weekly') },
|
||||||
|
{ value: '@monthly', label: t('pages.settings.notifyTime.monthly') },
|
||||||
|
{ value: 'custom', label: t('pages.settings.notifyTime.custom') },
|
||||||
|
];
|
||||||
|
const unitOptions = [
|
||||||
|
{ value: 's', label: t('pages.settings.notifyTime.seconds') },
|
||||||
|
{ value: 'm', label: t('pages.settings.notifyTime.minutes') },
|
||||||
|
{ value: 'h', label: t('pages.settings.notifyTime.hours') },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||||
|
<Select<Mode>
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={state.mode}
|
||||||
|
options={modeOptions}
|
||||||
|
onChange={onModeChange}
|
||||||
|
/>
|
||||||
|
{state.mode === 'every' && (
|
||||||
|
<Space.Compact style={{ width: '100%' }}>
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
style={{ width: '50%' }}
|
||||||
|
value={state.num}
|
||||||
|
onChange={(v) => update({ num: Math.max(1, Number(v) || 1) })}
|
||||||
|
/>
|
||||||
|
<Select<Unit>
|
||||||
|
style={{ width: '50%' }}
|
||||||
|
value={state.unit}
|
||||||
|
options={unitOptions}
|
||||||
|
onChange={(unit) => update({ unit })}
|
||||||
|
/>
|
||||||
|
</Space.Compact>
|
||||||
|
)}
|
||||||
|
{state.mode === 'custom' && (
|
||||||
|
<Input
|
||||||
|
value={state.custom}
|
||||||
|
placeholder="0 30 8 * * *"
|
||||||
|
onChange={(e) => update({ custom: e.target.value })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function TelegramTab({ allSetting, updateSetting }: TelegramTabProps) {
|
export default function TelegramTab({ allSetting, updateSetting }: TelegramTabProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isMobile } = useMediaQuery();
|
const { isMobile } = useMediaQuery();
|
||||||
@@ -79,7 +207,7 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
|
|||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.telegramNotifyTime')} description={t('pages.settings.telegramNotifyTimeDesc')}>
|
<SettingListItem paddings="small" title={t('pages.settings.telegramNotifyTime')} description={t('pages.settings.telegramNotifyTimeDesc')}>
|
||||||
<Input value={allSetting.tgRunTime} onChange={(e) => updateSetting({ tgRunTime: e.target.value })} />
|
<NotifyTimeField value={allSetting.tgRunTime} onChange={(v) => updateSetting({ tgRunTime: v })} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.tgNotifyBackup')} description={t('pages.settings.tgNotifyBackupDesc')}>
|
<SettingListItem paddings="small" title={t('pages.settings.tgNotifyBackup')} description={t('pages.settings.tgNotifyBackupDesc')}>
|
||||||
<Switch checked={allSetting.tgBotBackup} onChange={(v) => updateSetting({ tgBotBackup: v })} />
|
<Switch checked={allSetting.tgBotBackup} onChange={(v) => updateSetting({ tgBotBackup: v })} />
|
||||||
|
|||||||
@@ -128,6 +128,26 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
|
|||||||
return list.map((server, idx) => ({ key: idx, server }));
|
return list.map((server, idx) => ({ key: idx, server }));
|
||||||
}, [dns?.servers]);
|
}, [dns?.servers]);
|
||||||
|
|
||||||
|
// Stable callbacks: the column definitions in useDnsServerColumns are
|
||||||
|
// memoized, so they must be able to depend on these (see issue #5155)
|
||||||
|
const openEditServer = useCallback(
|
||||||
|
(idx: number) => {
|
||||||
|
setEditingServer((dns?.servers || [])[idx] || null);
|
||||||
|
setEditingIndex(idx);
|
||||||
|
setServerModalOpen(true);
|
||||||
|
},
|
||||||
|
[dns?.servers],
|
||||||
|
);
|
||||||
|
const deleteServer = useCallback(
|
||||||
|
(idx: number) => {
|
||||||
|
mutate((tt) => {
|
||||||
|
const cfg = tt.dns as DnsConfig | undefined;
|
||||||
|
if (cfg?.servers) cfg.servers.splice(idx, 1);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[mutate],
|
||||||
|
);
|
||||||
|
|
||||||
const dnsColumns = useDnsServerColumns({ openEditServer, deleteServer });
|
const dnsColumns = useDnsServerColumns({ openEditServer, deleteServer });
|
||||||
|
|
||||||
function openAddServer() {
|
function openAddServer() {
|
||||||
@@ -135,11 +155,6 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
|
|||||||
setEditingIndex(null);
|
setEditingIndex(null);
|
||||||
setServerModalOpen(true);
|
setServerModalOpen(true);
|
||||||
}
|
}
|
||||||
function openEditServer(idx: number) {
|
|
||||||
setEditingServer((dns?.servers || [])[idx] || null);
|
|
||||||
setEditingIndex(idx);
|
|
||||||
setServerModalOpen(true);
|
|
||||||
}
|
|
||||||
function onServerConfirm(value: DnsServerValue) {
|
function onServerConfirm(value: DnsServerValue) {
|
||||||
mutate((tt) => {
|
mutate((tt) => {
|
||||||
if (!tt.dns) return;
|
if (!tt.dns) return;
|
||||||
@@ -150,12 +165,6 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
|
|||||||
});
|
});
|
||||||
setServerModalOpen(false);
|
setServerModalOpen(false);
|
||||||
}
|
}
|
||||||
function deleteServer(idx: number) {
|
|
||||||
mutate((tt) => {
|
|
||||||
const cfg = tt.dns as DnsConfig | undefined;
|
|
||||||
if (cfg?.servers) cfg.servers.splice(idx, 1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function clearAllServers() {
|
function clearAllServers() {
|
||||||
modal.confirm({
|
modal.confirm({
|
||||||
title: t('pages.xray.dns.clearAllTitle'),
|
title: t('pages.xray.dns.clearAllTitle'),
|
||||||
@@ -182,6 +191,28 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
|
|||||||
return list.map((entry, idx) => ({ key: idx, ...entry }));
|
return list.map((entry, idx) => ({ key: idx, ...entry }));
|
||||||
}, [templateSettings?.fakedns]);
|
}, [templateSettings?.fakedns]);
|
||||||
|
|
||||||
|
const deleteFakedns = useCallback(
|
||||||
|
(idx: number) => {
|
||||||
|
mutate((tt) => {
|
||||||
|
const list = tt.fakedns as FakednsRow[] | undefined;
|
||||||
|
if (!list) return;
|
||||||
|
list.splice(idx, 1);
|
||||||
|
if (list.length === 0) tt.fakedns = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[mutate],
|
||||||
|
);
|
||||||
|
const updateFakednsField = useCallback(
|
||||||
|
(idx: number, field: 'ipPool' | 'poolSize', value: string | number) => {
|
||||||
|
mutate((tt) => {
|
||||||
|
const list = tt.fakedns as FakednsRow[] | undefined;
|
||||||
|
if (!list?.[idx]) return;
|
||||||
|
(list[idx] as unknown as Record<string, unknown>)[field] = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[mutate],
|
||||||
|
);
|
||||||
|
|
||||||
const fakednsColumns = useFakednsColumns({ deleteFakedns, updateFakednsField });
|
const fakednsColumns = useFakednsColumns({ deleteFakedns, updateFakednsField });
|
||||||
|
|
||||||
function addFakedns() {
|
function addFakedns() {
|
||||||
@@ -190,21 +221,6 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
|
|||||||
(tt.fakedns as FakednsRow[]).push(DEFAULT_FAKEDNS());
|
(tt.fakedns as FakednsRow[]).push(DEFAULT_FAKEDNS());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function deleteFakedns(idx: number) {
|
|
||||||
mutate((tt) => {
|
|
||||||
const list = tt.fakedns as FakednsRow[] | undefined;
|
|
||||||
if (!list) return;
|
|
||||||
list.splice(idx, 1);
|
|
||||||
if (list.length === 0) tt.fakedns = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function updateFakednsField(idx: number, field: 'ipPool' | 'poolSize', value: string | number) {
|
|
||||||
mutate((tt) => {
|
|
||||||
const list = tt.fakedns as FakednsRow[] | undefined;
|
|
||||||
if (!list?.[idx]) return;
|
|
||||||
(list[idx] as unknown as Record<string, unknown>)[field] = value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = useMemo(() => {
|
const items = useMemo(() => {
|
||||||
const out = [
|
const out = [
|
||||||
|
|||||||
@@ -61,8 +61,7 @@ export function useDnsServerColumns({
|
|||||||
render: (_v, record) => <span className="muted">{expectedIPsFor(record.server)}</span>,
|
render: (_v, record) => <span className="muted">{expectedIPsFor(record.server)}</span>,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
[t, openEditServer, deleteServer],
|
||||||
[t],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +115,6 @@ export function useFakednsColumns({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
[deleteFakedns, updateFakednsField],
|
||||||
[],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import {
|
|||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
VerticalAlignTopOutlined,
|
VerticalAlignTopOutlined,
|
||||||
ThunderboltOutlined,
|
ThunderboltOutlined,
|
||||||
CheckCircleFilled,
|
|
||||||
CloseCircleFilled,
|
|
||||||
LoadingOutlined,
|
LoadingOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
@@ -17,6 +15,7 @@ import { OutboundProtocols as Protocols } from '@/schemas/primitives';
|
|||||||
import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||||
|
|
||||||
import type { OutboundRow } from './outbounds-tab-types';
|
import type { OutboundRow } from './outbounds-tab-types';
|
||||||
|
import TestResultPopover from './TestResultPopover';
|
||||||
import {
|
import {
|
||||||
isTesting,
|
isTesting,
|
||||||
isUntestable,
|
isUntestable,
|
||||||
@@ -102,10 +101,7 @@ export default function OutboundCardList({
|
|||||||
<span className="traffic-down">↓ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).down)}</span>
|
<span className="traffic-down">↓ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).down)}</span>
|
||||||
<span className="card-test">
|
<span className="card-test">
|
||||||
{testResult(outboundTestStates, index) ? (
|
{testResult(outboundTestStates, index) ? (
|
||||||
<span className={testResult(outboundTestStates, index)!.success ? 'pill-ok' : 'pill-fail'}>
|
<TestResultPopover result={testResult(outboundTestStates, index)!} />
|
||||||
{testResult(outboundTestStates, index)!.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
|
|
||||||
{testResult(outboundTestStates, index)!.success ? <span>{testResult(outboundTestStates, index)!.delay} ms</span> : <span>failed</span>}
|
|
||||||
</span>
|
|
||||||
) : isTesting(outboundTestStates, index) ? (
|
) : isTesting(outboundTestStates, index) ? (
|
||||||
<LoadingOutlined />
|
<LoadingOutlined />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -210,6 +210,25 @@
|
|||||||
color: #e04141;
|
color: #e04141;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.outbound-test-popover .breakdown-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outbound-test-popover .breakdown-row .bd-label {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outbound-test-popover .breakdown-row .bd-value {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
.subscription-outbounds-head {
|
.subscription-outbounds-head {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Popover, Table, Tag, Tooltip } from 'antd';
|
import { Button, Table, Tag, Tooltip } from 'antd';
|
||||||
import {
|
import { ThunderboltOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||||
ThunderboltOutlined,
|
|
||||||
CheckCircleFilled,
|
|
||||||
CloseCircleFilled,
|
|
||||||
LoadingOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
|
||||||
import { SizeFormatter } from '@/utils';
|
import { SizeFormatter } from '@/utils';
|
||||||
@@ -15,8 +10,8 @@ import { isUdpOutbound } from '@/hooks/useXraySetting';
|
|||||||
import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||||
|
|
||||||
import type { OutboundRow } from './outbounds-tab-types';
|
import type { OutboundRow } from './outbounds-tab-types';
|
||||||
|
import TestResultPopover from './TestResultPopover';
|
||||||
import {
|
import {
|
||||||
hasBreakdown,
|
|
||||||
isTesting,
|
isTesting,
|
||||||
isUntestable,
|
isUntestable,
|
||||||
outboundAddresses,
|
outboundAddresses,
|
||||||
@@ -103,36 +98,7 @@ export default function SubscriptionOutbounds({
|
|||||||
const key = record.tag || '';
|
const key = record.tag || '';
|
||||||
const r = testResult(subscriptionTestStates, key);
|
const r = testResult(subscriptionTestStates, key);
|
||||||
if (!r) return isTesting(subscriptionTestStates, key) ? <LoadingOutlined /> : <span className="empty">—</span>;
|
if (!r) return isTesting(subscriptionTestStates, key) ? <LoadingOutlined /> : <span className="empty">—</span>;
|
||||||
return (
|
return <TestResultPopover result={r} />;
|
||||||
<Popover
|
|
||||||
placement="topLeft"
|
|
||||||
rootClassName="outbound-test-popover"
|
|
||||||
content={
|
|
||||||
<div className="timing-breakdown">
|
|
||||||
<div className={`td-head ${r.success ? 'ok' : 'fail'}`}>
|
|
||||||
{r.success ? <span>{r.delay} ms</span> : <span>{r.error || 'failed'}</span>}
|
|
||||||
{r.mode && <span className="mode-badge">{String(r.mode).toUpperCase()}</span>}
|
|
||||||
</div>
|
|
||||||
{hasBreakdown(r) && (
|
|
||||||
<>
|
|
||||||
{(r.endpoints || []).map((ep) => (
|
|
||||||
<div key={ep.address} className="endpoint-row">
|
|
||||||
<span className={ep.success ? 'dot-ok' : 'dot-fail'}>●</span>
|
|
||||||
<span className="ep-addr">{ep.address}</span>
|
|
||||||
<span className="ep-meta">{ep.success ? `${ep.delay} ms` : ep.error || 'failed'}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className={r.success ? 'pill-ok' : 'pill-fail'}>
|
|
||||||
{r.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
|
|
||||||
{r.success ? <span>{r.delay} ms</span> : <span>failed</span>}
|
|
||||||
</span>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const testButton = (record: OutboundRow) => {
|
const testButton = (record: OutboundRow) => {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Popover } from 'antd';
|
||||||
|
import { CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import type { OutboundTestResult } from '@/hooks/useXraySetting';
|
||||||
|
|
||||||
|
interface TestResultPopoverProps {
|
||||||
|
result: OutboundTestResult;
|
||||||
|
// Custom trigger element; defaults to the ok/fail latency pill.
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latency pill + detail popover for an outbound test result: per-endpoint
|
||||||
|
// dial outcomes for TCP probes, HTTP status and the timing breakdown for
|
||||||
|
// HTTP probes.
|
||||||
|
export default function TestResultPopover({ result: r, children }: TestResultPopoverProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const breakdown: Array<{ key: string; label: string; value: string }> = [];
|
||||||
|
if (typeof r.httpStatus === 'number') {
|
||||||
|
breakdown.push({ key: 'status', label: t('pages.xray.outbound.httpStatus'), value: String(r.httpStatus) });
|
||||||
|
}
|
||||||
|
if (typeof r.connectMs === 'number') {
|
||||||
|
breakdown.push({ key: 'connect', label: t('pages.xray.outbound.breakdownConnect'), value: `${r.connectMs} ms` });
|
||||||
|
}
|
||||||
|
if (typeof r.tlsMs === 'number') {
|
||||||
|
breakdown.push({ key: 'tls', label: t('pages.xray.outbound.breakdownTls'), value: `${r.tlsMs} ms` });
|
||||||
|
}
|
||||||
|
if (typeof r.ttfbMs === 'number') {
|
||||||
|
breakdown.push({ key: 'ttfb', label: t('pages.xray.outbound.breakdownTtfb'), value: `${r.ttfbMs} ms` });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
placement="topLeft"
|
||||||
|
rootClassName="outbound-test-popover"
|
||||||
|
content={
|
||||||
|
<div className="timing-breakdown">
|
||||||
|
<div className={`td-head ${r.success ? 'ok' : 'fail'}`}>
|
||||||
|
{r.success ? <span>{r.delay} ms</span> : <span>{r.error || 'failed'}</span>}
|
||||||
|
{r.mode && <span className="mode-badge">{String(r.mode).toUpperCase()}</span>}
|
||||||
|
</div>
|
||||||
|
{(r.endpoints || []).map((ep) => (
|
||||||
|
<div key={ep.address} className="endpoint-row">
|
||||||
|
<span className={ep.success ? 'dot-ok' : 'dot-fail'}>●</span>
|
||||||
|
<span className="ep-addr">{ep.address}</span>
|
||||||
|
<span className="ep-meta">{ep.success ? `${ep.delay} ms` : ep.error || 'failed'}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{breakdown.map((row) => (
|
||||||
|
<div key={row.key} className="breakdown-row">
|
||||||
|
<span className="bd-label">{row.label}</span>
|
||||||
|
<span className="bd-value">{row.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children ?? (
|
||||||
|
<span className={r.success ? 'pill-ok' : 'pill-fail'}>
|
||||||
|
{r.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
|
||||||
|
{r.success ? <span>{r.delay} ms</span> : <span>failed</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ export function newStreamSlice(network: string): Record<string, unknown> {
|
|||||||
network: 'xhttp',
|
network: 'xhttp',
|
||||||
xhttpSettings: {
|
xhttpSettings: {
|
||||||
path: '/', host: '', mode: '', headers: [],
|
path: '/', host: '', mode: '', headers: [],
|
||||||
xPaddingBytes: '100-1000', scMaxEachPostBytes: '1000000',
|
xPaddingBytes: '100-1000',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
case 'hysteria':
|
case 'hysteria':
|
||||||
|
|||||||
@@ -42,12 +42,6 @@ export function showSecurity(security?: string): boolean {
|
|||||||
return security === 'tls' || security === 'reality';
|
return security === 'tls' || security === 'reality';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasBreakdown(r: { endpoints?: unknown[]; error?: string } | null | undefined): boolean {
|
|
||||||
if (!r) return false;
|
|
||||||
if (r.endpoints?.length) return true;
|
|
||||||
return !!r.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function trafficFor(outboundsTraffic: OutboundTrafficRow[], o: OutboundRow): { up: number; down: number } {
|
export function trafficFor(outboundsTraffic: OutboundTrafficRow[], o: OutboundRow): { up: number; down: number } {
|
||||||
const tr = outboundsTraffic.find((x) => x.tag === o.tag);
|
const tr = outboundsTraffic.find((x) => x.tag === o.tag);
|
||||||
return { up: tr?.up || 0, down: tr?.down || 0 };
|
return { up: tr?.up || 0, down: tr?.down || 0 };
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
|
import { AutoComplete, Button, Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
|
||||||
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { OutboundDomainStrategies } from '@/schemas/primitives';
|
import { OutboundDomainStrategies } from '@/schemas/primitives';
|
||||||
@@ -67,12 +67,24 @@ export default function FreedomFields({ form }: { form: FormInstance<OutboundFor
|
|||||||
<Form.Item
|
<Form.Item
|
||||||
label={t('pages.settings.subFormats.packets')}
|
label={t('pages.settings.subFormats.packets')}
|
||||||
name={['settings', 'fragment', 'packets']}
|
name={['settings', 'fragment', 'packets']}
|
||||||
|
rules={[{
|
||||||
|
validator: (_rule, value) => {
|
||||||
|
const str = String(value ?? '').trim();
|
||||||
|
// xray accepts "tlshello" or any packet-number range (#5075)
|
||||||
|
if (str === '' || str === 'tlshello' || /^\d+-\d+$/.test(str)) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error('Use "tlshello" or a packet range like 1-3'));
|
||||||
|
},
|
||||||
|
}]}
|
||||||
>
|
>
|
||||||
<Select
|
<AutoComplete
|
||||||
options={[
|
options={[
|
||||||
{ value: '1-3', label: '1-3' },
|
|
||||||
{ value: 'tlshello', label: 'tlshello' },
|
{ value: 'tlshello', label: 'tlshello' },
|
||||||
|
{ value: '1-3', label: '1-3' },
|
||||||
|
{ value: '1-5', label: '1-5' },
|
||||||
]}
|
]}
|
||||||
|
placeholder="tlshello or n-m, e.g. 1-3"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t('pages.settings.subFormats.length')} name={['settings', 'fragment', 'length']}>
|
<Form.Item label={t('pages.settings.subFormats.length')} name={['settings', 'fragment', 'length']}>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default function HysteriaForm({ form }: { form: FormInstance }) {
|
|||||||
label={t('pages.inbounds.form.udpIdleTimeout')}
|
label={t('pages.inbounds.form.udpIdleTimeout')}
|
||||||
name={['streamSettings', 'hysteriaSettings', 'udpIdleTimeout']}
|
name={['streamSettings', 'hysteriaSettings', 'udpIdleTimeout']}
|
||||||
>
|
>
|
||||||
<InputNumber min={1} style={{ width: '100%' }} />
|
<InputNumber min={2} max={600} style={{ width: '100%' }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label={t('pages.inbounds.form.masquerade')}>
|
<Form.Item label={t('pages.inbounds.form.masquerade')}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Dropdown, Popover, Tag, Tooltip } from 'antd';
|
import { Button, Dropdown, Tag, Tooltip } from 'antd';
|
||||||
import {
|
import {
|
||||||
RetweetOutlined,
|
RetweetOutlined,
|
||||||
MoreOutlined,
|
MoreOutlined,
|
||||||
@@ -8,8 +8,6 @@ import {
|
|||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
VerticalAlignTopOutlined,
|
VerticalAlignTopOutlined,
|
||||||
ThunderboltOutlined,
|
ThunderboltOutlined,
|
||||||
CheckCircleFilled,
|
|
||||||
CloseCircleFilled,
|
|
||||||
LoadingOutlined,
|
LoadingOutlined,
|
||||||
ArrowUpOutlined,
|
ArrowUpOutlined,
|
||||||
ArrowDownOutlined,
|
ArrowDownOutlined,
|
||||||
@@ -22,8 +20,8 @@ import { isUdpOutbound } from '@/hooks/useXraySetting';
|
|||||||
import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||||
|
|
||||||
import type { OutboundRow } from './outbounds-tab-types';
|
import type { OutboundRow } from './outbounds-tab-types';
|
||||||
|
import TestResultPopover from './TestResultPopover';
|
||||||
import {
|
import {
|
||||||
hasBreakdown,
|
|
||||||
isTesting,
|
isTesting,
|
||||||
isUntestable,
|
isUntestable,
|
||||||
outboundAddresses,
|
outboundAddresses,
|
||||||
@@ -160,36 +158,7 @@ export function useOutboundColumns({
|
|||||||
render: (_v, _record, index) => {
|
render: (_v, _record, index) => {
|
||||||
const r = testResult(outboundTestStates, index);
|
const r = testResult(outboundTestStates, index);
|
||||||
if (!r) return isTesting(outboundTestStates, index) ? <LoadingOutlined /> : <span className="empty">—</span>;
|
if (!r) return isTesting(outboundTestStates, index) ? <LoadingOutlined /> : <span className="empty">—</span>;
|
||||||
return (
|
return <TestResultPopover result={r} />;
|
||||||
<Popover
|
|
||||||
placement="topLeft"
|
|
||||||
rootClassName="outbound-test-popover"
|
|
||||||
content={
|
|
||||||
<div className="timing-breakdown">
|
|
||||||
<div className={`td-head ${r.success ? 'ok' : 'fail'}`}>
|
|
||||||
{r.success ? <span>{r.delay} ms</span> : <span>{r.error || 'failed'}</span>}
|
|
||||||
{r.mode && <span className="mode-badge">{String(r.mode).toUpperCase()}</span>}
|
|
||||||
</div>
|
|
||||||
{hasBreakdown(r) && (
|
|
||||||
<>
|
|
||||||
{(r.endpoints || []).map((ep) => (
|
|
||||||
<div key={ep.address} className="endpoint-row">
|
|
||||||
<span className={ep.success ? 'dot-ok' : 'dot-fail'}>●</span>
|
|
||||||
<span className="ep-addr">{ep.address}</span>
|
|
||||||
<span className="ep-meta">{ep.success ? `${ep.delay} ms` : ep.error || 'failed'}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className={r.success ? 'pill-ok' : 'pill-fail'}>
|
|
||||||
{r.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
|
|
||||||
{r.success ? <span>{r.delay} ms</span> : <span>failed</span>}
|
|
||||||
</span>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -209,7 +209,10 @@ export default function NordModal({
|
|||||||
secretKey: nordData?.private_key,
|
secretKey: nordData?.private_key,
|
||||||
address: ['10.5.0.2/32'],
|
address: ['10.5.0.2/32'],
|
||||||
peers: [{ publicKey, endpoint: `${server.station}:51820` }],
|
peers: [{ publicKey, endpoint: `${server.station}:51820` }],
|
||||||
noKernelTun: false,
|
// Userspace TUN — same reasoning as the WARP outbound (#5205): kernel
|
||||||
|
// TUN fails silently on many VPS setups and diverges from the data
|
||||||
|
// path the panel's connectivity test exercises.
|
||||||
|
noKernelTun: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,9 +103,17 @@ export default function WarpModal({
|
|||||||
secretKey: data?.private_key,
|
secretKey: data?.private_key,
|
||||||
address: addressesFor(cfg.interface?.addresses || {}),
|
address: addressesFor(cfg.interface?.addresses || {}),
|
||||||
reserved: reservedFor(cfg.client_id ?? data?.client_id),
|
reserved: reservedFor(cfg.client_id ?? data?.client_id),
|
||||||
domainStrategy: 'ForceIP',
|
// Prefer IPv4 with IPv6 fallback: plain ForceIP may pick the AAAA
|
||||||
|
// record for engage.cloudflareclient.com, and a host with
|
||||||
|
// half-configured IPv6 then blackholes the handshake with no error
|
||||||
|
// logged (#5205).
|
||||||
|
domainStrategy: 'ForceIPv4v6',
|
||||||
peers: [{ publicKey: peer.public_key, endpoint: peer.endpoint?.host }],
|
peers: [{ publicKey: peer.public_key, endpoint: peer.endpoint?.host }],
|
||||||
noKernelTun: false,
|
// Userspace TUN: kernel TUN needs CAP_NET_ADMIN + fwmark routing and
|
||||||
|
// fails silently on many VPS setups, and it is a different data path
|
||||||
|
// than the panel's connectivity test (which always probes with
|
||||||
|
// noKernelTun=true), so "test ok" and "traffic flows" can disagree.
|
||||||
|
noKernelTun: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
setStagedOutbound(outbound);
|
setStagedOutbound(outbound);
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export const InboundCoreSchema = z.object({
|
|||||||
listen: z.string().default(''),
|
listen: z.string().default(''),
|
||||||
port: InboundPortSchema,
|
port: InboundPortSchema,
|
||||||
tag: z.string().default(''),
|
tag: z.string().default(''),
|
||||||
|
shareAddrStrategy: z.enum(['node', 'listen', 'custom']).default('node'),
|
||||||
|
shareAddr: z.string().default(''),
|
||||||
sniffing: SniffingSchema.default({
|
sniffing: SniffingSchema.default({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
destOverride: ['http', 'tls', 'quic', 'fakedns'],
|
destOverride: ['http', 'tls', 'quic', 'fakedns'],
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export const InboundOptionSchema = z.object({
|
|||||||
port: z.number().optional(),
|
port: z.number().optional(),
|
||||||
tlsFlowCapable: z.boolean().optional(),
|
tlsFlowCapable: z.boolean().optional(),
|
||||||
ssMethod: z.string().optional(),
|
ssMethod: z.string().optional(),
|
||||||
|
// Hosting node id; absent/null for this panel's own inbounds (#4997).
|
||||||
|
nodeId: z.number().nullable().optional(),
|
||||||
}).loose();
|
}).loose();
|
||||||
|
|
||||||
export const InboundOptionsSchema = z.array(InboundOptionSchema);
|
export const InboundOptionsSchema = z.array(InboundOptionSchema);
|
||||||
@@ -127,6 +129,8 @@ export const GroupSummarySchema = z.object({
|
|||||||
name: z.string(),
|
name: z.string(),
|
||||||
clientCount: z.number(),
|
clientCount: z.number(),
|
||||||
trafficUsed: z.number().nullable().transform((v) => v ?? 0),
|
trafficUsed: z.number().nullable().transform((v) => v ?? 0),
|
||||||
|
up: z.number().nullable().transform((v) => v ?? 0),
|
||||||
|
down: z.number().nullable().transform((v) => v ?? 0),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const GroupSummaryListSchema = z.array(GroupSummarySchema).nullable().transform((v) => v ?? []);
|
export const GroupSummaryListSchema = z.array(GroupSummarySchema).nullable().transform((v) => v ?? []);
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export type InboundStreamFormValues = z.infer<typeof InboundStreamFormSchema>;
|
|||||||
|
|
||||||
export const TrafficResetSchema = z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']);
|
export const TrafficResetSchema = z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']);
|
||||||
export type TrafficReset = z.infer<typeof TrafficResetSchema>;
|
export type TrafficReset = z.infer<typeof TrafficResetSchema>;
|
||||||
|
export const ShareAddrStrategySchema = z.enum(['node', 'listen', 'custom']);
|
||||||
|
export type ShareAddrStrategy = z.infer<typeof ShareAddrStrategySchema>;
|
||||||
|
|
||||||
// Db-side fields layered on top of the xray slice. These mirror the
|
// Db-side fields layered on top of the xray slice. These mirror the
|
||||||
// DBInbound model — they live in the SQL row, not in xray's config.
|
// DBInbound model — they live in the SQL row, not in xray's config.
|
||||||
@@ -35,6 +37,9 @@ export const InboundDbFieldsSchema = z.object({
|
|||||||
trafficReset: TrafficResetSchema.default('never'),
|
trafficReset: TrafficResetSchema.default('never'),
|
||||||
lastTrafficResetTime: z.number().int().default(0),
|
lastTrafficResetTime: z.number().int().default(0),
|
||||||
nodeId: z.number().int().nullable().optional(),
|
nodeId: z.number().int().nullable().optional(),
|
||||||
|
shareAddrStrategy: ShareAddrStrategySchema.default('node'),
|
||||||
|
shareAddr: z.string().default(''),
|
||||||
|
subSortIndex: z.number().int().min(1).default(1),
|
||||||
});
|
});
|
||||||
export type InboundDbFields = z.infer<typeof InboundDbFieldsSchema>;
|
export type InboundDbFields = z.infer<typeof InboundDbFieldsSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ export const NodeRecordSchema = z.object({
|
|||||||
allowPrivateAddress: z.boolean().optional(),
|
allowPrivateAddress: z.boolean().optional(),
|
||||||
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']).optional(),
|
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']).optional(),
|
||||||
pinnedCertSha256: z.string().optional(),
|
pinnedCertSha256: z.string().optional(),
|
||||||
|
inboundSyncMode: z.enum(['all', 'selected']).optional(),
|
||||||
|
// Backend serializes a nil []string as null for nodes saved before #5178.
|
||||||
|
inboundTags: z.array(z.string()).nullish(),
|
||||||
// Multi-hop node tree (#4983): a node's stable GUID, its parent's GUID, and
|
// Multi-hop node tree (#4983): a node's stable GUID, its parent's GUID, and
|
||||||
// whether it's a read-only transitive sub-node surfaced from a downstream node.
|
// whether it's a read-only transitive sub-node surfaced from a downstream node.
|
||||||
guid: z.string().optional(),
|
guid: z.string().optional(),
|
||||||
@@ -63,6 +66,10 @@ export const NodeFormSchema = z.object({
|
|||||||
allowPrivateAddress: z.boolean(),
|
allowPrivateAddress: z.boolean(),
|
||||||
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']),
|
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']),
|
||||||
pinnedCertSha256: z.string().optional().default(''),
|
pinnedCertSha256: z.string().optional().default(''),
|
||||||
|
inboundSyncMode: z.enum(['all', 'selected']).optional().default('all'),
|
||||||
|
// Unmounted when sync mode is "all" (absent from antd onFinish values) and
|
||||||
|
// serialized as null by the backend for a nil slice — tolerate both.
|
||||||
|
inboundTags: z.array(z.string()).nullish().transform((tags) => tags ?? []),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type NodeRecord = z.infer<typeof NodeRecordSchema>;
|
export type NodeRecord = z.infer<typeof NodeRecordSchema>;
|
||||||
|
|||||||
@@ -22,5 +22,12 @@ export const MtprotoInboundSettingsSchema = z.object({
|
|||||||
preferIp: z.enum(['prefer-ipv6', 'prefer-ipv4', 'only-ipv6', 'only-ipv4']).optional(),
|
preferIp: z.enum(['prefer-ipv6', 'prefer-ipv4', 'only-ipv6', 'only-ipv4']).optional(),
|
||||||
debug: z.boolean().optional(),
|
debug: z.boolean().optional(),
|
||||||
domainFronting: MtprotoDomainFrontingSchema.optional(),
|
domainFronting: MtprotoDomainFrontingSchema.optional(),
|
||||||
|
// When set, the mtg sidecar dials Telegram through a loopback SOCKS bridge in
|
||||||
|
// the Xray config so the egress obeys routing rules. `outboundTag` optionally
|
||||||
|
// forces that traffic out a specific outbound/balancer. `routeXrayPort` is the
|
||||||
|
// bridge port; it is allocated and owned by the backend (never edited here).
|
||||||
|
routeThroughXray: z.boolean().optional(),
|
||||||
|
outboundTag: z.string().optional(),
|
||||||
|
routeXrayPort: z.number().int().min(0).max(65535).optional(),
|
||||||
});
|
});
|
||||||
export type MtprotoInboundSettings = z.infer<typeof MtprotoInboundSettingsSchema>;
|
export type MtprotoInboundSettings = z.infer<typeof MtprotoInboundSettingsSchema>;
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ export const WireguardInboundPeerSchema = z.object({
|
|||||||
preSharedKey: z.string().optional(),
|
preSharedKey: z.string().optional(),
|
||||||
allowedIPs: z.array(z.string()).default([]),
|
allowedIPs: z.array(z.string()).default([]),
|
||||||
keepAlive: optionalClearedInt(z.number().int().min(0)),
|
keepAlive: optionalClearedInt(z.number().int().min(0)),
|
||||||
|
// Panel-only annotation (#5168): which client/device this peer belongs to.
|
||||||
|
// Rides along in the settings JSON like privateKey does; xray-core ignores
|
||||||
|
// unknown peer fields.
|
||||||
|
comment: z.string().optional(),
|
||||||
});
|
});
|
||||||
export type WireguardInboundPeer = z.infer<typeof WireguardInboundPeerSchema>;
|
export type WireguardInboundPeer = z.infer<typeof WireguardInboundPeerSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ export type HysteriaMasquerade = z.infer<typeof HysteriaMasqueradeSchema>;
|
|||||||
export const HysteriaStreamSettingsSchema = z.object({
|
export const HysteriaStreamSettingsSchema = z.object({
|
||||||
version: z.literal(2).default(2),
|
version: z.literal(2).default(2),
|
||||||
auth: z.string().default(''),
|
auth: z.string().default(''),
|
||||||
udpIdleTimeout: z.number().int().min(1).default(60),
|
// Xray-core rejects values outside 2-600 seconds at startup.
|
||||||
|
udpIdleTimeout: z.number().int().min(2).max(600).default(60),
|
||||||
masquerade: HysteriaMasqueradeSchema.optional(),
|
masquerade: HysteriaMasqueradeSchema.optional(),
|
||||||
});
|
});
|
||||||
export type HysteriaStreamSettings = z.infer<typeof HysteriaStreamSettingsSchema>;
|
export type HysteriaStreamSettings = z.infer<typeof HysteriaStreamSettingsSchema>;
|
||||||
|
|||||||
@@ -41,7 +41,10 @@ export const XHttpStreamSettingsSchema = z.object({
|
|||||||
seqKey: z.string().default(''),
|
seqKey: z.string().default(''),
|
||||||
uplinkDataPlacement: z.string().default(''),
|
uplinkDataPlacement: z.string().default(''),
|
||||||
uplinkDataKey: z.string().default(''),
|
uplinkDataKey: z.string().default(''),
|
||||||
scMaxEachPostBytes: z.string().default('1000000'),
|
// Empty default on purpose: xray-core already defaults to 1MB/30ms, and
|
||||||
|
// baking the literal values into every config and share link gives DPI a
|
||||||
|
// stable fingerprint (#5141 — TSPU keys on scMinPostsIntervalMs=30).
|
||||||
|
scMaxEachPostBytes: z.string().default(''),
|
||||||
noSSEHeader: z.boolean().default(false),
|
noSSEHeader: z.boolean().default(false),
|
||||||
scMaxBufferedPosts: z.number().int().min(0).default(30),
|
scMaxBufferedPosts: z.number().int().min(0).default(30),
|
||||||
scStreamUpServerSecs: z.string().default('20-80'),
|
scStreamUpServerSecs: z.string().default('20-80'),
|
||||||
@@ -51,7 +54,7 @@ export const XHttpStreamSettingsSchema = z.object({
|
|||||||
// Outbound-only fields. Server (inbound) listener ignores these. The
|
// Outbound-only fields. Server (inbound) listener ignores these. The
|
||||||
// panel embeds them in share-link `extra` blobs so the same xhttp
|
// panel embeds them in share-link `extra` blobs so the same xhttp
|
||||||
// config can roundtrip on both sides.
|
// config can roundtrip on both sides.
|
||||||
scMinPostsIntervalMs: z.string().default('30'),
|
scMinPostsIntervalMs: z.string().default(''),
|
||||||
uplinkChunkSize: z.number().int().min(0).default(0),
|
uplinkChunkSize: z.number().int().min(0).default(0),
|
||||||
noGRPCHeader: z.boolean().default(false),
|
noGRPCHeader: z.boolean().default(false),
|
||||||
xmux: XHttpXmuxSchema.optional(),
|
xmux: XHttpXmuxSchema.optional(),
|
||||||
|
|||||||
@@ -56,10 +56,18 @@ export const OutboundTrafficRowSchema = z.object({
|
|||||||
export const OutboundTrafficListSchema = z.array(OutboundTrafficRowSchema);
|
export const OutboundTrafficListSchema = z.array(OutboundTrafficRowSchema);
|
||||||
|
|
||||||
export const OutboundTestResultSchema = z.object({
|
export const OutboundTestResultSchema = z.object({
|
||||||
|
tag: z.string().optional(),
|
||||||
success: z.boolean(),
|
success: z.boolean(),
|
||||||
delay: z.number().optional(),
|
delay: z.number().optional(),
|
||||||
error: z.string().optional(),
|
error: z.string().optional(),
|
||||||
mode: z.string().optional(),
|
mode: z.string().optional(),
|
||||||
|
// HTTP-mode extras: status answered by the test URL plus the httptrace
|
||||||
|
// timing breakdown (dial to local inbound / target TLS via the outbound /
|
||||||
|
// time to first byte).
|
||||||
|
httpStatus: z.number().optional(),
|
||||||
|
connectMs: z.number().optional(),
|
||||||
|
tlsMs: z.number().optional(),
|
||||||
|
ttfbMs: z.number().optional(),
|
||||||
endpoints: z
|
endpoints: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -72,6 +80,9 @@ export const OutboundTestResultSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
}).loose();
|
}).loose();
|
||||||
|
|
||||||
|
// Batch results from /xray/testOutbounds, aligned with the request order.
|
||||||
|
export const OutboundTestResultListSchema = z.array(OutboundTestResultSchema);
|
||||||
|
|
||||||
export const RuleFormSchema = z.object({
|
export const RuleFormSchema = z.object({
|
||||||
domain: z.string(),
|
domain: z.string(),
|
||||||
ip: z.string(),
|
ip: z.string(),
|
||||||
|
|||||||
@@ -89,6 +89,13 @@
|
|||||||
min-height: calc(100vh - 120px);
|
min-height: calc(100vh - 120px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tall action menus (e.g. the inbound/client context menus) must stay inside
|
||||||
|
the viewport even when antd flips them upward near the screen edge. */
|
||||||
|
.ant-dropdown .ant-dropdown-menu {
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.ant-dropdown-menu-item:not(.ant-dropdown-menu-item-disabled):not(.ant-dropdown-menu-item-danger):hover,
|
.ant-dropdown-menu-item:not(.ant-dropdown-menu-item-disabled):not(.ant-dropdown-menu-item-danger):hover,
|
||||||
.ant-dropdown-menu-item:not(.ant-dropdown-menu-item-disabled):not(.ant-dropdown-menu-item-danger):hover .ant-dropdown-menu-title-content,
|
.ant-dropdown-menu-item:not(.ant-dropdown-menu-item-disabled):not(.ant-dropdown-menu-item-danger):hover .ant-dropdown-menu-title-content,
|
||||||
.ant-dropdown-menu-item:not(.ant-dropdown-menu-item-disabled):not(.ant-dropdown-menu-item-danger):hover > .anticon {
|
.ant-dropdown-menu-item:not(.ant-dropdown-menu-item-disabled):not(.ant-dropdown-menu-item-danger):hover > .anticon {
|
||||||
|
|||||||
@@ -130,5 +130,6 @@ exports[`inbound transport forms > XhttpForm field structure is stable 1`] = `
|
|||||||
"Session Placement",
|
"Session Placement",
|
||||||
"Sequence Placement",
|
"Sequence Placement",
|
||||||
"No SSE Header",
|
"No SSE Header",
|
||||||
|
"XMUX",
|
||||||
]
|
]
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ exports[`InboundFormModal > field structure is stable for every protocol > http
|
|||||||
"Remark",
|
"Remark",
|
||||||
"Protocol",
|
"Protocol",
|
||||||
"Address",
|
"Address",
|
||||||
|
"Share address strategy",
|
||||||
|
"Subscription sort order",
|
||||||
"Port",
|
"Port",
|
||||||
"Total Flow",
|
"Total Flow",
|
||||||
"Traffic Reset",
|
"Traffic Reset",
|
||||||
@@ -20,6 +22,8 @@ exports[`InboundFormModal > field structure is stable for every protocol > hyste
|
|||||||
"Remark",
|
"Remark",
|
||||||
"Protocol",
|
"Protocol",
|
||||||
"Address",
|
"Address",
|
||||||
|
"Share address strategy",
|
||||||
|
"Subscription sort order",
|
||||||
"Port",
|
"Port",
|
||||||
"Total Flow",
|
"Total Flow",
|
||||||
"Traffic Reset",
|
"Traffic Reset",
|
||||||
@@ -34,6 +38,8 @@ exports[`InboundFormModal > field structure is stable for every protocol > mixed
|
|||||||
"Remark",
|
"Remark",
|
||||||
"Protocol",
|
"Protocol",
|
||||||
"Address",
|
"Address",
|
||||||
|
"Share address strategy",
|
||||||
|
"Subscription sort order",
|
||||||
"Port",
|
"Port",
|
||||||
"Total Flow",
|
"Total Flow",
|
||||||
"Traffic Reset",
|
"Traffic Reset",
|
||||||
@@ -48,6 +54,8 @@ exports[`InboundFormModal > field structure is stable for every protocol > shado
|
|||||||
"Remark",
|
"Remark",
|
||||||
"Protocol",
|
"Protocol",
|
||||||
"Address",
|
"Address",
|
||||||
|
"Share address strategy",
|
||||||
|
"Subscription sort order",
|
||||||
"Port",
|
"Port",
|
||||||
"Total Flow",
|
"Total Flow",
|
||||||
"Traffic Reset",
|
"Traffic Reset",
|
||||||
@@ -62,6 +70,8 @@ exports[`InboundFormModal > field structure is stable for every protocol > troja
|
|||||||
"Remark",
|
"Remark",
|
||||||
"Protocol",
|
"Protocol",
|
||||||
"Address",
|
"Address",
|
||||||
|
"Share address strategy",
|
||||||
|
"Subscription sort order",
|
||||||
"Port",
|
"Port",
|
||||||
"Total Flow",
|
"Total Flow",
|
||||||
"Traffic Reset",
|
"Traffic Reset",
|
||||||
@@ -76,6 +86,8 @@ exports[`InboundFormModal > field structure is stable for every protocol > tun 1
|
|||||||
"Remark",
|
"Remark",
|
||||||
"Protocol",
|
"Protocol",
|
||||||
"Address",
|
"Address",
|
||||||
|
"Share address strategy",
|
||||||
|
"Subscription sort order",
|
||||||
"Port",
|
"Port",
|
||||||
"Total Flow",
|
"Total Flow",
|
||||||
"Traffic Reset",
|
"Traffic Reset",
|
||||||
@@ -90,6 +102,8 @@ exports[`InboundFormModal > field structure is stable for every protocol > tunne
|
|||||||
"Remark",
|
"Remark",
|
||||||
"Protocol",
|
"Protocol",
|
||||||
"Address",
|
"Address",
|
||||||
|
"Share address strategy",
|
||||||
|
"Subscription sort order",
|
||||||
"Port",
|
"Port",
|
||||||
"Total Flow",
|
"Total Flow",
|
||||||
"Traffic Reset",
|
"Traffic Reset",
|
||||||
@@ -104,6 +118,8 @@ exports[`InboundFormModal > field structure is stable for every protocol > vless
|
|||||||
"Remark",
|
"Remark",
|
||||||
"Protocol",
|
"Protocol",
|
||||||
"Address",
|
"Address",
|
||||||
|
"Share address strategy",
|
||||||
|
"Subscription sort order",
|
||||||
"Port",
|
"Port",
|
||||||
"Total Flow",
|
"Total Flow",
|
||||||
"Traffic Reset",
|
"Traffic Reset",
|
||||||
@@ -118,6 +134,8 @@ exports[`InboundFormModal > field structure is stable for every protocol > vmess
|
|||||||
"Remark",
|
"Remark",
|
||||||
"Protocol",
|
"Protocol",
|
||||||
"Address",
|
"Address",
|
||||||
|
"Share address strategy",
|
||||||
|
"Subscription sort order",
|
||||||
"Port",
|
"Port",
|
||||||
"Total Flow",
|
"Total Flow",
|
||||||
"Traffic Reset",
|
"Traffic Reset",
|
||||||
@@ -132,6 +150,8 @@ exports[`InboundFormModal > field structure is stable for every protocol > wireg
|
|||||||
"Remark",
|
"Remark",
|
||||||
"Protocol",
|
"Protocol",
|
||||||
"Address",
|
"Address",
|
||||||
|
"Share address strategy",
|
||||||
|
"Subscription sort order",
|
||||||
"Port",
|
"Port",
|
||||||
"Total Flow",
|
"Total Flow",
|
||||||
"Traffic Reset",
|
"Traffic Reset",
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ exports[`InboundSchema (full) fixtures > parses hysteria-v1-tls byte-stably 1`]
|
|||||||
],
|
],
|
||||||
"version": 1,
|
"version": 1,
|
||||||
},
|
},
|
||||||
|
"shareAddr": "",
|
||||||
|
"shareAddrStrategy": "node",
|
||||||
"sniffing": {
|
"sniffing": {
|
||||||
"destOverride": [
|
"destOverride": [
|
||||||
"http",
|
"http",
|
||||||
@@ -112,6 +114,8 @@ exports[`InboundSchema (full) fixtures > parses shadowsocks-tcp-2022 byte-stably
|
|||||||
"network": "tcp,udp",
|
"network": "tcp,udp",
|
||||||
"password": "ZmFrZS1zZXJ2ZXItcGFzc3dvcmQtMDAwMQ==",
|
"password": "ZmFrZS1zZXJ2ZXItcGFzc3dvcmQtMDAwMQ==",
|
||||||
},
|
},
|
||||||
|
"shareAddr": "",
|
||||||
|
"shareAddrStrategy": "node",
|
||||||
"sniffing": {
|
"sniffing": {
|
||||||
"destOverride": [
|
"destOverride": [
|
||||||
"http",
|
"http",
|
||||||
@@ -168,6 +172,8 @@ exports[`InboundSchema (full) fixtures > parses trojan-ws-tls byte-stably 1`] =
|
|||||||
],
|
],
|
||||||
"fallbacks": [],
|
"fallbacks": [],
|
||||||
},
|
},
|
||||||
|
"shareAddr": "",
|
||||||
|
"shareAddrStrategy": "node",
|
||||||
"sniffing": {
|
"sniffing": {
|
||||||
"destOverride": [
|
"destOverride": [
|
||||||
"http",
|
"http",
|
||||||
@@ -257,6 +263,8 @@ exports[`InboundSchema (full) fixtures > parses vless-tcp-reality byte-stably 1`
|
|||||||
"encryption": "none",
|
"encryption": "none",
|
||||||
"fallbacks": [],
|
"fallbacks": [],
|
||||||
},
|
},
|
||||||
|
"shareAddr": "",
|
||||||
|
"shareAddrStrategy": "node",
|
||||||
"sniffing": {
|
"sniffing": {
|
||||||
"destOverride": [
|
"destOverride": [
|
||||||
"http",
|
"http",
|
||||||
@@ -341,6 +349,8 @@ exports[`InboundSchema (full) fixtures > parses vless-ws-tls byte-stably 1`] = `
|
|||||||
"encryption": "none",
|
"encryption": "none",
|
||||||
"fallbacks": [],
|
"fallbacks": [],
|
||||||
},
|
},
|
||||||
|
"shareAddr": "",
|
||||||
|
"shareAddrStrategy": "node",
|
||||||
"sniffing": {
|
"sniffing": {
|
||||||
"destOverride": [
|
"destOverride": [
|
||||||
"http",
|
"http",
|
||||||
@@ -430,6 +440,8 @@ exports[`InboundSchema (full) fixtures > parses vless-ws-tls-pinned byte-stably
|
|||||||
"encryption": "none",
|
"encryption": "none",
|
||||||
"fallbacks": [],
|
"fallbacks": [],
|
||||||
},
|
},
|
||||||
|
"shareAddr": "",
|
||||||
|
"shareAddrStrategy": "node",
|
||||||
"sniffing": {
|
"sniffing": {
|
||||||
"destOverride": [
|
"destOverride": [
|
||||||
"http",
|
"http",
|
||||||
@@ -520,6 +532,8 @@ exports[`InboundSchema (full) fixtures > parses vmess-tcp-tls byte-stably 1`] =
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
"shareAddr": "",
|
||||||
|
"shareAddrStrategy": "node",
|
||||||
"sniffing": {
|
"sniffing": {
|
||||||
"destOverride": [
|
"destOverride": [
|
||||||
"http",
|
"http",
|
||||||
@@ -603,6 +617,8 @@ exports[`InboundSchema (full) fixtures > parses wireguard-server byte-stably 1`]
|
|||||||
],
|
],
|
||||||
"secretKey": "iJ2cBkrSGqRwIfYIDIxk7hr5RXfdR93MfJUL7yqkkH8=",
|
"secretKey": "iJ2cBkrSGqRwIfYIDIxk7hr5RXfdR93MfJUL7yqkkH8=",
|
||||||
},
|
},
|
||||||
|
"shareAddr": "",
|
||||||
|
"shareAddrStrategy": "node",
|
||||||
"sniffing": {
|
"sniffing": {
|
||||||
"destOverride": [
|
"destOverride": [
|
||||||
"http",
|
"http",
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ exports[`NetworkSettingsSchema fixtures > parses xhttp-basic byte-stably 1`] = `
|
|||||||
"noSSEHeader": false,
|
"noSSEHeader": false,
|
||||||
"path": "/sp",
|
"path": "/sp",
|
||||||
"scMaxBufferedPosts": 30,
|
"scMaxBufferedPosts": 30,
|
||||||
"scMaxEachPostBytes": "1000000",
|
"scMaxEachPostBytes": "",
|
||||||
"scMinPostsIntervalMs": "30",
|
"scMinPostsIntervalMs": "",
|
||||||
"scStreamUpServerSecs": "20-80",
|
"scStreamUpServerSecs": "20-80",
|
||||||
"seqKey": "",
|
"seqKey": "",
|
||||||
"seqPlacement": "",
|
"seqPlacement": "",
|
||||||
@@ -81,8 +81,8 @@ exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-padding byte-stably
|
|||||||
"noSSEHeader": false,
|
"noSSEHeader": false,
|
||||||
"path": "/sp",
|
"path": "/sp",
|
||||||
"scMaxBufferedPosts": 30,
|
"scMaxBufferedPosts": 30,
|
||||||
"scMaxEachPostBytes": "1000000",
|
"scMaxEachPostBytes": "",
|
||||||
"scMinPostsIntervalMs": "30",
|
"scMinPostsIntervalMs": "",
|
||||||
"scStreamUpServerSecs": "20-80",
|
"scStreamUpServerSecs": "20-80",
|
||||||
"seqKey": "",
|
"seqKey": "",
|
||||||
"seqPlacement": "",
|
"seqPlacement": "",
|
||||||
@@ -115,8 +115,8 @@ exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-placement byte-stab
|
|||||||
"noSSEHeader": false,
|
"noSSEHeader": false,
|
||||||
"path": "/sp",
|
"path": "/sp",
|
||||||
"scMaxBufferedPosts": 30,
|
"scMaxBufferedPosts": 30,
|
||||||
"scMaxEachPostBytes": "1000000",
|
"scMaxEachPostBytes": "",
|
||||||
"scMinPostsIntervalMs": "30",
|
"scMinPostsIntervalMs": "",
|
||||||
"scStreamUpServerSecs": "20-80",
|
"scStreamUpServerSecs": "20-80",
|
||||||
"seqKey": "X-Seq",
|
"seqKey": "X-Seq",
|
||||||
"seqPlacement": "cookie",
|
"seqPlacement": "cookie",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
formValuesToWirePayload,
|
formValuesToWirePayload,
|
||||||
type RawInboundRow,
|
type RawInboundRow,
|
||||||
} from '@/lib/xray/inbound-form-adapter';
|
} from '@/lib/xray/inbound-form-adapter';
|
||||||
import { InboundFormSchema } from '@/schemas/forms/inbound-form';
|
import { InboundDbFieldsSchema, InboundFormSchema } from '@/schemas/forms/inbound-form';
|
||||||
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
|
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
|
||||||
|
|
||||||
// Round-trip: raw DB row → InboundFormValues → wire payload, asserting
|
// Round-trip: raw DB row → InboundFormValues → wire payload, asserting
|
||||||
@@ -104,6 +104,8 @@ describe('rawInboundToFormValues', () => {
|
|||||||
if (name === 'empty stream settings drop to undefined') {
|
if (name === 'empty stream settings drop to undefined') {
|
||||||
expect(values.streamSettings).toBeUndefined();
|
expect(values.streamSettings).toBeUndefined();
|
||||||
}
|
}
|
||||||
|
expect(values.shareAddrStrategy).toBe('node');
|
||||||
|
expect(values.shareAddr).toBe('');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +217,17 @@ describe('formValuesToWirePayload', () => {
|
|||||||
expect(payload.nodeId).toBe(42);
|
expect(payload.nodeId).toBe(42);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('round-trips share address strategy fields', () => {
|
||||||
|
const values = rawInboundToFormValues({
|
||||||
|
...vlessRow,
|
||||||
|
shareAddrStrategy: 'custom',
|
||||||
|
shareAddr: 'edge.example.test',
|
||||||
|
});
|
||||||
|
const payload = formValuesToWirePayload(values);
|
||||||
|
expect(payload.shareAddrStrategy).toBe('custom');
|
||||||
|
expect(payload.shareAddr).toBe('edge.example.test');
|
||||||
|
});
|
||||||
|
|
||||||
it('round-trips top-level fields through raw → values → payload → values', () => {
|
it('round-trips top-level fields through raw → values → payload → values', () => {
|
||||||
// settings/streamSettings/sniffing don't round-trip byte-equal because
|
// settings/streamSettings/sniffing don't round-trip byte-equal because
|
||||||
// the wire payload prunes empty arrays and collapses disabled sniffing
|
// the wire payload prunes empty arrays and collapses disabled sniffing
|
||||||
@@ -249,3 +262,35 @@ describe('formValuesToWirePayload', () => {
|
|||||||
expect(replay.streamSettings).toEqual(original.streamSettings);
|
expect(replay.streamSettings).toEqual(original.streamSettings);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('subSortIndex', () => {
|
||||||
|
it('rawInboundToFormValues defaults to 1 when field is absent', () => {
|
||||||
|
const values = rawInboundToFormValues({ ...vlessRow, subSortIndex: undefined });
|
||||||
|
expect(values.subSortIndex).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rawInboundToFormValues preserves valid values and clamps below-minimum ones to 1', () => {
|
||||||
|
expect(rawInboundToFormValues({ ...vlessRow, subSortIndex: 5 }).subSortIndex).toBe(5);
|
||||||
|
expect(rawInboundToFormValues({ ...vlessRow, subSortIndex: 0 }).subSortIndex).toBe(1);
|
||||||
|
expect(rawInboundToFormValues({ ...vlessRow, subSortIndex: -10 }).subSortIndex).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formValuesToWirePayload includes subSortIndex in the payload', () => {
|
||||||
|
const values = rawInboundToFormValues({ ...vlessRow, subSortIndex: 3 });
|
||||||
|
const payload = formValuesToWirePayload(values);
|
||||||
|
expect(payload.subSortIndex).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('subSortIndex round-trips through raw → values → payload', () => {
|
||||||
|
const values = rawInboundToFormValues({ ...vlessRow, subSortIndex: 42 });
|
||||||
|
const payload = formValuesToWirePayload(values);
|
||||||
|
const replay = rawInboundToFormValues({ ...vlessRow, subSortIndex: payload.subSortIndex });
|
||||||
|
expect(replay.subSortIndex).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('InboundDbFieldsSchema enforces an integer minimum of 1 and defaults to 1', () => {
|
||||||
|
expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 1.5 }).success).toBe(false);
|
||||||
|
expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 0 }).success).toBe(false);
|
||||||
|
expect(InboundDbFieldsSchema.parse({}).subSortIndex).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { screen } from '@testing-library/react';
|
||||||
|
|
||||||
import InboundFormModal from '@/pages/inbounds/form/InboundFormModal';
|
import InboundFormModal from '@/pages/inbounds/form/InboundFormModal';
|
||||||
|
import { DBInbound } from '@/models/dbinbound';
|
||||||
import {
|
import {
|
||||||
renderWithProviders,
|
renderWithProviders,
|
||||||
fieldLabels,
|
fieldLabels,
|
||||||
@@ -38,4 +40,39 @@ describe('InboundFormModal', () => {
|
|||||||
expect(fieldLabels()).toMatchSnapshot(proto);
|
expect(fieldLabels()).toMatchSnapshot(proto);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves custom share address strategy when editing a local inbound', async () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<InboundFormModal
|
||||||
|
open
|
||||||
|
mode="edit"
|
||||||
|
dbInbound={new DBInbound({
|
||||||
|
id: 1,
|
||||||
|
port: 12345,
|
||||||
|
listen: '',
|
||||||
|
protocol: 'shadowsocks',
|
||||||
|
remark: 'edge',
|
||||||
|
enable: true,
|
||||||
|
settings: {
|
||||||
|
method: '2022-blake3-aes-128-gcm',
|
||||||
|
password: 'server-password',
|
||||||
|
network: 'tcp,udp',
|
||||||
|
clients: [],
|
||||||
|
},
|
||||||
|
streamSettings: { network: 'tcp', security: 'none', tcpSettings: {} },
|
||||||
|
sniffing: { enabled: false },
|
||||||
|
nodeId: null,
|
||||||
|
shareAddrStrategy: 'custom',
|
||||||
|
shareAddr: 'edge.example.test',
|
||||||
|
})}
|
||||||
|
dbInbounds={[]}
|
||||||
|
availableNodes={[]}
|
||||||
|
onClose={() => {}}
|
||||||
|
onSaved={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const shareAddrInput = await screen.findByDisplayValue('edge.example.test');
|
||||||
|
expect((shareAddrInput as HTMLInputElement).value).toBe('edge.example.test');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -309,6 +309,58 @@ describe('resolveAddr precedence', () => {
|
|||||||
'fallback.test',
|
'fallback.test',
|
||||||
)).toBe('fallback.test');
|
)).toBe('fallback.test');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses listen strategy with a shareable IPv6 listen before node override', () => {
|
||||||
|
expect(resolveAddr(
|
||||||
|
{ ...baseInbound, listen: '[2001:db8::1]', shareAddrStrategy: 'listen', shareAddr: '' } as never,
|
||||||
|
'node.example.test',
|
||||||
|
'fallback.test',
|
||||||
|
)).toBe('[2001:db8::1]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses listen strategy to prefer listen and fall back to node override', () => {
|
||||||
|
expect(resolveAddr(
|
||||||
|
{ ...baseInbound, listen: '10.0.0.1', shareAddrStrategy: 'listen', shareAddr: '' } as never,
|
||||||
|
'node.example.test',
|
||||||
|
'fallback.test',
|
||||||
|
)).toBe('10.0.0.1');
|
||||||
|
expect(resolveAddr(
|
||||||
|
{ ...baseInbound, listen: '0.0.0.0', shareAddrStrategy: 'listen', shareAddr: '' } as never,
|
||||||
|
'node.example.test',
|
||||||
|
'fallback.test',
|
||||||
|
)).toBe('node.example.test');
|
||||||
|
expect(resolveAddr(
|
||||||
|
{ ...baseInbound, listen: 'localhost', shareAddrStrategy: 'listen', shareAddr: '' } as never,
|
||||||
|
'node.example.test',
|
||||||
|
'fallback.test',
|
||||||
|
)).toBe('node.example.test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses custom strategy address before node override', () => {
|
||||||
|
expect(resolveAddr(
|
||||||
|
{ ...baseInbound, listen: '10.0.0.1', shareAddrStrategy: 'custom', shareAddr: 'edge.example.test' } as never,
|
||||||
|
'node.example.test',
|
||||||
|
'fallback.test',
|
||||||
|
)).toBe('edge.example.test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes a bare IPv6 custom strategy address', () => {
|
||||||
|
expect(resolveAddr(
|
||||||
|
{ ...baseInbound, listen: '10.0.0.1', shareAddrStrategy: 'custom', shareAddr: '2001:db8::2' } as never,
|
||||||
|
'node.example.test',
|
||||||
|
'fallback.test',
|
||||||
|
)).toBe('[2001:db8::2]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores invalid custom strategy addresses and falls back to node override', () => {
|
||||||
|
for (const shareAddr of ['https://edge.example.test', 'edge.example.test:8443', '[2001:db8::2]:8443', 'bad host']) {
|
||||||
|
expect(resolveAddr(
|
||||||
|
{ ...baseInbound, listen: '10.0.0.1', shareAddrStrategy: 'custom', shareAddr } as never,
|
||||||
|
'node.example.test',
|
||||||
|
'fallback.test',
|
||||||
|
)).toBe('node.example.test');
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// #4829: reaching the panel through an SSH tunnel (127.0.0.1/localhost) must not
|
// #4829: reaching the panel through an SSH tunnel (127.0.0.1/localhost) must not
|
||||||
|
|||||||
@@ -399,11 +399,15 @@ describe('outbound-form-adapter: xhttp xmux toggle', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('round-trips xmux on save and strips the UI-only enableXmux flag', () => {
|
it('round-trips xmux on save, strips enableXmux, and enforces xmux exclusivity', () => {
|
||||||
const back = formValuesToWirePayload(rawOutboundToFormValues(xmuxWire));
|
const back = formValuesToWirePayload(rawOutboundToFormValues(xmuxWire));
|
||||||
const xhttp = (back.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
|
const xhttp = (back.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
|
||||||
expect(xhttp).not.toHaveProperty('enableXmux');
|
expect(xhttp).not.toHaveProperty('enableXmux');
|
||||||
expect(xhttp.xmux).toMatchObject({ maxConcurrency: '11', maxConnections: '1' });
|
const xmux = xhttp.xmux as Record<string, unknown>;
|
||||||
|
// xray-core rejects maxConnections + maxConcurrency together; the
|
||||||
|
// explicit maxConnections wins and maxConcurrency is dropped.
|
||||||
|
expect(xmux).not.toHaveProperty('maxConcurrency');
|
||||||
|
expect(xmux).toMatchObject({ maxConnections: '1', hMaxRequestTimes: '1', hMaxReusableSecs: '1' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('drops xmux on save when the toggle is off', () => {
|
it('drops xmux on save when the toggle is off', () => {
|
||||||
|
|||||||
@@ -65,6 +65,69 @@ describe('normalizeXhttpForWire stream-one', () => {
|
|||||||
expect(out.xmux).toEqual({ maxConcurrency: '16-32' });
|
expect(out.xmux).toEqual({ maxConcurrency: '16-32' });
|
||||||
expect(out).not.toHaveProperty('scMaxEachPostBytes');
|
expect(out).not.toHaveProperty('scMaxEachPostBytes');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps inbound xmux when enableXmux is on (for the share-link extra)', () => {
|
||||||
|
const out = normalizeXhttpForWire({
|
||||||
|
path: '/app',
|
||||||
|
mode: 'auto',
|
||||||
|
enableXmux: true,
|
||||||
|
xmux: { maxConcurrency: '16-32' },
|
||||||
|
}, 'inbound');
|
||||||
|
|
||||||
|
expect(out).not.toHaveProperty('enableXmux');
|
||||||
|
expect(out.xmux).toEqual({ maxConcurrency: '16-32' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops inbound xmux when enableXmux is off', () => {
|
||||||
|
const out = normalizeXhttpForWire({
|
||||||
|
path: '/app',
|
||||||
|
mode: 'auto',
|
||||||
|
enableXmux: false,
|
||||||
|
xmux: { maxConcurrency: '16-32' },
|
||||||
|
}, 'inbound');
|
||||||
|
|
||||||
|
expect(out).not.toHaveProperty('enableXmux');
|
||||||
|
expect(out).not.toHaveProperty('xmux');
|
||||||
|
});
|
||||||
|
|
||||||
|
// xray-core rejects a config with both maxConnections and maxConcurrency.
|
||||||
|
it('drops maxConcurrency when maxConnections is set (xray-core exclusivity)', () => {
|
||||||
|
const out = normalizeXhttpForWire({
|
||||||
|
path: '/app',
|
||||||
|
mode: 'auto',
|
||||||
|
enableXmux: true,
|
||||||
|
xmux: { maxConcurrency: '16-32', maxConnections: 4, hKeepAlivePeriod: 30 },
|
||||||
|
}, 'inbound');
|
||||||
|
|
||||||
|
const xmux = out.xmux as Record<string, unknown>;
|
||||||
|
expect(xmux).not.toHaveProperty('maxConcurrency');
|
||||||
|
expect(xmux.maxConnections).toBe(4);
|
||||||
|
expect(xmux.hKeepAlivePeriod).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps maxConcurrency when maxConnections is 0/unset', () => {
|
||||||
|
const out = normalizeXhttpForWire({
|
||||||
|
path: '/app',
|
||||||
|
mode: 'stream-one',
|
||||||
|
xmux: { maxConcurrency: '16-32', maxConnections: 0 },
|
||||||
|
}, 'outbound');
|
||||||
|
|
||||||
|
const xmux = out.xmux as Record<string, unknown>;
|
||||||
|
expect(xmux.maxConcurrency).toBe('16-32');
|
||||||
|
expect(xmux.maxConnections).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies xmux exclusivity on the outbound side too', () => {
|
||||||
|
const out = normalizeXhttpForWire({
|
||||||
|
path: '/app',
|
||||||
|
mode: 'stream-one',
|
||||||
|
xmux: { maxConcurrency: '16-32', maxConnections: '8' },
|
||||||
|
}, 'outbound');
|
||||||
|
|
||||||
|
const xmux = out.xmux as Record<string, unknown>;
|
||||||
|
expect(xmux).not.toHaveProperty('maxConcurrency');
|
||||||
|
expect(xmux.maxConnections).toBe('8');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('normalizeSockoptForWire', () => {
|
describe('normalizeSockoptForWire', () => {
|
||||||
|
|||||||
@@ -102,15 +102,15 @@ require (
|
|||||||
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||||
golang.org/x/arch v0.28.0 // indirect
|
golang.org/x/arch v0.28.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20260603202125-055de637280b // indirect
|
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
|
||||||
golang.org/x/mod v0.37.0 // indirect
|
golang.org/x/mod v0.37.0 // indirect
|
||||||
golang.org/x/net v0.56.0
|
golang.org/x/net v0.56.0
|
||||||
golang.org/x/sync v0.21.0 // indirect
|
golang.org/x/sync v0.21.0 // indirect
|
||||||
golang.org/x/time v0.15.0 // indirect
|
golang.org/x/time v0.15.0 // indirect
|
||||||
golang.org/x/tools v0.45.0 // indirect
|
golang.org/x/tools v0.46.0 // indirect
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect
|
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260608224507-4308a22a1bab // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
|
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
|
||||||
lukechampine.com/blake3 v1.4.1 // indirect
|
lukechampine.com/blake3 v1.4.1 // indirect
|
||||||
|
|||||||
@@ -250,8 +250,8 @@ golang.org/x/arch v0.28.0 h1:wVwVdqsTuUbJvhYVCspQYwZXHNYeLSoZnmHD+ggddpQ=
|
|||||||
golang.org/x/arch v0.28.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
golang.org/x/arch v0.28.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||||
golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q=
|
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
|
||||||
golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
|
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
|
||||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
@@ -269,8 +269,8 @@ golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
|||||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
|
||||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w=
|
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w=
|
||||||
@@ -279,8 +279,8 @@ golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH
|
|||||||
golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
|
golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
|
||||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260608224507-4308a22a1bab h1:cY0oV1VnAqvaim8VsR8ZyEKAudzbRJMRGwD3W/L7yOw=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad h1:45WmJvIV6C2+O/jjLkPUH+F3aOj/1miDoU2DD0+NWbg=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260608224507-4308a22a1bab/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||||
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
+14
-2
@@ -56,6 +56,18 @@ is_domain() {
|
|||||||
[[ "$1" =~ ^([A-Za-z0-9](-*[A-Za-z0-9])*\.)+(xn--[a-z0-9]{2,}|[A-Za-z]{2,})$ ]] && return 0 || return 1
|
[[ "$1" =~ ^([A-Za-z0-9](-*[A-Za-z0-9])*\.)+(xn--[a-z0-9]{2,}|[A-Za-z]{2,})$ ]] && return 0 || return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# acme.sh's standalone server binds IPv4 by default; --listen-v6 makes it
|
||||||
|
# v6-only, which breaks HTTP-01 validation when the domain's A record points
|
||||||
|
# at this host's IPv4 (#4994). Only force IPv6 when the host has no global
|
||||||
|
# IPv4 address at all.
|
||||||
|
acme_listen_flag() {
|
||||||
|
if ip -4 addr show scope global 2> /dev/null | grep -q "inet "; then
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
echo "--listen-v6"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# Port helpers
|
# Port helpers
|
||||||
is_port_in_use() {
|
is_port_in_use() {
|
||||||
local port="$1"
|
local port="$1"
|
||||||
@@ -292,7 +304,7 @@ setup_ssl_certificate() {
|
|||||||
echo -e "${yellow}Note: Port 80 must be open and accessible from the internet${plain}"
|
echo -e "${yellow}Note: Port 80 must be open and accessible from the internet${plain}"
|
||||||
|
|
||||||
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force > /dev/null 2>&1
|
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force > /dev/null 2>&1
|
||||||
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport 80 --force
|
~/.acme.sh/acme.sh --issue -d ${domain} $(acme_listen_flag) --standalone --httpport 80 --force
|
||||||
|
|
||||||
if [ $? -ne 0 ]; then
|
if [ $? -ne 0 ]; then
|
||||||
echo -e "${yellow}Failed to issue certificate for ${domain}${plain}"
|
echo -e "${yellow}Failed to issue certificate for ${domain}${plain}"
|
||||||
@@ -576,7 +588,7 @@ ssl_cert_issue() {
|
|||||||
if [[ ${cert_exists} -eq 0 ]]; then
|
if [[ ${cert_exists} -eq 0 ]]; then
|
||||||
# issue the certificate
|
# issue the certificate
|
||||||
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
|
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
|
||||||
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
|
~/.acme.sh/acme.sh --issue -d ${domain} $(acme_listen_flag) --standalone --httpport ${WebPort} --force
|
||||||
if [ $? -ne 0 ]; then
|
if [ $? -ne 0 ]; then
|
||||||
echo -e "${red}Issuing certificate failed, please check logs.${plain}"
|
echo -e "${red}Issuing certificate failed, please check logs.${plain}"
|
||||||
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
|
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
3.3.0
|
3.3.1
|
||||||
@@ -91,6 +91,9 @@ func initModels() error {
|
|||||||
if err := pruneOrphanedClientInbounds(); err != nil {
|
if err := pruneOrphanedClientInbounds(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := normalizeInboundSubSortIndex(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if IsPostgres() {
|
if IsPostgres() {
|
||||||
if err := resyncPostgresSequences(db, models); err != nil {
|
if err := resyncPostgresSequences(db, models); err != nil {
|
||||||
log.Printf("Error resyncing postgres sequences: %v", err)
|
log.Printf("Error resyncing postgres sequences: %v", err)
|
||||||
@@ -123,6 +126,21 @@ func pruneOrphanedClientInbounds() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeInboundSubSortIndex lifts sub_sort_index values below the 1-based
|
||||||
|
// minimum (rows written by builds that defaulted the column to 0, or by nodes
|
||||||
|
// predating the field) so they cannot sort ahead of explicitly ranked inbounds.
|
||||||
|
func normalizeInboundSubSortIndex() error {
|
||||||
|
res := db.Exec("UPDATE inbounds SET sub_sort_index = 1 WHERE sub_sort_index < 1")
|
||||||
|
if res.Error != nil {
|
||||||
|
log.Printf("Error normalizing inbound sub_sort_index: %v", res.Error)
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected > 0 {
|
||||||
|
log.Printf("Normalized sub_sort_index on %d inbound(s)", res.RowsAffected)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func isIgnorableDuplicateColumnErr(err error, mdl any) bool {
|
func isIgnorableDuplicateColumnErr(err error, mdl any) bool {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ type Inbound struct {
|
|||||||
Down int64 `json:"down" form:"down"` // Download traffic in bytes
|
Down int64 `json:"down" form:"down"` // Download traffic in bytes
|
||||||
Total int64 `json:"total" form:"total"` // Total traffic limit in bytes
|
Total int64 `json:"total" form:"total"` // Total traffic limit in bytes
|
||||||
Remark string `json:"remark" form:"remark" example:"VLESS-443"` // Human-readable remark
|
Remark string `json:"remark" form:"remark" example:"VLESS-443"` // Human-readable remark
|
||||||
|
SubSortIndex int `json:"subSortIndex" form:"subSortIndex" gorm:"default:1" validate:"omitempty,gte=1" example:"1"` // 1-based sort order of this inbound's links in subscription output only (lower first; ties by id)
|
||||||
Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1" example:"true"` // Whether the inbound is enabled
|
Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1" example:"true"` // Whether the inbound is enabled
|
||||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
||||||
TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule
|
TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule
|
||||||
@@ -57,14 +58,16 @@ type Inbound struct {
|
|||||||
ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"` // Client traffic statistics
|
ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"` // Client traffic statistics
|
||||||
|
|
||||||
// Xray configuration fields
|
// Xray configuration fields
|
||||||
Listen string `json:"listen" form:"listen"`
|
Listen string `json:"listen" form:"listen"`
|
||||||
Port int `json:"port" form:"port" validate:"gte=0,lte=65535" example:"443"`
|
Port int `json:"port" form:"port" validate:"gte=0,lte=65535" example:"443"`
|
||||||
Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun mtproto" example:"vless"`
|
Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun mtproto" example:"vless"`
|
||||||
Settings string `json:"settings" form:"settings"`
|
Settings string `json:"settings" form:"settings"`
|
||||||
StreamSettings string `json:"streamSettings" form:"streamSettings"`
|
StreamSettings string `json:"streamSettings" form:"streamSettings"`
|
||||||
Tag string `json:"tag" form:"tag" gorm:"unique" example:"in-443-tcp"`
|
Tag string `json:"tag" form:"tag" gorm:"unique" example:"in-443-tcp"`
|
||||||
Sniffing string `json:"sniffing" form:"sniffing"`
|
Sniffing string `json:"sniffing" form:"sniffing"`
|
||||||
NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
|
NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
|
||||||
|
ShareAddrStrategy string `json:"shareAddrStrategy" form:"shareAddrStrategy" gorm:"column:share_addr_strategy;default:node" validate:"omitempty,oneof=node listen custom"`
|
||||||
|
ShareAddr string `json:"shareAddr" form:"shareAddr" gorm:"column:share_addr"`
|
||||||
|
|
||||||
// OriginNodeGuid is the panelGuid of the node that physically hosts this
|
// OriginNodeGuid is the panelGuid of the node that physically hosts this
|
||||||
// inbound, propagated up across hops (#4983). Empty for an inbound that
|
// inbound, propagated up across hops (#4983). Empty for an inbound that
|
||||||
@@ -445,18 +448,20 @@ type Setting struct {
|
|||||||
// endpoint over HTTP using the per-node ApiToken to populate the runtime
|
// endpoint over HTTP using the per-node ApiToken to populate the runtime
|
||||||
// status fields below.
|
// status fields below.
|
||||||
type Node struct {
|
type Node struct {
|
||||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"1"`
|
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"1"`
|
||||||
Name string `json:"name" form:"name" gorm:"uniqueIndex" validate:"required" example:"de-fra-1"`
|
Name string `json:"name" form:"name" gorm:"uniqueIndex" validate:"required" example:"de-fra-1"`
|
||||||
Remark string `json:"remark" form:"remark"`
|
Remark string `json:"remark" form:"remark"`
|
||||||
Scheme string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https" example:"https"`
|
Scheme string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https" example:"https"`
|
||||||
Address string `json:"address" form:"address" validate:"required" example:"node1.example.com"`
|
Address string `json:"address" form:"address" validate:"required" example:"node1.example.com"`
|
||||||
Port int `json:"port" form:"port" validate:"gte=1,lte=65535" example:"2053"`
|
Port int `json:"port" form:"port" validate:"gte=1,lte=65535" example:"2053"`
|
||||||
BasePath string `json:"basePath" form:"basePath" example:"/"`
|
BasePath string `json:"basePath" form:"basePath" example:"/"`
|
||||||
ApiToken string `json:"apiToken" form:"apiToken" validate:"required" example:"abcdef0123456789"`
|
ApiToken string `json:"apiToken" form:"apiToken" validate:"required" example:"abcdef0123456789"`
|
||||||
Enable bool `json:"enable" form:"enable" gorm:"default:true" example:"true"`
|
Enable bool `json:"enable" form:"enable" gorm:"default:true" example:"true"`
|
||||||
AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
|
AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
|
||||||
TlsVerifyMode string `json:"tlsVerifyMode" form:"tlsVerifyMode" gorm:"column:tls_verify_mode;default:verify" validate:"omitempty,oneof=verify skip pin"`
|
TlsVerifyMode string `json:"tlsVerifyMode" form:"tlsVerifyMode" gorm:"column:tls_verify_mode;default:verify" validate:"omitempty,oneof=verify skip pin"`
|
||||||
PinnedCertSha256 string `json:"pinnedCertSha256" form:"pinnedCertSha256" gorm:"column:pinned_cert_sha256"`
|
PinnedCertSha256 string `json:"pinnedCertSha256" form:"pinnedCertSha256" gorm:"column:pinned_cert_sha256"`
|
||||||
|
InboundSyncMode string `json:"inboundSyncMode" form:"inboundSyncMode" gorm:"column:inbound_sync_mode;default:all" validate:"omitempty,oneof=all selected"`
|
||||||
|
InboundTags []string `json:"inboundTags" form:"inboundTags" gorm:"serializer:json;column:inbound_tags"`
|
||||||
|
|
||||||
// Guid is the remote panel's stable self-identifier (its panelGuid),
|
// Guid is the remote panel's stable self-identifier (its panelGuid),
|
||||||
// learned from each heartbeat. It is the globally stable node identity used
|
// learned from each heartbeat. It is the globally stable node identity used
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ type Instance struct {
|
|||||||
FrontingIP string
|
FrontingIP string
|
||||||
FrontingPort int
|
FrontingPort int
|
||||||
FrontingProxyProtocol bool
|
FrontingProxyProtocol bool
|
||||||
|
|
||||||
|
// When RouteThroughXray is set, mtg dials Telegram through the loopback
|
||||||
|
// SOCKS bridge the panel injects into the Xray config at XrayRoutePort, so
|
||||||
|
// the egress obeys the core's routing rules instead of going out directly.
|
||||||
|
RouteThroughXray bool
|
||||||
|
XrayRoutePort int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (inst Instance) bindTo() string {
|
func (inst Instance) bindTo() string {
|
||||||
@@ -54,6 +60,8 @@ func (inst Instance) fingerprint() string {
|
|||||||
inst.FrontingIP,
|
inst.FrontingIP,
|
||||||
strconv.Itoa(inst.FrontingPort),
|
strconv.Itoa(inst.FrontingPort),
|
||||||
strconv.FormatBool(inst.FrontingProxyProtocol),
|
strconv.FormatBool(inst.FrontingProxyProtocol),
|
||||||
|
strconv.FormatBool(inst.RouteThroughXray),
|
||||||
|
strconv.Itoa(inst.XrayRoutePort),
|
||||||
}, "|")
|
}, "|")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +125,8 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
|
|||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
ProxyProtocol bool `json:"proxyProtocol"`
|
ProxyProtocol bool `json:"proxyProtocol"`
|
||||||
} `json:"domainFronting"`
|
} `json:"domainFronting"`
|
||||||
|
RouteThroughXray bool `json:"routeThroughXray"`
|
||||||
|
RouteXrayPort int `json:"routeXrayPort"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
|
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
|
||||||
return Instance{}, false
|
return Instance{}, false
|
||||||
@@ -136,6 +146,8 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
|
|||||||
FrontingIP: parsed.DomainFronting.IP,
|
FrontingIP: parsed.DomainFronting.IP,
|
||||||
FrontingPort: parsed.DomainFronting.Port,
|
FrontingPort: parsed.DomainFronting.Port,
|
||||||
FrontingProxyProtocol: parsed.DomainFronting.ProxyProtocol,
|
FrontingProxyProtocol: parsed.DomainFronting.ProxyProtocol,
|
||||||
|
RouteThroughXray: parsed.RouteThroughXray,
|
||||||
|
XrayRoutePort: parsed.RouteXrayPort,
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +184,7 @@ func (m *Manager) ensureLocked(inst Instance) error {
|
|||||||
cur.proc.Stop()
|
cur.proc.Stop()
|
||||||
delete(m.procs, inst.Id)
|
delete(m.procs, inst.Id)
|
||||||
}
|
}
|
||||||
metricsPort, err := freeLocalPort()
|
metricsPort, err := FreeLocalPort()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -307,7 +319,10 @@ func (m *Manager) CollectTraffic() []Traffic {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func freeLocalPort() (int, error) {
|
// FreeLocalPort asks the OS for an unused loopback TCP port. It is used both
|
||||||
|
// for mtg's metrics endpoint and to allocate the per-inbound SOCKS egress
|
||||||
|
// bridge port persisted into mtproto inbound settings.
|
||||||
|
func FreeLocalPort() (int, error) {
|
||||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -345,6 +360,12 @@ func renderConfig(inst Instance, metricsPort int) string {
|
|||||||
b.WriteString("proxy-protocol = true\n")
|
b.WriteString("proxy-protocol = true\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// When the inbound opts into Xray routing, mtg reaches Telegram through the
|
||||||
|
// loopback SOCKS bridge the panel injects into the running Xray config. mtg
|
||||||
|
// only supports SOCKS5 upstreams, which is exactly what the bridge exposes.
|
||||||
|
if inst.RouteThroughXray && inst.XrayRoutePort > 0 {
|
||||||
|
fmt.Fprintf(&b, "\n[network]\nproxies = [\"socks5://127.0.0.1:%d\"]\n", inst.XrayRoutePort)
|
||||||
|
}
|
||||||
fmt.Fprintf(&b, "\n[stats.prometheus]\nenabled = true\nbind-to = \"127.0.0.1:%d\"\nhttp-path = \"/metrics\"\nmetric-prefix = \"mtg\"\n", metricsPort)
|
fmt.Fprintf(&b, "\n[stats.prometheus]\nenabled = true\nbind-to = \"127.0.0.1:%d\"\nhttp-path = \"/metrics\"\nmetric-prefix = \"mtg\"\n", metricsPort)
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ func TestInstanceFromInbound(t *testing.T) {
|
|||||||
Protocol: model.MTProto,
|
Protocol: model.MTProto,
|
||||||
Settings: `{"fakeTlsDomain":"example.com","secret":"",` +
|
Settings: `{"fakeTlsDomain":"example.com","secret":"",` +
|
||||||
`"debug":true,"proxyProtocolListener":true,"preferIp":"prefer-ipv4",` +
|
`"debug":true,"proxyProtocolListener":true,"preferIp":"prefer-ipv4",` +
|
||||||
`"domainFronting":{"ip":"127.0.0.1","port":9443,"proxyProtocol":true}}`,
|
`"domainFronting":{"ip":"127.0.0.1","port":9443,"proxyProtocol":true},` +
|
||||||
|
`"routeThroughXray":true,"routeXrayPort":50000}`,
|
||||||
}
|
}
|
||||||
inst, ok := InstanceFromInbound(ib)
|
inst, ok := InstanceFromInbound(ib)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -58,6 +59,9 @@ func TestInstanceFromInbound(t *testing.T) {
|
|||||||
if inst.FrontingIP != "127.0.0.1" || inst.FrontingPort != 9443 || !inst.FrontingProxyProtocol {
|
if inst.FrontingIP != "127.0.0.1" || inst.FrontingPort != 9443 || !inst.FrontingProxyProtocol {
|
||||||
t.Fatalf("domain-fronting not parsed: %+v", inst)
|
t.Fatalf("domain-fronting not parsed: %+v", inst)
|
||||||
}
|
}
|
||||||
|
if !inst.RouteThroughXray || inst.XrayRoutePort != 50000 {
|
||||||
|
t.Fatalf("xray routing not parsed: %+v", inst)
|
||||||
|
}
|
||||||
|
|
||||||
if _, ok := InstanceFromInbound(&model.Inbound{Protocol: model.VLESS}); ok {
|
if _, ok := InstanceFromInbound(&model.Inbound{Protocol: model.VLESS}); ok {
|
||||||
t.Fatal("non-mtproto inbound should not produce an instance")
|
t.Fatal("non-mtproto inbound should not produce an instance")
|
||||||
@@ -108,6 +112,32 @@ func TestRenderConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRenderConfigXrayEgress(t *testing.T) {
|
||||||
|
// Routing through Xray emits a [network] proxies upstream pointing at the
|
||||||
|
// loopback SOCKS bridge, before the prometheus block.
|
||||||
|
routed := renderConfig(Instance{
|
||||||
|
Secret: "ee22", Listen: "0.0.0.0", Port: 443,
|
||||||
|
RouteThroughXray: true, XrayRoutePort: 50000,
|
||||||
|
}, 7000)
|
||||||
|
if !strings.Contains(routed, "[network]") ||
|
||||||
|
!strings.Contains(routed, `proxies = ["socks5://127.0.0.1:50000"]`) {
|
||||||
|
t.Fatalf("routed config must emit the SOCKS upstream:\n%s", routed)
|
||||||
|
}
|
||||||
|
if strings.Index(routed, "[network]") > strings.Index(routed, "[stats.prometheus]") {
|
||||||
|
t.Fatalf("[network] must precede [stats.prometheus]:\n%s", routed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without the flag (or without a port) the section is omitted.
|
||||||
|
for _, inst := range []Instance{
|
||||||
|
{Secret: "ee", Listen: "0.0.0.0", Port: 443},
|
||||||
|
{Secret: "ee", Listen: "0.0.0.0", Port: 443, RouteThroughXray: true},
|
||||||
|
} {
|
||||||
|
if got := renderConfig(inst, 7000); strings.Contains(got, "[network]") {
|
||||||
|
t.Fatalf("unrouted config must omit [network]:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFingerprintReactsToOptions(t *testing.T) {
|
func TestFingerprintReactsToOptions(t *testing.T) {
|
||||||
base := Instance{Secret: "ee", Listen: "0.0.0.0", Port: 443}
|
base := Instance{Secret: "ee", Listen: "0.0.0.0", Port: 443}
|
||||||
for name, mutate := range map[string]func(*Instance){
|
for name, mutate := range map[string]func(*Instance){
|
||||||
@@ -117,6 +147,8 @@ func TestFingerprintReactsToOptions(t *testing.T) {
|
|||||||
"frontingIP": func(i *Instance) { i.FrontingIP = "127.0.0.1" },
|
"frontingIP": func(i *Instance) { i.FrontingIP = "127.0.0.1" },
|
||||||
"frontingPort": func(i *Instance) { i.FrontingPort = 9443 },
|
"frontingPort": func(i *Instance) { i.FrontingPort = 9443 },
|
||||||
"frontingProxy": func(i *Instance) { i.FrontingProxyProtocol = true },
|
"frontingProxy": func(i *Instance) { i.FrontingProxyProtocol = true },
|
||||||
|
"routeXray": func(i *Instance) { i.RouteThroughXray = true },
|
||||||
|
"routeXrayPort": func(i *Instance) { i.XrayRoutePort = 50000 },
|
||||||
} {
|
} {
|
||||||
changed := base
|
changed := base
|
||||||
mutate(&changed)
|
mutate(&changed)
|
||||||
|
|||||||
@@ -69,3 +69,51 @@ func TestBuildURLs_EmptySubId(t *testing.T) {
|
|||||||
t.Fatalf("empty subId must yield empty URLs, got %q %q %q", a, b, c)
|
t.Fatalf("empty subId must yield empty URLs, got %q %q %q", a, b, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A subscriber arriving via a reverse proxy (subURI configured with full
|
||||||
|
// HTTPS URL) must see the same scheme+host in the JSON and Clash Copy
|
||||||
|
// URLs as in the main subURL — not the raw sub-server port 2096.
|
||||||
|
func TestBuildURLs_DerivesJsonFromConfiguredSubURI(t *testing.T) {
|
||||||
|
initSubDB(t)
|
||||||
|
s := &SubService{}
|
||||||
|
s.PrepareForRequest("sub.example.com")
|
||||||
|
|
||||||
|
// Simulate the admin having set subURI (reverse-proxy setup).
|
||||||
|
database.GetDB().Exec(
|
||||||
|
"INSERT INTO settings (key, value) VALUES (?, ?)",
|
||||||
|
"subURI", "https://example.com/sub-xxx/")
|
||||||
|
|
||||||
|
subURL, jsonURL, clashURL := s.BuildURLs("/sub-xxx/", "/json/", "/clash/", "ABC")
|
||||||
|
|
||||||
|
if subURL != "https://example.com/sub-xxx/ABC" {
|
||||||
|
t.Fatalf("subURL = %q", subURL)
|
||||||
|
}
|
||||||
|
if jsonURL != "https://example.com/json/ABC" {
|
||||||
|
t.Fatalf("jsonURL = %q (should derive scheme+host from subURI), want %q", jsonURL, "https://example.com/json/ABC")
|
||||||
|
}
|
||||||
|
if clashURL != "https://example.com/clash/ABC" {
|
||||||
|
t.Fatalf("clashURL = %q (should derive scheme+host from subURI), want %q", clashURL, "https://example.com/clash/ABC")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A malformed subURI (no scheme/host) must not leak a broken base into the
|
||||||
|
// JSON/Clash URLs; BuildURLs should fall back to the request-derived base.
|
||||||
|
func TestBuildURLs_MalformedSubURIFallsBackToRequestBase(t *testing.T) {
|
||||||
|
initSubDB(t)
|
||||||
|
s := &SubService{}
|
||||||
|
s.PrepareForRequest("sub.example.com")
|
||||||
|
|
||||||
|
// A value with no scheme can't yield a usable scheme+host.
|
||||||
|
database.GetDB().Exec(
|
||||||
|
"INSERT INTO settings (key, value) VALUES (?, ?)",
|
||||||
|
"subURI", "example.com/sub-xxx/")
|
||||||
|
|
||||||
|
_, jsonURL, clashURL := s.BuildURLs("/sub-xxx/", "/json/", "/clash/", "ABC")
|
||||||
|
|
||||||
|
if jsonURL != "http://sub.example.com:2096/json/ABC" {
|
||||||
|
t.Fatalf("jsonURL = %q, want fallback to request base %q", jsonURL, "http://sub.example.com:2096/json/ABC")
|
||||||
|
}
|
||||||
|
if clashURL != "http://sub.example.com:2096/clash/ABC" {
|
||||||
|
t.Fatalf("clashURL = %q, want fallback to request base %q", clashURL, "http://sub.example.com:2096/clash/ABC")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,15 +9,12 @@ import (
|
|||||||
yaml "github.com/goccy/go-yaml"
|
yaml "github.com/goccy/go-yaml"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type SubClashService struct {
|
type SubClashService struct {
|
||||||
inboundService service.InboundService
|
enableRouting bool
|
||||||
enableRouting bool
|
clashRules string
|
||||||
clashRules string
|
SubService *SubService
|
||||||
SubService *SubService
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSubClashService(enableRouting bool, clashRules string, subService *SubService) *SubClashService {
|
func NewSubClashService(enableRouting bool, clashRules string, subService *SubService) *SubClashService {
|
||||||
@@ -36,19 +33,14 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
|
|||||||
|
|
||||||
seenEmails := make(map[string]struct{})
|
seenEmails := make(map[string]struct{})
|
||||||
for _, inbound := range inbounds {
|
for _, inbound := range inbounds {
|
||||||
clients, err := s.inboundService.GetClients(inbound)
|
clients := s.SubService.matchingClients(inbound, subId)
|
||||||
if err != nil {
|
if len(clients) == 0 {
|
||||||
logger.Error("SubClashService - GetClients: Unable to get clients from inbound")
|
|
||||||
}
|
|
||||||
if clients == nil {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
s.SubService.projectThroughFallbackMaster(inbound)
|
s.SubService.projectThroughFallbackMaster(inbound)
|
||||||
for _, client := range clients {
|
for _, client := range clients {
|
||||||
if client.SubID == subId {
|
seenEmails[client.Email] = struct{}{}
|
||||||
seenEmails[client.Email] = struct{}{}
|
proxies = append(proxies, s.getProxies(inbound, client, host)...)
|
||||||
proxies = append(proxies, s.getProxies(inbound, client, host)...)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/random"
|
"github.com/mhsanaei/3x-ui/v3/internal/util/random"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed default.json
|
//go:embed default.json
|
||||||
@@ -24,8 +22,7 @@ type SubJsonService struct {
|
|||||||
finalMask string
|
finalMask string
|
||||||
mux string
|
mux string
|
||||||
|
|
||||||
inboundService service.InboundService
|
SubService *SubService
|
||||||
SubService *SubService
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSubJsonService creates a new JSON subscription service with the given configuration.
|
// NewSubJsonService creates a new JSON subscription service with the given configuration.
|
||||||
@@ -75,20 +72,15 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
|
|||||||
seenEmails := make(map[string]struct{})
|
seenEmails := make(map[string]struct{})
|
||||||
// Prepare Inbounds
|
// Prepare Inbounds
|
||||||
for _, inbound := range inbounds {
|
for _, inbound := range inbounds {
|
||||||
clients, err := s.inboundService.GetClients(inbound)
|
clients := s.SubService.matchingClients(inbound, subId)
|
||||||
if err != nil {
|
if len(clients) == 0 {
|
||||||
logger.Error("SubJsonService - GetClients: Unable to get clients from inbound")
|
|
||||||
}
|
|
||||||
if clients == nil {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
s.SubService.projectThroughFallbackMaster(inbound)
|
s.SubService.projectThroughFallbackMaster(inbound)
|
||||||
|
|
||||||
for _, client := range clients {
|
for _, client := range clients {
|
||||||
if client.SubID == subId {
|
seenEmails[client.Email] = struct{}{}
|
||||||
seenEmails[client.Email] = struct{}{}
|
configArray = append(configArray, s.getConfig(inbound, client, host)...)
|
||||||
configArray = append(configArray, s.getConfig(inbound, client, host)...)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +217,15 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
|
|||||||
delete(xhttp, "scMaxBufferedPosts")
|
delete(xhttp, "scMaxBufferedPosts")
|
||||||
delete(xhttp, "scStreamUpServerSecs")
|
delete(xhttp, "scStreamUpServerSecs")
|
||||||
delete(xhttp, "serverMaxHeaderBytes")
|
delete(xhttp, "serverMaxHeaderBytes")
|
||||||
|
// Values matching xray-core's own defaults stay off the wire:
|
||||||
|
// old panels seeded them into every stored config and the
|
||||||
|
// literal scMinPostsIntervalMs=30 is a DPI fingerprint (#5141).
|
||||||
|
if v, _ := xhttp["scMaxEachPostBytes"].(string); v == "" || v == "1000000" {
|
||||||
|
delete(xhttp, "scMaxEachPostBytes")
|
||||||
|
}
|
||||||
|
if v, _ := xhttp["scMinPostsIntervalMs"].(string); v == "" || v == "30" {
|
||||||
|
delete(xhttp, "scMinPostsIntervalMs")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return streamSettings
|
return streamSettings
|
||||||
|
|||||||
+117
-27
@@ -106,6 +106,36 @@ func listenIsInternalOnly(listen string) bool {
|
|||||||
return isLoopbackHost(listen)
|
return isLoopbackHost(listen)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// matchingClients returns the inbound's clients whose SubID equals subId,
|
||||||
|
// deduplicated by email. settings.clients can accumulate duplicate entries
|
||||||
|
// for the same client (multi-node sync/import drift, old DBs): SyncInbound
|
||||||
|
// dedupes the normalized client_inbounds rows on write but never rewrites
|
||||||
|
// the legacy JSON, and the subscription builders iterate that JSON — so
|
||||||
|
// without this guard every duplicate became a duplicate profile in the
|
||||||
|
// output (#5134). Link generation keys purely on (inbound, email), so
|
||||||
|
// same-email entries are pure duplicates and dropping them is lossless.
|
||||||
|
func (s *SubService) matchingClients(inbound *model.Inbound, subId string) []model.Client {
|
||||||
|
clients, err := s.inboundService.GetClients(inbound)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("SubService - GetClients: Unable to get clients from inbound")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []model.Client
|
||||||
|
seen := make(map[string]struct{}, len(clients))
|
||||||
|
for _, client := range clients {
|
||||||
|
if client.SubID != subId {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToLower(client.Email)
|
||||||
|
if _, dup := seen[key]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
out = append(out, client)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// GetSubs retrieves subscription links for a given subscription ID and host.
|
// GetSubs retrieves subscription links for a given subscription ID and host.
|
||||||
func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int64, xray.ClientTraffic, error) {
|
func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int64, xray.ClientTraffic, error) {
|
||||||
s.PrepareForRequest(host)
|
s.PrepareForRequest(host)
|
||||||
@@ -134,23 +164,18 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int
|
|||||||
|
|
||||||
seenEmails := make(map[string]struct{})
|
seenEmails := make(map[string]struct{})
|
||||||
for _, inbound := range inbounds {
|
for _, inbound := range inbounds {
|
||||||
clients, err := s.inboundService.GetClients(inbound)
|
clients := s.matchingClients(inbound, subId)
|
||||||
if err != nil {
|
if len(clients) == 0 {
|
||||||
logger.Error("SubService - GetClients: Unable to get clients from inbound")
|
|
||||||
}
|
|
||||||
if clients == nil {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
s.projectThroughFallbackMaster(inbound)
|
s.projectThroughFallbackMaster(inbound)
|
||||||
for _, client := range clients {
|
for _, client := range clients {
|
||||||
if client.SubID == subId {
|
if client.Enable {
|
||||||
if client.Enable {
|
hasEnabledClient = true
|
||||||
hasEnabledClient = true
|
|
||||||
}
|
|
||||||
result = append(result, s.GetLink(inbound, client.Email))
|
|
||||||
emails = append(emails, client.Email)
|
|
||||||
seenEmails[client.Email] = struct{}{}
|
|
||||||
}
|
}
|
||||||
|
result = append(result, s.GetLink(inbound, client.Email))
|
||||||
|
emails = append(emails, client.Email)
|
||||||
|
seenEmails[client.Email] = struct{}{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,7 +284,7 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error)
|
|||||||
WHERE
|
WHERE
|
||||||
inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria')
|
inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria')
|
||||||
AND clients.sub_id = ? AND inbounds.enable = ?
|
AND clients.sub_id = ? AND inbounds.enable = ?
|
||||||
)`, subId, true).Find(&inbounds).Error
|
)`, subId, true).Order("sub_sort_index ASC").Order("id ASC").Find(&inbounds).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -788,23 +813,42 @@ func (s *SubService) loadNodes() {
|
|||||||
s.nodesByID = m
|
s.nodesByID = m
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveInboundAddress picks the host an external client should connect to:
|
// resolveInboundAddress picks the host an external client should connect to,
|
||||||
// 1. node-managed inbound -> the node's address
|
// honoring the inbound's share address strategy the same way the panel's
|
||||||
// 2. an explicit, client-reachable bind Listen -> that Listen
|
// share/QR link builder does (#5208):
|
||||||
// 3. otherwise the subscriber's request host (s.address)
|
// - "listen": an explicit, client-reachable bind Listen wins, backed by the
|
||||||
|
// node's address for node-managed inbounds;
|
||||||
|
// - "custom": the inbound's ShareAddr wins, then node, then listen;
|
||||||
|
// - "node" (default, and any unknown value): the node's address for
|
||||||
|
// node-managed inbounds, then a routable Listen — the pre-strategy order.
|
||||||
//
|
//
|
||||||
// A loopback/wildcard bind or a unix-domain-socket listen is a server-side
|
// Every chain ends at the subscriber's request host (s.address). A
|
||||||
// detail and is never advertised; External Proxy remains the way to advertise
|
// loopback/wildcard bind or a unix-domain-socket listen is a server-side
|
||||||
// an arbitrary endpoint. Mirrors the frontend's resolveAddr so the panel QR and
|
// detail and is never advertised; External Proxy still overrides everything
|
||||||
// the subscription agree.
|
// upstream of this call.
|
||||||
func (s *SubService) resolveInboundAddress(inbound *model.Inbound) string {
|
func (s *SubService) resolveInboundAddress(inbound *model.Inbound) string {
|
||||||
|
var nodeAddr string
|
||||||
if inbound.NodeID != nil && s.nodesByID != nil {
|
if inbound.NodeID != nil && s.nodesByID != nil {
|
||||||
if n, ok := s.nodesByID[*inbound.NodeID]; ok && n.Address != "" {
|
if n, ok := s.nodesByID[*inbound.NodeID]; ok {
|
||||||
return n.Address
|
nodeAddr = n.Address
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var listenAddr string
|
||||||
if listen := inbound.Listen; listen != "" && listen[0] != '@' && listen[0] != '/' && isRoutableHost(listen) {
|
if listen := inbound.Listen; listen != "" && listen[0] != '@' && listen[0] != '/' && isRoutableHost(listen) {
|
||||||
return listen
|
listenAddr = listen
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := []string{nodeAddr, listenAddr}
|
||||||
|
switch inbound.ShareAddrStrategy {
|
||||||
|
case "listen":
|
||||||
|
candidates = []string{listenAddr, nodeAddr}
|
||||||
|
case "custom":
|
||||||
|
candidates = []string{strings.TrimSpace(inbound.ShareAddr), nodeAddr, listenAddr}
|
||||||
|
}
|
||||||
|
for _, c := range candidates {
|
||||||
|
if c != "" {
|
||||||
|
return c
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return s.address
|
return s.address
|
||||||
}
|
}
|
||||||
@@ -1475,6 +1519,19 @@ func (s *SubService) genRemark(inbound *model.Inbound, email string, extra strin
|
|||||||
if len(extra) > 0 {
|
if len(extra) > 0 {
|
||||||
orders['o'] = extra
|
orders['o'] = extra
|
||||||
}
|
}
|
||||||
|
// A node-hosted inbound usually shares its remark with the local copy it
|
||||||
|
// was synced from, so a multi-node subscription would list several
|
||||||
|
// identically-named entries differing only by address (#5035). Tag such
|
||||||
|
// entries with the node name unless the admin already put it in the remark.
|
||||||
|
if inbound.NodeID != nil && s.nodesByID != nil {
|
||||||
|
if n, ok := s.nodesByID[*inbound.NodeID]; ok && n != nil && n.Name != "" && !strings.Contains(orders['i'], n.Name) {
|
||||||
|
if orders['i'] != "" {
|
||||||
|
orders['i'] += "@" + n.Name
|
||||||
|
} else {
|
||||||
|
orders['i'] = n.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var remark []string
|
var remark []string
|
||||||
for i := 0; i < len(orderChars); i++ {
|
for i := 0; i < len(orderChars); i++ {
|
||||||
@@ -1604,8 +1661,16 @@ func buildXhttpExtra(xhttp map[string]any) map[string]any {
|
|||||||
"uplinkDataPlacement", "uplinkDataKey",
|
"uplinkDataPlacement", "uplinkDataKey",
|
||||||
"scMaxEachPostBytes", "scMinPostsIntervalMs",
|
"scMaxEachPostBytes", "scMinPostsIntervalMs",
|
||||||
}
|
}
|
||||||
|
// Values matching xray-core's own defaults are redundant on the wire and
|
||||||
|
// the literal scMinPostsIntervalMs=30 is a known DPI fingerprint (#5141).
|
||||||
|
// Old panels seeded these defaults into every xhttp inbound, so filter
|
||||||
|
// them here instead of requiring every stored config to be re-saved.
|
||||||
|
coreDefaults := map[string]string{
|
||||||
|
"scMaxEachPostBytes": "1000000",
|
||||||
|
"scMinPostsIntervalMs": "30",
|
||||||
|
}
|
||||||
for _, field := range stringFields {
|
for _, field := range stringFields {
|
||||||
if v, ok := xhttp[field].(string); ok && len(v) > 0 {
|
if v, ok := xhttp[field].(string); ok && len(v) > 0 && v != coreDefaults[field] {
|
||||||
extra[field] = v
|
extra[field] = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2123,12 +2188,37 @@ func (s *SubService) BuildURLs(subPath, subJsonPath, subClashPath, subId string)
|
|||||||
base := s.settingService.BuildSubURIBase(s.address)
|
base := s.settingService.BuildSubURIBase(s.address)
|
||||||
|
|
||||||
subURL = s.buildSingleURL(configuredSubURI, base, subPath, subId)
|
subURL = s.buildSingleURL(configuredSubURI, base, subPath, subId)
|
||||||
subJsonURL = s.buildSingleURL(configuredSubJsonURI, base, subJsonPath, subId)
|
|
||||||
subClashURL = s.buildSingleURL(configuredSubClashURI, base, subClashPath, subId)
|
// When subURI is explicitly configured (reverse-proxy setup), use its
|
||||||
|
// scheme+host as the base for JSON and Clash URLs so they match the
|
||||||
|
// reverse-proxy endpoint instead of the raw sub-server port. Fall back
|
||||||
|
// to the request-derived base if subURI is empty or can't be parsed
|
||||||
|
// into a scheme+host (e.g. a malformed value with no scheme).
|
||||||
|
jsonClashBase := base
|
||||||
|
if configuredSubURI != "" {
|
||||||
|
if derived := s.extractBaseFromURI(configuredSubURI); derived != "" {
|
||||||
|
jsonClashBase = derived
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subJsonURL = s.buildSingleURL(configuredSubJsonURI, jsonClashBase, subJsonPath, subId)
|
||||||
|
subClashURL = s.buildSingleURL(configuredSubClashURI, jsonClashBase, subClashPath, subId)
|
||||||
|
|
||||||
return subURL, subJsonURL, subClashURL
|
return subURL, subJsonURL, subClashURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractBaseFromURI extracts scheme://host from a configured URI.
|
||||||
|
// e.g., "https://example.com/sub-xxx/" → "https://example.com".
|
||||||
|
// Returns "" when the URI is empty or lacks a scheme/host, so callers can
|
||||||
|
// fall back to the request-derived base instead of emitting a broken value.
|
||||||
|
func (s *SubService) extractBaseFromURI(uri string) string {
|
||||||
|
u, err := url.Parse(uri)
|
||||||
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s://%s", u.Scheme, u.Host)
|
||||||
|
}
|
||||||
|
|
||||||
// buildSingleURL constructs a single URL using configured URI or base components
|
// buildSingleURL constructs a single URL using configured URI or base components
|
||||||
func (s *SubService) buildSingleURL(configuredURI, base, basePath, subId string) string {
|
func (s *SubService) buildSingleURL(configuredURI, base, basePath, subId string) string {
|
||||||
if configuredURI != "" {
|
if configuredURI != "" {
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package sub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestGetSubs_DuplicateSettingsClients_Deduped reproduces #5134: multi-node
|
||||||
|
// sync/import drift can leave the same client twice inside an inbound's
|
||||||
|
// legacy settings.clients JSON while the normalized client_inbounds table
|
||||||
|
// stays clean. The subscription output must still contain one profile per
|
||||||
|
// (inbound, client).
|
||||||
|
func TestGetSubs_DuplicateSettingsClients_Deduped(t *testing.T) {
|
||||||
|
dbDir := t.TempDir()
|
||||||
|
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||||
|
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
||||||
|
t.Fatalf("InitDB: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.CloseDB() })
|
||||||
|
|
||||||
|
const subId = "sub-dup"
|
||||||
|
const email = "dup@example.com"
|
||||||
|
const uuid = "f1b9265f-26a8-4b75-9be2-c64a94b15de1"
|
||||||
|
|
||||||
|
db := database.GetDB()
|
||||||
|
settings := fmt.Sprintf(`{"clients": [
|
||||||
|
{"id": %q, "email": %q, "subId": %q, "enable": true},
|
||||||
|
{"id": %q, "email": %q, "subId": %q, "enable": true}
|
||||||
|
]}`, uuid, email, subId, uuid, email, subId)
|
||||||
|
ib := &model.Inbound{
|
||||||
|
UserId: 1,
|
||||||
|
Tag: "dup-in",
|
||||||
|
Enable: true,
|
||||||
|
Port: 42001,
|
||||||
|
Protocol: model.VLESS,
|
||||||
|
Settings: settings,
|
||||||
|
StreamSettings: `{"network": "tcp", "security": "none"}`,
|
||||||
|
}
|
||||||
|
if err := db.Create(ib).Error; err != nil {
|
||||||
|
t.Fatalf("seed inbound: %v", err)
|
||||||
|
}
|
||||||
|
client := &model.ClientRecord{Email: email, SubID: subId, UUID: uuid, Enable: true}
|
||||||
|
if err := db.Create(client).Error; err != nil {
|
||||||
|
t.Fatalf("seed client: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
|
||||||
|
t.Fatalf("seed client_inbound: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := NewSubService(false, "-ieo")
|
||||||
|
links, emails, _, _, err := s.GetSubs(subId, "sub.example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSubs: %v", err)
|
||||||
|
}
|
||||||
|
if len(links) != 1 {
|
||||||
|
t.Fatalf("links = %d, want 1 (duplicate settings.clients entries must collapse)", len(links))
|
||||||
|
}
|
||||||
|
if len(emails) != 1 {
|
||||||
|
t.Fatalf("emails = %d, want 1, got %v", len(emails), emails)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package sub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestGetSubs_OrdersBySubSortIndexThenId verifies that subscription output
|
||||||
|
// lists inbound links ordered by sub_sort_index ASC, breaking ties by id ASC.
|
||||||
|
// The same query feeds the raw body, the HTML sub page, and the JSON/Clash
|
||||||
|
// formats, so asserting on GetSubs covers all of them.
|
||||||
|
func TestGetSubs_OrdersBySubSortIndexThenId(t *testing.T) {
|
||||||
|
dbDir := t.TempDir()
|
||||||
|
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||||
|
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
||||||
|
t.Fatalf("InitDB: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.CloseDB() })
|
||||||
|
|
||||||
|
const subId = "sub-sort"
|
||||||
|
db := database.GetDB()
|
||||||
|
|
||||||
|
seed := []struct {
|
||||||
|
tag string
|
||||||
|
port int
|
||||||
|
subSortIndex int
|
||||||
|
email string
|
||||||
|
uuid string
|
||||||
|
}{
|
||||||
|
// Created in this order on purpose: without the ORDER BY the links
|
||||||
|
// would come out s3, s1, s2a, s2b (creation order).
|
||||||
|
{"sort-3", 42101, 3, "s3@example.com", "0d68a695-4be1-4d92-a9c3-8c0f1c2cf001"},
|
||||||
|
{"sort-1", 42102, 1, "s1@example.com", "0d68a695-4be1-4d92-a9c3-8c0f1c2cf002"},
|
||||||
|
{"sort-2a", 42103, 2, "s2a@example.com", "0d68a695-4be1-4d92-a9c3-8c0f1c2cf003"},
|
||||||
|
{"sort-2b", 42104, 2, "s2b@example.com", "0d68a695-4be1-4d92-a9c3-8c0f1c2cf004"},
|
||||||
|
}
|
||||||
|
for _, s := range seed {
|
||||||
|
settings := fmt.Sprintf(`{"clients": [{"id": %q, "email": %q, "subId": %q, "enable": true}]}`, s.uuid, s.email, subId)
|
||||||
|
ib := &model.Inbound{
|
||||||
|
UserId: 1,
|
||||||
|
Tag: s.tag,
|
||||||
|
Enable: true,
|
||||||
|
Port: s.port,
|
||||||
|
Protocol: model.VLESS,
|
||||||
|
Settings: settings,
|
||||||
|
StreamSettings: `{"network": "tcp", "security": "none"}`,
|
||||||
|
SubSortIndex: s.subSortIndex,
|
||||||
|
}
|
||||||
|
if err := db.Create(ib).Error; err != nil {
|
||||||
|
t.Fatalf("seed inbound %s: %v", s.tag, err)
|
||||||
|
}
|
||||||
|
client := &model.ClientRecord{Email: s.email, SubID: subId, UUID: s.uuid, Enable: true}
|
||||||
|
if err := db.Create(client).Error; err != nil {
|
||||||
|
t.Fatalf("seed client %s: %v", s.email, err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
|
||||||
|
t.Fatalf("seed client_inbound %s: %v", s.email, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s := NewSubService(false, "-ieo")
|
||||||
|
links, emails, _, _, err := s.GetSubs(subId, "sub.example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSubs: %v", err)
|
||||||
|
}
|
||||||
|
if len(links) != len(seed) {
|
||||||
|
t.Fatalf("links = %d, want %d", len(links), len(seed))
|
||||||
|
}
|
||||||
|
want := []string{"s1@example.com", "s2a@example.com", "s2b@example.com", "s3@example.com"}
|
||||||
|
for i, email := range want {
|
||||||
|
if emails[i] != email {
|
||||||
|
t.Fatalf("emails order = %v, want %v (sub_sort_index ASC, id ASC)", emails, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -127,6 +127,68 @@ func TestResolveInboundAddress(t *testing.T) {
|
|||||||
t.Fatalf("unknown-node address = %q, want subscriber host %q", got, reqHost)
|
t.Fatalf("unknown-node address = %q, want subscriber host %q", got, reqHost)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Per-inbound share address strategy (#5208): subscriptions follow the
|
||||||
|
// same order as the panel's share/QR links.
|
||||||
|
t.Run("listen strategy prefers the bind over the node address", func(t *testing.T) {
|
||||||
|
id := 7
|
||||||
|
s := &SubService{
|
||||||
|
address: reqHost,
|
||||||
|
nodesByID: map[int]*model.Node{7: {Id: 7, Address: "node7.example.com"}},
|
||||||
|
}
|
||||||
|
ib := &model.Inbound{NodeID: &id, Listen: "203.0.113.7", ShareAddrStrategy: "listen"}
|
||||||
|
if got := s.resolveInboundAddress(ib); got != "203.0.113.7" {
|
||||||
|
t.Fatalf("listen-strategy address = %q, want the bind 203.0.113.7", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("listen strategy falls back to node address on a wildcard bind", func(t *testing.T) {
|
||||||
|
id := 7
|
||||||
|
s := &SubService{
|
||||||
|
address: reqHost,
|
||||||
|
nodesByID: map[int]*model.Node{7: {Id: 7, Address: "node7.example.com"}},
|
||||||
|
}
|
||||||
|
ib := &model.Inbound{NodeID: &id, Listen: "0.0.0.0", ShareAddrStrategy: "listen"}
|
||||||
|
if got := s.resolveInboundAddress(ib); got != "node7.example.com" {
|
||||||
|
t.Fatalf("listen-strategy wildcard address = %q, want node7.example.com", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("custom strategy uses the share address", func(t *testing.T) {
|
||||||
|
id := 7
|
||||||
|
s := &SubService{
|
||||||
|
address: reqHost,
|
||||||
|
nodesByID: map[int]*model.Node{7: {Id: 7, Address: "node7.example.com"}},
|
||||||
|
}
|
||||||
|
ib := &model.Inbound{NodeID: &id, Listen: "203.0.113.7", ShareAddrStrategy: "custom", ShareAddr: "edge.example.com"}
|
||||||
|
if got := s.resolveInboundAddress(ib); got != "edge.example.com" {
|
||||||
|
t.Fatalf("custom-strategy address = %q, want edge.example.com", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("custom strategy with empty share address falls back to node", func(t *testing.T) {
|
||||||
|
id := 7
|
||||||
|
s := &SubService{
|
||||||
|
address: reqHost,
|
||||||
|
nodesByID: map[int]*model.Node{7: {Id: 7, Address: "node7.example.com"}},
|
||||||
|
}
|
||||||
|
ib := &model.Inbound{NodeID: &id, ShareAddrStrategy: "custom"}
|
||||||
|
if got := s.resolveInboundAddress(ib); got != "node7.example.com" {
|
||||||
|
t.Fatalf("custom-strategy fallback address = %q, want node7.example.com", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("node strategy keeps the pre-strategy order", func(t *testing.T) {
|
||||||
|
id := 7
|
||||||
|
s := &SubService{
|
||||||
|
address: reqHost,
|
||||||
|
nodesByID: map[int]*model.Node{7: {Id: 7, Address: "node7.example.com"}},
|
||||||
|
}
|
||||||
|
ib := &model.Inbound{NodeID: &id, Listen: "203.0.113.7", ShareAddrStrategy: "node"}
|
||||||
|
if got := s.resolveInboundAddress(ib); got != "node7.example.com" {
|
||||||
|
t.Fatalf("node-strategy address = %q, want node7.example.com", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUnmarshalStreamSettings(t *testing.T) {
|
func TestUnmarshalStreamSettings(t *testing.T) {
|
||||||
|
|||||||
@@ -545,9 +545,11 @@ func buildStream(network, security string) map[string]any {
|
|||||||
case "httpupgrade":
|
case "httpupgrade":
|
||||||
stream["httpupgradeSettings"] = map[string]any{"path": "/", "host": "", "headers": map[string]any{}}
|
stream["httpupgradeSettings"] = map[string]any{"path": "/", "host": "", "headers": map[string]any{}}
|
||||||
case "xhttp":
|
case "xhttp":
|
||||||
|
// No scMaxEachPostBytes/scMinPostsIntervalMs seed: xray-core's own
|
||||||
|
// defaults apply, and the literal values fingerprint traffic (#5141).
|
||||||
stream["xhttpSettings"] = map[string]any{
|
stream["xhttpSettings"] = map[string]any{
|
||||||
"path": "/", "host": "", "mode": "auto", "headers": map[string]any{},
|
"path": "/", "host": "", "mode": "auto", "headers": map[string]any{},
|
||||||
"xPaddingBytes": "100-1000", "scMaxEachPostBytes": "1000000",
|
"xPaddingBytes": "100-1000",
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
stream["tcpSettings"] = map[string]any{"header": map[string]any{"type": "none"}}
|
stream["tcpSettings"] = map[string]any{"header": map[string]any{"type": "none"}}
|
||||||
|
|||||||
@@ -118,7 +118,14 @@ func (a *ClientController) get(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
rec.Flow = flow
|
rec.Flow = flow
|
||||||
jsonObj(c, gin.H{"client": rec, "inboundIds": inboundIds}, nil)
|
// Consumed bytes (up+down, including cross-node global overlay) so API
|
||||||
|
// consumers can pair usage with the client's totalGB quota (#4973).
|
||||||
|
// Best-effort: a traffic lookup failure must not break the client fetch.
|
||||||
|
var usedTraffic int64
|
||||||
|
if t, tErr := a.inboundService.GetClientTrafficByEmail(email); tErr == nil && t != nil {
|
||||||
|
usedTraffic = t.Up + t.Down
|
||||||
|
}
|
||||||
|
jsonObj(c, gin.H{"client": rec, "inboundIds": inboundIds, "usedTraffic": usedTraffic}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ClientController) create(c *gin.Context) {
|
func (a *ClientController) create(c *gin.Context) {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ func (a *NodeController) initRouter(g *gin.RouterGroup) {
|
|||||||
|
|
||||||
g.POST("/test", a.test)
|
g.POST("/test", a.test)
|
||||||
g.POST("/certFingerprint", a.certFingerprint)
|
g.POST("/certFingerprint", a.certFingerprint)
|
||||||
|
g.POST("/inbounds", a.inbounds)
|
||||||
g.POST("/probe/:id", a.probe)
|
g.POST("/probe/:id", a.probe)
|
||||||
g.POST("/updatePanel", a.updatePanel)
|
g.POST("/updatePanel", a.updatePanel)
|
||||||
g.GET("/history/:id/:metric/:bucket", a.history)
|
g.GET("/history/:id/:metric/:bucket", a.history)
|
||||||
@@ -160,6 +161,18 @@ func (a *NodeController) setEnable(c *gin.Context) {
|
|||||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), nil)
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *NodeController) inbounds(c *gin.Context) {
|
||||||
|
n := &model.Node{}
|
||||||
|
if err := c.ShouldBind(n); err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
options, err := a.nodeService.GetRemoteInboundOptions(ctx, n)
|
||||||
|
jsonObj(c, options, err)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *NodeController) test(c *gin.Context) {
|
func (a *NodeController) test(c *gin.Context) {
|
||||||
n := &model.Node{}
|
n := &model.Node{}
|
||||||
if err := c.ShouldBind(n); err != nil {
|
if err := c.ShouldBind(n); err != nil {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
|
|||||||
g.POST("/update", a.updateSetting)
|
g.POST("/update", a.updateSetting)
|
||||||
g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
|
g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
|
||||||
g.POST("/testOutbound", a.testOutbound)
|
g.POST("/testOutbound", a.testOutbound)
|
||||||
|
g.POST("/testOutbounds", a.testOutbounds)
|
||||||
g.POST("/balancerStatus", a.balancerStatus)
|
g.POST("/balancerStatus", a.balancerStatus)
|
||||||
g.POST("/balancerOverride", a.balancerOverride)
|
g.POST("/balancerOverride", a.balancerOverride)
|
||||||
g.POST("/routeTest", a.routeTest)
|
g.POST("/routeTest", a.routeTest)
|
||||||
@@ -286,6 +287,39 @@ func (a *XraySettingController) testOutbound(c *gin.Context) {
|
|||||||
jsonObj(c, result, nil)
|
jsonObj(c, result, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// testOutbounds tests a batch of outbound configurations through one shared
|
||||||
|
// temp xray instance and returns an array of results in input order.
|
||||||
|
// Form "outbounds": JSON array of outbound configs (required).
|
||||||
|
// Optional form "allOutbounds": JSON array of all outbounds; used to resolve sockopt.dialerProxy dependencies.
|
||||||
|
// Optional form "mode": "tcp" for fast dial-only probes, anything else
|
||||||
|
// (default) for real HTTP requests routed through each outbound.
|
||||||
|
func (a *XraySettingController) testOutbounds(c *gin.Context) {
|
||||||
|
outboundsJSON := c.PostForm("outbounds")
|
||||||
|
allOutboundsJSON := c.PostForm("allOutbounds")
|
||||||
|
mode := c.PostForm("mode")
|
||||||
|
|
||||||
|
if outboundsJSON == "" {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("outbounds parameter is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the test URL from server settings to prevent SSRF via user-controlled URLs
|
||||||
|
testURL, _ := a.SettingService.GetXrayOutboundTestUrl()
|
||||||
|
testURL, err := service.SanitizePublicHTTPURL(testURL, false)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := a.OutboundService.TestOutbounds(outboundsJSON, testURL, allOutboundsJSON, mode)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonObj(c, results, nil)
|
||||||
|
}
|
||||||
|
|
||||||
// balancerStatus reports the live state (override + strategy picks) of the
|
// balancerStatus reports the live state (override + strategy picks) of the
|
||||||
// balancer tags given as a comma-separated "tags" form field.
|
// balancer tags given as a comma-separated "tags" form field.
|
||||||
func (a *XraySettingController) balancerStatus(c *gin.Context) {
|
func (a *XraySettingController) balancerStatus(c *gin.Context) {
|
||||||
|
|||||||
@@ -31,12 +31,16 @@ func (j *MtprotoJob) Run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var desired []mtproto.Instance
|
var desired []mtproto.Instance
|
||||||
|
routedTags := make(map[string]bool)
|
||||||
for _, ib := range inbounds {
|
for _, ib := range inbounds {
|
||||||
if ib.Protocol != model.MTProto || !ib.Enable || ib.NodeID != nil {
|
if ib.Protocol != model.MTProto || !ib.Enable || ib.NodeID != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if inst, ok := mtproto.InstanceFromInbound(ib); ok {
|
if inst, ok := mtproto.InstanceFromInbound(ib); ok {
|
||||||
desired = append(desired, inst)
|
desired = append(desired, inst)
|
||||||
|
if inst.RouteThroughXray {
|
||||||
|
routedTags[inst.Tag] = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +53,12 @@ func (j *MtprotoJob) Run() {
|
|||||||
}
|
}
|
||||||
traffics := make([]*xray.Traffic, 0, len(deltas))
|
traffics := make([]*xray.Traffic, 0, len(deltas))
|
||||||
for _, d := range deltas {
|
for _, d := range deltas {
|
||||||
|
// Routed inbounds egress through the Xray SOCKS bridge, which carries the
|
||||||
|
// inbound's tag and is metered by xray_traffic_job. Folding mtg's own
|
||||||
|
// metrics in too would double-count, so skip them here.
|
||||||
|
if routedTags[d.Tag] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
traffics = append(traffics, &xray.Traffic{
|
traffics = append(traffics, &xray.Traffic{
|
||||||
IsInbound: true,
|
IsInbound: true,
|
||||||
Tag: d.Tag,
|
Tag: d.Tag,
|
||||||
@@ -56,6 +66,9 @@ func (j *MtprotoJob) Run() {
|
|||||||
Down: d.Down,
|
Down: d.Down,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if len(traffics) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
if _, _, err := j.inboundService.AddTraffic(traffics, nil); err != nil {
|
if _, _, err := j.inboundService.AddTraffic(traffics, nil); err != nil {
|
||||||
logger.Warning("mtproto job: add traffic failed:", err)
|
logger.Warning("mtproto job: add traffic failed:", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user