mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-12 14:34:44 +03:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f3202f431 | |||
| 0cb6568fd5 | |||
| 6a90f98412 | |||
| 9318c2105f | |||
| e642f7324e | |||
| 7214ffafc5 | |||
| 88061bac10 | |||
| b5479f3f30 | |||
| d8aedcdde4 | |||
| 5f3e9ed0ea | |||
| 3e8a0eb93e | |||
| 4c2915586c | |||
| 9f06bffbea | |||
| e20d73ba7e | |||
| 8834e5fbbe | |||
| 6d732d8d32 | |||
| e4900f1bd4 | |||
| 04828246fc | |||
| c1efc48694 | |||
| f1760b0a28 | |||
| 745e394c74 | |||
| 737300b14b | |||
| 30469fcd10 | |||
| 887fca86ec |
+2
-2
@@ -43,10 +43,10 @@ actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
|
||||
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
|
||||
|
||||
actionban = <iptables> -I f2b-<name> 1 -s <ip> -j <blocktype>
|
||||
echo "\$(date +"%Y/%m/%d %H:%M:%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> $LOG_FOLDER/3xipl-banned.log
|
||||
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> $LOG_FOLDER/3xipl-banned.log
|
||||
|
||||
actionunban = <iptables> -D f2b-<name> -s <ip> -j <blocktype>
|
||||
echo "\$(date +"%Y/%m/%d %H:%M:%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> $LOG_FOLDER/3xipl-banned.log
|
||||
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> $LOG_FOLDER/3xipl-banned.log
|
||||
|
||||
[Init]
|
||||
name = default
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.0.0
|
||||
3.0.1
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>3x-ui · API Docs</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/api-docs.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+113
-156
@@ -1,20 +1,18 @@
|
||||
{
|
||||
"name": "3x-ui-frontend",
|
||||
"version": "0.0.0",
|
||||
"version": "0.0.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "3x-ui-frontend",
|
||||
"version": "0.0.0",
|
||||
"version": "0.0.2",
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"axios": "^1.7.9",
|
||||
"dayjs": "^1.11.20",
|
||||
"moment": "^2.30.1",
|
||||
"otpauth": "^9.5.1",
|
||||
"qrious": "^4.0.2",
|
||||
"qs": "^6.13.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^11.1.4",
|
||||
@@ -207,42 +205,6 @@
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array/node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-helpers": {
|
||||
"version": "0.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz",
|
||||
@@ -462,18 +424,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.128.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.128.0.tgz",
|
||||
"integrity": "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==",
|
||||
"version": "0.129.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz",
|
||||
"integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz",
|
||||
"integrity": "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz",
|
||||
"integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -487,9 +449,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz",
|
||||
"integrity": "sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz",
|
||||
"integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -503,9 +465,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz",
|
||||
"integrity": "sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz",
|
||||
"integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -519,9 +481,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz",
|
||||
"integrity": "sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz",
|
||||
"integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -535,9 +497,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz",
|
||||
"integrity": "sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz",
|
||||
"integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -551,9 +513,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz",
|
||||
"integrity": "sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz",
|
||||
"integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -567,9 +529,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz",
|
||||
"integrity": "sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz",
|
||||
"integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -583,9 +545,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz",
|
||||
"integrity": "sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz",
|
||||
"integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -599,9 +561,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz",
|
||||
"integrity": "sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz",
|
||||
"integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -615,9 +577,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz",
|
||||
"integrity": "sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz",
|
||||
"integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -631,9 +593,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz",
|
||||
"integrity": "sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz",
|
||||
"integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -647,9 +609,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz",
|
||||
"integrity": "sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz",
|
||||
"integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -663,9 +625,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz",
|
||||
"integrity": "sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz",
|
||||
"integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -681,9 +643,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz",
|
||||
"integrity": "sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz",
|
||||
"integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -697,9 +659,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz",
|
||||
"integrity": "sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz",
|
||||
"integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -968,12 +930,33 @@
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/boolbase": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
@@ -1306,42 +1289,6 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/espree": {
|
||||
"version": "11.2.0",
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
|
||||
@@ -2073,6 +2020,21 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/moment": {
|
||||
"version": "2.30.1",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||
@@ -2318,11 +2280,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrious": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/qrious/-/qrious-4.0.2.tgz",
|
||||
"integrity": "sha512-xWPJIrK1zu5Ypn898fBp8RHkT/9ibquV2Kv24S/JY9VYEhMBMKur1gHVsOiNUh7PHP9uCgejjpZUHUIXXKoU/g=="
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
||||
@@ -2343,13 +2300,13 @@
|
||||
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz",
|
||||
"integrity": "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz",
|
||||
"integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.128.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.18"
|
||||
"@oxc-project/types": "=0.129.0",
|
||||
"@rolldown/pluginutils": "1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"rolldown": "bin/cli.mjs"
|
||||
@@ -2358,27 +2315,27 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.0.0-rc.18",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.18",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.18",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.18",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.18",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.18",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.18",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18"
|
||||
"@rolldown/binding-android-arm64": "1.0.0",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz",
|
||||
"integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz",
|
||||
"integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/scroll-into-view-if-needed": {
|
||||
@@ -2567,15 +2524,15 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.11",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz",
|
||||
"integrity": "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==",
|
||||
"version": "8.0.12",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz",
|
||||
"integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.14",
|
||||
"rolldown": "1.0.0-rc.18",
|
||||
"rolldown": "1.0.0",
|
||||
"tinyglobby": "^0.2.16"
|
||||
},
|
||||
"bin": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "3x-ui-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.2",
|
||||
"type": "module",
|
||||
"description": "3x-ui panel frontend (Vue 3 + Ant Design Vue 4 + Vite 8).",
|
||||
"scripts": {
|
||||
@@ -15,9 +15,7 @@
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"axios": "^1.7.9",
|
||||
"dayjs": "^1.11.20",
|
||||
"moment": "^2.30.1",
|
||||
"otpauth": "^9.5.1",
|
||||
"qrious": "^4.0.2",
|
||||
"qs": "^6.13.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^11.1.4",
|
||||
|
||||
@@ -22,7 +22,7 @@ function readMetaToken() {
|
||||
// recurse through this same interceptor.
|
||||
async function fetchCsrfToken() {
|
||||
try {
|
||||
const basePath = window.__X_UI_BASE_PATH__;
|
||||
const basePath = window.X_UI_BASE_PATH;
|
||||
const url = (typeof basePath === 'string' && basePath !== '' && basePath !== '/'
|
||||
? basePath.replace(/\/$/, '') + CSRF_TOKEN_PATH
|
||||
: CSRF_TOKEN_PATH);
|
||||
@@ -59,7 +59,7 @@ export function setupAxios() {
|
||||
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
|
||||
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
|
||||
const basePath = window.__X_UI_BASE_PATH__;
|
||||
const basePath = window.X_UI_BASE_PATH;
|
||||
if (typeof basePath === 'string' && basePath !== '' && basePath !== '/') {
|
||||
axios.defaults.baseURL = basePath;
|
||||
}
|
||||
@@ -98,7 +98,7 @@ export function setupAxios() {
|
||||
// the user right back on the dashboard and the interceptor
|
||||
// would loop. Navigate to the dev login entry instead.
|
||||
if (import.meta.env.DEV) {
|
||||
const basePath = window.__X_UI_BASE_PATH__ || '/';
|
||||
const basePath = window.X_UI_BASE_PATH || '/';
|
||||
window.location.href = `${basePath}login.html`;
|
||||
} else {
|
||||
window.location.reload();
|
||||
|
||||
@@ -140,7 +140,7 @@ export class WebSocketClient {
|
||||
|
||||
#buildUrl() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
// basePath comes from window.__X_UI_BASE_PATH__ which is only injected
|
||||
// basePath comes from window.X_UI_BASE_PATH which is only injected
|
||||
// by the Go binary in production. In dev (Vite serves directly) the
|
||||
// global is missing and basePath would be '' — without the fallback to
|
||||
// '/' we'd build `ws://host:portws` (no separator) and the WebSocket
|
||||
|
||||
@@ -9,28 +9,23 @@ import {
|
||||
ClusterOutlined,
|
||||
LogoutOutlined,
|
||||
CloseOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuOutlined,
|
||||
ApiOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
import { currentTheme } from '@/composables/useTheme.js';
|
||||
import ThemeSwitch from '@/components/ThemeSwitch.vue';
|
||||
import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
|
||||
|
||||
const props = defineProps({
|
||||
// Path prefix (e.g. /custom-base/) the panel is served under. Defaults
|
||||
// to '' which means tab keys end up as '/panel/...'. Pages pass the
|
||||
// value the Go backend gave them (in production via a meta tag).
|
||||
basePath: { type: String, default: '' },
|
||||
// Current request URI so the matching menu item highlights.
|
||||
requestUri: { type: String, default: '' },
|
||||
});
|
||||
|
||||
// AD-Vue 4 dropped <a-icon :type="x"> in favor of explicit icon
|
||||
// imports — keep a small name-to-component map so tab definitions stay
|
||||
// declarative.
|
||||
|
||||
const iconByName = {
|
||||
dashboard: DashboardOutlined,
|
||||
user: UserOutlined,
|
||||
@@ -38,30 +33,27 @@ const iconByName = {
|
||||
tool: ToolOutlined,
|
||||
cluster: ClusterOutlined,
|
||||
logout: LogoutOutlined,
|
||||
apidocs: ApiOutlined,
|
||||
};
|
||||
|
||||
// basePath comes from Go (`/` by default, `/myprefix/` when configured) so
|
||||
// these concatenations land on absolute paths. In dev we synthesize the prop
|
||||
// from a window global which can be empty — force a leading slash so the
|
||||
// browser doesn't resolve the link relative to the current pathname (which
|
||||
// would turn /panel/settings + 'panel/...' into /panel/panel/...).
|
||||
const prefix = props.basePath?.startsWith('/') ? props.basePath : `/${props.basePath || ''}`;
|
||||
|
||||
// Labels are i18n-driven so the sidebar matches the locale picked
|
||||
// in panel settings without a page reload of the sidebar component.
|
||||
const tabs = computed(() => [
|
||||
{ key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
|
||||
{ key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
|
||||
{ key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') },
|
||||
{ key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
|
||||
{ key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
|
||||
{ key: `${prefix}panel/api-docs`, icon: 'apidocs', title: t('menu.apiDocs') },
|
||||
{ key: `${prefix}logout`, icon: 'logout', title: t('logout') },
|
||||
]);
|
||||
|
||||
const navTabs = computed(() => tabs.value.filter((tab) => tab.icon !== 'logout'));
|
||||
const utilTabs = computed(() => tabs.value.filter((tab) => tab.icon === 'logout'));
|
||||
const activeTab = ref([props.requestUri]);
|
||||
|
||||
const drawerOpen = ref(false);
|
||||
const collapsed = ref(JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false'));
|
||||
const drawerWidth = 'min(82vw, 320px)';
|
||||
|
||||
function openLink(key) {
|
||||
if (key.startsWith('http')) {
|
||||
@@ -86,14 +78,55 @@ function toggleDrawer() {
|
||||
function closeDrawer() {
|
||||
drawerOpen.value = false;
|
||||
}
|
||||
|
||||
function cycleTheme() {
|
||||
pauseAnimationsUntilLeave('theme-cycle');
|
||||
if (!theme.isDark) {
|
||||
toggleTheme();
|
||||
if (theme.isUltra) toggleUltra();
|
||||
} else if (!theme.isUltra) {
|
||||
toggleUltra();
|
||||
} else {
|
||||
toggleUltra();
|
||||
toggleTheme();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ant-sidebar">
|
||||
<a-layout-sider :theme="currentTheme" collapsible :collapsed="collapsed" breakpoint="md" @collapse="onCollapse">
|
||||
<ThemeSwitch />
|
||||
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" @click="({ key }) => openLink(key)">
|
||||
<a-menu-item v-for="tab in tabs" :key="tab.key">
|
||||
<div class="sider-brand" :class="{ 'sider-brand-collapsed': collapsed }">
|
||||
<span class="brand-text">{{ collapsed ? '3X' : '3X-UI' }}</span>
|
||||
<button v-if="!collapsed" id="theme-cycle" type="button" class="theme-cycle" :aria-label="t('menu.theme')"
|
||||
:title="t('menu.theme')" @click="cycleTheme">
|
||||
<svg v-if="!theme.isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path
|
||||
d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||
</svg>
|
||||
<svg v-else-if="!theme.isUltra" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
<path fill="none" d="M19 3l0.7 1.4 1.4 0.7-1.4 0.7L19 7.2l-0.7-1.4-1.4-0.7 1.4-0.7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="sider-nav"
|
||||
@click="({ key }) => openLink(key)">
|
||||
<a-menu-item v-for="tab in navTabs" :key="tab.key">
|
||||
<component :is="iconByName[tab.icon]" />
|
||||
<span>{{ tab.title }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="sider-utility"
|
||||
@click="({ key }) => openLink(key)">
|
||||
<a-menu-item v-for="tab in utilTabs" :key="tab.key">
|
||||
<component :is="iconByName[tab.icon]" />
|
||||
<span>{{ tab.title }}</span>
|
||||
</a-menu-item>
|
||||
@@ -101,43 +134,218 @@ function closeDrawer() {
|
||||
</a-layout-sider>
|
||||
|
||||
<a-drawer placement="left" :closable="false" :open="drawerOpen" :wrap-class-name="currentTheme"
|
||||
:wrap-style="{ padding: 0 }" :style="{ height: '100%' }" @close="closeDrawer">
|
||||
<ThemeSwitch />
|
||||
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" @click="({ key }) => openLink(key)">
|
||||
<a-menu-item v-for="tab in tabs" :key="tab.key">
|
||||
:wrap-style="{ padding: 0 }" :width="drawerWidth"
|
||||
:body-style="{ padding: 0, display: 'flex', flexDirection: 'column', height: '100%' }"
|
||||
:header-style="{ display: 'none' }" @close="closeDrawer">
|
||||
<div class="drawer-header">
|
||||
<span class="drawer-brand">3X-UI</span>
|
||||
<div class="drawer-header-actions">
|
||||
<button id="theme-cycle-drawer" type="button" class="theme-cycle" :aria-label="t('menu.theme')"
|
||||
:title="t('menu.theme')" @click="cycleTheme">
|
||||
<svg v-if="!theme.isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path
|
||||
d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||
</svg>
|
||||
<svg v-else-if="!theme.isUltra" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
<path fill="none" d="M19 3l0.7 1.4 1.4 0.7-1.4 0.7L19 7.2l-0.7-1.4-1.4-0.7 1.4-0.7z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="drawer-close" type="button" :aria-label="t('close')" @click="closeDrawer">
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="drawer-menu drawer-nav"
|
||||
@click="({ key }) => openLink(key)">
|
||||
<a-menu-item v-for="tab in navTabs" :key="tab.key">
|
||||
<component :is="iconByName[tab.icon]" />
|
||||
<span>{{ tab.title }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="drawer-menu drawer-utility"
|
||||
@click="({ key }) => openLink(key)">
|
||||
<a-menu-item v-for="tab in utilTabs" :key="tab.key">
|
||||
<component :is="iconByName[tab.icon]" />
|
||||
<span>{{ tab.title }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</a-drawer>
|
||||
|
||||
<button class="drawer-handle" type="button" @click="toggleDrawer">
|
||||
<CloseOutlined v-if="drawerOpen" />
|
||||
<MenuFoldOutlined v-else />
|
||||
<button v-show="!drawerOpen" class="drawer-handle" type="button" :aria-label="t('menu.dashboard')"
|
||||
@click="toggleDrawer">
|
||||
<MenuOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ant-sidebar>.ant-layout-sider {
|
||||
height: 100%;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.sider-brand,
|
||||
.drawer-brand {
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.sider-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 14px 14px;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Collapsed sider only has room for the '3X' brand — center it and
|
||||
* hide the theme cycle button (which is `v-if`-ed out in template). */
|
||||
.sider-brand-collapsed {
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
padding: 14px 4px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.sider-brand-collapsed .brand-text {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.theme-cycle {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.2s, transform 0.15s, color 0.2s;
|
||||
}
|
||||
|
||||
.theme-cycle:hover,
|
||||
.theme-cycle:focus-visible {
|
||||
background-color: rgba(64, 150, 255, 0.1);
|
||||
color: #4096ff;
|
||||
transform: scale(1.08);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.theme-cycle svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.drawer-header-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.drawer-handle {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
z-index: 1100;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
border: none;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
.drawer-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.drawer-close:hover,
|
||||
.drawer-close:focus-visible {
|
||||
background: rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
|
||||
.drawer-menu :deep(.ant-menu-item) {
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.drawer-menu :deep(.ant-menu-item .anticon) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.drawer-utility {
|
||||
margin-top: auto;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-children) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sider-brand {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sider-nav {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sider-utility {
|
||||
flex: 0 0 auto;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -145,9 +353,6 @@ function closeDrawer() {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* On mobile the drawer is the menu — hide the inline sider's content
|
||||
* + the collapse trigger so the sider stops taking layout space and
|
||||
* leaves no remnant button next to the page. */
|
||||
.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-children),
|
||||
.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-trigger) {
|
||||
display: none;
|
||||
@@ -161,3 +366,58 @@ function closeDrawer() {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
body.dark .drawer-brand,
|
||||
body.dark .sider-brand {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .drawer-brand,
|
||||
html[data-theme='ultra-dark'] .sider-brand {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
body.dark .drawer-close {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .drawer-close {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
body.dark .theme-cycle {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .theme-cycle {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
body.dark .ant-drawer .ant-drawer-content,
|
||||
body.dark .ant-drawer .ant-drawer-body {
|
||||
background: #252526 !important;
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-content,
|
||||
html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-body {
|
||||
background: #0a0a0a !important;
|
||||
}
|
||||
|
||||
.sider-nav .ant-menu-item-selected,
|
||||
.sider-utility .ant-menu-item-selected,
|
||||
.drawer-menu .ant-menu-item-selected {
|
||||
background-color: rgba(64, 150, 255, 0.2) !important;
|
||||
color: #4096ff !important;
|
||||
}
|
||||
|
||||
.sider-nav .ant-menu-item-active:not(.ant-menu-item-selected),
|
||||
.sider-utility .ant-menu-item-active:not(.ant-menu-item-selected),
|
||||
.drawer-menu .ant-menu-item-active:not(.ant-menu-item-selected),
|
||||
.sider-nav .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
|
||||
.sider-utility .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
|
||||
.drawer-menu .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover {
|
||||
background-color: rgba(64, 150, 255, 0.1) !important;
|
||||
color: #4096ff !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -235,8 +235,8 @@ function onAntChange(next) {
|
||||
/* ===== Dark (navy) ======================================================= */
|
||||
|
||||
body.dark .persian-datepicker-input {
|
||||
background: #142340;
|
||||
border-color: #1f3358;
|
||||
background: #252526;
|
||||
border-color: #3c3c3c;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
@@ -251,14 +251,14 @@ body.dark .persian-datepicker-input:focus {
|
||||
|
||||
body.dark .vpd-main .vpd-icon-btn {
|
||||
background: rgba(255, 255, 255, 0.04) !important;
|
||||
border: 1px solid #1f3358 !important;
|
||||
border: 1px solid #3c3c3c !important;
|
||||
border-right: none !important;
|
||||
border-radius: 6px 0 0 6px !important;
|
||||
color: rgba(255, 255, 255, 0.75) !important;
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-content {
|
||||
background: #1a2c4d;
|
||||
background: #2d2d30;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.32),
|
||||
0 3px 6px -4px rgba(0, 0, 0, 0.48),
|
||||
@@ -266,7 +266,7 @@ body.dark .vpd-wrapper .vpd-content {
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-body {
|
||||
background: #1a2c4d;
|
||||
background: #2d2d30;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
@@ -315,7 +315,7 @@ body.dark .vpd-wrapper .vpd-actions button:hover {
|
||||
|
||||
body.dark .vpd-wrapper .vpd-addon-list,
|
||||
body.dark .vpd-wrapper .vpd-addon-list-content {
|
||||
background: #1a2c4d;
|
||||
background: #2d2d30;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { BulbFilled, BulbOutlined } from '@ant-design/icons-vue';
|
||||
import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const BulbIcon = computed(() => (theme.isDark ? BulbFilled : BulbOutlined));
|
||||
|
||||
function onDarkChange() {
|
||||
pauseAnimationsUntilLeave('change-theme');
|
||||
toggleTheme();
|
||||
}
|
||||
|
||||
function onUltraClick() {
|
||||
pauseAnimationsUntilLeave('change-theme-ultra');
|
||||
toggleUltra();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-menu :theme="currentTheme" mode="inline" :selected-keys="[]">
|
||||
<a-sub-menu>
|
||||
<template #title>
|
||||
<span>
|
||||
<component :is="BulbIcon" />
|
||||
<span class="theme-label">{{ t('menu.theme') }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<a-menu-item id="change-theme" class="ant-menu-theme-switch">
|
||||
<span>{{ t('menu.dark') }}</span>
|
||||
<a-switch :style="{ marginLeft: '2px' }" size="small" :checked="theme.isDark" @change="onDarkChange" />
|
||||
</a-menu-item>
|
||||
|
||||
<a-menu-item v-if="theme.isDark" id="change-theme-ultra" class="ant-menu-theme-switch">
|
||||
<span>{{ t('menu.ultraDark') }}</span>
|
||||
<a-checkbox :style="{ marginLeft: '2px' }" :checked="theme.isUltra" @click="onUltraClick" />
|
||||
</a-menu-item>
|
||||
</a-sub-menu>
|
||||
</a-menu>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.theme-label {
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,25 +0,0 @@
|
||||
<script setup>
|
||||
import { theme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
|
||||
|
||||
function onDarkChange() {
|
||||
pauseAnimationsUntilLeave('change-theme');
|
||||
toggleTheme();
|
||||
}
|
||||
|
||||
function onUltraClick() {
|
||||
toggleUltra();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-space id="change-theme" direction="vertical" :size="10" :style="{ width: '100%' }">
|
||||
<a-space direction="horizontal" size="small">
|
||||
<a-switch size="small" :checked="theme.isDark" @change="onDarkChange" />
|
||||
<span>Dark</span>
|
||||
</a-space>
|
||||
<a-space v-if="theme.isDark" direction="horizontal" size="small">
|
||||
<a-checkbox :checked="theme.isUltra" @click="onUltraClick" />
|
||||
<span>Ultra dark</span>
|
||||
</a-space>
|
||||
</a-space>
|
||||
</template>
|
||||
@@ -27,14 +27,15 @@ export const currentTheme = computed(() => (theme.isDark ? 'dark' : 'light'));
|
||||
|
||||
// AD-Vue 4 theme config consumed by every page's <a-config-provider>.
|
||||
// Three modes — light / dark / ultra-dark — all share AD-Vue's vanilla
|
||||
// blue primary. Dark uses a navy palette across page/cards/modals so
|
||||
// the sidebar blends with the rest of the surface; ultra-dark stays
|
||||
// neutral black on top of darkAlgorithm.
|
||||
// blue primary. Dark uses a neutral grey palette modelled on VS Code's
|
||||
// Dark+ chrome (`#1e1e1e` editor, `#252526` sidebar, `#2d2d30` panel),
|
||||
// so the panel reads as a familiar modern IDE rather than the older
|
||||
// navy shade. Ultra-dark stays pure-black on darkAlgorithm.
|
||||
const DARK_TOKENS = {
|
||||
colorBgBase: '#0a1426',
|
||||
colorBgLayout: '#0a1426',
|
||||
colorBgContainer: '#142340',
|
||||
colorBgElevated: '#1a2c4d',
|
||||
colorBgBase: '#1e1e1e',
|
||||
colorBgLayout: '#1e1e1e',
|
||||
colorBgContainer: '#252526',
|
||||
colorBgElevated: '#2d2d30',
|
||||
};
|
||||
const ULTRA_DARK_TOKENS = {
|
||||
colorBgBase: '#000',
|
||||
@@ -47,13 +48,12 @@ const ULTRA_DARK_TOKENS = {
|
||||
// + trigger backgrounds and `#001529` / `#000c17` as the dark Menu item
|
||||
// backgrounds (see node_modules/ant-design-vue/es/{layout,menu}/style/
|
||||
// index.js). Override at the component-token level so the sider blends
|
||||
// with darkAlgorithm's neutral surfaces.
|
||||
// Dark theme uses a refined navy for the sidebar — distinct from the
|
||||
// neutral ultra-dark and warmer than AD-Vue's stock #001529.
|
||||
// with darkAlgorithm's neutral surfaces. Sider/trigger use the same
|
||||
// `#252526` / `#333333` tones VS Code does for its activity bar.
|
||||
const DARK_LAYOUT_TOKENS = {
|
||||
colorBgHeader: '#0d1d33',
|
||||
colorBgTrigger: '#15294a',
|
||||
colorBgBody: '#000',
|
||||
colorBgHeader: '#252526',
|
||||
colorBgTrigger: '#333333',
|
||||
colorBgBody: '#1e1e1e',
|
||||
};
|
||||
const ULTRA_DARK_LAYOUT_TOKENS = {
|
||||
colorBgHeader: '#0a0a0a',
|
||||
@@ -61,9 +61,9 @@ const ULTRA_DARK_LAYOUT_TOKENS = {
|
||||
colorBgBody: '#000',
|
||||
};
|
||||
const DARK_MENU_TOKENS = {
|
||||
colorItemBg: '#0d1d33',
|
||||
colorSubItemBg: '#08142a',
|
||||
menuSubMenuBg: '#0d1d33',
|
||||
colorItemBg: '#252526',
|
||||
colorSubItemBg: '#1e1e1e',
|
||||
menuSubMenuBg: '#252526',
|
||||
};
|
||||
const ULTRA_DARK_MENU_TOKENS = {
|
||||
colorItemBg: '#0a0a0a',
|
||||
|
||||
@@ -9,7 +9,7 @@ let sharedClient = null;
|
||||
|
||||
function getSharedClient() {
|
||||
if (sharedClient) return sharedClient;
|
||||
const basePath = (typeof window !== 'undefined' && window.__X_UI_BASE_PATH__) || '';
|
||||
const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
|
||||
sharedClient = new WebSocketClient(basePath);
|
||||
return sharedClient;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import ApiDocsPage from '@/pages/api-docs/ApiDocsPage.vue';
|
||||
|
||||
setupAxios();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
createApp(ApiDocsPage).use(Antd).use(i18n).mount('#app');
|
||||
@@ -1926,13 +1926,13 @@ Outbound.VmessSettings = class extends CommonClass {
|
||||
}
|
||||
};
|
||||
Outbound.VLESSSettings = class extends CommonClass {
|
||||
constructor(address, port, id, flow, encryption, reverseTag = '', reverseSniffing = new ReverseSniffing(), testpre = 0, testseed = []) {
|
||||
constructor(address, port, id, flow, encryption = 'none', reverseTag = '', reverseSniffing = new ReverseSniffing(), testpre = 0, testseed = []) {
|
||||
super();
|
||||
this.address = address;
|
||||
this.port = port;
|
||||
this.id = id;
|
||||
this.flow = flow;
|
||||
this.encryption = encryption;
|
||||
this.encryption = encryption || 'none';
|
||||
this.reverseTag = reverseTag;
|
||||
this.reverseSniffing = reverseSniffing;
|
||||
this.testpre = testpre;
|
||||
@@ -1966,7 +1966,7 @@ Outbound.VLESSSettings = class extends CommonClass {
|
||||
port: this.port,
|
||||
id: this.id,
|
||||
flow: this.flow,
|
||||
encryption: this.encryption,
|
||||
encryption: this.encryption || 'none',
|
||||
};
|
||||
if (!ObjectUtil.isEmpty(this.reverseTag)) {
|
||||
const reverseSniffing = this.reverseSniffing ? this.reverseSniffing.toJson() : {};
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import {
|
||||
KeyOutlined,
|
||||
ReloadOutlined,
|
||||
CopyOutlined,
|
||||
EyeOutlined,
|
||||
EyeInvisibleOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
|
||||
import AppSidebar from '@/components/AppSidebar.vue';
|
||||
import { HttpUtil, ClipboardManager } from '@/utils/index.js';
|
||||
import { sections } from './endpoints.js';
|
||||
import EndpointSection from './EndpointSection.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
const requestUri = window.location.pathname;
|
||||
|
||||
const apiToken = ref('');
|
||||
const tokenLoading = ref(false);
|
||||
const tokenRotating = ref(false);
|
||||
const tokenVisible = ref(false);
|
||||
|
||||
const curlExample = `curl -X GET \\
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN" \\
|
||||
-H "Accept: application/json" \\
|
||||
https://your-panel.example.com/panel/api/inbounds/list`;
|
||||
|
||||
async function loadApiToken() {
|
||||
tokenLoading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/setting/getApiToken');
|
||||
if (msg?.success) apiToken.value = msg.obj || '';
|
||||
} finally {
|
||||
tokenLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function regenerateApiToken() {
|
||||
Modal.confirm({
|
||||
title: t('pages.nodes.regenerateConfirm'),
|
||||
okText: t('confirm'),
|
||||
cancelText: t('cancel'),
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
tokenRotating.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/setting/regenerateApiToken');
|
||||
if (msg?.success) {
|
||||
apiToken.value = msg.obj || '';
|
||||
message.success(t('success'));
|
||||
}
|
||||
} finally {
|
||||
tokenRotating.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function copyApiToken() {
|
||||
if (!apiToken.value) return;
|
||||
const ok = await ClipboardManager.copy(apiToken.value);
|
||||
if (ok) message.success(t('success'));
|
||||
}
|
||||
|
||||
function scrollToSection(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadApiToken();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-config-provider :theme="antdThemeConfig">
|
||||
<a-layout class="api-docs-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
|
||||
<AppSidebar :base-path="basePath" :request-uri="requestUri" />
|
||||
|
||||
<a-layout class="content-shell">
|
||||
<a-layout-content class="content-area">
|
||||
<div class="docs-wrapper">
|
||||
<header class="docs-header">
|
||||
<h1 class="docs-title">API Documentation</h1>
|
||||
<p class="docs-lead">
|
||||
The 3x-ui panel exposes a REST API under <code>/panel/api/</code>. Authenticate with the panel session
|
||||
cookie, or with the <code>Authorization: Bearer <token></code> header below. Every endpoint
|
||||
returns a uniform <code>{ success, msg, obj }</code> envelope unless otherwise noted.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<a-card class="token-card" size="small">
|
||||
<div class="token-card-head">
|
||||
<div class="token-card-title">
|
||||
<KeyOutlined />
|
||||
<span>API Token</span>
|
||||
</div>
|
||||
<a-space size="small" wrap>
|
||||
<a-button size="small" @click="tokenVisible = !tokenVisible">
|
||||
<template #icon>
|
||||
<EyeInvisibleOutlined v-if="tokenVisible" />
|
||||
<EyeOutlined v-else />
|
||||
</template>
|
||||
{{ tokenVisible ? 'Hide' : 'Show' }}
|
||||
</a-button>
|
||||
<a-button size="small" :disabled="!apiToken" @click="copyApiToken">
|
||||
<template #icon>
|
||||
<CopyOutlined />
|
||||
</template>
|
||||
Copy
|
||||
</a-button>
|
||||
<a-button size="small" danger :loading="tokenRotating" @click="regenerateApiToken">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
Regenerate
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
<a-spin :spinning="tokenLoading" size="small">
|
||||
<pre
|
||||
class="token-value">{{ tokenVisible ? (apiToken || '—') : (apiToken ? '••••••••••••••••••••••••••••' : '—') }}</pre>
|
||||
</a-spin>
|
||||
<p class="token-hint">
|
||||
Send it on every request as <code>Authorization: Bearer <token></code>. Token-authenticated
|
||||
callers skip CSRF and don't need a session cookie. Regenerating rotates the secret immediately —
|
||||
running bots will need the new value.
|
||||
</p>
|
||||
</a-card>
|
||||
|
||||
<a-card class="curl-card" size="small" title="Quick example">
|
||||
<pre class="code-block">{{ curlExample }}</pre>
|
||||
</a-card>
|
||||
|
||||
<nav class="toc-nav">
|
||||
<span class="toc-label">On this page:</span>
|
||||
<a v-for="s in sections" :key="s.id" class="toc-link" :href="`#${s.id}`"
|
||||
@click.prevent="scrollToSection(s.id)">
|
||||
{{ s.title }}
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<EndpointSection v-for="s in sections" :key="s.id" :section="s" />
|
||||
</div>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.api-docs-page {
|
||||
--bg-page: #e6e8ec;
|
||||
--bg-card: #ffffff;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark {
|
||||
--bg-page: #1e1e1e;
|
||||
--bg-card: #252526;
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark.is-ultra {
|
||||
--bg-page: #000;
|
||||
--bg-card: #0a0a0a;
|
||||
}
|
||||
|
||||
.content-shell {
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.content-area {
|
||||
padding: 24px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-area {
|
||||
padding: 16px 12px 12px;
|
||||
padding-top: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
.docs-wrapper {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.docs-header {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.docs-title {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.docs-lead {
|
||||
margin: 0;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
line-height: 1.6;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.docs-lead code,
|
||||
.token-hint code {
|
||||
background: rgba(128, 128, 128, 0.12);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.token-card,
|
||||
.curl-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.token-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.token-card-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.token-value {
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.token-hint {
|
||||
margin: 10px 0 0;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.toc-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 14px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.toc-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.toc-link {
|
||||
color: #1677ff;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.toc-link:hover {
|
||||
color: #4096ff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
body.dark .docs-title {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
body.dark .docs-lead,
|
||||
body.dark .token-hint {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
body.dark .docs-lead code,
|
||||
body.dark .token-hint code {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
body.dark .token-value,
|
||||
body.dark .code-block {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
body.dark .toc-nav {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
body.dark .toc-label {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { methodColors } from './endpoints.js';
|
||||
|
||||
const props = defineProps({
|
||||
endpoint: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const tagColor = computed(() => methodColors[props.endpoint.method] || 'default');
|
||||
const hasParams = computed(() => Array.isArray(props.endpoint.params) && props.endpoint.params.length > 0);
|
||||
|
||||
const paramColumns = [
|
||||
{ title: 'Name', dataIndex: 'name', key: 'name', width: 180 },
|
||||
{ title: 'In', dataIndex: 'in', key: 'in', width: 100 },
|
||||
{ title: 'Type', dataIndex: 'type', key: 'type', width: 120 },
|
||||
{ title: 'Description', dataIndex: 'desc', key: 'desc' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="endpoint-row">
|
||||
<div class="endpoint-header">
|
||||
<a-tag :color="tagColor" class="method-tag">{{ endpoint.method }}</a-tag>
|
||||
<code class="endpoint-path">{{ endpoint.path }}</code>
|
||||
</div>
|
||||
|
||||
<p v-if="endpoint.summary" class="endpoint-summary">{{ endpoint.summary }}</p>
|
||||
|
||||
<div v-if="hasParams" class="endpoint-block">
|
||||
<div class="block-label">Parameters</div>
|
||||
<a-table :columns="paramColumns" :data-source="endpoint.params" :pagination="false" size="small" row-key="name" />
|
||||
</div>
|
||||
|
||||
<div v-if="endpoint.body" class="endpoint-block">
|
||||
<div class="block-label">Request body</div>
|
||||
<a-typography-paragraph :copyable="{ text: endpoint.body }">
|
||||
<pre class="code-block">{{ endpoint.body }}</pre>
|
||||
</a-typography-paragraph>
|
||||
</div>
|
||||
|
||||
<div v-if="endpoint.response" class="endpoint-block">
|
||||
<div class="block-label">Response</div>
|
||||
<a-typography-paragraph :copyable="{ text: endpoint.response }">
|
||||
<pre class="code-block">{{ endpoint.response }}</pre>
|
||||
</a-typography-paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.endpoint-row {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.endpoint-row + .endpoint-row {
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
.endpoint-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.method-tag {
|
||||
font-weight: 600;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
letter-spacing: 0.5px;
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.endpoint-path {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.endpoint-summary {
|
||||
margin: 8px 0 0;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.endpoint-block {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.block-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
body.dark .endpoint-summary {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
body.dark .block-label {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
body.dark .code-block {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup>
|
||||
import EndpointRow from './EndpointRow.vue';
|
||||
|
||||
defineProps({
|
||||
section: { type: Object, required: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :id="section.id" class="api-section">
|
||||
<h2 class="section-title">{{ section.title }}</h2>
|
||||
<p v-if="section.description" class="section-description">{{ section.description }}</p>
|
||||
<div class="endpoints">
|
||||
<EndpointRow v-for="(endpoint, idx) in section.endpoints" :key="idx" :endpoint="endpoint" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.api-section {
|
||||
background: #fff;
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-radius: 8px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 20px;
|
||||
scroll-margin-top: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.section-description {
|
||||
margin: 6px 0 14px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.endpoints > :first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
body.dark .api-section {
|
||||
background: #252526;
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .api-section {
|
||||
background: #0a0a0a;
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
body.dark .section-title {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
body.dark .section-description {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,548 @@
|
||||
export const sections = [
|
||||
{
|
||||
id: 'auth',
|
||||
title: 'Authentication',
|
||||
description:
|
||||
'Two authentication modes are supported. UI sessions use a cookie set by the login endpoint. Programmatic clients (bots, scripts, remote panels) authenticate with a Bearer token taken from Settings → Security → API Token. Both work for every endpoint under /panel/api/*.',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/login',
|
||||
summary: 'Authenticate with username + password and receive a session cookie. Required before any cookie-based API call.',
|
||||
params: [
|
||||
{ name: 'username', in: 'body', type: 'string', desc: 'Panel admin username.' },
|
||||
{ name: 'password', in: 'body', type: 'string', desc: 'Panel admin password.' },
|
||||
{ name: 'twoFactorCode', in: 'body', type: 'string', desc: 'OTP code when 2FA is enabled. Omit otherwise.' },
|
||||
],
|
||||
body: '{\n "username": "admin",\n "password": "admin",\n "twoFactorCode": "123456"\n}',
|
||||
response:
|
||||
'{\n "success": true,\n "msg": "Logged in successfully"\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/logout',
|
||||
summary: 'Clear the session cookie. Redirects back to the login page; not useful from non-browser clients.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/csrf-token',
|
||||
summary: 'Mint a CSRF token for the current session. The SPA replays it in the X-CSRF-Token header on unsafe requests. Bearer-token callers can skip this — the middleware short-circuits CSRF for authenticated API requests.',
|
||||
response:
|
||||
'{\n "success": true,\n "obj": "csrf-token-string"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/getTwoFactorEnable',
|
||||
summary: 'Returns whether 2FA is enabled on the panel — used by the login page to decide whether to show the OTP field.',
|
||||
response: '{\n "success": true,\n "obj": false\n}',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'inbounds',
|
||||
title: 'Inbounds API',
|
||||
description:
|
||||
'Manage inbound configurations and their clients. All endpoints live under /panel/api/inbounds and require a logged-in session or Bearer token. Link-generating endpoints honour X-Forwarded-Host / X-Forwarded-Proto, so callers behind a reverse proxy get the correct external host in returned URLs.',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/inbounds/list',
|
||||
summary: 'List every inbound owned by the authenticated user, including each inbound’s clientStats traffic counters.',
|
||||
response:
|
||||
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "userId": 1,\n "up": 0,\n "down": 0,\n "total": 0,\n "remark": "VLESS-443",\n "enable": true,\n "expiryTime": 0,\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "settings": "{\\"clients\\":[...]}",\n "streamSettings": "{...}",\n "tag": "inbound-443",\n "sniffing": "{...}",\n "clientStats": [...]\n }\n ]\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/inbounds/get/:id',
|
||||
summary: 'Fetch a single inbound by numeric ID.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/inbounds/getClientTraffics/:email',
|
||||
summary: 'Traffic counters for a client identified by email.',
|
||||
params: [
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique across the panel).' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/inbounds/getClientTrafficsById/:id',
|
||||
summary: 'Traffic counters for a client identified by its UUID/password.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'string', desc: 'Client subId / UUID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/add',
|
||||
summary: 'Create a new inbound. Send the full inbound payload (protocol, port, settings JSON, streamSettings JSON, sniffing JSON, remark, expiryTime, total, enable).',
|
||||
body:
|
||||
'{\n "enable": true,\n "remark": "VLESS-443",\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "expiryTime": 0,\n "total": 0,\n "settings": "{\\"clients\\":[{\\"id\\":\\"...\\",\\"email\\":\\"user1\\"}],\\"decryption\\":\\"none\\",\\"fallbacks\\":[]}",\n "streamSettings": "{\\"network\\":\\"tcp\\",\\"security\\":\\"reality\\",\\"realitySettings\\":{...}}",\n "sniffing": "{\\"enabled\\":true,\\"destOverride\\":[\\"http\\",\\"tls\\"]}"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/del/:id',
|
||||
summary: 'Delete an inbound by ID. Also removes its associated client stats rows.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/update/:id',
|
||||
summary: 'Replace an inbound’s configuration. Body shape mirrors /add. Heavy on inbounds with thousands of clients — prefer /setEnable for enable-only flips.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/setEnable/:id',
|
||||
summary: 'Toggle only the enable flag without serialising the whole settings JSON. Recommended for UI switches on large inbounds.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
],
|
||||
body: '{\n "enable": false\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/clientIps/:email',
|
||||
summary: 'List source IPs that have connected with the given client’s credentials. Returns an array of "ip (timestamp)" strings.',
|
||||
params: [
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/clearClientIps/:email',
|
||||
summary: 'Reset the recorded IP list for a client.',
|
||||
params: [
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/addClient',
|
||||
summary: 'Add one or more clients to an existing inbound. The settings field is the JSON-encoded settings.clients array of the target inbound.',
|
||||
body:
|
||||
'{\n "id": 1,\n "settings": "{\\"clients\\":[{\\"id\\":\\"uuid-here\\",\\"email\\":\\"newuser\\",\\"limitIp\\":0,\\"totalGB\\":0,\\"expiryTime\\":0,\\"enable\\":true,\\"flow\\":\\"\\"}]}"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/:id/copyClients',
|
||||
summary: 'Copy selected clients from one inbound into another. Useful for duplicating user lists across protocols.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Target inbound ID.' },
|
||||
{ name: 'sourceInboundId', in: 'body', type: 'number', desc: 'Inbound ID to read clients from.' },
|
||||
{ name: 'clientEmails', in: 'body', type: 'string[]', desc: 'Emails of clients to copy. Empty means all clients.' },
|
||||
{ name: 'flow', in: 'body', type: 'string', desc: 'Override the flow field on copied clients (e.g. "xtls-rprx-vision"). Empty to keep source flow.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/:id/delClient/:clientId',
|
||||
summary: 'Delete a client by its UUID/password from a specific inbound.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
{ name: 'clientId', in: 'path', type: 'string', desc: 'Client UUID / password.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/updateClient/:clientId',
|
||||
summary: 'Update a single client without rewriting the whole settings JSON. Send the target inbound payload with the new client values.',
|
||||
params: [
|
||||
{ name: 'clientId', in: 'path', type: 'string', desc: 'Client UUID / password.' },
|
||||
],
|
||||
body:
|
||||
'{\n "id": 1,\n "settings": "{\\"clients\\":[{\\"id\\":\\"uuid-here\\",\\"email\\":\\"user1\\",\\"limitIp\\":2,\\"totalGB\\":10737418240,\\"expiryTime\\":1735689600000,\\"enable\\":true}]}"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/:id/resetClientTraffic/:email',
|
||||
summary: 'Zero out upload + download counters for one client.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/resetAllTraffics',
|
||||
summary: 'Reset upload + download counters on every inbound. Destructive — accounting history is lost.',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/resetAllClientTraffics/:id',
|
||||
summary: 'Reset traffic for every client in one inbound.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/delDepletedClients/:id',
|
||||
summary: 'Delete clients in this inbound whose traffic cap or expiry has elapsed. Pass id=-1 to sweep every inbound.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID, or -1 for all inbounds.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/import',
|
||||
summary: 'Bulk-import an inbound from a JSON blob (e.g. one exported via the UI). The body uses form encoding with a single "data" field.',
|
||||
params: [
|
||||
{ name: 'data', in: 'body (form)', type: 'string', desc: 'JSON-encoded inbound payload.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/onlines',
|
||||
summary: 'List the emails of currently connected clients (last seen within the heartbeat window).',
|
||||
response: '{\n "success": true,\n "obj": ["user1", "user2"]\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/lastOnline',
|
||||
summary: 'Map of client email → last-seen unix timestamp.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/inbounds/getSubLinks/:subId',
|
||||
summary:
|
||||
'Return every protocol URL (vless://, vmess://, trojan://, ss://, hysteria://, hy2://) for clients matching the subscription ID. Same result set as /sub/<subId>, but as a JSON array — no base64. When an inbound has streamSettings.externalProxy set, one URL is emitted per external proxy. Empty array when the subId has no enabled clients.',
|
||||
params: [
|
||||
{ name: 'subId', in: 'path', type: 'string', desc: "Subscription ID, taken from the client's subId field." },
|
||||
],
|
||||
response:
|
||||
'{\n "success": true,\n "obj": [\n "vless://uuid@host:443?security=reality&...#user1",\n "vmess://eyJ2IjoyLC..."\n ]\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/inbounds/getClientLinks/:id/:email',
|
||||
summary:
|
||||
"Return the URL(s) for one client on one inbound — the same string the Copy URL button copies in the panel UI. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria, hysteria2. If streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) return an empty array.",
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||
],
|
||||
response:
|
||||
'{\n "success": true,\n "obj": [\n "vless://uuid@host:443?...#user1"\n ]\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/updateClientTraffic/:email',
|
||||
summary: 'Manually adjust a client’s upload + download counters. Useful for migrations from external accounting systems.',
|
||||
params: [
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||
],
|
||||
body: '{\n "upload": 1073741824,\n "download": 5368709120\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/:id/delClientByEmail/:email',
|
||||
summary: 'Delete a client identified by email rather than UUID.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'server',
|
||||
title: 'Server API',
|
||||
description:
|
||||
'System status, log retrieval, certificate generators, Xray binary management, and backup/restore. All under /panel/api/server.',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/status',
|
||||
summary: 'Real-time machine snapshot: CPU, memory, swap, disk, network IO, load averages, open connections, Xray state. Cached and refreshed every 2 seconds in the background.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/cpuHistory/:bucket',
|
||||
summary: 'Legacy: aggregated CPU history. Use /history/cpu/:bucket instead — same data with a uniform {t, v} shape.',
|
||||
params: [
|
||||
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/history/:metric/:bucket',
|
||||
summary: 'Aggregated time-series for one metric. Returns an array of {t, v} samples covering the last ~6 hours.',
|
||||
params: [
|
||||
{ name: 'metric', in: 'path', type: 'string', desc: 'cpu | mem | swap | netIn | netOut | tcpCount | udpCount | load1 | online.' },
|
||||
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getXrayVersion',
|
||||
summary: 'List Xray binary versions available for install on this host.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getPanelUpdateInfo',
|
||||
summary: 'Check whether a newer 3x-ui release is available on GitHub.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getConfigJson',
|
||||
summary: 'Return the assembled Xray config that’s currently running on this host.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getDb',
|
||||
summary: 'Stream the SQLite database file as an attachment. Use as a manual backup.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getNewUUID',
|
||||
summary: 'Generate a fresh UUID v4. Convenience helper for client IDs.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getNewX25519Cert',
|
||||
summary: 'Generate a new X25519 keypair for Reality.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getNewmldsa65',
|
||||
summary: 'Generate a new ML-DSA-65 keypair (post-quantum signature). Returns {privateKey, publicKey, seed}.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getNewmlkem768',
|
||||
summary: 'Generate a new ML-KEM-768 keypair (post-quantum KEM). Returns {clientKey, serverKey}.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getNewVlessEnc',
|
||||
summary: 'Generate a new VLESS encryption keypair.',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/stopXrayService',
|
||||
summary: 'Stop the Xray binary. All proxies go offline immediately.',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/restartXrayService',
|
||||
summary: 'Reload Xray with the current config. Typically required after structural inbound or routing changes.',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/installXray/:version',
|
||||
summary: 'Download and install the specified Xray version. Pass "latest" for the newest release.',
|
||||
params: [
|
||||
{ name: 'version', in: 'path', type: 'string', desc: 'Xray tag (e.g. v25.10.31) or "latest".' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/updatePanel',
|
||||
summary: 'Self-update the panel to the latest version. The server restarts on success.',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/updateGeofile',
|
||||
summary: 'Refresh the default GeoIP / GeoSite data files. Body can include a fileName, or use the /:fileName variant.',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/updateGeofile/:fileName',
|
||||
summary: 'Refresh a single Geo file by filename (e.g. geoip.dat, geosite.dat).',
|
||||
params: [
|
||||
{ name: 'fileName', in: 'path', type: 'string', desc: 'Filename of the data file to refresh.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/logs/:count',
|
||||
summary: 'Return the last N lines of the panel’s own log.',
|
||||
params: [
|
||||
{ name: 'count', in: 'path', type: 'number', desc: 'Number of trailing log lines.' },
|
||||
],
|
||||
body: '{\n "level": "info",\n "syslog": false\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/xraylogs/:count',
|
||||
summary: 'Return the last N lines of the Xray process log.',
|
||||
params: [
|
||||
{ name: 'count', in: 'path', type: 'number', desc: 'Number of trailing log lines.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/importDB',
|
||||
summary: 'Restore the panel DB from an uploaded SQLite file (multipart form, field name "db"). The panel restarts after restore. Destructive.',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/getNewEchCert',
|
||||
summary: 'Generate a new ECH (Encrypted Client Hello) keypair. Body picks the algorithm.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'nodes',
|
||||
title: 'Nodes API',
|
||||
description:
|
||||
'Manage remote 3x-ui panels acting as nodes for a central panel. All endpoints under /panel/api/nodes.',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/nodes/list',
|
||||
summary: 'List every configured node with its connection details, health, and last heartbeat patch.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/nodes/get/:id',
|
||||
summary: 'Fetch a single node by ID.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/nodes/add',
|
||||
summary: 'Register a new remote node. Provide its URL, apiToken, and optional label/notes.',
|
||||
body:
|
||||
'{\n "name": "de-fra-1",\n "scheme": "https",\n "host": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/nodes/update/:id',
|
||||
summary: 'Replace a node’s connection details. Same body shape as /add.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/nodes/del/:id',
|
||||
summary: 'Delete a node. Inbounds bound to it are not auto-migrated.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/nodes/setEnable/:id',
|
||||
summary: 'Pause or resume traffic sync with this node.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
||||
],
|
||||
body: '{\n "enable": true\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/nodes/test',
|
||||
summary: 'Probe a node without saving it. Uses the body as connection details and returns whether the handshake succeeds.',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/nodes/probe/:id',
|
||||
summary: 'Probe an existing node, updating its cached health state.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/nodes/history/:id/:metric/:bucket',
|
||||
summary: 'Aggregated metric history for a node — same shape as /server/history, scoped to one node.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
||||
{ name: 'metric', in: 'path', type: 'string', desc: 'Metric key (cpu, mem, netIn, …).' },
|
||||
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'customGeo',
|
||||
title: 'Custom Geo API',
|
||||
description:
|
||||
'Manage user-supplied GeoIP / GeoSite source files. All endpoints under /panel/api/custom-geo.',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/custom-geo/list',
|
||||
summary: 'List configured custom geo sources with their type, alias, URL, status, and last-download timestamp.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/custom-geo/aliases',
|
||||
summary: 'List geo aliases currently usable in routing rules — both built-in defaults and the user-configured ones.',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/custom-geo/add',
|
||||
summary: 'Register a custom geo source. Alias is auto-normalised; URL must point to a .dat / .json blob.',
|
||||
body:
|
||||
'{\n "type": "geoip",\n "alias": "myips",\n "url": "https://example.com/geo/my.dat"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/custom-geo/update/:id',
|
||||
summary: 'Replace a custom geo source. Same body shape as /add.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Custom geo source ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/custom-geo/delete/:id',
|
||||
summary: 'Remove a custom geo source and its cached file.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Custom geo source ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/custom-geo/download/:id',
|
||||
summary: 'Re-download one custom geo source on demand.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Custom geo source ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/custom-geo/update-all',
|
||||
summary: 'Re-download every configured custom geo source. Errors are reported per-source in the response.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'backup',
|
||||
title: 'Backup',
|
||||
description: 'Operations that interact with the configured Telegram bot.',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/backuptotgbot',
|
||||
summary: 'Send a fresh DB backup to every Telegram chat configured as an admin recipient. No body, no params.',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const methodColors = {
|
||||
GET: 'blue',
|
||||
POST: 'green',
|
||||
PUT: 'orange',
|
||||
PATCH: 'orange',
|
||||
DELETE: 'red',
|
||||
};
|
||||
@@ -53,6 +53,7 @@ const form = reactive({
|
||||
flow: '',
|
||||
subId: '',
|
||||
tgId: 0,
|
||||
comment: '',
|
||||
limitIp: 0,
|
||||
totalGB: 0,
|
||||
expiryTime: 0, // ms epoch; negative => delayed start days
|
||||
@@ -85,6 +86,7 @@ watch(() => props.open, (next) => {
|
||||
form.flow = '';
|
||||
form.subId = '';
|
||||
form.tgId = 0;
|
||||
form.comment = '';
|
||||
form.limitIp = 0;
|
||||
form.totalGB = 0;
|
||||
form.expiryTime = 0;
|
||||
@@ -135,6 +137,7 @@ function buildClients() {
|
||||
|
||||
if (form.subId.length > 0) c.subId = form.subId;
|
||||
c.tgId = form.tgId;
|
||||
if (form.comment.length > 0) c.comment = form.comment;
|
||||
c.security = form.security;
|
||||
c.limitIp = form.limitIp;
|
||||
// Use the clien's totalGB setter (ms epoch and bytes already handled
|
||||
@@ -227,6 +230,10 @@ async function submit() {
|
||||
<a-input-number v-model:value="form.tgId" :min="0" :style="{ width: '50%' }" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item :label="t('comment')">
|
||||
<a-input v-model:value="form.comment" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="ipLimitEnable" :label="t('pages.inbounds.IPLimit')">
|
||||
<a-input-number v-model:value="form.limitIp" :min="0" />
|
||||
</a-form-item>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
EditOutlined,
|
||||
@@ -31,6 +31,7 @@ const props = defineProps({
|
||||
onlineClients: { type: Array, default: () => [] },
|
||||
lastOnlineMap: { type: Object, default: () => ({}) },
|
||||
isDarkTheme: { type: Boolean, default: false },
|
||||
pageSize: { type: Number, default: 0 },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -39,12 +40,27 @@ const emit = defineEmits([
|
||||
'info-client',
|
||||
'reset-traffic-client',
|
||||
'delete-client',
|
||||
'delete-clients',
|
||||
'toggle-enable-client',
|
||||
]);
|
||||
|
||||
const inbound = computed(() => props.dbInbound.toInbound());
|
||||
const clients = computed(() => inbound.value?.clients || []);
|
||||
|
||||
const currentPage = ref(1);
|
||||
const paginatedClients = computed(() => {
|
||||
if (!props.pageSize || props.pageSize <= 0) return clients.value;
|
||||
const start = (currentPage.value - 1) * props.pageSize;
|
||||
return clients.value.slice(start, start + props.pageSize);
|
||||
});
|
||||
|
||||
watch([clients, () => props.pageSize], () => {
|
||||
const total = clients.value.length;
|
||||
const size = props.pageSize > 0 ? props.pageSize : (total || 1);
|
||||
const maxPage = Math.max(1, Math.ceil(total / size));
|
||||
if (currentPage.value > maxPage) currentPage.value = maxPage;
|
||||
});
|
||||
|
||||
// === Per-client stats lookup =======================================
|
||||
const statsMap = computed(() => {
|
||||
const m = new Map();
|
||||
@@ -162,23 +178,111 @@ function confirmDelete(client) {
|
||||
function rowKey(client) {
|
||||
return client.email || client.id || client.password || JSON.stringify(client);
|
||||
}
|
||||
|
||||
const selected = ref(new Set());
|
||||
|
||||
const allSelected = computed(() =>
|
||||
clients.value.length > 0 && clients.value.every((c) => selected.value.has(rowKey(c))),
|
||||
);
|
||||
const someSelected = computed(() =>
|
||||
clients.value.some((c) => selected.value.has(rowKey(c))),
|
||||
);
|
||||
const selectedCount = computed(() => selected.value.size);
|
||||
|
||||
function isSelected(key) {
|
||||
return selected.value.has(key);
|
||||
}
|
||||
function toggleSelect(key, next) {
|
||||
const s = new Set(selected.value);
|
||||
if (next) s.add(key); else s.delete(key);
|
||||
selected.value = s;
|
||||
}
|
||||
function selectAll(next) {
|
||||
if (next) {
|
||||
selected.value = new Set(clients.value.map(rowKey));
|
||||
} else {
|
||||
selected.value = new Set();
|
||||
}
|
||||
}
|
||||
function clearSelection() {
|
||||
selected.value = new Set();
|
||||
}
|
||||
|
||||
watch(clients, (list) => {
|
||||
if (selected.value.size === 0) return;
|
||||
const valid = new Set(list.map(rowKey));
|
||||
const next = new Set();
|
||||
for (const k of selected.value) if (valid.has(k)) next.add(k);
|
||||
if (next.size !== selected.value.size) selected.value = next;
|
||||
});
|
||||
|
||||
function confirmBulkDelete() {
|
||||
const picked = clients.value.filter((c) => selected.value.has(rowKey(c)));
|
||||
if (picked.length === 0) return;
|
||||
|
||||
const total = clients.value.length;
|
||||
const keepLast = picked.length === total;
|
||||
const toDelete = keepLast ? picked.slice(0, -1) : picked;
|
||||
|
||||
if (toDelete.length === 0) {
|
||||
Modal.warning({
|
||||
title: t('pages.inbounds.deleteClient'),
|
||||
content: 'Inbound must keep at least one client — delete the inbound to remove all.',
|
||||
okText: t('confirm'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: `${t('pages.inbounds.deleteClient')} — ${toDelete.length}${keepLast ? ` / ${total}` : ''}`,
|
||||
content: keepLast
|
||||
? 'Inbound must keep at least one client — the last selected will remain. Delete the inbound to remove all.'
|
||||
: t('pages.inbounds.deleteClientContent'),
|
||||
okText: t('delete'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => {
|
||||
emit('delete-clients', { dbInbound: props.dbInbound, clients: toDelete });
|
||||
clearSelection();
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="client-list" :class="{ 'is-mobile': isMobile, 'is-dark': isDarkTheme }">
|
||||
<div class="client-list"
|
||||
:class="{ 'is-mobile': isMobile, 'is-dark': isDarkTheme, 'has-select': isRemovable }">
|
||||
<div v-if="isRemovable && selectedCount > 0" class="bulk-bar">
|
||||
<span class="bulk-count">{{ selectedCount }} selected</span>
|
||||
<a-button size="small" type="link" @click="clearSelection">{{ t('cancel') }}</a-button>
|
||||
<a-button size="small" danger @click="confirmBulkDelete">
|
||||
<DeleteOutlined /> {{ t('delete') }}
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- ====================== Desktop: grid table ===================== -->
|
||||
<template v-if="!isMobile">
|
||||
<div class="client-row client-list-header">
|
||||
<div v-if="isRemovable" class="cell cell-select">
|
||||
<a-checkbox :checked="allSelected" :indeterminate="someSelected && !allSelected"
|
||||
@change="(e) => selectAll(e.target.checked)" />
|
||||
</div>
|
||||
<div class="cell cell-actions">{{ t('pages.settings.actions') }}</div>
|
||||
<div class="cell cell-enable">{{ t('enable') }}</div>
|
||||
<div class="cell cell-online">{{ t('online') }}</div>
|
||||
<div class="cell cell-client">{{ t('pages.inbounds.client') }}</div>
|
||||
<div class="cell cell-traffic">{{ t('pages.inbounds.traffic') }}</div>
|
||||
<div class="cell cell-remained">{{ t('remained') }}</div>
|
||||
<div class="cell cell-alltime">{{ t('pages.inbounds.allTimeTraffic') }}</div>
|
||||
<div class="cell cell-expiry">{{ t('pages.inbounds.expireDate') }}</div>
|
||||
</div>
|
||||
|
||||
<div v-for="client in clients" :key="rowKey(client)" class="client-row">
|
||||
<div v-for="client in paginatedClients" :key="rowKey(client)" class="client-row"
|
||||
:class="{ 'is-selected': isSelected(rowKey(client)) }">
|
||||
<div v-if="isRemovable" class="cell cell-select">
|
||||
<a-checkbox :checked="isSelected(rowKey(client))"
|
||||
@change="(e) => toggleSelect(rowKey(client), e.target.checked)" />
|
||||
</div>
|
||||
<div class="cell cell-actions">
|
||||
<a-tooltip v-if="dbInbound.hasLink()" :title="t('qrCode')">
|
||||
<QrcodeOutlined class="row-icon" @click="emit('qrcode-client', { dbInbound, client })" />
|
||||
@@ -262,6 +366,15 @@ function rowKey(client) {
|
||||
</a-popover>
|
||||
</div>
|
||||
|
||||
<div class="cell cell-remained">
|
||||
<a-tag v-if="isUnlimitedTotal(client)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
|
||||
<InfinityIcon />
|
||||
</a-tag>
|
||||
<a-tag v-else :color="isClientDepleted(client.email) ? 'red' : ''">
|
||||
{{ SizeFormatter.sizeFormat(getRem(client.email)) }}
|
||||
</a-tag>
|
||||
</div>
|
||||
|
||||
<div class="cell cell-alltime">
|
||||
<a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
|
||||
</div>
|
||||
@@ -301,8 +414,11 @@ function rowKey(client) {
|
||||
|
||||
<!-- ====================== Mobile: card list ======================= -->
|
||||
<template v-else>
|
||||
<div v-for="client in clients" :key="rowKey(client)" class="client-card">
|
||||
<div v-for="client in paginatedClients" :key="rowKey(client)" class="client-card"
|
||||
:class="{ 'is-selected': isSelected(rowKey(client)) }">
|
||||
<div class="client-card-head">
|
||||
<a-checkbox v-if="isRemovable" :checked="isSelected(rowKey(client))"
|
||||
@change="(e) => toggleSelect(rowKey(client), e.target.checked)" />
|
||||
<a-tooltip>
|
||||
<template #title>
|
||||
<template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template>
|
||||
@@ -356,6 +472,15 @@ function rowKey(client) {
|
||||
<template v-else>{{ totalGbDisplay(client) }}</template>
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('remained') }}</span>
|
||||
<a-tag v-if="isUnlimitedTotal(client)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
|
||||
<InfinityIcon />
|
||||
</a-tag>
|
||||
<a-tag v-else :color="isClientDepleted(client.email) ? 'red' : ''">
|
||||
{{ SizeFormatter.sizeFormat(getRem(client.email)) }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
|
||||
<a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
|
||||
@@ -380,6 +505,10 @@ function rowKey(client) {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<a-pagination v-if="pageSize > 0 && clients.length > pageSize" v-model:current="currentPage"
|
||||
:page-size="pageSize" :total="clients.length" :show-size-changer="false" size="small"
|
||||
class="client-list-pagination" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -389,8 +518,28 @@ function rowKey(client) {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.bulk-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 16px;
|
||||
background: rgba(22, 119, 255, 0.08);
|
||||
border-bottom: 1px solid rgba(22, 119, 255, 0.18);
|
||||
}
|
||||
|
||||
.bulk-count {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.is-selected {
|
||||
background: rgba(22, 119, 255, 0.06);
|
||||
}
|
||||
|
||||
.client-row {
|
||||
display: grid;
|
||||
/* Default — no select column (single-client inbounds). The .has-select
|
||||
* modifier below prepends the 40px checkbox column. */
|
||||
grid-template-columns:
|
||||
140px
|
||||
/* actions */
|
||||
@@ -404,6 +553,8 @@ function rowKey(client) {
|
||||
/* traffic */
|
||||
130px
|
||||
/* all-time */
|
||||
130px
|
||||
/* remained */
|
||||
140px;
|
||||
/* expiry */
|
||||
gap: 12px;
|
||||
@@ -412,6 +563,28 @@ function rowKey(client) {
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.12);
|
||||
}
|
||||
|
||||
.client-list.has-select .client-row {
|
||||
grid-template-columns:
|
||||
40px
|
||||
/* select */
|
||||
140px
|
||||
/* actions */
|
||||
60px
|
||||
/* enable */
|
||||
80px
|
||||
/* online */
|
||||
minmax(160px, 2fr)
|
||||
/* client identity */
|
||||
minmax(160px, 2fr)
|
||||
/* traffic */
|
||||
130px
|
||||
/* all-time */
|
||||
130px
|
||||
/* remained */
|
||||
140px;
|
||||
/* expiry */
|
||||
}
|
||||
|
||||
.client-row:last-child {
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.12);
|
||||
}
|
||||
@@ -432,10 +605,12 @@ function rowKey(client) {
|
||||
/* allow grid children to shrink instead of overflowing */
|
||||
}
|
||||
|
||||
.cell-select,
|
||||
.cell-actions,
|
||||
.cell-enable,
|
||||
.cell-online,
|
||||
.cell-alltime {
|
||||
.cell-alltime,
|
||||
.cell-remained {
|
||||
text-align: center;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -547,6 +722,12 @@ function rowKey(client) {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.client-list-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 10px 16px 4px;
|
||||
}
|
||||
|
||||
/* ===== Mobile card list =========================================== */
|
||||
.client-list.is-mobile {
|
||||
display: flex;
|
||||
|
||||
@@ -387,6 +387,9 @@ const showSubscriptionTab = computed(
|
||||
<td>
|
||||
<a-tag v-if="clientStats && clientSettings.totalGB > 0" :color="statsColor(clientStats)">{{
|
||||
getRemainingStats() }}</a-tag>
|
||||
<a-tag v-else-if="!clientSettings.totalGB || clientSettings.totalGB <= 0" color="purple">
|
||||
<InfinityIcon />
|
||||
</a-tag>
|
||||
</td>
|
||||
<td>
|
||||
<a-tag v-if="clientSettings.totalGB > 0" :color="clientStats ? statsColor(clientStats) : 'default'">{{
|
||||
|
||||
@@ -62,6 +62,7 @@ const emit = defineEmits([
|
||||
'info-client',
|
||||
'reset-traffic-client',
|
||||
'delete-client',
|
||||
'delete-clients',
|
||||
'toggle-enable-client',
|
||||
]);
|
||||
|
||||
@@ -121,13 +122,19 @@ const visibleInbounds = computed(() => {
|
||||
// `key`-driven so we can render via the body-cell slot below. AD-Vue 4's
|
||||
// `responsive` array still works on column defs. Computed so column
|
||||
// labels react to live locale switches.
|
||||
const hasAnyRemark = computed(() =>
|
||||
props.dbInbounds.some((i) => typeof i?.remark === 'string' && i.remark.trim() !== ''),
|
||||
);
|
||||
|
||||
const desktopColumns = computed(() => {
|
||||
const cols = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 30, responsive: ['xs'] },
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 30 },
|
||||
{ title: t('pages.inbounds.operate'), key: 'action', align: 'center', width: 30 },
|
||||
{ title: t('pages.inbounds.enable'), key: 'enable', align: 'center', width: 35 },
|
||||
{ title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'center', width: 60 },
|
||||
];
|
||||
if (hasAnyRemark.value) {
|
||||
cols.push({ title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'center', width: 60 });
|
||||
}
|
||||
if (props.nodesById.size > 0) {
|
||||
cols.push({ title: t('pages.inbounds.node'), key: 'node', align: 'center', width: 60 });
|
||||
}
|
||||
@@ -400,10 +407,12 @@ function showQrCodeMenu(dbInbound) {
|
||||
<div v-if="record.isMultiUser() && isExpanded(record.id)" class="card-clients">
|
||||
<ClientRowTable :db-inbound="record" :is-mobile="true" :traffic-diff="trafficDiff" :expire-diff="expireDiff"
|
||||
:online-clients="onlineClients" :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme"
|
||||
:page-size="pageSize"
|
||||
@edit-client="(p) => emit('edit-client', p)" @qrcode-client="(p) => emit('qrcode-client', p)"
|
||||
@info-client="(p) => emit('info-client', p)"
|
||||
@reset-traffic-client="(p) => emit('reset-traffic-client', p)"
|
||||
@delete-client="(p) => emit('delete-client', p)"
|
||||
@delete-clients="(p) => emit('delete-clients', p)"
|
||||
@toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -419,10 +428,12 @@ function showQrCodeMenu(dbInbound) {
|
||||
<template #expandedRowRender="{ record }">
|
||||
<ClientRowTable v-if="record.isMultiUser()" :db-inbound="record" :is-mobile="isMobile"
|
||||
:traffic-diff="trafficDiff" :expire-diff="expireDiff" :online-clients="onlineClients"
|
||||
:last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme" @edit-client="(p) => emit('edit-client', p)"
|
||||
:last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme" :page-size="pageSize"
|
||||
@edit-client="(p) => emit('edit-client', p)"
|
||||
@qrcode-client="(p) => emit('qrcode-client', p)" @info-client="(p) => emit('info-client', p)"
|
||||
@reset-traffic-client="(p) => emit('reset-traffic-client', p)"
|
||||
@delete-client="(p) => emit('delete-client', p)"
|
||||
@delete-clients="(p) => emit('delete-clients', p)"
|
||||
@toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
|
||||
</template>
|
||||
|
||||
@@ -523,27 +534,35 @@ function showQrCodeMenu(dbInbound) {
|
||||
<a-tag color="green" style="margin: 0">{{ clientCount[record.id].clients }}</a-tag>
|
||||
<a-popover v-if="clientCount[record.id].deactive.length" :title="t('disabled')">
|
||||
<template #content>
|
||||
<div v-for="email in clientCount[record.id].deactive" :key="email">{{ email }}</div>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in clientCount[record.id].deactive" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag style="margin: 0; padding: 0 2px">{{ clientCount[record.id].deactive.length }}</a-tag>
|
||||
</a-popover>
|
||||
<a-popover v-if="clientCount[record.id].depleted.length" :title="t('depleted')">
|
||||
<template #content>
|
||||
<div v-for="email in clientCount[record.id].depleted" :key="email">{{ email }}</div>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in clientCount[record.id].depleted" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag color="red" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].depleted.length
|
||||
}}</a-tag>
|
||||
</a-popover>
|
||||
<a-popover v-if="clientCount[record.id].expiring.length" :title="t('depletingSoon')">
|
||||
<template #content>
|
||||
<div v-for="email in clientCount[record.id].expiring" :key="email">{{ email }}</div>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in clientCount[record.id].expiring" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag color="orange" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].expiring.length
|
||||
}}</a-tag>
|
||||
</a-popover>
|
||||
<a-popover v-if="clientCount[record.id].online.length" :title="t('online')">
|
||||
<template #content>
|
||||
<div v-for="email in clientCount[record.id].online" :key="email">{{ email }}</div>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in clientCount[record.id].online" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag color="blue" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].online.length }}</a-tag>
|
||||
</a-popover>
|
||||
|
||||
@@ -67,7 +67,7 @@ const { isMobile } = useMediaQuery();
|
||||
// the id→node map for the new "Node" column. Fetched once on mount.
|
||||
const { byId: nodesById } = useNodeList();
|
||||
|
||||
const basePath = window.__X_UI_BASE_PATH__ || '';
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
const requestUri = window.location.pathname;
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -322,6 +322,14 @@ async function onDeleteClient({ dbInbound, client }) {
|
||||
if (msg?.success) await refresh();
|
||||
}
|
||||
|
||||
async function onDeleteClients({ dbInbound, clients }) {
|
||||
for (const client of clients) {
|
||||
const clientId = getClientId(dbInbound.protocol, client);
|
||||
await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/delClient/${clientId}`);
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function onToggleEnableClient({ dbInbound, client, next }) {
|
||||
// Mirror legacy: clone the parsed inbound, flip enable on the matching
|
||||
// client, and post the whole client back through updateClient. This
|
||||
@@ -593,9 +601,38 @@ function onRowAction({ key, dbInbound }) {
|
||||
<a-space direction="horizontal">
|
||||
<TeamOutlined />
|
||||
<a-tag color="green">{{ totals.clients }}</a-tag>
|
||||
<a-tag v-if="totals.deactive.length">{{ totals.deactive.length }}</a-tag>
|
||||
<a-tag v-if="totals.depleted.length" color="red">{{ totals.depleted.length }}</a-tag>
|
||||
<a-tag v-if="totals.expiring.length" color="orange">{{ totals.expiring.length }}</a-tag>
|
||||
<a-popover v-if="totals.deactive.length" :title="t('disabled')">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in totals.deactive" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag>{{ totals.deactive.length }}</a-tag>
|
||||
</a-popover>
|
||||
<a-popover v-if="totals.depleted.length" :title="t('depleted')">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in totals.depleted" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag color="red">{{ totals.depleted.length }}</a-tag>
|
||||
</a-popover>
|
||||
<a-popover v-if="totals.expiring.length" :title="t('depletingSoon')">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in totals.expiring" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag color="orange">{{ totals.expiring.length }}</a-tag>
|
||||
</a-popover>
|
||||
<a-popover v-if="totals.online.length" :title="t('online')">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in totals.online" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag color="blue">{{ totals.online.length }}</a-tag>
|
||||
</a-popover>
|
||||
</a-space>
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
@@ -613,7 +650,7 @@ function onRowAction({ key, dbInbound }) {
|
||||
@add-inbound="onAddInbound" @general-action="onGeneralAction" @row-action="onRowAction"
|
||||
@edit-client="onEditClient" @qrcode-client="onQrcodeClient" @info-client="onInfoClient"
|
||||
@reset-traffic-client="onResetTrafficClient" @delete-client="onDeleteClient"
|
||||
@toggle-enable-client="onToggleEnableClient" />
|
||||
@delete-clients="onDeleteClients" @toggle-enable-client="onToggleEnableClient" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-spin>
|
||||
@@ -631,7 +668,7 @@ function onRowAction({ key, dbInbound }) {
|
||||
:ip-limit-enable="ipLimitEnable" :tg-bot-enable="tgBotEnable" :sub-settings="subSettings"
|
||||
:last-online-map="lastOnlineMap" :node-address="infoNodeAddress" />
|
||||
<QrCodeModal v-model:open="qrOpen" :db-inbound="qrDbInbound" :client="qrClient" :remark-model="remarkModel"
|
||||
:node-address="qrNodeAddress" />
|
||||
:node-address="qrNodeAddress" :sub-settings="subSettings" />
|
||||
|
||||
<TextModal v-model:open="textOpen" :title="textTitle" :content="textContent" :file-name="textFileName" />
|
||||
<PromptModal v-model:open="promptOpen" :title="promptTitle" :ok-text="promptOkText" :type="promptType"
|
||||
@@ -650,8 +687,8 @@ function onRowAction({ key, dbInbound }) {
|
||||
}
|
||||
|
||||
.inbounds-page.is-dark {
|
||||
--bg-page: #0a1222;
|
||||
--bg-card: #151f31;
|
||||
--bg-page: #1e1e1e;
|
||||
--bg-card: #252526;
|
||||
}
|
||||
|
||||
.inbounds-page.is-dark.is-ultra {
|
||||
@@ -692,3 +729,20 @@ function onRowAction({ key, dbInbound }) {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* AD-Vue popovers teleport their content to <body>, so scoped styles
|
||||
don't reach them — this block has to be unscoped. */
|
||||
.client-email-list {
|
||||
max-height: 280px;
|
||||
min-width: 160px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.client-email-list > div {
|
||||
padding: 2px 0;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,26 +1,21 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { Protocols } from '@/models/inbound.js';
|
||||
import QrPanel from './QrPanel.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Light QR-only modal — used for the "qrcode" row action on
|
||||
// single-user Shadowsocks and WireGuard inbounds. The big info modal
|
||||
// (InboundInfoModal) is too detailed when the user just wants the
|
||||
// share link as a QR.
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
dbInbound: { type: Object, default: null },
|
||||
client: { type: Object, default: null },
|
||||
remarkModel: { type: String, default: '-ieo' },
|
||||
// Address of the node hosting this inbound (empty string for local).
|
||||
// When set, share/QR links use it as the host instead of the panel's
|
||||
// origin — node-managed inbounds proxy from the node, not the panel.
|
||||
nodeAddress: { type: String, default: '' },
|
||||
subSettings: {
|
||||
type: Object,
|
||||
default: () => ({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false }),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open']);
|
||||
@@ -28,6 +23,50 @@ const emit = defineEmits(['update:open']);
|
||||
const links = ref([]);
|
||||
const wireguardConfigs = ref([]);
|
||||
const wireguardLinks = ref([]);
|
||||
const subLink = ref('');
|
||||
const subJsonLink = ref('');
|
||||
const activeKeys = ref([]);
|
||||
|
||||
const qrItems = computed(() => {
|
||||
const items = [];
|
||||
if (subLink.value) {
|
||||
items.push({
|
||||
key: 'sub',
|
||||
header: t('subscription.title'),
|
||||
value: subLink.value,
|
||||
});
|
||||
}
|
||||
if (subJsonLink.value) {
|
||||
items.push({
|
||||
key: 'sub-json',
|
||||
header: `${t('subscription.title')} (JSON)`,
|
||||
value: subJsonLink.value,
|
||||
});
|
||||
}
|
||||
links.value.forEach((link, idx) => {
|
||||
items.push({
|
||||
key: `l${idx}`,
|
||||
header: link.remark || `Link ${idx + 1}`,
|
||||
value: link.link,
|
||||
});
|
||||
});
|
||||
wireguardConfigs.value.forEach((cfg, idx) => {
|
||||
items.push({
|
||||
key: `wc${idx}`,
|
||||
header: `Peer ${idx + 1} config`,
|
||||
value: cfg,
|
||||
downloadName: `peer-${idx + 1}.conf`,
|
||||
});
|
||||
if (wireguardLinks.value[idx]) {
|
||||
items.push({
|
||||
key: `wl${idx}`,
|
||||
header: `Peer ${idx + 1} link`,
|
||||
value: wireguardLinks.value[idx],
|
||||
});
|
||||
}
|
||||
});
|
||||
return items;
|
||||
});
|
||||
|
||||
watch(() => props.open, (next) => {
|
||||
if (!next || !props.dbInbound) return;
|
||||
@@ -46,6 +85,21 @@ watch(() => props.open, (next) => {
|
||||
wireguardConfigs.value = [];
|
||||
wireguardLinks.value = [];
|
||||
}
|
||||
|
||||
const subId = props.client?.subId;
|
||||
if (props.subSettings?.enable && subId) {
|
||||
subLink.value = (props.subSettings.subURI || '') + subId;
|
||||
subJsonLink.value = props.subSettings.subJsonEnable
|
||||
? (props.subSettings.subJsonURI || '') + subId
|
||||
: '';
|
||||
} else {
|
||||
subLink.value = '';
|
||||
subJsonLink.value = '';
|
||||
}
|
||||
const open = [];
|
||||
if (subLink.value) open.push('sub');
|
||||
if (subJsonLink.value) open.push('sub-json');
|
||||
activeKeys.value = open;
|
||||
});
|
||||
|
||||
function close() {
|
||||
@@ -56,12 +110,17 @@ function close() {
|
||||
<template>
|
||||
<a-modal :open="open" :title="t('qrCode')" :footer="null" width="420px" @cancel="close">
|
||||
<template v-if="dbInbound">
|
||||
<QrPanel v-for="(link, idx) in links" :key="`l${idx}`" :value="link.link"
|
||||
:remark="link.remark || `Link ${idx + 1}`" />
|
||||
<template v-for="(cfg, idx) in wireguardConfigs" :key="`w${idx}`">
|
||||
<QrPanel :value="cfg" :remark="`Peer ${idx + 1} config`" :download-name="`peer-${idx + 1}.conf`" />
|
||||
<QrPanel v-if="wireguardLinks[idx]" :value="wireguardLinks[idx]" :remark="`Peer ${idx + 1} link`" />
|
||||
</template>
|
||||
<a-collapse v-model:active-key="activeKeys" ghost class="qr-collapse">
|
||||
<a-collapse-panel v-for="item in qrItems" :key="item.key" :header="item.header">
|
||||
<QrPanel :value="item.value" :remark="item.header" :download-name="item.downloadName || ''" />
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.qr-collapse :deep(.ant-collapse-content-box) {
|
||||
padding: 8px 0 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<script setup>
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import QRious from 'qrious';
|
||||
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
@@ -9,73 +7,14 @@ import { ClipboardManager, FileManager } from '@/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Renders a single share-link as a clickable QR code + a copy button
|
||||
// + (optional) a download button. Used per-link inside the inbound
|
||||
// info modal — the canvas is repainted whenever `value` changes.
|
||||
|
||||
const props = defineProps({
|
||||
// The link or config text to encode + display.
|
||||
value: { type: String, required: true },
|
||||
// Header label shown next to the copy button.
|
||||
remark: { type: String, default: '' },
|
||||
// Optional download filename — when set, surfaces a download button.
|
||||
downloadName: { type: String, default: '' },
|
||||
// Final on-screen QR size in CSS pixels. The canvas drawing buffer
|
||||
// is rounded down to a multiple of the QR matrix width (so the QR
|
||||
// fills it edge-to-edge) and CSS then scales the canvas to exactly
|
||||
// this size — so a denser QR (e.g. WireGuard config) and a sparser
|
||||
// one (its link) display at identical dimensions.
|
||||
size: { type: Number, default: 240 },
|
||||
// Toggle the QR rendering off when callers only want the "row of buttons"
|
||||
// styling (used when the legacy panel rendered links without QRs).
|
||||
showQr: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
const canvas = ref(null);
|
||||
|
||||
// Byte-mode capacities (level M) for QR versions 1..40 — used to pick
|
||||
// the matrix width up front so we can size the canvas as a multiple
|
||||
// of pixelSize. Without this, QRious renders at floor(size/matrix)
|
||||
// and centers, leaving a white margin whenever size isn't divisible.
|
||||
const QR_M_BYTE_CAPACITY = [
|
||||
14, 26, 42, 62, 84, 106, 122, 152, 180, 213,
|
||||
251, 287, 331, 362, 412, 450, 504, 560, 624, 666,
|
||||
711, 779, 857, 911, 997, 1059, 1125, 1190, 1264, 1370,
|
||||
1452, 1538, 1628, 1722, 1809, 1911, 1989, 2099, 2213, 2331,
|
||||
];
|
||||
|
||||
function pickQrMatrixWidth(value) {
|
||||
const byteLen = new TextEncoder().encode(value).length;
|
||||
for (let i = 0; i < QR_M_BYTE_CAPACITY.length; i++) {
|
||||
if (byteLen <= QR_M_BYTE_CAPACITY[i]) return 17 + 4 * (i + 1);
|
||||
}
|
||||
return 17 + 4 * 40; // version 40 (177 modules)
|
||||
}
|
||||
|
||||
function paint() {
|
||||
if (!props.showQr || !canvas.value || !props.value) return;
|
||||
// Canvas size = matrixWidth × pixelSize, so the QR fills it edge-to-
|
||||
// edge. pixelSize is floored against the requested size so the QR
|
||||
// never grows past the host's expected box.
|
||||
const matrixWidth = pickQrMatrixWidth(props.value);
|
||||
const pixelSize = Math.max(1, Math.floor(props.size / matrixWidth));
|
||||
const exactSize = matrixWidth * pixelSize;
|
||||
new QRious({
|
||||
element: canvas.value,
|
||||
size: exactSize,
|
||||
value: props.value,
|
||||
background: 'white',
|
||||
backgroundAlpha: 1,
|
||||
foreground: 'black',
|
||||
padding: 0,
|
||||
level: 'M',
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(paint);
|
||||
watch(() => props.value, paint);
|
||||
watch(() => props.size, paint);
|
||||
|
||||
async function copy() {
|
||||
const ok = await ClipboardManager.copyText(props.value);
|
||||
if (ok) message.success(t('copied'));
|
||||
@@ -107,7 +46,8 @@ function download() {
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<div v-if="showQr" class="qr-panel-canvas">
|
||||
<canvas ref="canvas" :style="{ width: `${size}px`, height: `${size}px` }" @click="copy" />
|
||||
<a-qrcode class="qr-code" :value="value" :size="size" type="svg" :bordered="false"
|
||||
:title="t('copy')" @click="copy" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -140,14 +80,10 @@ function download() {
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.qr-panel-canvas canvas {
|
||||
.qr-panel-canvas .qr-code {
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
padding: 0 !important;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
/* Drawing buffer is matrix-snapped (smaller than display size for
|
||||
* dense QRs); scale up crisply so dense and sparse QRs share the
|
||||
* same on-screen footprint without blurring. */
|
||||
image-rendering: pixelated;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -287,6 +287,7 @@ export function useInbounds() {
|
||||
const deactive = [];
|
||||
const depleted = [];
|
||||
const expiring = [];
|
||||
const online = [];
|
||||
for (const ib of dbInbounds.value) {
|
||||
up += ib.up || 0;
|
||||
down += ib.down || 0;
|
||||
@@ -297,9 +298,10 @@ export function useInbounds() {
|
||||
deactive.push(...c.deactive);
|
||||
depleted.push(...c.depleted);
|
||||
expiring.push(...c.expiring);
|
||||
online.push(...c.online);
|
||||
}
|
||||
}
|
||||
return { up, down, allTime, clients, deactive, depleted, expiring };
|
||||
return { up, down, allTime, clients, deactive, depleted, expiring, online };
|
||||
});
|
||||
|
||||
// ObjectUtil reference is wired at module load — keeping a no-op import
|
||||
|
||||
@@ -20,7 +20,7 @@ function exportDb() {
|
||||
// The Go endpoint streams x-ui.db as a download. Setting
|
||||
// window.location triggers a browser download without leaving
|
||||
// the page (the Go side responds with Content-Disposition: attachment).
|
||||
window.location = '/panel/api/server/getDb';
|
||||
window.location = window.X_UI_BASE_PATH+'panel/api/server/getDb';
|
||||
}
|
||||
|
||||
function importDb() {
|
||||
|
||||
@@ -53,14 +53,14 @@ onMounted(() => {
|
||||
});
|
||||
});
|
||||
|
||||
const basePath = window.__X_UI_BASE_PATH__ || '';
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
const requestUri = window.location.pathname;
|
||||
|
||||
// In production, dist.go injects window.__X_UI_CUR_VER__ at serve time.
|
||||
// In production, dist.go injects window.X_UI_CUR_VER at serve time.
|
||||
// In dev, Vite serves the HTML directly so the global is missing — fall
|
||||
// back to currentVersion from the panel-update API once it answers.
|
||||
const displayVersion = computed(
|
||||
() => panelUpdateInfo.value?.currentVersion || window.__X_UI_CUR_VER__ || '?',
|
||||
() => panelUpdateInfo.value?.currentVersion || window.X_UI_CUR_VER || '?',
|
||||
);
|
||||
|
||||
// Hide/reveal the public IPv4/IPv6 — same pattern as legacy.
|
||||
@@ -336,8 +336,8 @@ async function openConfig() {
|
||||
}
|
||||
|
||||
.index-page.is-dark {
|
||||
--bg-page: #0a1222;
|
||||
--bg-card: #151f31;
|
||||
--bg-page: #1e1e1e;
|
||||
--bg-card: #252526;
|
||||
}
|
||||
|
||||
.index-page.is-dark.is-ultra {
|
||||
|
||||
@@ -8,11 +8,25 @@ import {
|
||||
antdThemeConfig,
|
||||
currentTheme,
|
||||
theme as themeState,
|
||||
toggleTheme,
|
||||
toggleUltra,
|
||||
pauseAnimationsUntilLeave,
|
||||
} from '@/composables/useTheme.js';
|
||||
import ThemeSwitchLogin from '@/components/ThemeSwitchLogin.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const fetched = ref(false);
|
||||
const submitting = ref(false);
|
||||
const twoFactorEnable = ref(false);
|
||||
|
||||
const user = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
twoFactorCode: '',
|
||||
});
|
||||
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
|
||||
const headlineWords = computed(() => [t('pages.login.hello'), t('pages.login.title')]);
|
||||
const HEADLINE_INTERVAL_MS = 2000;
|
||||
const headlineIndex = ref(0);
|
||||
@@ -28,23 +42,9 @@ onBeforeUnmount(() => {
|
||||
if (headlineTimer != null) window.clearInterval(headlineTimer);
|
||||
});
|
||||
|
||||
const fetched = ref(false);
|
||||
const submitting = ref(false);
|
||||
const twoFactorEnable = ref(false);
|
||||
|
||||
const user = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
twoFactorCode: '',
|
||||
});
|
||||
|
||||
const basePath = window.__X_UI_BASE_PATH__ || '';
|
||||
|
||||
onMounted(async () => {
|
||||
const msg = await HttpUtil.post('/getTwoFactorEnable');
|
||||
if (msg.success) {
|
||||
twoFactorEnable.value = !!msg.obj;
|
||||
}
|
||||
if (msg.success) twoFactorEnable.value = !!msg.obj;
|
||||
fetched.value = true;
|
||||
});
|
||||
|
||||
@@ -52,9 +52,7 @@ async function login() {
|
||||
submitting.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post('/login', user);
|
||||
if (msg.success) {
|
||||
window.location.href = basePath + 'panel/';
|
||||
}
|
||||
if (msg.success) window.location.href = basePath + 'panel/';
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
@@ -64,108 +62,123 @@ const lang = ref(LanguageManager.getLanguage());
|
||||
function onLangChange(next) {
|
||||
LanguageManager.setLanguage(next);
|
||||
}
|
||||
|
||||
/* Same Light -> Dark -> Ultra Dark -> Light cycle the sidebar's brand
|
||||
* button uses, so the login chrome offers a one-click theme toggle
|
||||
* without the popover ceremony. */
|
||||
function cycleTheme() {
|
||||
pauseAnimationsUntilLeave('login-theme-cycle');
|
||||
if (!themeState.isDark) {
|
||||
toggleTheme();
|
||||
if (themeState.isUltra) toggleUltra();
|
||||
} else if (!themeState.isUltra) {
|
||||
toggleUltra();
|
||||
} else {
|
||||
toggleUltra();
|
||||
toggleTheme();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-config-provider :theme="antdThemeConfig">
|
||||
<a-layout class="login-app" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
|
||||
<a-layout-content class="login-content">
|
||||
<div class="waves-header">
|
||||
<div class="waves-inner-header"></div>
|
||||
<svg class="waves" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 24 150 28" preserveAspectRatio="none" shape-rendering="auto">
|
||||
<defs>
|
||||
<path id="gentle-wave" d="M-160 44c30 0 58-18 88-18s 58 18 88 18 58-18 88-18 58 18 88 18 v44h-352z" />
|
||||
</defs>
|
||||
<g class="parallax">
|
||||
<use xlink:href="#gentle-wave" x="48" y="0" />
|
||||
<use xlink:href="#gentle-wave" x="48" y="3" />
|
||||
<use xlink:href="#gentle-wave" x="48" y="5" />
|
||||
<use xlink:href="#gentle-wave" x="48" y="7" />
|
||||
</g>
|
||||
</svg>
|
||||
<!-- Floating chrome at top-right: theme cycle (Light/Dark/Ultra)
|
||||
plus a language picker hidden behind the gear popover. -->
|
||||
<div class="login-toolbar">
|
||||
<button type="button" class="theme-cycle" :aria-label="t('menu.theme')" :title="t('menu.theme')"
|
||||
@click="cycleTheme">
|
||||
<svg v-if="!themeState.isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path
|
||||
d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||
</svg>
|
||||
<svg v-else-if="!themeState.isUltra" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
<path fill="none" d="M19 3l0.7 1.4 1.4 0.7-1.4 0.7L19 7.2l-0.7-1.4-1.4-0.7 1.4-0.7z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<a-popover :overlay-class-name="currentTheme" :title="t('pages.settings.language')" placement="bottomRight"
|
||||
trigger="click">
|
||||
<template #content>
|
||||
<a-space direction="vertical" :size="10" class="settings-popover">
|
||||
<a-select v-model:value="lang" class="lang-select" @change="onLangChange">
|
||||
<a-select-option v-for="l in LanguageManager.supportedLanguages" :key="l.value" :value="l.value">
|
||||
<span :aria-label="l.name">{{ l.icon }}</span>
|
||||
<span>{{ l.name }}</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-button shape="circle" class="toolbar-btn" :aria-label="t('menu.settings')">
|
||||
<template #icon>
|
||||
<SettingOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-popover>
|
||||
</div>
|
||||
|
||||
<a-row type="flex" justify="center" align="middle" class="login-row">
|
||||
<a-col class="login-card">
|
||||
<div v-if="!fetched" class="login-loading">
|
||||
<a-spin size="large" />
|
||||
</div>
|
||||
<div class="login-wrapper">
|
||||
<div v-if="!fetched" class="login-loading">
|
||||
<a-spin size="large" />
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="login-settings">
|
||||
<a-popover :overlay-class-name="currentTheme" :title="t('menu.settings')" placement="bottomRight"
|
||||
trigger="click">
|
||||
<template #content>
|
||||
<a-space direction="vertical" :size="10" class="settings-popover">
|
||||
<ThemeSwitchLogin />
|
||||
<span>{{ t('pages.settings.language') }}</span>
|
||||
<a-select v-model:value="lang" class="lang-select" @change="onLangChange">
|
||||
<a-select-option v-for="l in LanguageManager.supportedLanguages" :key="l.value"
|
||||
:value="l.value">
|
||||
<span :aria-label="l.name">{{ l.icon }}</span>
|
||||
<span>{{ l.name }}</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-space>
|
||||
<div v-else class="login-card">
|
||||
<div class="brand">
|
||||
<span class="brand-name">3X-UI</span>
|
||||
<span class="brand-accent" aria-hidden="true"></span>
|
||||
</div>
|
||||
<h2 class="welcome">
|
||||
<Transition name="headline" mode="out-in">
|
||||
<b :key="headlineIndex">{{ headlineWords[headlineIndex] }}</b>
|
||||
</Transition>
|
||||
</h2>
|
||||
|
||||
<a-form layout="vertical" class="login-form" @submit.prevent="login">
|
||||
<a-form-item :label="t('username')">
|
||||
<a-input v-model:value="user.username" autocomplete="username" name="username" size="large"
|
||||
:placeholder="t('username')" autofocus required>
|
||||
<template #prefix>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
<a-button shape="circle">
|
||||
<template #icon>
|
||||
<SettingOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-popover>
|
||||
</div>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-row justify="center">
|
||||
<a-col :span="24">
|
||||
<h2 class="login-title">
|
||||
<Transition name="headline" mode="out-in">
|
||||
<b :key="headlineIndex">{{ headlineWords[headlineIndex] }}</b>
|
||||
</Transition>
|
||||
</h2>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item :label="t('password')">
|
||||
<a-input-password v-model:value="user.password" autocomplete="current-password" name="password"
|
||||
size="large" :placeholder="t('password')" required>
|
||||
<template #prefix>
|
||||
<LockOutlined />
|
||||
</template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<a-form layout="vertical" @submit.prevent="login">
|
||||
<a-form-item>
|
||||
<a-input v-model:value="user.username" autocomplete="username" name="username"
|
||||
:placeholder="t('username')" autofocus required>
|
||||
<template #prefix>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="twoFactorEnable" :label="t('twoFactorCode')">
|
||||
<a-input v-model:value="user.twoFactorCode" autocomplete="one-time-code" name="twoFactorCode"
|
||||
size="large" :placeholder="t('twoFactorCode')" required>
|
||||
<template #prefix>
|
||||
<KeyOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-input-password v-model:value="user.password" autocomplete="current-password" name="password"
|
||||
:placeholder="t('password')" required>
|
||||
<template #prefix>
|
||||
<LockOutlined />
|
||||
</template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
<a-form-item class="submit-row">
|
||||
<a-button type="primary" html-type="submit" :loading="submitting" size="large" block>
|
||||
{{ submitting ? '' : t('login') }}
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<a-form-item v-if="twoFactorEnable">
|
||||
<a-input v-model:value="user.twoFactorCode" autocomplete="one-time-code" name="twoFactorCode"
|
||||
:placeholder="t('twoFactorCode')" required>
|
||||
<template #prefix>
|
||||
<KeyOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-row justify="center">
|
||||
<a-button type="primary" html-type="submit" :loading="submitting" block>
|
||||
{{ submitting ? '' : t('login') }}
|
||||
</a-button>
|
||||
</a-row>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</div>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-config-provider>
|
||||
@@ -173,59 +186,293 @@ function onLangChange(next) {
|
||||
|
||||
<style scoped>
|
||||
.login-app {
|
||||
--bg-page: #c7ebe2;
|
||||
--bg-wave-header: #dbf5ed;
|
||||
--bg-page: #f5f7fa;
|
||||
--bg-card: #ffffff;
|
||||
--color-title: #008771;
|
||||
--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.09);
|
||||
--wave-fill: rgba(0, 135, 113, 0.12);
|
||||
--wave-fill-bottom: #c7ebe2;
|
||||
--color-text: rgba(0, 0, 0, 0.88);
|
||||
--color-text-subtle: rgba(0, 0, 0, 0.55);
|
||||
--color-accent: #1677ff;
|
||||
--color-border: rgba(0, 0, 0, 0.08);
|
||||
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(0, 0, 0, 0.06);
|
||||
--blob-1: rgba(99, 102, 241, 0.45);
|
||||
--blob-2: rgba(236, 72, 153, 0.38);
|
||||
--blob-3: rgba(20, 184, 166, 0.32);
|
||||
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.login-app.is-dark {
|
||||
--bg-page: #222d42;
|
||||
--bg-wave-header: #0a1222;
|
||||
--bg-card: #151f31;
|
||||
--color-title: rgba(255, 255, 255, 0.92);
|
||||
--shadow-card: 0 4px 16px rgba(0, 0, 0, 0.45);
|
||||
--wave-fill: #222d42;
|
||||
--wave-fill-bottom: #222d42;
|
||||
--bg-page: #1e1e1e;
|
||||
--bg-card: #252526;
|
||||
--color-text: rgba(255, 255, 255, 0.92);
|
||||
--color-text-subtle: rgba(255, 255, 255, 0.55);
|
||||
--color-accent: #4096ff;
|
||||
--color-border: rgba(255, 255, 255, 0.08);
|
||||
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.3), 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
--blob-1: rgba(64, 150, 255, 0.40);
|
||||
--blob-2: rgba(168, 85, 247, 0.34);
|
||||
--blob-3: rgba(34, 211, 238, 0.22);
|
||||
}
|
||||
|
||||
.login-app.is-dark.is-ultra {
|
||||
--bg-page: #0f2d32;
|
||||
--bg-wave-header: #0a2227;
|
||||
--bg-card: #0c0e12;
|
||||
--wave-fill: #1f4d52;
|
||||
--wave-fill-bottom: #0f2d32;
|
||||
--bg-page: #000;
|
||||
--bg-card: #141414;
|
||||
--color-border: rgba(255, 255, 255, 0.06);
|
||||
--blob-1: rgba(64, 150, 255, 0.22);
|
||||
--blob-2: rgba(168, 85, 247, 0.18);
|
||||
--blob-3: rgba(34, 211, 238, 0.12);
|
||||
}
|
||||
|
||||
/* Three blurred blobs slowly drift across the page; ::before and
|
||||
* ::after carry two of them, the third lives on .login-content::before
|
||||
* so we can animate it independently. */
|
||||
.login-app::before,
|
||||
.login-app::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 70vw;
|
||||
height: 70vw;
|
||||
max-width: 900px;
|
||||
max-height: 900px;
|
||||
border-radius: 50%;
|
||||
filter: blur(90px);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.login-app::before {
|
||||
top: -25vw;
|
||||
left: -20vw;
|
||||
background: radial-gradient(circle, var(--blob-1) 0%, transparent 65%);
|
||||
animation: blob-drift-a 24s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.login-app::after {
|
||||
bottom: -25vw;
|
||||
right: -20vw;
|
||||
background: radial-gradient(circle, var(--blob-2) 0%, transparent 65%);
|
||||
animation: blob-drift-b 30s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.login-content::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 30%;
|
||||
left: 50%;
|
||||
width: 50vw;
|
||||
height: 50vw;
|
||||
max-width: 700px;
|
||||
max-height: 700px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, var(--blob-3) 0%, transparent 65%);
|
||||
filter: blur(90px);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
will-change: transform;
|
||||
animation: blob-drift-c 36s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes blob-drift-a {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(18vw, 10vh) scale(1.15);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(34vw, 22vh) scale(1.25);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blob-drift-b {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(-16vw, -10vh) scale(1.12);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(-30vw, -22vh) scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blob-drift-c {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(-20%, -20%) scale(1.1);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(-80%, -10%) scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
.login-app::before,
|
||||
.login-app::after,
|
||||
.login-content::before {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.login-app,
|
||||
.login-app :deep(.ant-layout-content) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.login-app {
|
||||
background: var(--bg-page);
|
||||
.login-content {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.login-content>* {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-toolbar {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 10;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.theme-cycle {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--bg-card);
|
||||
color: var(--color-text);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: background-color 0.2s, transform 0.15s, color 0.2s;
|
||||
}
|
||||
|
||||
.theme-cycle:hover,
|
||||
.theme-cycle:focus-visible {
|
||||
background-color: rgba(64, 150, 255, 0.1);
|
||||
color: #4096ff;
|
||||
transform: scale(1.05);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.theme-cycle svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.login-wrapper {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.login-loading {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
padding: 40px 32px 28px;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.login-title {
|
||||
color: var(--color-title);
|
||||
@media (max-width: 480px) {
|
||||
.login-card {
|
||||
padding: 32px 20px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.login-settings {
|
||||
.brand {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.brand-accent {
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.welcome {
|
||||
text-align: center;
|
||||
color: var(--color-text);
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
min-height: 42px;
|
||||
margin: 12px 0 28px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.welcome b {
|
||||
display: inline-block;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
.headline-enter-active,
|
||||
.headline-leave-active {
|
||||
transition: opacity 280ms ease, transform 280ms ease;
|
||||
}
|
||||
|
||||
.headline-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
|
||||
.headline-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
|
||||
.login-form :deep(.ant-form-item-label > label) {
|
||||
color: var(--color-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.submit-row {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.settings-popover {
|
||||
min-width: 220px;
|
||||
}
|
||||
@@ -233,118 +480,4 @@ function onLangChange(next) {
|
||||
.lang-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-content {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.login-row {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: 100vh;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: clamp(280px, 90vw, 300px);
|
||||
border-radius: 2rem;
|
||||
padding: clamp(2rem, 5vw, 4rem) 1.5rem;
|
||||
transition: background 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.login-loading {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
font-size: 2rem;
|
||||
font-weight: 500;
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
|
||||
.login-title b {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.headline-enter-active,
|
||||
.headline-leave-active {
|
||||
transition: opacity 0.4s ease, transform 0.4s ease;
|
||||
}
|
||||
|
||||
.headline-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-12px);
|
||||
}
|
||||
|
||||
.headline-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
|
||||
.waves-header {
|
||||
position: fixed;
|
||||
inset: 0 0 auto 0;
|
||||
width: 100%;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background: var(--bg-wave-header);
|
||||
}
|
||||
|
||||
.waves-inner-header {
|
||||
height: 50vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.waves {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 15vh;
|
||||
min-height: 100px;
|
||||
max-height: 150px;
|
||||
margin-bottom: -8px;
|
||||
}
|
||||
|
||||
.parallax>use {
|
||||
fill: var(--wave-fill);
|
||||
animation: move-forever 25s cubic-bezier(0.55, 0.5, 0.45, 0.5) infinite;
|
||||
}
|
||||
|
||||
.parallax>use:nth-child(1) {
|
||||
animation-delay: -2s;
|
||||
animation-duration: 4s;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.parallax>use:nth-child(2) {
|
||||
animation-delay: -3s;
|
||||
animation-duration: 7s;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.parallax>use:nth-child(3) {
|
||||
animation-delay: -4s;
|
||||
animation-duration: 10s;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.parallax>use:nth-child(4) {
|
||||
animation-delay: -5s;
|
||||
animation-duration: 13s;
|
||||
fill: var(--wave-fill-bottom);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes move-forever {
|
||||
0% {
|
||||
transform: translate3d(-90px, 0, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate3d(85px, 0, 0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -39,7 +39,7 @@ useWebSocket({ nodes: applyNodesEvent });
|
||||
|
||||
const { isMobile } = useMediaQuery();
|
||||
|
||||
const basePath = window.__X_UI_BASE_PATH__ || '';
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
const requestUri = window.location.pathname;
|
||||
|
||||
// === Form modal state =================================================
|
||||
@@ -172,8 +172,8 @@ async function onToggleEnable(node, next) {
|
||||
}
|
||||
|
||||
.nodes-page.is-dark {
|
||||
--bg-page: #0a1222;
|
||||
--bg-card: #151f31;
|
||||
--bg-page: #1e1e1e;
|
||||
--bg-card: #252526;
|
||||
}
|
||||
|
||||
.nodes-page.is-dark.is-ultra {
|
||||
|
||||
@@ -54,7 +54,7 @@ async function sendUpdateUser() {
|
||||
if (msg?.success) {
|
||||
// Force re-login at the standard logout path; basePath is handled
|
||||
// by the Go router so a relative redirect is correct here.
|
||||
const basePath = window.__X_UI_BASE_PATH__ || '';
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
window.location.replace(`${basePath}logout`);
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -26,7 +26,7 @@ const { t } = useI18n();
|
||||
const { fetched, spinning, saveDisabled, allSetting, saveAll } = useAllSetting();
|
||||
const { isMobile } = useMediaQuery();
|
||||
|
||||
const basePath = window.__X_UI_BASE_PATH__ || '';
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
const requestUri = window.location.pathname;
|
||||
|
||||
// AD-Vue 4's <a-back-top> calls `target()` after mount to find the
|
||||
@@ -256,8 +256,8 @@ const alertVisible = ref(true);
|
||||
}
|
||||
|
||||
.settings-page.is-dark {
|
||||
--bg-page: #0a1222;
|
||||
--bg-card: #151f31;
|
||||
--bg-page: #1e1e1e;
|
||||
--bg-card: #252526;
|
||||
}
|
||||
|
||||
.settings-page.is-dark.is-ultra {
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
<script setup>
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
import { ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { message } from 'ant-design-vue';
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import QRious from 'qrious';
|
||||
|
||||
import { ClipboardManager } from '@/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Two flavors of this modal:
|
||||
// • type='set' shows a QR code + manual key + a 6-digit verifier
|
||||
// (used when enabling 2FA the first time);
|
||||
// • type='confirm' shows just the 6-digit verifier (used when
|
||||
// toggling 2FA off and when changing the admin user/password).
|
||||
//
|
||||
// Either way the parent supplies a `confirm(success: boolean)`
|
||||
// callback — we run it with `true` only if the entered code matches
|
||||
// the live TOTP value, otherwise `false`.
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
title: { type: String, default: '' },
|
||||
@@ -30,29 +19,10 @@ const props = defineProps({
|
||||
const emit = defineEmits(['update:open', 'confirm']);
|
||||
|
||||
const enteredCode = ref('');
|
||||
const qrCanvas = ref(null);
|
||||
const qrValue = ref('');
|
||||
|
||||
let totp = null;
|
||||
|
||||
// Byte-mode capacities (level L) for QR versions 1..40 — used to pick
|
||||
// the matrix width up front so the canvas size is an exact multiple of
|
||||
// pixelSize. Without this, QRious renders at floor(size/matrix) and
|
||||
// centers, leaving a white margin around the QR.
|
||||
const QR_L_BYTE_CAPACITY = [
|
||||
17, 32, 53, 78, 106, 134, 154, 192, 230, 271,
|
||||
321, 367, 425, 458, 520, 586, 644, 718, 792, 858,
|
||||
929, 1003, 1091, 1171, 1273, 1367, 1465, 1528, 1628, 1732,
|
||||
1840, 1952, 2068, 2188, 2303, 2431, 2563, 2699, 2809, 2953,
|
||||
];
|
||||
|
||||
function pickQrMatrixWidth(value) {
|
||||
const byteLen = new TextEncoder().encode(value).length;
|
||||
for (let i = 0; i < QR_L_BYTE_CAPACITY.length; i++) {
|
||||
if (byteLen <= QR_L_BYTE_CAPACITY[i]) return 17 + 4 * (i + 1);
|
||||
}
|
||||
return 17 + 4 * 40;
|
||||
}
|
||||
|
||||
function buildTotp() {
|
||||
totp = new OTPAuth.TOTP({
|
||||
issuer: '3x-ui',
|
||||
@@ -62,25 +32,7 @@ function buildTotp() {
|
||||
period: 30,
|
||||
secret: props.token,
|
||||
});
|
||||
}
|
||||
|
||||
async function paintQr() {
|
||||
await nextTick();
|
||||
if (!qrCanvas.value || !totp) return;
|
||||
const value = totp.toString();
|
||||
const matrixWidth = pickQrMatrixWidth(value);
|
||||
const pixelSize = Math.max(1, Math.floor(200 / matrixWidth));
|
||||
const exactSize = matrixWidth * pixelSize;
|
||||
new QRious({
|
||||
element: qrCanvas.value,
|
||||
size: exactSize,
|
||||
value,
|
||||
background: 'white',
|
||||
backgroundAlpha: 1,
|
||||
foreground: 'black',
|
||||
padding: 0,
|
||||
level: 'L',
|
||||
});
|
||||
qrValue.value = totp.toString();
|
||||
}
|
||||
|
||||
watch(() => props.open, (next) => {
|
||||
@@ -88,7 +40,6 @@ watch(() => props.open, (next) => {
|
||||
enteredCode.value = '';
|
||||
if (props.token) {
|
||||
buildTotp();
|
||||
if (props.type === 'set') paintQr();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -124,9 +75,8 @@ async function copyToken() {
|
||||
<a-divider />
|
||||
<p>{{ t('pages.settings.security.twoFactorModalFirstStep') }}</p>
|
||||
<div class="qr-wrap">
|
||||
<div class="qr-bg">
|
||||
<canvas ref="qrCanvas" class="qr-cv" @click="copyToken" />
|
||||
</div>
|
||||
<a-qrcode class="qr-code" :value="qrValue" :size="180" type="svg" :bordered="false"
|
||||
error-level="L" :title="t('copy')" @click="copyToken" />
|
||||
<span class="qr-token">{{ token }}</span>
|
||||
</div>
|
||||
<a-divider />
|
||||
@@ -154,22 +104,11 @@ async function copyToken() {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.qr-bg {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
background: #fff;
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.qr-cv {
|
||||
.qr-code {
|
||||
cursor: pointer;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
/* Drawing buffer is matrix-snapped (smaller than display size); scale
|
||||
* up crisply so the QR fills the box without blurring. */
|
||||
image-rendering: pixelated;
|
||||
image-rendering: crisp-edges;
|
||||
padding: 0 !important;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.qr-token {
|
||||
|
||||
@@ -9,14 +9,15 @@ import {
|
||||
CopyOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import QRious from 'qrious';
|
||||
|
||||
import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
|
||||
import {
|
||||
theme as themeState,
|
||||
antdThemeConfig,
|
||||
toggleTheme,
|
||||
toggleUltra,
|
||||
pauseAnimationsUntilLeave,
|
||||
} from '@/composables/useTheme.js';
|
||||
import ThemeSwitchLogin from '@/components/ThemeSwitchLogin.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -38,6 +39,8 @@ const lastOnlineMs = Number(subData.lastOnline || 0);
|
||||
const subUrl = subData.subUrl || '';
|
||||
const subJsonUrl = subData.subJsonUrl || '';
|
||||
const subClashUrl = subData.subClashUrl || '';
|
||||
const subTitle = subData.subTitle || '';
|
||||
const subSupportUrl = subData.subSupportUrl || '';
|
||||
const links = Array.isArray(subData.links) ? subData.links : [];
|
||||
// Panel's "Calendar Type" setting; controls whether expiry / lastOnline
|
||||
// render in Gregorian or Jalali on this standalone subscription page.
|
||||
@@ -71,32 +74,23 @@ function onLangChange(next) {
|
||||
LanguageManager.setLanguage(next);
|
||||
}
|
||||
|
||||
// QR code rendering ===========================================
|
||||
// Each ref points at a canvas element we paint after mount; QRious
|
||||
// sizes itself from the element's `size` attribute.
|
||||
const subQr = ref(null);
|
||||
const subJsonQr = ref(null);
|
||||
const subClashQr = ref(null);
|
||||
|
||||
function paintQr(canvas, value) {
|
||||
if (!canvas || !value) return;
|
||||
new QRious({
|
||||
element: canvas,
|
||||
size: 220,
|
||||
value,
|
||||
background: 'white',
|
||||
backgroundAlpha: 1,
|
||||
foreground: 'black',
|
||||
padding: 4,
|
||||
level: 'M',
|
||||
});
|
||||
/* Same Light -> Dark -> Ultra Dark -> Light cycle the panel sidebar
|
||||
* uses, so the standalone subscription page offers a one-click theme
|
||||
* toggle without the popover ceremony. */
|
||||
function cycleTheme() {
|
||||
pauseAnimationsUntilLeave('sub-theme-cycle');
|
||||
if (!themeState.isDark) {
|
||||
toggleTheme();
|
||||
if (themeState.isUltra) toggleUltra();
|
||||
} else if (!themeState.isUltra) {
|
||||
toggleUltra();
|
||||
} else {
|
||||
toggleUltra();
|
||||
toggleTheme();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
paintQr(subQr.value, subUrl);
|
||||
paintQr(subJsonQr.value, subJsonUrl);
|
||||
paintQr(subClashQr.value, subClashUrl);
|
||||
});
|
||||
const QR_SIZE = 240;
|
||||
|
||||
// Actions =====================================================
|
||||
async function copy(value) {
|
||||
@@ -128,7 +122,14 @@ function linkName(link, idx) {
|
||||
|
||||
// iOS deep links — taken verbatim from the legacy subpage. Each
|
||||
// client expects the sub URL in a slightly different param name.
|
||||
const shadowrocketUrl = computed(() => `sub://${btoa(subUrl)}`);
|
||||
const shadowrocketUrl = computed(() => {
|
||||
if (!subUrl) return '';
|
||||
const separator = subUrl.includes('?') ? '&' : '?';
|
||||
const rawUrl = subUrl + separator + 'flag=shadowrocket';
|
||||
const base64Url = encodeURIComponent(btoa(rawUrl));
|
||||
const remark = encodeURIComponent(subTitle || sId || 'Subscription');
|
||||
return `shadowrocket://add/sub/${base64Url}?remark=${remark}`;
|
||||
});
|
||||
const v2boxUrl = computed(() => `v2box://install-sub?url=${encodeURIComponent(subUrl)}&name=${encodeURIComponent(sId)}`);
|
||||
const streisandUrl = computed(() => `streisand://import/${encodeURIComponent(subUrl)}`);
|
||||
const v2raytunUrl = computed(() => subUrl);
|
||||
@@ -157,26 +158,45 @@ const themeClass = computed(() => ({
|
||||
</a-space>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-popover :title="t('menu.settings')" placement="bottomRight" trigger="click">
|
||||
<template #content>
|
||||
<a-space direction="vertical" :size="10" class="settings-popover">
|
||||
<ThemeSwitchLogin />
|
||||
<span>{{ t('pages.settings.language') }}</span>
|
||||
<a-select v-model:value="lang" class="lang-select" @change="onLangChange">
|
||||
<a-select-option v-for="l in LanguageManager.supportedLanguages" :key="l.value"
|
||||
:value="l.value">
|
||||
<span :aria-label="l.name">{{ l.icon }}</span>
|
||||
<span>{{ l.name }}</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-button shape="circle">
|
||||
<template #icon>
|
||||
<SettingOutlined />
|
||||
<a-space :size="8" align="center">
|
||||
<button type="button" class="theme-cycle" :aria-label="t('menu.theme')" :title="t('menu.theme')"
|
||||
@click="cycleTheme">
|
||||
<svg v-if="!themeState.isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path
|
||||
d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||
</svg>
|
||||
<svg v-else-if="!themeState.isUltra" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
<path fill="none" d="M19 3l0.7 1.4 1.4 0.7-1.4 0.7L19 7.2l-0.7-1.4-1.4-0.7 1.4-0.7z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<a-popover :title="t('pages.settings.language')" placement="bottomRight" trigger="click">
|
||||
<template #content>
|
||||
<a-space direction="vertical" :size="10" class="settings-popover">
|
||||
<a-select v-model:value="lang" class="lang-select" @change="onLangChange">
|
||||
<a-select-option v-for="l in LanguageManager.supportedLanguages" :key="l.value"
|
||||
:value="l.value">
|
||||
<span :aria-label="l.name">{{ l.icon }}</span>
|
||||
<span>{{ l.name }}</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-button>
|
||||
</a-popover>
|
||||
<a-button shape="circle">
|
||||
<template #icon>
|
||||
<SettingOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-popover>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<!-- ============== QR codes ============== -->
|
||||
@@ -184,7 +204,8 @@ const themeClass = computed(() => ({
|
||||
<a-col :xs="24" :sm="subJsonUrl || subClashUrl ? 12 : 24" class="qr-col">
|
||||
<div class="qr-box">
|
||||
<a-tag color="purple" class="qr-tag">{{ t('pages.settings.subSettings') }}</a-tag>
|
||||
<canvas ref="subQr" class="qr-canvas" :title="t('copy')" @click="copy(subUrl)" />
|
||||
<a-qrcode class="qr-code" :value="subUrl" :size="QR_SIZE" type="svg" :bordered="false"
|
||||
:title="t('copy')" @click="copy(subUrl)" />
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col v-if="subJsonUrl" :xs="24" :sm="12" class="qr-col">
|
||||
@@ -192,13 +213,15 @@ const themeClass = computed(() => ({
|
||||
<a-tag color="purple" class="qr-tag">
|
||||
{{ t('pages.settings.subSettings') }} JSON
|
||||
</a-tag>
|
||||
<canvas ref="subJsonQr" class="qr-canvas" :title="t('copy')" @click="copy(subJsonUrl)" />
|
||||
<a-qrcode class="qr-code" :value="subJsonUrl" :size="QR_SIZE" type="svg" :bordered="false"
|
||||
:title="t('copy')" @click="copy(subJsonUrl)" />
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col v-if="subClashUrl" :xs="24" :sm="12" class="qr-col">
|
||||
<div class="qr-box">
|
||||
<a-tag color="purple" class="qr-tag">Clash / Mihomo</a-tag>
|
||||
<canvas ref="subClashQr" class="qr-canvas" :title="t('copy')" @click="copy(subClashUrl)" />
|
||||
<a-qrcode class="qr-code" :value="subClashUrl" :size="QR_SIZE" type="svg" :bordered="false"
|
||||
:title="t('copy')" @click="copy(subClashUrl)" />
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
@@ -299,8 +322,8 @@ const themeClass = computed(() => ({
|
||||
}
|
||||
|
||||
.subscription-page.is-dark {
|
||||
--bg-page: #0a1222;
|
||||
--bg-card: #151f31;
|
||||
--bg-page: #1e1e1e;
|
||||
--bg-card: #252526;
|
||||
}
|
||||
|
||||
.subscription-page.is-dark.is-ultra {
|
||||
@@ -336,7 +359,7 @@ const themeClass = computed(() => ({
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 220px;
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.qr-tag {
|
||||
@@ -345,8 +368,9 @@ const themeClass = computed(() => ({
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.qr-canvas {
|
||||
.qr-code {
|
||||
cursor: pointer;
|
||||
padding: 0 !important;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
@@ -463,6 +487,45 @@ const themeClass = computed(() => ({
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.theme-cycle {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
background: var(--bg-card);
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: background-color 0.2s, transform 0.15s, color 0.2s;
|
||||
}
|
||||
|
||||
.theme-cycle:hover,
|
||||
.theme-cycle:focus-visible {
|
||||
background-color: rgba(64, 150, 255, 0.1);
|
||||
color: #4096ff;
|
||||
transform: scale(1.05);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.theme-cycle svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.is-dark .theme-cycle {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.is-dark .theme-cycle:hover,
|
||||
.is-dark .theme-cycle:focus-visible {
|
||||
background-color: rgba(64, 150, 255, 0.1);
|
||||
color: #4096ff;
|
||||
}
|
||||
|
||||
.lang-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
PlusOutlined,
|
||||
@@ -30,6 +30,30 @@ const STRATEGY_LABELS = {
|
||||
leastPing: 'Least ping',
|
||||
};
|
||||
|
||||
// Observatory defaults — values that the legacy panel seeded when a
|
||||
// leastPing balancer first appeared. ProbeURL / interval follow Xray's
|
||||
// own docs (https://xtls.github.io/config/observatory.html).
|
||||
const DEFAULT_OBSERVATORY = Object.freeze({
|
||||
subjectSelector: [],
|
||||
probeURL: 'https://www.google.com/generate_204',
|
||||
probeInterval: '1m',
|
||||
enableConcurrency: true,
|
||||
});
|
||||
|
||||
// BurstObservatory defaults — seeded when a leastLoad balancer is
|
||||
// configured. Hicloud's generate_204 is the same connectivity probe
|
||||
// the legacy panel used (https://xtls.github.io/config/burstobservatory.html).
|
||||
const DEFAULT_BURST_OBSERVATORY = Object.freeze({
|
||||
subjectSelector: [],
|
||||
pingConfig: {
|
||||
destination: 'https://www.google.com/generate_204',
|
||||
interval: '1m',
|
||||
connectivity: 'http://connectivitycheck.platform.hicloud.com/generate_204',
|
||||
timeout: '5s',
|
||||
sampling: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const rows = computed(() => {
|
||||
const list = props.templateSettings?.routing?.balancers || [];
|
||||
return list.map((b, idx) => ({
|
||||
@@ -83,6 +107,41 @@ function ensureBalancersArray() {
|
||||
return props.templateSettings.routing.balancers;
|
||||
}
|
||||
|
||||
// Keep observatory / burstObservatory in sync with the configured
|
||||
// balancers. leastPing balancers feed Observatory's subjectSelector;
|
||||
// leastLoad balancers feed BurstObservatory's. When the matching
|
||||
// strategy disappears we drop the observatory entirely so the rendered
|
||||
// xray config stays minimal.
|
||||
function collectSelectors(list) {
|
||||
const out = new Set();
|
||||
list.forEach((b) => (b.selector || []).forEach((s) => s && out.add(s)));
|
||||
return [...out];
|
||||
}
|
||||
|
||||
function syncObservatories() {
|
||||
const t = props.templateSettings;
|
||||
if (!t) return;
|
||||
const balancers = t.routing?.balancers || [];
|
||||
|
||||
const leastPings = balancers.filter((b) => b.strategy?.type === 'leastPing');
|
||||
if (leastPings.length > 0) {
|
||||
if (!t.observatory) t.observatory = JSON.parse(JSON.stringify(DEFAULT_OBSERVATORY));
|
||||
t.observatory.subjectSelector = collectSelectors(leastPings);
|
||||
} else {
|
||||
delete t.observatory;
|
||||
}
|
||||
|
||||
const leastLoads = balancers.filter((b) => b.strategy?.type === 'leastLoad');
|
||||
if (leastLoads.length > 0) {
|
||||
if (!t.burstObservatory) {
|
||||
t.burstObservatory = JSON.parse(JSON.stringify(DEFAULT_BURST_OBSERVATORY));
|
||||
}
|
||||
t.burstObservatory.subjectSelector = collectSelectors(leastLoads);
|
||||
} else {
|
||||
delete t.burstObservatory;
|
||||
}
|
||||
}
|
||||
|
||||
function buildWireBalancer(form) {
|
||||
const out = {
|
||||
tag: form.tag,
|
||||
@@ -115,6 +174,7 @@ function onConfirm(form) {
|
||||
}
|
||||
}
|
||||
}
|
||||
syncObservatories();
|
||||
modalOpen.value = false;
|
||||
}
|
||||
|
||||
@@ -128,7 +188,10 @@ function confirmDelete(idx) {
|
||||
// 4 leaves the modal open if onOk returns a truthy non-thenable
|
||||
// (it expects a Promise to await), and splice() returns the array
|
||||
// of removed items.
|
||||
onOk: () => { props.templateSettings.routing.balancers.splice(idx, 1); },
|
||||
onOk: () => {
|
||||
props.templateSettings.routing.balancers.splice(idx, 1);
|
||||
syncObservatories();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -139,6 +202,49 @@ const columns = computed(() => [
|
||||
{ title: 'Selector', key: 'selector', align: 'center' },
|
||||
{ title: 'Fallback', dataIndex: 'fallbackTag', key: 'fallbackTag', align: 'center', width: 160 },
|
||||
]);
|
||||
|
||||
// === Observatory / BurstObservatory inline editor ====================
|
||||
// The legacy panel surfaced both top-level observatory blocks here as a
|
||||
// raw JSON editor so admins could tune probeURL / interval / sampling
|
||||
// without having to drop into the full xray template tab. We keep that
|
||||
// affordance but only render it when the matching observatory exists —
|
||||
// which is itself driven by syncObservatories() above.
|
||||
const hasObservatory = computed(() => !!props.templateSettings?.observatory);
|
||||
const hasBurstObservatory = computed(() => !!props.templateSettings?.burstObservatory);
|
||||
const showObsEditor = computed(() => hasObservatory.value || hasBurstObservatory.value);
|
||||
|
||||
const obsView = ref('observatory');
|
||||
|
||||
// Keep the radio selection valid as observatories appear/disappear —
|
||||
// e.g. deleting the last leastPing balancer should flip the editor to
|
||||
// the burstObservatory pane instead of leaving it pointing at the
|
||||
// (now-removed) observatory key.
|
||||
watch(showObsEditor, () => {
|
||||
if (obsView.value === 'observatory' && !hasObservatory.value && hasBurstObservatory.value) {
|
||||
obsView.value = 'burstObservatory';
|
||||
} else if (obsView.value === 'burstObservatory' && !hasBurstObservatory.value && hasObservatory.value) {
|
||||
obsView.value = 'observatory';
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
const obsText = computed({
|
||||
get: () => {
|
||||
const t = props.templateSettings;
|
||||
if (!t) return '';
|
||||
const src = obsView.value === 'observatory' ? t.observatory : t.burstObservatory;
|
||||
return src ? JSON.stringify(src, null, 2) : '';
|
||||
},
|
||||
set: (next) => {
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(next); } catch (_e) { return; }
|
||||
if (!props.templateSettings) return;
|
||||
if (obsView.value === 'observatory') {
|
||||
props.templateSettings.observatory = parsed;
|
||||
} else {
|
||||
props.templateSettings.burstObservatory = parsed;
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -192,6 +298,16 @@ const columns = computed(() => [
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<template v-if="showObsEditor">
|
||||
<a-divider :style="{ margin: '8px 0' }" />
|
||||
<a-radio-group v-model:value="obsView" button-style="solid" size="small">
|
||||
<a-radio-button v-if="hasObservatory" value="observatory">Observatory</a-radio-button>
|
||||
<a-radio-button v-if="hasBurstObservatory" value="burstObservatory">Burst Observatory</a-radio-button>
|
||||
</a-radio-group>
|
||||
<a-textarea v-model:value="obsText" :auto-size="{ minRows: 8, maxRows: 24 }" spellcheck="false"
|
||||
class="json-editor" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<BalancerFormModal v-model:open="modalOpen" :balancer="editingBalancer" :outbound-tags="outboundTags"
|
||||
@@ -213,4 +329,10 @@ const columns = computed(() => [
|
||||
.danger {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.json-editor {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -22,6 +22,14 @@ const props = defineProps({
|
||||
|
||||
const STRATEGIES = ['UseSystem', 'UseIP', 'UseIPv4', 'UseIPv6'];
|
||||
|
||||
const dnsFieldOmit = Object.freeze(Object.create(null));
|
||||
|
||||
function dnsValueEmptyForOmit(v) {
|
||||
if (v === undefined || v === null) return true;
|
||||
if (typeof v === 'string') return v.trim() === '';
|
||||
return false;
|
||||
}
|
||||
|
||||
const enableDNS = computed({
|
||||
get: () => !!props.templateSettings?.dns,
|
||||
set: (next) => {
|
||||
@@ -29,7 +37,6 @@ const enableDNS = computed({
|
||||
if (next) {
|
||||
props.templateSettings.dns = {
|
||||
tag: 'dns_inbound',
|
||||
clientIp: '',
|
||||
queryStrategy: 'UseIP',
|
||||
disableCache: false,
|
||||
disableFallback: false,
|
||||
@@ -50,16 +57,30 @@ const enableDNS = computed({
|
||||
});
|
||||
|
||||
function dnsField(field, fallback) {
|
||||
const omitWhenUnset = fallback === dnsFieldOmit;
|
||||
return computed({
|
||||
get: () => props.templateSettings?.dns?.[field] ?? fallback,
|
||||
get: () => {
|
||||
const raw = props.templateSettings?.dns?.[field];
|
||||
if (fallback === dnsFieldOmit) return raw ?? '';
|
||||
return raw ?? fallback;
|
||||
},
|
||||
set: (v) => {
|
||||
if (props.templateSettings?.dns) props.templateSettings.dns[field] = v;
|
||||
if (!props.templateSettings?.dns) return;
|
||||
if (omitWhenUnset) {
|
||||
if (dnsValueEmptyForOmit(v)) {
|
||||
if (field in props.templateSettings.dns) delete props.templateSettings.dns[field];
|
||||
} else {
|
||||
props.templateSettings.dns[field] = v;
|
||||
}
|
||||
} else {
|
||||
props.templateSettings.dns[field] = v;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const dnsTag = dnsField('tag', 'dns_inbound');
|
||||
const dnsClientIp = dnsField('clientIp', '');
|
||||
const dnsClientIp = dnsField('clientIp', dnsFieldOmit);
|
||||
const dnsStrategy = dnsField('queryStrategy', 'UseIP');
|
||||
const dnsDisableCache = dnsField('disableCache', false);
|
||||
const dnsDisableFallback = dnsField('disableFallback', false);
|
||||
|
||||
@@ -222,6 +222,12 @@ function resetOutbound() {
|
||||
}
|
||||
|
||||
function close() { emit('update:open', false); }
|
||||
|
||||
function loadColor(load) {
|
||||
if (load < 30) return 'green';
|
||||
if (load < 70) return 'orange';
|
||||
return 'red';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -299,9 +305,13 @@ function close() { emit('update:open', false); }
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="filteredServers.length > 0" label="Server">
|
||||
<a-select v-model:value="serverId">
|
||||
<a-select-option v-for="s in filteredServers" :key="s.id" :value="s.id">
|
||||
{{ s.cityName }} - {{ s.name }} (load: {{ s.load }}%)
|
||||
<a-select v-model:value="serverId" show-search option-filter-prop="label">
|
||||
<a-select-option v-for="s in filteredServers" :key="s.id" :value="s.id"
|
||||
:label="`${s.cityName} ${s.name} ${s.hostname}`">
|
||||
<span class="server-row">
|
||||
<span class="server-name">{{ s.cityName }} - {{ s.name }}</span>
|
||||
<a-tag :color="loadColor(s.load)" class="server-load-tag">{{ s.load }}%</a-tag>
|
||||
</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
@@ -376,4 +386,22 @@ function close() { emit('update:open', false); }
|
||||
.ml-8 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.server-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.server-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.server-load-tag {
|
||||
margin-right: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -34,7 +34,6 @@ const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
outbound: { type: Object, default: null },
|
||||
existingTags: { type: Array, default: () => [] },
|
||||
inboundTags: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open', 'confirm']);
|
||||
@@ -318,10 +317,8 @@ function regenerateWgKeys() {
|
||||
<!-- ============== Loopback ============== -->
|
||||
<template v-if="isLoopback">
|
||||
<a-form-item label="Inbound tag">
|
||||
<a-auto-complete v-model:value="outbound.settings.inboundTag"
|
||||
:options="inboundTags.map((tag) => ({ value: tag }))"
|
||||
:filter-option="(input, option) => option.value.toLowerCase().includes(input.toLowerCase())"
|
||||
placeholder="tag of an existing inbound to re-route into" />
|
||||
<a-input v-model:value="outbound.settings.inboundTag"
|
||||
placeholder="inbound tag using in routing rules" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
LoadingOutlined,
|
||||
ArrowUpOutlined,
|
||||
ArrowDownOutlined,
|
||||
PlayCircleOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { Modal } from 'ant-design-vue';
|
||||
|
||||
@@ -25,30 +26,18 @@ import OutboundFormModal from './OutboundFormModal.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Outbounds tab — list + actions over templateSettings.outbounds.
|
||||
// Mirrors the legacy outbound table layout (identity / address /
|
||||
// traffic / test result / test button) plus the row action menu
|
||||
// (set first / edit / reset traffic / delete). Mobile collapses to
|
||||
// a card list.
|
||||
|
||||
const props = defineProps({
|
||||
templateSettings: { type: Object, default: null },
|
||||
outboundsTraffic: { type: Array, default: () => [] },
|
||||
outboundTestStates: { type: Object, default: () => ({}) },
|
||||
testingAll: { type: Boolean, default: false },
|
||||
inboundTags: { type: Array, default: () => [] },
|
||||
isMobile: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const inboundTagOptions = computed(() => {
|
||||
const out = new Set();
|
||||
for (const ib of props.templateSettings?.inbounds || []) {
|
||||
if (ib.tag) out.add(ib.tag);
|
||||
}
|
||||
for (const t of props.inboundTags || []) out.add(t);
|
||||
return [...out];
|
||||
});
|
||||
const emit = defineEmits(['reset-traffic', 'test', 'test-all', 'show-warp', 'show-nord', 'delete']);
|
||||
|
||||
const emit = defineEmits(['reset-traffic', 'test', 'show-warp', 'show-nord', 'delete']);
|
||||
const testMode = ref('tcp');
|
||||
|
||||
// === Modal state ====================================================
|
||||
const modalOpen = ref(false);
|
||||
@@ -141,10 +130,13 @@ function outboundAddresses(o) {
|
||||
return serverObj ? serverObj.map((s) => `${s.address}:${s.port}`) : [];
|
||||
}
|
||||
|
||||
function isUntestable(o) {
|
||||
return o.protocol === Protocols.Blackhole
|
||||
function isUntestable(o, mode = testMode.value) {
|
||||
if (!o) return true;
|
||||
if (o.protocol === Protocols.Blackhole
|
||||
|| o.protocol === Protocols.Loopback
|
||||
|| o.tag === 'blocked';
|
||||
|| o.tag === 'blocked') return true;
|
||||
if (mode === 'tcp' && (o.protocol === Protocols.Freedom || o.protocol === Protocols.DNS)) return true;
|
||||
return false;
|
||||
}
|
||||
function isTesting(idx) {
|
||||
return !!props.outboundTestStates?.[idx]?.testing;
|
||||
@@ -156,6 +148,12 @@ function showSecurity(security) {
|
||||
return security === 'tls' || security === 'reality';
|
||||
}
|
||||
|
||||
function hasBreakdown(r) {
|
||||
if (!r) return false;
|
||||
if (r.endpoints?.length) return true;
|
||||
return !!(r.ttfbMs || r.tlsMs || r.connectMs || r.dnsMs || r.statusCode || r.error);
|
||||
}
|
||||
|
||||
// === Columns ========================================================
|
||||
// Computed so titles re-render after a locale swap.
|
||||
const columns = computed(() => [
|
||||
@@ -163,7 +161,7 @@ const columns = computed(() => [
|
||||
{ title: 'Tag', key: 'identity', align: 'left', width: 220 },
|
||||
{ title: t('pages.inbounds.address'), key: 'address', align: 'left', width: 230 },
|
||||
{ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'left', width: 200 },
|
||||
{ title: t('check'), key: 'testResult', align: 'left', width: 140 },
|
||||
{ title: t('pages.xray.latency') !== 'pages.xray.latency' ? t('pages.xray.latency') : 'Latency', key: 'testResult', align: 'left', width: 140 },
|
||||
{ title: t('check'), key: 'test', align: 'center', width: 80 },
|
||||
]);
|
||||
|
||||
@@ -177,8 +175,8 @@ const rows = computed(() => {
|
||||
<a-space direction="vertical" size="middle" :style="{ width: '100%' }">
|
||||
<!-- Toolbar -->
|
||||
<a-row :gutter="[12, 12]" align="middle" justify="space-between">
|
||||
<a-col :xs="24" :sm="14">
|
||||
<a-space size="small">
|
||||
<a-col :xs="24" :sm="12">
|
||||
<a-space size="small" wrap>
|
||||
<a-button type="primary" @click="openAdd">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
@@ -199,15 +197,29 @@ const rows = computed(() => {
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="10" class="toolbar-right">
|
||||
<a-popconfirm placement="topRight" :ok-text="t('reset')" :cancel-text="t('cancel')"
|
||||
:title="t('pages.inbounds.resetAllTrafficContent')" @confirm="emit('reset-traffic', '-alltags-')">
|
||||
<a-button>
|
||||
<a-col :xs="24" :sm="12" class="toolbar-right">
|
||||
<a-space size="small" wrap>
|
||||
<a-tooltip :title="t('pages.xray.testModeHint') !== 'pages.xray.testModeHint' ? t('pages.xray.testModeHint') : 'TCP: fast dial-only probe. HTTP: full request through xray.'">
|
||||
<a-radio-group v-model:value="testMode" size="small" button-style="solid">
|
||||
<a-radio-button value="tcp">TCP</a-radio-button>
|
||||
<a-radio-button value="http">HTTP</a-radio-button>
|
||||
</a-radio-group>
|
||||
</a-tooltip>
|
||||
<a-button type="primary" :loading="testingAll" @click="emit('test-all', testMode)">
|
||||
<template #icon>
|
||||
<RetweetOutlined />
|
||||
<PlayCircleOutlined />
|
||||
</template>
|
||||
<span v-if="!isMobile">{{ t('pages.xray.testAll') !== 'pages.xray.testAll' ? t('pages.xray.testAll') : 'Test all' }}</span>
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
<a-popconfirm placement="topRight" :ok-text="t('reset')" :cancel-text="t('cancel')"
|
||||
:title="t('pages.inbounds.resetAllTrafficContent')" @confirm="emit('reset-traffic', '-alltags-')">
|
||||
<a-button>
|
||||
<template #icon>
|
||||
<RetweetOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
@@ -262,15 +274,39 @@ const rows = computed(() => {
|
||||
<span class="traffic-sep" />
|
||||
<span class="traffic-down">↓ {{ SizeFormatter.sizeFormat(trafficFor(record).down) }}</span>
|
||||
<span class="card-test">
|
||||
<span v-if="testResult(index)" :class="testResult(index).success ? 'pill-ok' : 'pill-fail'">
|
||||
<CheckCircleFilled v-if="testResult(index).success" />
|
||||
<CloseCircleFilled v-else />
|
||||
<span v-if="testResult(index).success">{{ testResult(index).delay }} ms</span>
|
||||
<span v-else>failed</span>
|
||||
</span>
|
||||
<a-popover v-if="testResult(index)" placement="topRight"
|
||||
:overlay-class-name="'outbound-test-popover'">
|
||||
<template #content>
|
||||
<div class="timing-breakdown">
|
||||
<div class="td-head" :class="testResult(index).success ? 'ok' : 'fail'">
|
||||
<span v-if="testResult(index).success">{{ testResult(index).delay }} ms</span>
|
||||
<span v-else>{{ testResult(index).error || 'failed' }}</span>
|
||||
<span v-if="testResult(index).mode" class="mode-badge">{{ testResult(index).mode.toUpperCase() }}</span>
|
||||
</div>
|
||||
<template v-if="hasBreakdown(testResult(index))">
|
||||
<div v-if="testResult(index).ttfbMs">TTFB: {{ testResult(index).ttfbMs }} ms</div>
|
||||
<div v-if="testResult(index).tlsMs">TLS: {{ testResult(index).tlsMs }} ms</div>
|
||||
<div v-if="testResult(index).connectMs">Connect: {{ testResult(index).connectMs }} ms</div>
|
||||
<div v-if="testResult(index).dnsMs">DNS: {{ testResult(index).dnsMs }} ms</div>
|
||||
<div v-if="testResult(index).statusCode">HTTP {{ testResult(index).statusCode }}</div>
|
||||
<div v-for="ep in testResult(index).endpoints || []" :key="ep.address" class="endpoint-row">
|
||||
<span :class="ep.success ? 'dot-ok' : 'dot-fail'">●</span>
|
||||
<span class="ep-addr">{{ ep.address }}</span>
|
||||
<span class="ep-meta">{{ ep.success ? `${ep.delay} ms` : (ep.error || 'failed') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<span :class="testResult(index).success ? 'pill-ok' : 'pill-fail'">
|
||||
<CheckCircleFilled v-if="testResult(index).success" />
|
||||
<CloseCircleFilled v-else />
|
||||
<span v-if="testResult(index).success">{{ testResult(index).delay }} ms</span>
|
||||
<span v-else>failed</span>
|
||||
</span>
|
||||
</a-popover>
|
||||
<LoadingOutlined v-else-if="isTesting(index)" />
|
||||
<a-button type="primary" shape="circle" size="small" :loading="isTesting(index)"
|
||||
:disabled="isUntestable(record) || isTesting(index)" @click="emit('test', index)">
|
||||
:disabled="isUntestable(record, testMode) || isTesting(index)" @click="emit('test', index, testMode)">
|
||||
<template #icon>
|
||||
<ThunderboltOutlined />
|
||||
</template>
|
||||
@@ -350,22 +386,44 @@ const rows = computed(() => {
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'testResult'">
|
||||
<span v-if="testResult(index)" :class="testResult(index).success ? 'pill-ok' : 'pill-fail'">
|
||||
<CheckCircleFilled v-if="testResult(index).success" />
|
||||
<CloseCircleFilled v-else />
|
||||
<span v-if="testResult(index).success">{{ testResult(index).delay }} ms</span>
|
||||
<a-tooltip v-else :title="testResult(index).error">
|
||||
<span>failed</span>
|
||||
</a-tooltip>
|
||||
</span>
|
||||
<a-popover v-if="testResult(index)" placement="topLeft"
|
||||
:overlay-class-name="'outbound-test-popover'">
|
||||
<template #content>
|
||||
<div class="timing-breakdown">
|
||||
<div class="td-head" :class="testResult(index).success ? 'ok' : 'fail'">
|
||||
<span v-if="testResult(index).success">{{ testResult(index).delay }} ms</span>
|
||||
<span v-else>{{ testResult(index).error || 'failed' }}</span>
|
||||
<span v-if="testResult(index).mode" class="mode-badge">{{ testResult(index).mode.toUpperCase() }}</span>
|
||||
</div>
|
||||
<template v-if="hasBreakdown(testResult(index))">
|
||||
<div v-if="testResult(index).ttfbMs">TTFB: {{ testResult(index).ttfbMs }} ms</div>
|
||||
<div v-if="testResult(index).tlsMs">TLS: {{ testResult(index).tlsMs }} ms</div>
|
||||
<div v-if="testResult(index).connectMs">Connect: {{ testResult(index).connectMs }} ms</div>
|
||||
<div v-if="testResult(index).dnsMs">DNS: {{ testResult(index).dnsMs }} ms</div>
|
||||
<div v-if="testResult(index).statusCode">HTTP {{ testResult(index).statusCode }}</div>
|
||||
<div v-for="ep in testResult(index).endpoints || []" :key="ep.address" class="endpoint-row">
|
||||
<span :class="ep.success ? 'dot-ok' : 'dot-fail'">●</span>
|
||||
<span class="ep-addr">{{ ep.address }}</span>
|
||||
<span class="ep-meta">{{ ep.success ? `${ep.delay} ms` : (ep.error || 'failed') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<span :class="testResult(index).success ? 'pill-ok' : 'pill-fail'">
|
||||
<CheckCircleFilled v-if="testResult(index).success" />
|
||||
<CloseCircleFilled v-else />
|
||||
<span v-if="testResult(index).success">{{ testResult(index).delay }} ms</span>
|
||||
<span v-else>failed</span>
|
||||
</span>
|
||||
</a-popover>
|
||||
<LoadingOutlined v-else-if="isTesting(index)" />
|
||||
<span v-else class="empty">—</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'test'">
|
||||
<a-tooltip :title="t('check')">
|
||||
<a-tooltip :title="`${t('check')} (${testMode.toUpperCase()})`">
|
||||
<a-button type="primary" shape="circle" :loading="isTesting(index)"
|
||||
:disabled="isUntestable(record) || isTesting(index)" @click="emit('test', index)">
|
||||
:disabled="isUntestable(record, testMode) || isTesting(index)" @click="emit('test', index, testMode)">
|
||||
<template #icon>
|
||||
<ThunderboltOutlined />
|
||||
</template>
|
||||
@@ -376,7 +434,7 @@ const rows = computed(() => {
|
||||
</a-table>
|
||||
|
||||
<OutboundFormModal v-model:open="modalOpen" :outbound="editingOutbound" :existing-tags="existingTags"
|
||||
:inbound-tags="inboundTagOptions" @confirm="onConfirm" />
|
||||
@confirm="onConfirm" />
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
@@ -532,3 +590,66 @@ const rows = computed(() => {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.outbound-test-popover .timing-breakdown {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
min-width: 180px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.outbound-test-popover .td-head {
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.outbound-test-popover .td-head.ok {
|
||||
color: #008771;
|
||||
}
|
||||
|
||||
.outbound-test-popover .td-head.fail {
|
||||
color: #e04141;
|
||||
}
|
||||
|
||||
.outbound-test-popover .mode-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 0 6px;
|
||||
border-radius: 8px;
|
||||
background: rgba(22, 119, 255, 0.12);
|
||||
color: #1677ff;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.outbound-test-popover .endpoint-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.outbound-test-popover .endpoint-row .ep-addr {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.outbound-test-popover .endpoint-row .ep-meta {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.outbound-test-popover .dot-ok {
|
||||
color: #008771;
|
||||
}
|
||||
|
||||
.outbound-test-popover .dot-fail {
|
||||
color: #e04141;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -65,18 +65,32 @@ const editingRule = ref(null);
|
||||
const editingIndex = ref(null);
|
||||
|
||||
const inboundTagOptions = computed(() => {
|
||||
const out = new Set();
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
|
||||
function pushUnique(tag) {
|
||||
if (!tag) return;
|
||||
if (seen.has(tag)) return;
|
||||
seen.add(tag);
|
||||
out.push(tag);
|
||||
}
|
||||
|
||||
for (const ib of props.templateSettings?.inbounds || []) {
|
||||
if (ib.tag) out.add(ib.tag);
|
||||
pushUnique(ib.tag);
|
||||
}
|
||||
for (const t of props.inboundTags || []) {
|
||||
pushUnique(t);
|
||||
}
|
||||
for (const t of props.inboundTags || []) out.add(t);
|
||||
for (const ob of props.templateSettings?.outbounds || []) {
|
||||
const rt = ob?.reverse?.tag || ob?.settings?.reverse?.tag;
|
||||
if (rt) out.add(rt);
|
||||
const rt = ob?.reverse?.tag || ob?.settings?.reverse?.tag || ob?.settings?.inboundTag;
|
||||
pushUnique(rt);
|
||||
}
|
||||
// dnsTag if DNS is configured.
|
||||
const dt = props.templateSettings?.dns?.tag;
|
||||
if (dt) out.add(dt);
|
||||
pushUnique(props.templateSettings?.dns?.tag);
|
||||
|
||||
for (const s of props.templateSettings?.dns?.servers || []) {
|
||||
if (typeof s === 'object' && s?.tag) pushUnique(s.tag);
|
||||
}
|
||||
|
||||
return [...out];
|
||||
});
|
||||
|
||||
|
||||
@@ -40,21 +40,26 @@ const {
|
||||
restartResult,
|
||||
outboundsTraffic,
|
||||
outboundTestStates,
|
||||
testingAll,
|
||||
fetchAll,
|
||||
resetOutboundsTraffic,
|
||||
testOutbound,
|
||||
testAllOutbounds,
|
||||
saveAll,
|
||||
resetToDefault,
|
||||
restartXray,
|
||||
applyOutboundsEvent,
|
||||
} = useXraySetting();
|
||||
|
||||
// Live outbounds traffic — pushed by xray_traffic_job every ~10s.
|
||||
useWebSocket({ outbounds: applyOutboundsEvent });
|
||||
|
||||
async function onTestOutbound(idx) {
|
||||
async function onTestOutbound(idx, mode = 'tcp') {
|
||||
const outbound = templateSettings.value?.outbounds?.[idx];
|
||||
if (outbound) await testOutbound(idx, outbound);
|
||||
if (outbound) await testOutbound(idx, outbound, mode);
|
||||
}
|
||||
|
||||
async function onTestAllOutbounds(mode = 'tcp') {
|
||||
await testAllOutbounds(mode);
|
||||
}
|
||||
|
||||
function onDeleteOutbound(idx) {
|
||||
@@ -186,7 +191,7 @@ function onRemoveRoutingRules({ prefix }) {
|
||||
void message;
|
||||
const { isMobile } = useMediaQuery();
|
||||
|
||||
const basePath = window.__X_UI_BASE_PATH__ || '';
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
const requestUri = window.location.pathname;
|
||||
|
||||
// See SettingsPage scrollTarget — wrap so `document` is in scope.
|
||||
@@ -278,8 +283,10 @@ function confirmRestart() {
|
||||
<UploadOutlined /> <span>{{ t('pages.xray.Outbounds') }}</span>
|
||||
</template>
|
||||
<OutboundsTab :template-settings="templateSettings" :outbounds-traffic="outboundsTraffic"
|
||||
:outbound-test-states="outboundTestStates" :inbound-tags="inboundTags" :is-mobile="isMobile"
|
||||
@reset-traffic="resetOutboundsTraffic" @test="onTestOutbound" @delete="onDeleteOutbound"
|
||||
:outbound-test-states="outboundTestStates" :testing-all="testingAll"
|
||||
:inbound-tags="inboundTags" :is-mobile="isMobile"
|
||||
@reset-traffic="resetOutboundsTraffic" @test="onTestOutbound"
|
||||
@test-all="onTestAllOutbounds" @delete="onDeleteOutbound"
|
||||
@show-warp="showWarp" @show-nord="showNord" />
|
||||
</a-tab-pane>
|
||||
|
||||
@@ -339,8 +346,8 @@ function confirmRestart() {
|
||||
}
|
||||
|
||||
.xray-page.is-dark {
|
||||
--bg-page: #0a1222;
|
||||
--bg-card: #151f31;
|
||||
--bg-page: #1e1e1e;
|
||||
--bg-card: #252526;
|
||||
}
|
||||
|
||||
.xray-page.is-dark.is-ultra {
|
||||
|
||||
@@ -1,50 +1,24 @@
|
||||
// Drives the xray page's fetch / dirty / save lifecycle. The Go side
|
||||
// returns the live xraySetting (the full JSON config), the inboundTags
|
||||
// list, and a few sidecar values (clientReverseTags, outboundTestUrl)
|
||||
// the structured tabs need. We keep the JSON as a string here — pretty-
|
||||
// printed for the textarea; tabs that want a parsed view can JSON.parse
|
||||
// it themselves.
|
||||
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { HttpUtil, PromiseUtil } from '@/utils';
|
||||
|
||||
const DIRTY_POLL_MS = 1000;
|
||||
|
||||
// Hoists the parsed `templateSettings` alongside the JSON string so
|
||||
// structured tabs (Basics/Routing/Outbounds/etc.) can mutate fields
|
||||
// directly while the Advanced (JSON) tab edits the same data as text.
|
||||
// We keep both in sync with two cooperating watches:
|
||||
// • mutating templateSettings re-stringifies into xraySetting;
|
||||
// • editing the JSON text re-parses into templateSettings (only on
|
||||
// valid JSON — invalid edits leave templateSettings untouched
|
||||
// so the structured tabs don't blow up while the user types).
|
||||
let syncing = false;
|
||||
|
||||
export function useXraySetting() {
|
||||
const fetched = ref(false);
|
||||
const spinning = ref(false);
|
||||
const saveDisabled = ref(true);
|
||||
// Holds a user-facing message when fetchAll fails; lets the page
|
||||
// render an error UI instead of an endless spinner.
|
||||
const fetchError = ref('');
|
||||
|
||||
const xraySetting = ref('');
|
||||
const oldXraySetting = ref('');
|
||||
|
||||
// Parsed mirror — null until first successful fetch / parse.
|
||||
const templateSettings = ref(null);
|
||||
|
||||
const outboundTestUrl = ref('https://www.google.com/generate_204');
|
||||
const oldOutboundTestUrl = ref('');
|
||||
|
||||
const inboundTags = ref([]);
|
||||
const clientReverseTags = ref([]);
|
||||
const restartResult = ref('');
|
||||
|
||||
// Outbounds tab data — traffic stats + per-row test state. Test
|
||||
// states are keyed by outbound index (sparse object), each entry
|
||||
// is `{ testing, result }` where result is the wire response from
|
||||
// /panel/xray/testOutbound or null while the test is in flight.
|
||||
const outboundsTraffic = ref([]);
|
||||
const outboundTestStates = ref({});
|
||||
|
||||
@@ -53,7 +27,6 @@ export function useXraySetting() {
|
||||
const msg = await HttpUtil.post('/panel/xray/');
|
||||
if (!msg?.success) {
|
||||
fetchError.value = msg?.msg || 'Failed to load xray config';
|
||||
// Mark as fetched so the spinner clears and the error UI renders.
|
||||
fetched.value = true;
|
||||
return;
|
||||
}
|
||||
@@ -79,8 +52,7 @@ export function useXraySetting() {
|
||||
saveDisabled.value = true;
|
||||
}
|
||||
|
||||
// Structured tabs mutate templateSettings deeply. Re-stringify on
|
||||
// change so the Advanced JSON view + the dirty-poll see the edits.
|
||||
|
||||
watch(
|
||||
templateSettings,
|
||||
(next) => {
|
||||
@@ -95,8 +67,6 @@ export function useXraySetting() {
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
// Advanced JSON edits — only refresh templateSettings when the text
|
||||
// parses, so structured tabs stay readable mid-edit.
|
||||
watch(xraySetting, (next) => {
|
||||
if (syncing) return;
|
||||
try {
|
||||
@@ -133,21 +103,19 @@ export function useXraySetting() {
|
||||
if (msg?.success) await fetchOutboundsTraffic();
|
||||
}
|
||||
|
||||
// Merges a WebSocket `outbounds` event into outboundsTraffic in place.
|
||||
// The xray traffic job pushes the full snapshot every ~10s so the user
|
||||
// doesn't have to click the (now-removed) refresh button.
|
||||
function applyOutboundsEvent(payload) {
|
||||
if (Array.isArray(payload)) outboundsTraffic.value = payload;
|
||||
}
|
||||
|
||||
async function testOutbound(index, outbound) {
|
||||
async function testOutbound(index, outbound, mode = 'tcp') {
|
||||
if (!outbound) return null;
|
||||
if (!outboundTestStates.value[index]) outboundTestStates.value[index] = {};
|
||||
outboundTestStates.value[index] = { testing: true, result: null };
|
||||
outboundTestStates.value[index] = { testing: true, result: null, mode };
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/xray/testOutbound', {
|
||||
outbound: JSON.stringify(outbound),
|
||||
allOutbounds: JSON.stringify(templateSettings.value?.outbounds || []),
|
||||
mode,
|
||||
});
|
||||
if (msg?.success) {
|
||||
outboundTestStates.value[index] = { testing: false, result: msg.obj };
|
||||
@@ -155,24 +123,53 @@ export function useXraySetting() {
|
||||
}
|
||||
outboundTestStates.value[index] = {
|
||||
testing: false,
|
||||
result: { success: false, error: msg?.msg || 'Unknown error' },
|
||||
result: { success: false, error: msg?.msg || 'Unknown error', mode },
|
||||
};
|
||||
} catch (e) {
|
||||
outboundTestStates.value[index] = {
|
||||
testing: false,
|
||||
result: { success: false, error: String(e) },
|
||||
result: { success: false, error: String(e), mode },
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const testingAll = ref(false);
|
||||
async function testAllOutbounds(mode = 'tcp') {
|
||||
const list = templateSettings.value?.outbounds || [];
|
||||
if (list.length === 0 || testingAll.value) return;
|
||||
testingAll.value = true;
|
||||
try {
|
||||
const concurrency = mode === 'tcp' ? 8 : 1;
|
||||
const queue = list
|
||||
.map((ob, i) => ({ index: i, outbound: ob }))
|
||||
.filter(({ outbound }) => {
|
||||
const tag = outbound?.tag;
|
||||
const proto = outbound?.protocol;
|
||||
if (proto === 'blackhole' || proto === 'loopback' || tag === 'blocked') return false;
|
||||
if (mode === 'tcp' && (proto === 'freedom' || proto === 'dns')) return false;
|
||||
return true;
|
||||
});
|
||||
async function worker() {
|
||||
while (queue.length > 0) {
|
||||
const item = queue.shift();
|
||||
if (!item) break;
|
||||
await testOutbound(item.index, item.outbound, mode);
|
||||
}
|
||||
}
|
||||
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, () => worker());
|
||||
await Promise.all(workers);
|
||||
} finally {
|
||||
testingAll.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resetToDefault() {
|
||||
spinning.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/setting/getDefaultJsonConfig');
|
||||
if (msg?.success) {
|
||||
// Mutate templateSettings — the watch above re-stringifies into
|
||||
// xraySetting so the Advanced JSON tab and dirty-poll see it.
|
||||
|
||||
templateSettings.value = JSON.parse(JSON.stringify(msg.obj));
|
||||
}
|
||||
} finally {
|
||||
@@ -234,11 +231,13 @@ export function useXraySetting() {
|
||||
restartResult,
|
||||
outboundsTraffic,
|
||||
outboundTestStates,
|
||||
testingAll,
|
||||
fetchAll,
|
||||
fetchOutboundsTraffic,
|
||||
resetOutboundsTraffic,
|
||||
applyOutboundsEvent,
|
||||
testOutbound,
|
||||
testAllOutbounds,
|
||||
saveAll,
|
||||
resetToDefault,
|
||||
restartXray,
|
||||
|
||||
@@ -26,6 +26,8 @@ const BASE_MIGRATED_ROUTES = {
|
||||
'panel/xray/': '/xray.html',
|
||||
'panel/nodes': '/nodes.html',
|
||||
'panel/nodes/': '/nodes.html',
|
||||
'panel/api-docs': '/api-docs.html',
|
||||
'panel/api-docs/': '/api-docs.html',
|
||||
};
|
||||
|
||||
let cachedBasePath = '/';
|
||||
@@ -57,7 +59,7 @@ function refreshBasePath() {
|
||||
}
|
||||
|
||||
// `apply: 'serve'` keeps the injection out of `vite build` — dist.go
|
||||
// already injects __X_UI_BASE_PATH__ at runtime in production.
|
||||
// already injects webBasePath at runtime in production.
|
||||
function injectBasePathPlugin() {
|
||||
return {
|
||||
name: 'xui-inject-base-path',
|
||||
@@ -65,7 +67,7 @@ function injectBasePathPlugin() {
|
||||
transformIndexHtml(html) {
|
||||
const basePath = refreshBasePath();
|
||||
const escaped = basePath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
const tag = `<script>window.__X_UI_BASE_PATH__="${escaped}";</script>`;
|
||||
const tag = `<script>window.X_UI_BASE_PATH="${escaped}";</script>`;
|
||||
return html.replace('</head>', `${tag}</head>`);
|
||||
},
|
||||
};
|
||||
@@ -150,6 +152,7 @@ export default defineConfig({
|
||||
inbounds: path.resolve(__dirname, 'inbounds.html'),
|
||||
xray: path.resolve(__dirname, 'xray.html'),
|
||||
nodes: path.resolve(__dirname, 'nodes.html'),
|
||||
apiDocs: path.resolve(__dirname, 'api-docs.html'),
|
||||
subpage: path.resolve(__dirname, 'subpage.html'),
|
||||
},
|
||||
output: {
|
||||
@@ -163,7 +166,6 @@ export default defineConfig({
|
||||
|| id.includes('/node_modules/@vue/')
|
||||
) return 'vendor-vue';
|
||||
if (id.includes('dayjs')) return 'vendor-dayjs';
|
||||
if (id.includes('qrious')) return 'vendor-qrious';
|
||||
if (id.includes('axios')) return 'vendor-axios';
|
||||
if (
|
||||
id.includes('vue3-persian-datetime-picker')
|
||||
|
||||
+52
-9
@@ -528,21 +528,24 @@ ssl_cert_issue() {
|
||||
# Sets global `SSL_HOST` to the chosen domain/IP for Access URL usage
|
||||
prompt_and_setup_ssl() {
|
||||
local panel_port="$1"
|
||||
local web_base_path="$2" # expected without leading slash
|
||||
local web_base_path="$2"
|
||||
local server_ip="$3"
|
||||
|
||||
local ssl_choice=""
|
||||
SSL_SCHEME="https"
|
||||
|
||||
echo -e "${yellow}Choose SSL certificate setup method:${plain}"
|
||||
echo -e "${green}1.${plain} Let's Encrypt for Domain (90-day validity, auto-renews)"
|
||||
echo -e "${green}2.${plain} Let's Encrypt for IP Address (6-day validity, auto-renews)"
|
||||
echo -e "${green}3.${plain} Custom SSL Certificate (Path to existing files)"
|
||||
echo -e "${green}4.${plain} Skip SSL (advanced — behind reverse proxy / SSH tunnel only)"
|
||||
echo -e "${blue}Note:${plain} Options 1 & 2 require port 80 open. Option 3 requires manual paths."
|
||||
echo -e "${blue}Note:${plain} Option 4 serves the panel over plain HTTP — only safe behind nginx/Caddy or an SSH tunnel."
|
||||
read -rp "Choose an option (default 2 for IP): " ssl_choice
|
||||
ssl_choice="${ssl_choice// /}" # Trim whitespace
|
||||
|
||||
# Default to 2 (IP cert) if input is empty or invalid (not 1 or 3)
|
||||
if [[ "$ssl_choice" != "1" && "$ssl_choice" != "3" ]]; then
|
||||
# Default to 2 (IP cert) if input is empty or invalid (not 1, 3 or 4)
|
||||
if [[ "$ssl_choice" != "1" && "$ssl_choice" != "3" && "$ssl_choice" != "4" ]]; then
|
||||
ssl_choice="2"
|
||||
fi
|
||||
|
||||
@@ -653,6 +656,41 @@ prompt_and_setup_ssl() {
|
||||
|
||||
systemctl restart x-ui > /dev/null 2>&1 || rc-service x-ui restart > /dev/null 2>&1
|
||||
;;
|
||||
4)
|
||||
echo ""
|
||||
echo -e "${red}⚠ Panel will be installed WITHOUT SSL/TLS.${plain}"
|
||||
echo -e "${yellow}Login credentials and cookies will travel as plain HTTP.${plain}"
|
||||
echo -e "${yellow}Only safe when:${plain}"
|
||||
echo -e "${yellow} • A reverse proxy (nginx, Caddy, Traefik) terminates TLS for you, or${plain}"
|
||||
echo -e "${yellow} • You access the panel exclusively via SSH tunnel${plain}"
|
||||
echo ""
|
||||
|
||||
SSL_SCHEME="http"
|
||||
SSL_HOST="${server_ip}"
|
||||
|
||||
local bind_local=""
|
||||
read -rp "Bind the panel to 127.0.0.1 only? (recommended — forces SSH tunnel / reverse-proxy access) [y/N]: " bind_local
|
||||
if [[ "$bind_local" == "y" || "$bind_local" == "Y" ]]; then
|
||||
${xui_folder}/x-ui setting -listenIP "127.0.0.1" > /dev/null 2>&1
|
||||
SSL_HOST="127.0.0.1"
|
||||
echo -e "${green}✓ Panel bound to 127.0.0.1 only. It is now unreachable from the public internet.${plain}"
|
||||
echo ""
|
||||
echo -e "${green}SSH Port Forwarding — open the panel from your local machine via:${plain}"
|
||||
echo -e " Standard SSH command:"
|
||||
echo -e " ${yellow}ssh -L 2222:127.0.0.1:${panel_port} root@${server_ip}${plain}"
|
||||
echo -e " If using an SSH key:"
|
||||
echo -e " ${yellow}ssh -i <sshkeypath> -L 2222:127.0.0.1:${panel_port} root@${server_ip}${plain}"
|
||||
echo -e " Then open in your browser:"
|
||||
echo -e " ${yellow}http://localhost:2222/${web_base_path}${plain}"
|
||||
echo ""
|
||||
echo -e "${yellow}Alternative: point a reverse proxy (nginx/Caddy) at 127.0.0.1:${panel_port} and let it terminate TLS.${plain}"
|
||||
else
|
||||
echo -e "${yellow}Panel will listen on all interfaces over plain HTTP. Make sure something else is terminating TLS in front of it.${plain}"
|
||||
fi
|
||||
|
||||
systemctl restart x-ui > /dev/null 2>&1 || rc-service x-ui restart > /dev/null 2>&1
|
||||
echo -e "${green}✓ SSL setup skipped.${plain}"
|
||||
;;
|
||||
*)
|
||||
echo -e "${red}Invalid option. Skipping SSL setup.${plain}"
|
||||
SSL_HOST="${server_ip}"
|
||||
@@ -716,9 +754,10 @@ config_after_install() {
|
||||
|
||||
echo ""
|
||||
echo -e "${green}═══════════════════════════════════════════${plain}"
|
||||
echo -e "${green} SSL Certificate Setup (MANDATORY) ${plain}"
|
||||
echo -e "${green} SSL Certificate Setup (RECOMMENDED) ${plain}"
|
||||
echo -e "${green}═══════════════════════════════════════════${plain}"
|
||||
echo -e "${yellow}For security, SSL certificate is required for all panels.${plain}"
|
||||
echo -e "${yellow}SSL is strongly recommended. Skip only if a reverse proxy${plain}"
|
||||
echo -e "${yellow}or SSH tunnel handles TLS for you.${plain}"
|
||||
echo -e "${yellow}Let's Encrypt now supports both domains and IP addresses!${plain}"
|
||||
echo ""
|
||||
|
||||
@@ -733,10 +772,14 @@ config_after_install() {
|
||||
echo -e "${green}Password: ${config_password}${plain}"
|
||||
echo -e "${green}Port: ${config_port}${plain}"
|
||||
echo -e "${green}WebBasePath: ${config_webBasePath}${plain}"
|
||||
echo -e "${green}Access URL: https://${SSL_HOST}:${config_port}/${config_webBasePath}${plain}"
|
||||
echo -e "${green}Access URL: ${SSL_SCHEME}://${SSL_HOST}:${config_port}/${config_webBasePath}${plain}"
|
||||
echo -e "${green}═══════════════════════════════════════════${plain}"
|
||||
echo -e "${yellow}⚠ IMPORTANT: Save these credentials securely!${plain}"
|
||||
echo -e "${yellow}⚠ SSL Certificate: Enabled and configured${plain}"
|
||||
if [[ "$SSL_SCHEME" == "https" ]]; then
|
||||
echo -e "${yellow}⚠ SSL Certificate: Enabled and configured${plain}"
|
||||
else
|
||||
echo -e "${yellow}⚠ SSL Certificate: Skipped — panel is HTTP-only. Use a reverse proxy or SSH tunnel.${plain}"
|
||||
fi
|
||||
else
|
||||
local config_webBasePath=$(gen_random_string 18)
|
||||
echo -e "${yellow}WebBasePath is missing or too short. Generating a new one...${plain}"
|
||||
@@ -752,7 +795,7 @@ config_after_install() {
|
||||
echo -e "${yellow}Let's Encrypt now supports both domains and IP addresses!${plain}"
|
||||
echo ""
|
||||
prompt_and_setup_ssl "${existing_port}" "${config_webBasePath}" "${server_ip}"
|
||||
echo -e "${green}Access URL: https://${SSL_HOST}:${existing_port}/${config_webBasePath}${plain}"
|
||||
echo -e "${green}Access URL: ${SSL_SCHEME}://${SSL_HOST}:${existing_port}/${config_webBasePath}${plain}"
|
||||
else
|
||||
# If a cert already exists, just show the access URL
|
||||
echo -e "${green}Access URL: https://${server_ip}:${existing_port}/${config_webBasePath}${plain}"
|
||||
@@ -785,7 +828,7 @@ config_after_install() {
|
||||
echo -e "${yellow}Let's Encrypt now supports both domains and IP addresses!${plain}"
|
||||
echo ""
|
||||
prompt_and_setup_ssl "${existing_port}" "${existing_webBasePath}" "${server_ip}"
|
||||
echo -e "${green}Access URL: https://${SSL_HOST}:${existing_port}/${existing_webBasePath}${plain}"
|
||||
echo -e "${green}Access URL: ${SSL_SCHEME}://${SSL_HOST}:${existing_port}/${existing_webBasePath}${plain}"
|
||||
else
|
||||
echo -e "${green}SSL certificate already configured. No action needed.${plain}"
|
||||
fi
|
||||
|
||||
@@ -61,6 +61,8 @@ func runWebServer() {
|
||||
}
|
||||
|
||||
var subServer *sub.Server
|
||||
sub.SetDistFS(web.EmbeddedDist())
|
||||
service.RegisterSubLinkProvider(sub.NewLinkProvider())
|
||||
subServer = sub.NewServer()
|
||||
global.SetSubServer(subServer)
|
||||
err = subServer.Start()
|
||||
@@ -101,6 +103,7 @@ func runWebServer() {
|
||||
}
|
||||
log.Println("Web server restarted successfully.")
|
||||
|
||||
sub.SetDistFS(web.EmbeddedDist())
|
||||
subServer = sub.NewServer()
|
||||
global.SetSubServer(subServer)
|
||||
err = subServer.Start()
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package sub
|
||||
|
||||
import "embed"
|
||||
|
||||
// distFS holds the Vite-built frontend filesystem, injected from main at
|
||||
// startup. The `web` package owns the //go:embed directive (because dist/
|
||||
// is at web/dist/), and hands the FS over via SetDistFS so the sub package
|
||||
// doesn't import web — that would create an import cycle once any
|
||||
// web/controller handler reuses sub's link-building service.
|
||||
var distFS embed.FS
|
||||
|
||||
// SetDistFS installs the embedded frontend filesystem the sub server uses
|
||||
// for its info page assets. Must be called before NewServer().Start().
|
||||
func SetDistFS(fs embed.FS) {
|
||||
distFS = fs
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/web/service"
|
||||
)
|
||||
|
||||
type LinkProvider struct {
|
||||
settingService service.SettingService
|
||||
}
|
||||
|
||||
func NewLinkProvider() *LinkProvider {
|
||||
return &LinkProvider{}
|
||||
}
|
||||
|
||||
func (p *LinkProvider) build(host string) *SubService {
|
||||
showInfo, _ := p.settingService.GetSubShowInfo()
|
||||
rModel, err := p.settingService.GetRemarkModel()
|
||||
if err != nil {
|
||||
rModel = "-ieo"
|
||||
}
|
||||
svc := NewSubService(showInfo, rModel)
|
||||
svc.PrepareForRequest(host)
|
||||
return svc
|
||||
}
|
||||
|
||||
func (p *LinkProvider) SubLinksForSubId(host, subId string) ([]string, error) {
|
||||
svc := p.build(host)
|
||||
links, _, _, err := svc.GetSubs(subId, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]string, 0, len(links))
|
||||
for _, l := range links {
|
||||
out = append(out, splitLinkLines(l)...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *LinkProvider) LinksForClient(host string, inbound *model.Inbound, email string) []string {
|
||||
svc := p.build(host)
|
||||
return splitLinkLines(svc.GetLink(inbound, email))
|
||||
}
|
||||
|
||||
func splitLinkLines(raw string) []string {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, "\n")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
+5
-3
@@ -12,10 +12,10 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/util/common"
|
||||
webpkg "github.com/mhsanaei/3x-ui/v3/web"
|
||||
"github.com/mhsanaei/3x-ui/v3/web/locale"
|
||||
"github.com/mhsanaei/3x-ui/v3/web/middleware"
|
||||
"github.com/mhsanaei/3x-ui/v3/web/network"
|
||||
@@ -188,7 +188,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||
var assetsFS http.FileSystem
|
||||
if _, err := os.Stat("web/dist/assets"); err == nil {
|
||||
assetsFS = http.FS(os.DirFS("web/dist/assets"))
|
||||
} else if subFS, err := fs.Sub(webpkg.EmbeddedDist(), "dist/assets"); err == nil {
|
||||
} else if subFS, err := fs.Sub(distFS, "dist/assets"); err == nil {
|
||||
assetsFS = http.FS(subFS)
|
||||
} else {
|
||||
logger.Error("sub: failed to mount embedded dist assets:", err)
|
||||
@@ -313,7 +313,9 @@ func (s *Server) Stop() error {
|
||||
var err1 error
|
||||
var err2 error
|
||||
if s.httpServer != nil {
|
||||
err1 = s.httpServer.Shutdown(s.ctx)
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer shutdownCancel()
|
||||
err1 = s.httpServer.Shutdown(shutdownCtx)
|
||||
}
|
||||
if s.listener != nil {
|
||||
err2 = s.listener.Close()
|
||||
|
||||
+14
-9
@@ -6,10 +6,10 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
webpkg "github.com/mhsanaei/3x-ui/v3/web"
|
||||
"github.com/mhsanaei/3x-ui/v3/web/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -127,7 +127,7 @@ func (a *SUBController) subs(c *gin.Context) {
|
||||
basePath = "/"
|
||||
}
|
||||
basePathStr := basePath.(string)
|
||||
page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL, subClashURL, basePathStr)
|
||||
page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL, subClashURL, basePathStr, a.subTitle, a.subSupportUrl)
|
||||
a.serveSubPage(c, basePathStr, page)
|
||||
return
|
||||
}
|
||||
@@ -150,15 +150,20 @@ func (a *SUBController) subs(c *gin.Context) {
|
||||
|
||||
// serveSubPage renders web/dist/subpage.html for the current subscription
|
||||
// request. The Vite-built SPA reads window.__SUB_PAGE_DATA__ on mount —
|
||||
// we inject that here, along with window.__X_UI_BASE_PATH__ so the
|
||||
// we inject that here, along with window.X_UI_BASE_PATH so the
|
||||
// page's static asset references resolve correctly when the panel runs
|
||||
// behind a URL prefix.
|
||||
func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageData) {
|
||||
dist := webpkg.EmbeddedDist()
|
||||
body, err := dist.ReadFile("dist/subpage.html")
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "missing embedded subpage")
|
||||
return
|
||||
var body []byte
|
||||
if diskBody, diskErr := os.ReadFile("web/dist/subpage.html"); diskErr == nil {
|
||||
body = diskBody
|
||||
} else {
|
||||
readBody, err := distFS.ReadFile("dist/subpage.html")
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "missing embedded subpage")
|
||||
return
|
||||
}
|
||||
body = readBody
|
||||
}
|
||||
|
||||
// Vite emits absolute asset URLs (`/assets/...`); when the panel is
|
||||
@@ -219,7 +224,7 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD
|
||||
)
|
||||
escapedBase := jsEscape.Replace(basePath)
|
||||
|
||||
inject := []byte(`<script>window.__X_UI_BASE_PATH__="` + escapedBase + `";` +
|
||||
inject := []byte(`<script>window.X_UI_BASE_PATH="` + escapedBase + `";` +
|
||||
`window.__SUB_PAGE_DATA__=` + string(subDataJSON) + `;</script></head>`)
|
||||
out := bytes.Replace(body, []byte("</head>"), inject, 1)
|
||||
|
||||
|
||||
+49
-41
@@ -98,7 +98,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
|
||||
if client.Enable {
|
||||
hasEnabledClient = true
|
||||
}
|
||||
result = append(result, s.getLink(inbound, client.Email))
|
||||
result = append(result, s.GetLink(inbound, client.Email))
|
||||
var ct xray.ClientTraffic
|
||||
ct, clientTraffics = s.appendUniqueTraffic(seenEmails, clientTraffics, inbound.ClientStats, client.Email)
|
||||
if ct.LastOnline > lastOnline {
|
||||
@@ -198,7 +198,11 @@ func (s *SubService) getFallbackMaster(dest string, streamSettings string) (stri
|
||||
return inbound.Listen, inbound.Port, string(modifiedStream), nil
|
||||
}
|
||||
|
||||
func (s *SubService) getLink(inbound *model.Inbound, email string) string {
|
||||
// GetLink dispatches to the protocol-specific generator for one (inbound, client)
|
||||
// pair. Returns "" when the inbound's protocol doesn't produce a subscription URL
|
||||
// (socks, http, mixed, wireguard, dokodemo, tunnel). The returned string may
|
||||
// contain multiple `\n`-separated URLs when the inbound has externalProxy set.
|
||||
func (s *SubService) GetLink(inbound *model.Inbound, email string) string {
|
||||
switch inbound.Protocol {
|
||||
case "vmess":
|
||||
return s.genVmessLink(inbound, email)
|
||||
@@ -1413,25 +1417,27 @@ func searchHost(headers any) string {
|
||||
// PageData is a view model for subpage.html
|
||||
// PageData contains data for rendering the subscription information page.
|
||||
type PageData struct {
|
||||
Host string
|
||||
BasePath string
|
||||
SId string
|
||||
Enabled bool
|
||||
Download string
|
||||
Upload string
|
||||
Total string
|
||||
Used string
|
||||
Remained string
|
||||
Expire int64
|
||||
LastOnline int64
|
||||
Datepicker string
|
||||
DownloadByte int64
|
||||
UploadByte int64
|
||||
TotalByte int64
|
||||
SubUrl string
|
||||
SubJsonUrl string
|
||||
SubClashUrl string
|
||||
Result []string
|
||||
Host string
|
||||
BasePath string
|
||||
SId string
|
||||
Enabled bool
|
||||
Download string
|
||||
Upload string
|
||||
Total string
|
||||
Used string
|
||||
Remained string
|
||||
Expire int64
|
||||
LastOnline int64
|
||||
Datepicker string
|
||||
DownloadByte int64
|
||||
UploadByte int64
|
||||
TotalByte int64
|
||||
SubUrl string
|
||||
SubJsonUrl string
|
||||
SubClashUrl string
|
||||
SubTitle string
|
||||
SubSupportUrl string
|
||||
Result []string
|
||||
}
|
||||
|
||||
// ResolveRequest extracts scheme and host info from request/headers consistently.
|
||||
@@ -1545,7 +1551,7 @@ func (s *SubService) joinPathWithID(basePath, subId string) string {
|
||||
|
||||
// BuildPageData parses header and prepares the template view model.
|
||||
// BuildPageData constructs page data for rendering the subscription information page.
|
||||
func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, subURL, subJsonURL, subClashURL string, basePath string) PageData {
|
||||
func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, subURL, subJsonURL, subClashURL string, basePath string, subTitle string, subSupportUrl string) PageData {
|
||||
download := common.FormatTraffic(traffic.Down)
|
||||
upload := common.FormatTraffic(traffic.Up)
|
||||
total := "∞"
|
||||
@@ -1563,25 +1569,27 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray
|
||||
}
|
||||
|
||||
return PageData{
|
||||
Host: hostHeader,
|
||||
BasePath: basePath,
|
||||
SId: subId,
|
||||
Enabled: traffic.Enable,
|
||||
Download: download,
|
||||
Upload: upload,
|
||||
Total: total,
|
||||
Used: used,
|
||||
Remained: remained,
|
||||
Expire: traffic.ExpiryTime / 1000,
|
||||
LastOnline: lastOnline,
|
||||
Datepicker: datepicker,
|
||||
DownloadByte: traffic.Down,
|
||||
UploadByte: traffic.Up,
|
||||
TotalByte: traffic.Total,
|
||||
SubUrl: subURL,
|
||||
SubJsonUrl: subJsonURL,
|
||||
SubClashUrl: subClashURL,
|
||||
Result: subs,
|
||||
Host: hostHeader,
|
||||
BasePath: basePath,
|
||||
SId: subId,
|
||||
Enabled: traffic.Enable,
|
||||
Download: download,
|
||||
Upload: upload,
|
||||
Total: total,
|
||||
Used: used,
|
||||
Remained: remained,
|
||||
Expire: traffic.ExpiryTime / 1000,
|
||||
LastOnline: lastOnline,
|
||||
Datepicker: datepicker,
|
||||
DownloadByte: traffic.Down,
|
||||
UploadByte: traffic.Up,
|
||||
TotalByte: traffic.Total,
|
||||
SubUrl: subURL,
|
||||
SubJsonUrl: subJsonURL,
|
||||
SubClashUrl: subClashURL,
|
||||
SubTitle: subTitle,
|
||||
SubSupportUrl: subSupportUrl,
|
||||
Result: subs,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-47
@@ -15,41 +15,14 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/web/session"
|
||||
)
|
||||
|
||||
// distFS is filled in once at startup by the web package via SetDistFS.
|
||||
// It holds the Vite-built frontend (the `dist/<page>.html` files) so
|
||||
// the panel's HTML routes can serve them in production.
|
||||
//
|
||||
// We can't `go:embed` the dist directory directly from this package
|
||||
// because embed.FS only accepts paths relative to the source file —
|
||||
// dist/ lives one directory up. The web package owns the embed and
|
||||
// hands the FS to us through this setter.
|
||||
var distFS embed.FS
|
||||
|
||||
// SetDistFS is called once during server bootstrap by the web package
|
||||
// to hand off the embedded `dist/` filesystem.
|
||||
func SetDistFS(fs embed.FS) {
|
||||
distFS = fs
|
||||
}
|
||||
|
||||
// distPageBuildTime is captured at startup so every served HTML page
|
||||
// reports a stable Last-Modified header and the browser's conditional
|
||||
// GETs can hit the 304 path on repeat loads.
|
||||
var distPageBuildTime = time.Now()
|
||||
|
||||
// serveDistPage reads `dist/<name>` from the embedded FS and writes it
|
||||
// to the response. Two transforms run before send:
|
||||
//
|
||||
// 1. `<script>window.__X_UI_BASE_PATH__ = "..."</script>` is injected
|
||||
// just before </head> so the AppSidebar's link generator sees the
|
||||
// right prefix.
|
||||
// 2. Absolute Vite-emitted asset URLs (`/assets/...`) are rewritten
|
||||
// to include the panel's basePath, so installs running under a
|
||||
// custom URL prefix (e.g. `/myprefix/`) load the bundle from
|
||||
// `/myprefix/assets/...` where the static handler actually lives.
|
||||
//
|
||||
// The HTML responses are served with no-cache so a panel update
|
||||
// reaches users on the next reload; the long-hashed JS/CSS files
|
||||
// under /assets/ stay cacheable indefinitely.
|
||||
func serveDistPage(c *gin.Context, name string) {
|
||||
body, err := distFS.ReadFile("dist/" + name)
|
||||
if err != nil {
|
||||
@@ -62,21 +35,11 @@ func serveDistPage(c *gin.Context, name string) {
|
||||
basePath = "/"
|
||||
}
|
||||
|
||||
// Rewrite asset URLs only when basePath isn't the root — for the
|
||||
// default `/` install, Vite's `/assets/...` already resolves
|
||||
// correctly and we save the byte churn.
|
||||
if basePath != "/" {
|
||||
// Vite emits these three attribute shapes for every entry's
|
||||
// JS / CSS / modulepreload reference. Anchoring the search to
|
||||
// the leading attribute name avoids matching unrelated /assets
|
||||
// substrings inside any inlined script.
|
||||
body = bytes.ReplaceAll(body, []byte(`src="/assets/`), []byte(`src="`+basePath+`assets/`))
|
||||
body = bytes.ReplaceAll(body, []byte(`href="/assets/`), []byte(`href="`+basePath+`assets/`))
|
||||
}
|
||||
|
||||
// Escape just enough that a hostile basePath setting can't break
|
||||
// out of the JS string literal. The setting is admin-controlled
|
||||
// but defense-in-depth costs nothing here.
|
||||
jsEscape := strings.NewReplacer(
|
||||
`\`, `\\`,
|
||||
`"`, `\"`,
|
||||
@@ -87,14 +50,6 @@ func serveDistPage(c *gin.Context, name string) {
|
||||
"&", `&`,
|
||||
)
|
||||
escapedBase := jsEscape.Replace(basePath)
|
||||
escapedVer := jsEscape.Replace(config.GetVersion())
|
||||
|
||||
// Embed a CSRF token in the served HTML the same way the legacy
|
||||
// templates did via `<meta name="csrf-token">`. Without this the
|
||||
// SPA login page has no way to acquire a token (the existing
|
||||
// /panel/csrf-token endpoint sits behind checkLogin), and POST
|
||||
// /login is rejected by CSRFMiddleware. EnsureCSRFToken creates
|
||||
// a session token on first call even for anonymous visitors.
|
||||
csrfToken, err := session.EnsureCSRFToken(c)
|
||||
if err != nil {
|
||||
logger.Warning("Unable to mint CSRF token for", name+":", err)
|
||||
@@ -102,8 +57,13 @@ func serveDistPage(c *gin.Context, name string) {
|
||||
}
|
||||
csrfMeta := []byte(`<meta name="csrf-token" content="` + htmlpkg.EscapeString(csrfToken) + `">`)
|
||||
|
||||
inject := []byte(`<script>window.__X_UI_BASE_PATH__="` + escapedBase +
|
||||
`";window.__X_UI_CUR_VER__="` + escapedVer + `";</script>`)
|
||||
script := `<script>window.X_UI_BASE_PATH="` + escapedBase + `"`
|
||||
if name != "login.html" {
|
||||
escapedVer := jsEscape.Replace(config.GetVersion())
|
||||
script += `;window.X_UI_CUR_VER="` + escapedVer + `"`
|
||||
}
|
||||
script += `;</script>`
|
||||
inject := []byte(script)
|
||||
inject = append(inject, csrfMeta...)
|
||||
inject = append(inject, []byte(`</head>`)...)
|
||||
out := bytes.Replace(body, []byte("</head>"), inject, 1)
|
||||
|
||||
@@ -3,7 +3,9 @@ package controller
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
@@ -62,6 +64,8 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/get/:id", a.getInbound)
|
||||
g.GET("/getClientTraffics/:email", a.getClientTraffics)
|
||||
g.GET("/getClientTrafficsById/:id", a.getClientTrafficsById)
|
||||
g.GET("/getSubLinks/:subId", a.getSubLinks)
|
||||
g.GET("/getClientLinks/:id/:email", a.getClientLinks)
|
||||
|
||||
g.POST("/add", a.addInbound)
|
||||
g.POST("/del/:id", a.delInbound)
|
||||
@@ -571,3 +575,55 @@ func (a *InboundController) delInboundClientByEmail(c *gin.Context) {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
}
|
||||
|
||||
// resolveHost mirrors what sub.SubService.ResolveRequest does for the host
|
||||
// field: prefers X-Forwarded-Host (first entry of any list, port stripped),
|
||||
// then X-Real-IP, then the host portion of c.Request.Host. Keeping it in the
|
||||
// controller layer means the service interface stays HTTP-agnostic — service
|
||||
// methods receive a plain host string instead of a *gin.Context.
|
||||
func resolveHost(c *gin.Context) string {
|
||||
if h := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")); h != "" {
|
||||
if i := strings.Index(h, ","); i >= 0 {
|
||||
h = strings.TrimSpace(h[:i])
|
||||
}
|
||||
if hp, _, err := net.SplitHostPort(h); err == nil {
|
||||
return hp
|
||||
}
|
||||
return h
|
||||
}
|
||||
if h := c.GetHeader("X-Real-IP"); h != "" {
|
||||
return h
|
||||
}
|
||||
if h, _, err := net.SplitHostPort(c.Request.Host); err == nil {
|
||||
return h
|
||||
}
|
||||
return c.Request.Host
|
||||
}
|
||||
|
||||
// getSubLinks returns every protocol URL produced for the given subscription
|
||||
// ID — the JSON-array equivalent of /sub/<subId> (no base64 wrap).
|
||||
func (a *InboundController) getSubLinks(c *gin.Context) {
|
||||
links, err := a.inboundService.GetSubLinks(resolveHost(c), c.Param("subId"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, links, nil)
|
||||
}
|
||||
|
||||
// getClientLinks returns the URL(s) for one client on one inbound — the same
|
||||
// string the Copy URL button copies in the panel UI. Empty array when the
|
||||
// protocol has no URL form, or when the email isn't found on the inbound.
|
||||
func (a *InboundController) getClientLinks(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
links, err := a.inboundService.GetClientLinks(resolveHost(c), id, c.Param("email"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, links, nil)
|
||||
}
|
||||
|
||||
@@ -343,10 +343,6 @@ func (a *ServerController) importDB(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
// Always restart Xray before return
|
||||
defer a.serverService.RestartXrayService()
|
||||
// lastGetStatusTime removed; no longer needed
|
||||
// Import it
|
||||
err = a.serverService.ImportDB(file)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.importDatabaseError"), err)
|
||||
|
||||
@@ -199,9 +199,12 @@ func (a *XraySettingController) resetOutboundsTraffic(c *gin.Context) {
|
||||
|
||||
// testOutbound tests an outbound configuration and returns the delay/response time.
|
||||
// Optional form "allOutbounds": JSON array of all outbounds; used to resolve sockopt.dialerProxy dependencies.
|
||||
// Optional form "mode": "tcp" for a fast dial-only probe (parallel-safe),
|
||||
// anything else (default) for a full HTTP probe through a temp xray instance.
|
||||
func (a *XraySettingController) testOutbound(c *gin.Context) {
|
||||
outboundJSON := c.PostForm("outbound")
|
||||
allOutboundsJSON := c.PostForm("allOutbounds")
|
||||
mode := c.PostForm("mode")
|
||||
|
||||
if outboundJSON == "" {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("outbound parameter is required"))
|
||||
@@ -211,7 +214,7 @@ func (a *XraySettingController) testOutbound(c *gin.Context) {
|
||||
// Load the test URL from server settings to prevent SSRF via user-controlled URLs
|
||||
testURL, _ := a.SettingService.GetXrayOutboundTestUrl()
|
||||
|
||||
result, err := a.OutboundService.TestOutbound(outboundJSON, testURL, allOutboundsJSON)
|
||||
result, err := a.OutboundService.TestOutbound(outboundJSON, testURL, allOutboundsJSON, mode)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
|
||||
@@ -36,6 +36,7 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/nodes", a.nodes)
|
||||
g.GET("/settings", a.settings)
|
||||
g.GET("/xray", a.xraySettings)
|
||||
g.GET("/api-docs", a.apiDocs)
|
||||
|
||||
// SPA pages built by Vite don't have a server-rendered <meta name="csrf-token">,
|
||||
// so they fetch the session token via this endpoint at startup and replay it
|
||||
@@ -76,6 +77,11 @@ func (a *XUIController) xraySettings(c *gin.Context) {
|
||||
serveDistPage(c, "xray.html")
|
||||
}
|
||||
|
||||
// apiDocs renders the in-panel API documentation page.
|
||||
func (a *XUIController) apiDocs(c *gin.Context) {
|
||||
serveDistPage(c, "api-docs.html")
|
||||
}
|
||||
|
||||
// csrfToken returns the session CSRF token to authenticated SPA clients.
|
||||
// The endpoint is GET (a safe method) so it bypasses CSRFMiddleware itself,
|
||||
// but checkLogin still gates the response — anonymous callers get 401/redirect.
|
||||
|
||||
+30
-9
@@ -291,12 +291,7 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
||||
return inbound, false, common.NewError("Port already exists:", inbound.Port)
|
||||
}
|
||||
|
||||
// pick a tag that won't collide with an existing row. for the common
|
||||
// case this is the same "inbound-<port>" string the controller already
|
||||
// set; only when this port already has another inbound on a different
|
||||
// transport (now possible after the transport-aware port check) does
|
||||
// this disambiguate with a -tcp/-udp suffix. see #4103.
|
||||
inbound.Tag, err = s.generateInboundTag(inbound, 0)
|
||||
inbound.Tag, err = s.resolveInboundTag(inbound, 0)
|
||||
if err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
@@ -636,9 +631,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
oldInbound.Settings = inbound.Settings
|
||||
oldInbound.StreamSettings = inbound.StreamSettings
|
||||
oldInbound.Sniffing = inbound.Sniffing
|
||||
// regenerate tag with collision-aware logic. for this row we pass
|
||||
// inbound.Id as ignoreId so it doesn't see its own old tag in the db.
|
||||
oldInbound.Tag, err = s.generateInboundTag(inbound, inbound.Id)
|
||||
oldInbound.Tag, err = s.resolveInboundTag(inbound, inbound.Id)
|
||||
if err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
@@ -3873,3 +3866,31 @@ func (s *InboundService) DelInboundClientByEmail(inboundId int, email string) (b
|
||||
|
||||
return needRestart, db.Save(oldInbound).Error
|
||||
}
|
||||
|
||||
type SubLinkProvider interface {
|
||||
SubLinksForSubId(host, subId string) ([]string, error)
|
||||
LinksForClient(host string, inbound *model.Inbound, email string) []string
|
||||
}
|
||||
|
||||
var registeredSubLinkProvider SubLinkProvider
|
||||
|
||||
func RegisterSubLinkProvider(p SubLinkProvider) {
|
||||
registeredSubLinkProvider = p
|
||||
}
|
||||
|
||||
func (s *InboundService) GetSubLinks(host, subId string) ([]string, error) {
|
||||
if registeredSubLinkProvider == nil {
|
||||
return nil, common.NewError("sub link provider not registered")
|
||||
}
|
||||
return registeredSubLinkProvider.SubLinksForSubId(host, subId)
|
||||
}
|
||||
func (s *InboundService) GetClientLinks(host string, id int, email string) ([]string, error) {
|
||||
inbound, err := s.GetInbound(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if registeredSubLinkProvider == nil {
|
||||
return nil, common.NewError("sub link provider not registered")
|
||||
}
|
||||
return registeredSubLinkProvider.LinksForClient(host, inbound, email), nil
|
||||
}
|
||||
|
||||
+7
-2
@@ -25,6 +25,9 @@ func (s *NordService) GetCountries() (string, error) {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", common.NewErrorf("NordVPN API error: %s", resp.Status)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -45,6 +48,9 @@ func (s *NordService) GetServers(countryId string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", common.NewErrorf("NordVPN API error: %s", resp.Status)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -97,8 +103,7 @@ func (s *NordService) GetCredentials(token string) (string, error) {
|
||||
}
|
||||
req.SetBasicAuth("token", token)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
resp, err := nordHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
+263
-109
@@ -1,13 +1,17 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptrace"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -15,7 +19,6 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/util/json_util"
|
||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||
|
||||
@@ -26,8 +29,10 @@ import (
|
||||
// It handles outbound traffic monitoring and statistics.
|
||||
type OutboundService struct{}
|
||||
|
||||
// testSemaphore limits concurrent outbound tests to prevent resource exhaustion.
|
||||
var testSemaphore sync.Mutex
|
||||
// httpTestSemaphore serialises HTTP-mode probes (each one spawns a temp xray
|
||||
// instance, which is too expensive to run in parallel). TCP-mode probes are
|
||||
// dial-only and don't need the semaphore.
|
||||
var httpTestSemaphore sync.Mutex
|
||||
|
||||
func (s *OutboundService) AddTraffic(traffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (error, bool) {
|
||||
var err error
|
||||
@@ -117,90 +122,230 @@ func (s *OutboundService) ResetOutboundTraffic(tag string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestOutboundResult represents the result of testing an outbound
|
||||
// TestOutboundResult represents the result of testing an outbound.
|
||||
// Delay/timing fields are in milliseconds. Endpoints is only populated for
|
||||
// TCP-mode probes; the HTTP-mode timing breakdown lives in DNSMs/ConnectMs/
|
||||
// TLSMs/TTFBMs (any of these can be 0 if the underlying step was skipped —
|
||||
// e.g. a non-TLS target leaves TLSMs at 0).
|
||||
type TestOutboundResult struct {
|
||||
Success bool `json:"success"`
|
||||
Delay int64 `json:"delay"` // Delay in milliseconds
|
||||
Delay int64 `json:"delay"`
|
||||
Error string `json:"error,omitempty"`
|
||||
StatusCode int `json:"statusCode,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
|
||||
DNSMs int64 `json:"dnsMs,omitempty"`
|
||||
ConnectMs int64 `json:"connectMs,omitempty"`
|
||||
TLSMs int64 `json:"tlsMs,omitempty"`
|
||||
TTFBMs int64 `json:"ttfbMs,omitempty"`
|
||||
|
||||
Endpoints []TestEndpointResult `json:"endpoints,omitempty"`
|
||||
}
|
||||
|
||||
// TestOutbound tests an outbound by creating a temporary xray instance and measuring response time.
|
||||
// allOutboundsJSON must be a JSON array of all outbounds; they are copied into the test config unchanged.
|
||||
// Only the test inbound and a route rule (to the tested outbound tag) are added.
|
||||
func (s *OutboundService) TestOutbound(outboundJSON string, testURL string, allOutboundsJSON string) (*TestOutboundResult, error) {
|
||||
// TestEndpointResult is one entry in a TCP-mode probe — the per-endpoint
|
||||
// dial outcome for outbounds that expose multiple servers/peers.
|
||||
type TestEndpointResult struct {
|
||||
Address string `json:"address"`
|
||||
Success bool `json:"success"`
|
||||
Delay int64 `json:"delay"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// TestOutbound dispatches to the chosen probe mode:
|
||||
// - mode="tcp": dial the outbound's host:port directly. No xray spin-up,
|
||||
// parallel-safe, ~100ms per endpoint. Doesn't validate the proxy
|
||||
// protocol — only that the remote is reachable on TCP.
|
||||
// - mode="" or "http": spin a temp xray instance, route a real HTTP
|
||||
// request through it, return delay + a DNS/Connect/TLS/TTFB breakdown.
|
||||
// Authoritative but expensive and serialised by httpTestSemaphore.
|
||||
//
|
||||
// allOutboundsJSON is only consulted in HTTP mode (it backs
|
||||
// sockopt.dialerProxy chains during test).
|
||||
func (s *OutboundService) TestOutbound(outboundJSON string, testURL string, allOutboundsJSON string, mode string) (*TestOutboundResult, error) {
|
||||
if mode == "tcp" {
|
||||
return s.testOutboundTCP(outboundJSON)
|
||||
}
|
||||
return s.testOutboundHTTP(outboundJSON, testURL, allOutboundsJSON)
|
||||
}
|
||||
|
||||
func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundResult, error) {
|
||||
var ob map[string]any
|
||||
if err := json.Unmarshal([]byte(outboundJSON), &ob); err != nil {
|
||||
return &TestOutboundResult{Mode: "tcp", Success: false, Error: fmt.Sprintf("Invalid outbound JSON: %v", err)}, nil
|
||||
}
|
||||
tag, _ := ob["tag"].(string)
|
||||
protocol, _ := ob["protocol"].(string)
|
||||
if protocol == "blackhole" || protocol == "freedom" || tag == "blocked" {
|
||||
return &TestOutboundResult{Mode: "tcp", Success: false, Error: "Outbound has no testable endpoint"}, nil
|
||||
}
|
||||
|
||||
endpoints := extractOutboundEndpoints(ob)
|
||||
if len(endpoints) == 0 {
|
||||
return &TestOutboundResult{Mode: "tcp", Success: false, Error: "No testable endpoint"}, nil
|
||||
}
|
||||
|
||||
results := make([]TestEndpointResult, len(endpoints))
|
||||
var wg sync.WaitGroup
|
||||
for i := range endpoints {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
results[i] = probeTCPEndpoint(endpoints[i], 5*time.Second)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var bestDelay int64 = -1
|
||||
var firstErr string
|
||||
for _, r := range results {
|
||||
if r.Success {
|
||||
if bestDelay < 0 || r.Delay < bestDelay {
|
||||
bestDelay = r.Delay
|
||||
}
|
||||
} else if firstErr == "" {
|
||||
firstErr = r.Error
|
||||
}
|
||||
}
|
||||
|
||||
out := &TestOutboundResult{Mode: "tcp", Endpoints: results}
|
||||
if bestDelay >= 0 {
|
||||
out.Success = true
|
||||
out.Delay = bestDelay
|
||||
} else {
|
||||
out.Error = firstErr
|
||||
if out.Error == "" {
|
||||
out.Error = "All endpoints unreachable"
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func probeTCPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult {
|
||||
r := TestEndpointResult{Address: endpoint}
|
||||
start := time.Now()
|
||||
conn, err := net.DialTimeout("tcp", endpoint, timeout)
|
||||
r.Delay = time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
r.Error = err.Error()
|
||||
return r
|
||||
}
|
||||
conn.Close()
|
||||
r.Success = true
|
||||
return r
|
||||
}
|
||||
|
||||
func extractOutboundEndpoints(ob map[string]any) []string {
|
||||
protocol, _ := ob["protocol"].(string)
|
||||
settings, _ := ob["settings"].(map[string]any)
|
||||
if settings == nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
addServer := func(addr any, port any) {
|
||||
host, _ := addr.(string)
|
||||
p := numAsInt(port)
|
||||
if host != "" && p > 0 {
|
||||
out = append(out, fmt.Sprintf("%s:%d", host, p))
|
||||
}
|
||||
}
|
||||
switch protocol {
|
||||
case "vmess":
|
||||
if vnext, ok := settings["vnext"].([]any); ok {
|
||||
for _, v := range vnext {
|
||||
if vm, ok := v.(map[string]any); ok {
|
||||
addServer(vm["address"], vm["port"])
|
||||
}
|
||||
}
|
||||
}
|
||||
case "vless":
|
||||
addServer(settings["address"], settings["port"])
|
||||
case "trojan", "shadowsocks", "http", "socks":
|
||||
if servers, ok := settings["servers"].([]any); ok {
|
||||
for _, sv := range servers {
|
||||
if sm, ok := sv.(map[string]any); ok {
|
||||
addServer(sm["address"], sm["port"])
|
||||
}
|
||||
}
|
||||
}
|
||||
case "wireguard":
|
||||
if peers, ok := settings["peers"].([]any); ok {
|
||||
for _, p := range peers {
|
||||
if pm, ok := p.(map[string]any); ok {
|
||||
if ep, _ := pm["endpoint"].(string); ep != "" {
|
||||
out = append(out, ep)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func numAsInt(v any) int {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n)
|
||||
case int:
|
||||
return n
|
||||
case int64:
|
||||
return int(n)
|
||||
case string:
|
||||
if i, err := strconv.Atoi(n); err == nil {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (s *OutboundService) testOutboundHTTP(outboundJSON string, testURL string, allOutboundsJSON string) (*TestOutboundResult, error) {
|
||||
if testURL == "" {
|
||||
testURL = "https://www.google.com/generate_204"
|
||||
}
|
||||
|
||||
// Limit to one concurrent test at a time
|
||||
if !testSemaphore.TryLock() {
|
||||
if !httpTestSemaphore.TryLock() {
|
||||
return &TestOutboundResult{
|
||||
Mode: "http",
|
||||
Success: false,
|
||||
Error: "Another outbound test is already running, please wait",
|
||||
}, nil
|
||||
}
|
||||
defer testSemaphore.Unlock()
|
||||
defer httpTestSemaphore.Unlock()
|
||||
|
||||
// Parse the outbound being tested to get its tag
|
||||
var testOutbound map[string]any
|
||||
if err := json.Unmarshal([]byte(outboundJSON), &testOutbound); err != nil {
|
||||
return &TestOutboundResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Invalid outbound JSON: %v", err),
|
||||
}, nil
|
||||
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Invalid outbound JSON: %v", err)}, nil
|
||||
}
|
||||
outboundTag, _ := testOutbound["tag"].(string)
|
||||
if outboundTag == "" {
|
||||
return &TestOutboundResult{
|
||||
Success: false,
|
||||
Error: "Outbound has no tag",
|
||||
}, nil
|
||||
return &TestOutboundResult{Mode: "http", Success: false, Error: "Outbound has no tag"}, nil
|
||||
}
|
||||
if protocol, _ := testOutbound["protocol"].(string); protocol == "blackhole" || outboundTag == "blocked" {
|
||||
return &TestOutboundResult{
|
||||
Success: false,
|
||||
Error: "Blocked/blackhole outbound cannot be tested",
|
||||
}, nil
|
||||
return &TestOutboundResult{Mode: "http", Success: false, Error: "Blocked/blackhole outbound cannot be tested"}, nil
|
||||
}
|
||||
|
||||
// Use all outbounds when provided; otherwise fall back to single outbound
|
||||
var allOutbounds []any
|
||||
if allOutboundsJSON != "" {
|
||||
if err := json.Unmarshal([]byte(allOutboundsJSON), &allOutbounds); err != nil {
|
||||
return &TestOutboundResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Invalid allOutbounds JSON: %v", err),
|
||||
}, nil
|
||||
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Invalid allOutbounds JSON: %v", err)}, nil
|
||||
}
|
||||
}
|
||||
if len(allOutbounds) == 0 {
|
||||
allOutbounds = []any{testOutbound}
|
||||
}
|
||||
|
||||
// Find an available port for test inbound
|
||||
testPort, err := findAvailablePort()
|
||||
if err != nil {
|
||||
return &TestOutboundResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Failed to find available port: %v", err),
|
||||
}, nil
|
||||
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Failed to find available port: %v", err)}, nil
|
||||
}
|
||||
|
||||
// Copy all outbounds as-is, add only test inbound and route rule
|
||||
testConfig := s.createTestConfig(outboundTag, allOutbounds, testPort)
|
||||
|
||||
// Use a temporary config file so the main config.json is never overwritten
|
||||
testConfigPath, err := createTestConfigPath()
|
||||
if err != nil {
|
||||
return &TestOutboundResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Failed to create test config path: %v", err),
|
||||
}, nil
|
||||
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Failed to create test config path: %v", err)}, nil
|
||||
}
|
||||
defer os.Remove(testConfigPath) // ensure temp file is removed even if process is not stopped
|
||||
defer os.Remove(testConfigPath)
|
||||
|
||||
// Create temporary xray process with its own config file
|
||||
testProcess := xray.NewTestProcess(testConfig, testConfigPath)
|
||||
defer func() {
|
||||
if testProcess.IsRunning() {
|
||||
@@ -208,52 +353,24 @@ func (s *OutboundService) TestOutbound(outboundJSON string, testURL string, allO
|
||||
}
|
||||
}()
|
||||
|
||||
// Start the test process
|
||||
if err := testProcess.Start(); err != nil {
|
||||
return &TestOutboundResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Failed to start test xray instance: %v", err),
|
||||
}, nil
|
||||
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Failed to start test xray instance: %v", err)}, nil
|
||||
}
|
||||
|
||||
// Wait for xray to start listening on the test port
|
||||
if err := waitForPort(testPort, 3*time.Second); err != nil {
|
||||
if !testProcess.IsRunning() {
|
||||
result := testProcess.GetResult()
|
||||
return &TestOutboundResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Xray process exited: %s", result),
|
||||
}, nil
|
||||
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Xray process exited: %s", result)}, nil
|
||||
}
|
||||
return &TestOutboundResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Xray failed to start listening: %v", err),
|
||||
}, nil
|
||||
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Xray failed to start listening: %v", err)}, nil
|
||||
}
|
||||
|
||||
// Check if process is still running
|
||||
if !testProcess.IsRunning() {
|
||||
result := testProcess.GetResult()
|
||||
return &TestOutboundResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Xray process exited: %s", result),
|
||||
}, nil
|
||||
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Xray process exited: %s", result)}, nil
|
||||
}
|
||||
|
||||
// Test the connection through proxy
|
||||
delay, statusCode, err := s.testConnection(testPort, testURL)
|
||||
if err != nil {
|
||||
return &TestOutboundResult{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &TestOutboundResult{
|
||||
Success: true,
|
||||
Delay: delay,
|
||||
StatusCode: statusCode,
|
||||
}, nil
|
||||
return s.testConnection(testPort, testURL)
|
||||
}
|
||||
|
||||
// createTestConfig creates a test config by copying all outbounds unchanged and adding
|
||||
@@ -329,55 +446,92 @@ func (s *OutboundService) createTestConfig(outboundTag string, allOutbounds []an
|
||||
return cfg
|
||||
}
|
||||
|
||||
// testConnection tests the connection through the proxy and measures delay.
|
||||
// It performs a warmup request first to establish the SOCKS connection and populate DNS caches,
|
||||
// then measures the second request for a more accurate latency reading.
|
||||
func (s *OutboundService) testConnection(proxyPort int, testURL string) (int64, int, error) {
|
||||
// Create SOCKS5 proxy URL
|
||||
proxyURL := fmt.Sprintf("socks5://127.0.0.1:%d", proxyPort)
|
||||
|
||||
// Parse proxy URL
|
||||
proxyURLParsed, err := url.Parse(proxyURL)
|
||||
// testConnection runs the actual HTTP probe through the local SOCKS proxy.
|
||||
// A warmup request seeds xray's DNS cache / handshake; then a fresh
|
||||
// transport runs the measured request so httptrace sees a real cold
|
||||
// connection and reports DNS/Connect/TLS/TTFB. Note that DNS and Connect
|
||||
// reflect *client → SOCKS-on-loopback*, not the remote target — those
|
||||
// happen inside xray and aren't visible to net/http. TLS and TTFB are
|
||||
// the meaningful breakdown values for a SOCKS-proxied HTTPS probe.
|
||||
func (s *OutboundService) testConnection(proxyPort int, testURL string) (*TestOutboundResult, error) {
|
||||
proxyURLStr := fmt.Sprintf("socks5://127.0.0.1:%d", proxyPort)
|
||||
proxyURLParsed, err := url.Parse(proxyURLStr)
|
||||
if err != nil {
|
||||
return 0, 0, common.NewErrorf("Invalid proxy URL: %v", err)
|
||||
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Invalid proxy URL: %v", err)}, nil
|
||||
}
|
||||
|
||||
// Create HTTP client with proxy and keep-alive for connection reuse
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyURL(proxyURLParsed),
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 5 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
MaxIdleConns: 1,
|
||||
IdleConnTimeout: 10 * time.Second,
|
||||
DisableCompression: true,
|
||||
},
|
||||
mkClient := func() *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyURL(proxyURLParsed),
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 5 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
MaxIdleConns: 1,
|
||||
IdleConnTimeout: 1 * time.Second,
|
||||
DisableCompression: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Warmup request: establishes SOCKS/TLS connection, DNS, and TCP to the target.
|
||||
// This mirrors real-world usage where connections are reused.
|
||||
warmupResp, err := client.Get(testURL)
|
||||
warmup := mkClient()
|
||||
warmupResp, err := warmup.Get(testURL)
|
||||
if err != nil {
|
||||
return 0, 0, common.NewErrorf("Request failed: %v", err)
|
||||
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Request failed: %v", err)}, nil
|
||||
}
|
||||
io.Copy(io.Discard, warmupResp.Body)
|
||||
warmupResp.Body.Close()
|
||||
warmup.CloseIdleConnections()
|
||||
|
||||
// Measure the actual request on the warm connection
|
||||
startTime := time.Now()
|
||||
resp, err := client.Get(testURL)
|
||||
delay := time.Since(startTime).Milliseconds()
|
||||
var dnsStart, dnsDone, connectStart, connectDone, tlsStart, tlsDone, firstByte time.Time
|
||||
trace := &httptrace.ClientTrace{
|
||||
DNSStart: func(_ httptrace.DNSStartInfo) { dnsStart = time.Now() },
|
||||
DNSDone: func(_ httptrace.DNSDoneInfo) { dnsDone = time.Now() },
|
||||
ConnectStart: func(_, _ string) { connectStart = time.Now() },
|
||||
ConnectDone: func(_, _ string, _ error) { connectDone = time.Now() },
|
||||
TLSHandshakeStart: func() { tlsStart = time.Now() },
|
||||
TLSHandshakeDone: func(_ tls.ConnectionState, _ error) { tlsDone = time.Now() },
|
||||
GotFirstResponseByte: func() { firstByte = time.Now() },
|
||||
}
|
||||
|
||||
client := mkClient()
|
||||
defer client.CloseIdleConnections()
|
||||
ctx := httptrace.WithClientTrace(context.Background(), trace)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, testURL, nil)
|
||||
if err != nil {
|
||||
return 0, 0, common.NewErrorf("Request failed: %v", err)
|
||||
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Request build failed: %v", err)}, nil
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
resp, err := client.Do(req)
|
||||
delay := time.Since(startTime).Milliseconds()
|
||||
if err != nil {
|
||||
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Request failed: %v", err)}, nil
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
return delay, resp.StatusCode, nil
|
||||
out := &TestOutboundResult{
|
||||
Mode: "http",
|
||||
Success: true,
|
||||
Delay: delay,
|
||||
StatusCode: resp.StatusCode,
|
||||
}
|
||||
if !dnsStart.IsZero() && !dnsDone.IsZero() {
|
||||
out.DNSMs = dnsDone.Sub(dnsStart).Milliseconds()
|
||||
}
|
||||
if !connectStart.IsZero() && !connectDone.IsZero() {
|
||||
out.ConnectMs = connectDone.Sub(connectStart).Milliseconds()
|
||||
}
|
||||
if !tlsStart.IsZero() && !tlsDone.IsZero() {
|
||||
out.TLSMs = tlsDone.Sub(tlsStart).Milliseconds()
|
||||
}
|
||||
if !firstByte.IsZero() {
|
||||
out.TTFBMs = firstByte.Sub(startTime).Milliseconds()
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// waitForPort polls until the given TCP port is accepting connections or the timeout expires.
|
||||
|
||||
@@ -118,15 +118,16 @@ func isAnyListen(s string) bool {
|
||||
// port-only check, this one understands that tcp/443 and udp/443 are
|
||||
// independent sockets in linux and may coexist on the same address.
|
||||
//
|
||||
// node scope: inbounds with different NodeID run on different physical
|
||||
// machines (local panel xray vs a remote node, or two remote nodes),
|
||||
// so their sockets can't collide. only candidates with the same NodeID
|
||||
// participate in the listen/transport overlap check.
|
||||
//
|
||||
// the listen-overlap rule (specific addr conflicts with any-addr on the
|
||||
// same port, both directions) is preserved from the previous check.
|
||||
func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int) (bool, error) {
|
||||
db := database.GetDB()
|
||||
|
||||
// pull every candidate on this port; we filter by listen-overlap and
|
||||
// transport in go to keep the sql plain. the port column is indexed
|
||||
// in practice by the existing port check, and the candidate set is
|
||||
// tiny (one per coexisting socket family at most).
|
||||
var candidates []*model.Inbound
|
||||
q := db.Model(model.Inbound{}).Where("port = ?", inbound.Port)
|
||||
if ignoreId > 0 {
|
||||
@@ -138,6 +139,9 @@ func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int)
|
||||
|
||||
newBits := inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings)
|
||||
for _, c := range candidates {
|
||||
if !sameNode(c.NodeID, inbound.NodeID) {
|
||||
continue
|
||||
}
|
||||
if !listenOverlaps(c.Listen, inbound.Listen) {
|
||||
continue
|
||||
}
|
||||
@@ -148,6 +152,21 @@ func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// sameNode reports whether two NodeID pointers refer to the same xray
|
||||
// process. nil/nil means both inbounds run on the local panel; non-nil
|
||||
// with equal value means they share the same remote node. any mix
|
||||
// (local vs remote, remote-A vs remote-B) is "different node" and
|
||||
// can't produce a real socket collision.
|
||||
func sameNode(a, b *int) bool {
|
||||
if a == nil && b == nil {
|
||||
return true
|
||||
}
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
// baseInboundTag is the historical "inbound-<port>" / "inbound-<listen>:<port>"
|
||||
// shape. kept exactly so existing routing rules that reference these tags
|
||||
// keep working after the upgrade.
|
||||
@@ -220,6 +239,32 @@ func (s *InboundService) generateInboundTag(inbound *model.Inbound, ignoreId int
|
||||
return "", common.NewError("could not pick a unique inbound tag for port:", inbound.Port)
|
||||
}
|
||||
|
||||
// resolveInboundTag chooses a tag for an Add or Update. when the caller
|
||||
// supplied a non-empty Tag (e.g. the central panel pushed its picked
|
||||
// tag to a node during a multi-node sync) and that tag is free in the
|
||||
// local DB, it's used verbatim so the two panels stay in agreement —
|
||||
// otherwise the node would regenerate (often back to bare
|
||||
// "inbound-<port>") and the eventual traffic sync-back would try to
|
||||
// INSERT a row whose tag already exists, hitting the UNIQUE constraint
|
||||
// on inbounds.tag and rolling the node-side row right back out.
|
||||
// when Tag is empty (the common UI path) or collides, fall back to the
|
||||
// transport-aware generateInboundTag.
|
||||
//
|
||||
// ignoreId mirrors generateInboundTag: pass 0 on add, the inbound's
|
||||
// own id on update so a row doesn't see its own current tag as taken.
|
||||
func (s *InboundService) resolveInboundTag(inbound *model.Inbound, ignoreId int) (string, error) {
|
||||
if inbound.Tag != "" {
|
||||
taken, err := s.tagExists(inbound.Tag, ignoreId)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !taken {
|
||||
return inbound.Tag, nil
|
||||
}
|
||||
}
|
||||
return s.generateInboundTag(inbound, ignoreId)
|
||||
}
|
||||
|
||||
func (s *InboundService) tagExists(tag string, ignoreId int) (bool, error) {
|
||||
db := database.GetDB()
|
||||
q := db.Model(model.Inbound{}).Where("tag = ?", tag)
|
||||
|
||||
@@ -35,6 +35,11 @@ func setupConflictDB(t *testing.T) {
|
||||
}
|
||||
|
||||
func seedInboundConflict(t *testing.T, tag, listen string, port int, protocol model.Protocol, streamSettings, settings string) {
|
||||
t.Helper()
|
||||
seedInboundConflictNode(t, tag, listen, port, protocol, streamSettings, settings, nil)
|
||||
}
|
||||
|
||||
func seedInboundConflictNode(t *testing.T, tag, listen string, port int, protocol model.Protocol, streamSettings, settings string, nodeID *int) {
|
||||
t.Helper()
|
||||
in := &model.Inbound{
|
||||
Tag: tag,
|
||||
@@ -44,12 +49,15 @@ func seedInboundConflict(t *testing.T, tag, listen string, port int, protocol mo
|
||||
Protocol: protocol,
|
||||
StreamSettings: streamSettings,
|
||||
Settings: settings,
|
||||
NodeID: nodeID,
|
||||
}
|
||||
if err := database.GetDB().Create(in).Error; err != nil {
|
||||
t.Fatalf("seed inbound %s: %v", tag, err)
|
||||
}
|
||||
}
|
||||
|
||||
func intPtr(v int) *int { return &v }
|
||||
|
||||
func TestInboundTransports(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -345,6 +353,123 @@ func TestGenerateInboundTag_SpecificListenSameDisambiguation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// inbounds bound to different nodes run on different physical machines,
|
||||
// so the same port + transport must be allowed across nodes. covers
|
||||
// local-vs-remote, remote-A-vs-remote-B, and the still-clashing
|
||||
// same-node case.
|
||||
func TestCheckPortConflict_NodeScope(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
seedInboundConflictNode(t, "local-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`, nil)
|
||||
seedInboundConflictNode(t, "node1-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`, intPtr(1))
|
||||
|
||||
svc := &InboundService{}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
nodeID *int
|
||||
want bool
|
||||
}{
|
||||
{"new local same port + tcp clashes with local", nil, true},
|
||||
{"new remote on different node from local is fine", intPtr(2), false},
|
||||
{"new remote on existing node 1 clashes", intPtr(1), true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
candidate := &model.Inbound{
|
||||
Listen: "0.0.0.0",
|
||||
Port: 443,
|
||||
Protocol: model.VLESS,
|
||||
StreamSettings: `{"network":"tcp"}`,
|
||||
NodeID: c.nodeID,
|
||||
}
|
||||
got, err := svc.checkPortConflict(candidate, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("checkPortConflict: %v", err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Fatalf("got conflict=%v, want %v", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// when the caller passes an explicit non-empty Tag that doesn't collide,
|
||||
// resolveInboundTag returns it verbatim. this is the cross-panel path:
|
||||
// the central panel picks a tag, pushes the inbound to a node, and the
|
||||
// node must keep that exact tag so the eventual traffic sync-back can
|
||||
// match the row by tag. previously the node regenerated and the two
|
||||
// panels diverged, causing a UNIQUE constraint failure on sync.
|
||||
func TestResolveInboundTag_RespectsCallerTagWhenFree(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
seedInboundConflictNode(t, "inbound-5000", "0.0.0.0", 5000, model.VLESS, `{"network":"tcp"}`, `{}`, nil)
|
||||
seedInboundConflictNode(t, "inbound-5000-udp", "0.0.0.0", 5000, model.Hysteria2, ``, ``, nil)
|
||||
|
||||
svc := &InboundService{}
|
||||
pushed := &model.Inbound{
|
||||
Tag: "inbound-5000-tcp",
|
||||
Listen: "0.0.0.0",
|
||||
Port: 5000,
|
||||
Protocol: model.VLESS,
|
||||
StreamSettings: `{"network":"tcp"}`,
|
||||
NodeID: intPtr(1),
|
||||
}
|
||||
got, err := svc.resolveInboundTag(pushed, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveInboundTag: %v", err)
|
||||
}
|
||||
if got != "inbound-5000-tcp" {
|
||||
t.Fatalf("caller tag must be preserved when free, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// when the caller leaves Tag empty (the local UI path) resolveInboundTag
|
||||
// falls back to generateInboundTag, which keeps the historical
|
||||
// "inbound-<port>" shape so existing routing rules don't change.
|
||||
func TestResolveInboundTag_GeneratesWhenTagEmpty(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
svc := &InboundService{}
|
||||
in := &model.Inbound{
|
||||
Listen: "0.0.0.0",
|
||||
Port: 8443,
|
||||
Protocol: model.VLESS,
|
||||
}
|
||||
got, err := svc.resolveInboundTag(in, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveInboundTag: %v", err)
|
||||
}
|
||||
if got != "inbound-8443" {
|
||||
t.Fatalf("expected generated inbound-8443, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// when the caller's Tag collides (e.g. a node that was used standalone
|
||||
// happens to already own the tag the central panel picked),
|
||||
// resolveInboundTag falls back to generateInboundTag rather than
|
||||
// failing — the inbound still lands, just under a slightly different
|
||||
// tag that the central will pick up via the AddInbound response.
|
||||
func TestResolveInboundTag_RegeneratesOnCollision(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
seedInboundConflictNode(t, "inbound-5000-tcp", "0.0.0.0", 5000, model.VLESS, `{"network":"tcp"}`, `{}`, nil)
|
||||
|
||||
svc := &InboundService{}
|
||||
pushed := &model.Inbound{
|
||||
Tag: "inbound-5000-tcp",
|
||||
Listen: "0.0.0.0",
|
||||
Port: 5000,
|
||||
Protocol: model.Hysteria2,
|
||||
StreamSettings: ``,
|
||||
Settings: ``,
|
||||
}
|
||||
got, err := svc.resolveInboundTag(pushed, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveInboundTag: %v", err)
|
||||
}
|
||||
if got == "inbound-5000-tcp" {
|
||||
t.Fatalf("colliding caller tag must be replaced, but resolver kept %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// updating an inbound must not see itself as a conflict, that's what
|
||||
// ignoreId is for.
|
||||
func TestCheckPortConflict_IgnoreSelfOnUpdate(t *testing.T) {
|
||||
|
||||
@@ -976,12 +976,18 @@ func (s *ServerService) ImportDB(file multipart.File) error {
|
||||
return common.NewErrorf("Invalid or corrupt db file: %v", err)
|
||||
}
|
||||
|
||||
// Stop Xray (ignore error but log)
|
||||
xrayStopped := true
|
||||
defer func() {
|
||||
if xrayStopped {
|
||||
if errR := s.RestartXrayService(); errR != nil {
|
||||
logger.Warningf("Failed to restart Xray after DB import error: %v", errR)
|
||||
}
|
||||
}
|
||||
}()
|
||||
if errStop := s.StopXrayService(); errStop != nil {
|
||||
logger.Warningf("Failed to stop Xray before DB import: %v", errStop)
|
||||
}
|
||||
|
||||
// Close existing DB to release file locks (especially on Windows)
|
||||
if errClose := database.CloseDB(); errClose != nil {
|
||||
logger.Warningf("Failed to close existing DB before replacement: %v", errClose)
|
||||
}
|
||||
@@ -1029,7 +1035,7 @@ func (s *ServerService) ImportDB(file multipart.File) error {
|
||||
|
||||
s.inboundService.MigrateDB()
|
||||
|
||||
// Start Xray
|
||||
xrayStopped = false
|
||||
if err = s.RestartXrayService(); err != nil {
|
||||
return common.NewErrorf("Imported DB but failed to start Xray: %v", err)
|
||||
}
|
||||
|
||||
@@ -21,39 +21,64 @@ type trafficWriteRequest struct {
|
||||
}
|
||||
|
||||
var (
|
||||
twMu sync.Mutex
|
||||
twQueue chan *trafficWriteRequest
|
||||
twCtx context.Context
|
||||
twCancel context.CancelFunc
|
||||
twDone chan struct{}
|
||||
twOnce sync.Once
|
||||
)
|
||||
|
||||
// StartTrafficWriter spins up the serial writer goroutine. Safe to call again
|
||||
// after StopTrafficWriter — each Start/Stop cycle gets fresh channels. The
|
||||
// previous sync.Once-based implementation deadlocked after a SIGHUP-driven
|
||||
// panel restart: Stop killed the consumer goroutine but Once prevented Start
|
||||
// from spawning a new one, so every later submitTrafficWrite blocked forever
|
||||
// on <-req.done with no consumer (including the AddTraffic call inside
|
||||
// XrayService.GetXrayConfig that runs from startTask).
|
||||
func StartTrafficWriter() {
|
||||
twOnce.Do(func() {
|
||||
twQueue = make(chan *trafficWriteRequest, trafficWriterQueueSize)
|
||||
twCtx, twCancel = context.WithCancel(context.Background())
|
||||
twDone = make(chan struct{})
|
||||
go runTrafficWriter()
|
||||
})
|
||||
twMu.Lock()
|
||||
defer twMu.Unlock()
|
||||
if twQueue != nil {
|
||||
return
|
||||
}
|
||||
queue := make(chan *trafficWriteRequest, trafficWriterQueueSize)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
twQueue = queue
|
||||
twCancel = cancel
|
||||
twDone = done
|
||||
go runTrafficWriter(queue, ctx, done)
|
||||
}
|
||||
|
||||
// StopTrafficWriter cancels the writer context and waits for the goroutine to
|
||||
// drain any pending requests before returning. Resets the package state so a
|
||||
// subsequent StartTrafficWriter can spawn a fresh consumer.
|
||||
func StopTrafficWriter() {
|
||||
if twCancel != nil {
|
||||
twCancel()
|
||||
<-twDone
|
||||
twMu.Lock()
|
||||
cancel := twCancel
|
||||
done := twDone
|
||||
twQueue = nil
|
||||
twCancel = nil
|
||||
twDone = nil
|
||||
twMu.Unlock()
|
||||
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
if done != nil {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
func runTrafficWriter() {
|
||||
defer close(twDone)
|
||||
func runTrafficWriter(queue chan *trafficWriteRequest, ctx context.Context, done chan struct{}) {
|
||||
defer close(done)
|
||||
for {
|
||||
select {
|
||||
case req := <-twQueue:
|
||||
case req := <-queue:
|
||||
req.done <- safeApply(req.apply)
|
||||
case <-twCtx.Done():
|
||||
case <-ctx.Done():
|
||||
for {
|
||||
select {
|
||||
case req := <-twQueue:
|
||||
case req := <-queue:
|
||||
req.done <- safeApply(req.apply)
|
||||
default:
|
||||
return
|
||||
@@ -74,12 +99,16 @@ func safeApply(fn func() error) (err error) {
|
||||
}
|
||||
|
||||
func submitTrafficWrite(fn func() error) error {
|
||||
if twQueue == nil {
|
||||
twMu.Lock()
|
||||
queue := twQueue
|
||||
twMu.Unlock()
|
||||
|
||||
if queue == nil {
|
||||
return safeApply(fn)
|
||||
}
|
||||
req := &trafficWriteRequest{apply: fn, done: make(chan error, 1)}
|
||||
select {
|
||||
case twQueue <- req:
|
||||
case queue <- req:
|
||||
case <-time.After(trafficWriterSubmitTimeout):
|
||||
return errors.New("traffic writer queue full")
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
"nodes": "النودز",
|
||||
"settings": "إعدادات البانل",
|
||||
"xray": "إعدادات Xray",
|
||||
"apiDocs": "API Docs",
|
||||
"logout": "تسجيل خروج",
|
||||
"link": "إدارة"
|
||||
},
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
"nodes": "Nodes",
|
||||
"settings": "Panel Settings",
|
||||
"xray": "Xray Configs",
|
||||
"apiDocs": "API Docs",
|
||||
"logout": "Log Out",
|
||||
"link": "Manage"
|
||||
},
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
"nodes": "Nodos",
|
||||
"settings": "Configuraciones",
|
||||
"xray": "Ajustes Xray",
|
||||
"apiDocs": "API Docs",
|
||||
"logout": "Cerrar Sesión",
|
||||
"link": "Gestionar"
|
||||
},
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
"nodes": "نودها",
|
||||
"settings": "تنظیمات پنل",
|
||||
"xray": "پیکربندی ایکسری",
|
||||
"apiDocs": "API Docs",
|
||||
"logout": "خروج",
|
||||
"link": "مدیریت"
|
||||
},
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
"nodes": "Node",
|
||||
"settings": "Pengaturan Panel",
|
||||
"xray": "Konfigurasi Xray",
|
||||
"apiDocs": "API Docs",
|
||||
"logout": "Keluar",
|
||||
"link": "Kelola"
|
||||
},
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
"nodes": "ノード",
|
||||
"settings": "パネル設定",
|
||||
"xray": "Xray設定",
|
||||
"apiDocs": "API Docs",
|
||||
"logout": "ログアウト",
|
||||
"link": "リンク管理"
|
||||
},
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
"nodes": "Nós",
|
||||
"settings": "Panel Settings",
|
||||
"xray": "Xray Configs",
|
||||
"apiDocs": "API Docs",
|
||||
"logout": "Sair",
|
||||
"link": "Gerenciar"
|
||||
},
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
"nodes": "Узлы",
|
||||
"settings": "Настройки",
|
||||
"xray": "Настройки Xray",
|
||||
"apiDocs": "API Docs",
|
||||
"logout": "Выход",
|
||||
"link": "Управление"
|
||||
},
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
"nodes": "Düğümler",
|
||||
"settings": "Panel Ayarları",
|
||||
"xray": "Xray Yapılandırmaları",
|
||||
"apiDocs": "API Docs",
|
||||
"logout": "Çıkış Yap",
|
||||
"link": "Yönet"
|
||||
},
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
"nodes": "Вузли",
|
||||
"settings": "Параметри панелі",
|
||||
"xray": "Конфігурації Xray",
|
||||
"apiDocs": "API Docs",
|
||||
"logout": "Вийти",
|
||||
"link": "Керувати"
|
||||
},
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"settings": "Cài đặt bảng điều khiển",
|
||||
"logout": "Đăng xuất",
|
||||
"xray": "Cài đặt Xray",
|
||||
"apiDocs": "API Docs",
|
||||
"link": "Quản lý"
|
||||
},
|
||||
"pages": {
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
"nodes": "节点",
|
||||
"settings": "面板设置",
|
||||
"xray": "Xray 设置",
|
||||
"apiDocs": "API Docs",
|
||||
"logout": "退出登录",
|
||||
"link": "管理"
|
||||
},
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
"nodes": "節點",
|
||||
"settings": "面板設定",
|
||||
"xray": "Xray 設定",
|
||||
"apiDocs": "API Docs",
|
||||
"logout": "退出登入",
|
||||
"link": "管理"
|
||||
},
|
||||
|
||||
+3
-1
@@ -456,7 +456,9 @@ func (s *Server) Stop() error {
|
||||
var err1 error
|
||||
var err2 error
|
||||
if s.httpServer != nil {
|
||||
err1 = s.httpServer.Shutdown(s.ctx)
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer shutdownCancel()
|
||||
err1 = s.httpServer.Shutdown(shutdownCtx)
|
||||
}
|
||||
if s.listener != nil {
|
||||
err2 = s.listener.Close()
|
||||
|
||||
@@ -13,6 +13,6 @@ start_pre(){
|
||||
}
|
||||
reload() {
|
||||
ebegin "Reloading ${RC_SVCNAME}"
|
||||
kill -USR1 $pidfile
|
||||
kill -USR1 $(cat $pidfile)
|
||||
eend $?
|
||||
}
|
||||
@@ -436,7 +436,11 @@ restart() {
|
||||
}
|
||||
|
||||
restart_xray() {
|
||||
systemctl reload x-ui
|
||||
if [[ $release == "alpine" ]]; then
|
||||
rc-service x-ui reload
|
||||
else
|
||||
systemctl reload x-ui
|
||||
fi
|
||||
LOGI "xray-core Restart signal sent successfully, Please check the log information to confirm whether xray restarted successfully"
|
||||
sleep 2
|
||||
show_xray_status
|
||||
@@ -2109,10 +2113,10 @@ actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
|
||||
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
|
||||
|
||||
actionban = <iptables> -I f2b-<name> 1 -s <ip> -j <blocktype>
|
||||
echo "\$(date +"%Y/%m/%d %H:%M:%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> ${iplimit_banned_log_path}
|
||||
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> ${iplimit_banned_log_path}
|
||||
|
||||
actionunban = <iptables> -D f2b-<name> -s <ip> -j <blocktype>
|
||||
echo "\$(date +"%Y/%m/%d %H:%M:%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> ${iplimit_banned_log_path}
|
||||
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> ${iplimit_banned_log_path}
|
||||
|
||||
[Init]
|
||||
name = default
|
||||
|
||||
+96
-22
@@ -10,6 +10,7 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -120,7 +121,8 @@ func NewTestProcess(xrayConfig *Config, configPath string) *Process {
|
||||
}
|
||||
|
||||
type process struct {
|
||||
cmd *exec.Cmd
|
||||
cmd *exec.Cmd
|
||||
done chan struct{}
|
||||
|
||||
version string
|
||||
apiPort int
|
||||
@@ -139,8 +141,15 @@ type process struct {
|
||||
logWriter *LogWriter
|
||||
exitErr error
|
||||
startTime time.Time
|
||||
|
||||
intentionalStop atomic.Bool
|
||||
}
|
||||
|
||||
var (
|
||||
xrayGracefulStopTimeout = 5 * time.Second
|
||||
xrayForceStopTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
// newProcess creates a new internal process struct for Xray.
|
||||
func newProcess(config *Config) *process {
|
||||
return &process{
|
||||
@@ -163,6 +172,13 @@ func (p *process) IsRunning() bool {
|
||||
if p.cmd == nil || p.cmd.Process == nil {
|
||||
return false
|
||||
}
|
||||
if p.done != nil {
|
||||
select {
|
||||
case <-p.done:
|
||||
return false
|
||||
default:
|
||||
}
|
||||
}
|
||||
if p.cmd.ProcessState == nil {
|
||||
return true
|
||||
}
|
||||
@@ -326,27 +342,13 @@ func (p *process) Start() (err error) {
|
||||
}
|
||||
|
||||
cmd := exec.Command(GetBinaryPath(), "-c", configPath)
|
||||
p.cmd = cmd
|
||||
|
||||
cmd.Stdout = p.logWriter
|
||||
cmd.Stderr = p.logWriter
|
||||
|
||||
go func() {
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
// On Windows, killing the process results in "exit status 1" which isn't an error for us
|
||||
if runtime.GOOS == "windows" {
|
||||
errStr := strings.ToLower(err.Error())
|
||||
if strings.Contains(errStr, "exit status 1") {
|
||||
// Suppress noisy log on graceful stop
|
||||
p.exitErr = err
|
||||
return
|
||||
}
|
||||
}
|
||||
logger.Error("Failure in running xray-core:", err)
|
||||
p.exitErr = err
|
||||
}
|
||||
}()
|
||||
err = p.startCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.refreshVersion()
|
||||
p.refreshAPIPort()
|
||||
@@ -354,11 +356,49 @@ func (p *process) Start() (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *process) startCommand(cmd *exec.Cmd) error {
|
||||
p.cmd = cmd
|
||||
p.done = make(chan struct{})
|
||||
p.exitErr = nil
|
||||
p.intentionalStop.Store(false)
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
close(p.done)
|
||||
p.cmd = nil
|
||||
return err
|
||||
}
|
||||
|
||||
go p.waitForCommand(cmd)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *process) waitForCommand(cmd *exec.Cmd) {
|
||||
defer close(p.done)
|
||||
|
||||
err := cmd.Wait()
|
||||
if err == nil || p.intentionalStop.Load() {
|
||||
return
|
||||
}
|
||||
|
||||
// On Windows, killing the process results in "exit status 1" which isn't an error for us.
|
||||
if runtime.GOOS == "windows" {
|
||||
errStr := strings.ToLower(err.Error())
|
||||
if strings.Contains(errStr, "exit status 1") {
|
||||
p.exitErr = err
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
logger.Error("Failure in running xray-core:", err)
|
||||
p.exitErr = err
|
||||
}
|
||||
|
||||
// Stop terminates the running Xray process.
|
||||
func (p *process) Stop() error {
|
||||
if !p.IsRunning() {
|
||||
return errors.New("xray is not running")
|
||||
}
|
||||
p.intentionalStop.Store(true)
|
||||
|
||||
// Remove temporary config file used for test runs so main config is never touched
|
||||
if p.configPath != "" {
|
||||
@@ -371,9 +411,43 @@ func (p *process) Stop() error {
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
return p.cmd.Process.Kill()
|
||||
} else {
|
||||
return p.cmd.Process.Signal(syscall.SIGTERM)
|
||||
if err := p.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
||||
return err
|
||||
}
|
||||
return p.waitForExit(xrayForceStopTimeout)
|
||||
}
|
||||
|
||||
if err := p.cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
||||
if errors.Is(err, os.ErrProcessDone) {
|
||||
return p.waitForExit(xrayForceStopTimeout)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if err := p.waitForExit(xrayGracefulStopTimeout); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Warning("xray-core did not stop after SIGTERM, killing process")
|
||||
if err := p.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
||||
return err
|
||||
}
|
||||
return p.waitForExit(xrayForceStopTimeout)
|
||||
}
|
||||
|
||||
func (p *process) waitForExit(timeout time.Duration) error {
|
||||
if p.done == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-p.done:
|
||||
return nil
|
||||
case <-timer.C:
|
||||
return common.NewErrorf("timed out waiting for xray-core process to stop after %s", timeout)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
//go:build !windows
|
||||
|
||||
package xray
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xuilogger "github.com/mhsanaei/3x-ui/v3/logger"
|
||||
"github.com/op/go-logging"
|
||||
)
|
||||
|
||||
func TestStopWaitsForGracefulExit(t *testing.T) {
|
||||
initProcessTestLogger(t)
|
||||
|
||||
p := startProcessHelper(t, "delayed-term")
|
||||
|
||||
start := time.Now()
|
||||
if err := p.Stop(); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed < 150*time.Millisecond {
|
||||
t.Fatalf("Stop returned before child exited; elapsed=%s", elapsed)
|
||||
}
|
||||
if p.IsRunning() {
|
||||
t.Fatal("process still reports running after Stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntentionalStopDoesNotRecordExitError(t *testing.T) {
|
||||
initProcessTestLogger(t)
|
||||
|
||||
p := startProcessHelper(t, "default-term")
|
||||
|
||||
if err := p.Stop(); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
if err := p.GetErr(); err != nil {
|
||||
t.Fatalf("GetErr after intentional stop = %v, want nil", err)
|
||||
}
|
||||
if result := p.GetResult(); result != "" {
|
||||
t.Fatalf("GetResult after intentional stop = %q, want empty", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopKillsProcessThatIgnoresSIGTERM(t *testing.T) {
|
||||
initProcessTestLogger(t)
|
||||
|
||||
oldGraceful := xrayGracefulStopTimeout
|
||||
oldForce := xrayForceStopTimeout
|
||||
xrayGracefulStopTimeout = 100 * time.Millisecond
|
||||
xrayForceStopTimeout = 2 * time.Second
|
||||
t.Cleanup(func() {
|
||||
xrayGracefulStopTimeout = oldGraceful
|
||||
xrayForceStopTimeout = oldForce
|
||||
})
|
||||
|
||||
p := startProcessHelper(t, "ignore-term")
|
||||
|
||||
if err := p.Stop(); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
if p.IsRunning() {
|
||||
t.Fatal("process still reports running after forced stop")
|
||||
}
|
||||
}
|
||||
|
||||
func initProcessTestLogger(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("XUI_LOG_FOLDER", t.TempDir())
|
||||
xuilogger.InitLogger(logging.ERROR)
|
||||
}
|
||||
|
||||
func startProcessHelper(t *testing.T, mode string) *process {
|
||||
t.Helper()
|
||||
|
||||
readyPath := filepath.Join(t.TempDir(), "ready")
|
||||
cmd := exec.Command(os.Args[0], "-test.run=TestXrayProcessHelper", "--", mode)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"XRAY_PROCESS_HELPER=1",
|
||||
"XRAY_PROCESS_READY="+readyPath,
|
||||
)
|
||||
|
||||
p := newProcess(nil)
|
||||
if err := p.startCommand(cmd); err != nil {
|
||||
t.Fatalf("start helper process: %v", err)
|
||||
}
|
||||
waitForProcessHelperReady(t, readyPath)
|
||||
|
||||
t.Cleanup(func() {
|
||||
if p.IsRunning() {
|
||||
p.intentionalStop.Store(true)
|
||||
_ = p.cmd.Process.Kill()
|
||||
_ = p.waitForExit(2 * time.Second)
|
||||
}
|
||||
})
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
func waitForProcessHelperReady(t *testing.T, readyPath string) {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := os.Stat(readyPath); err == nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("helper process did not become ready")
|
||||
}
|
||||
|
||||
func TestXrayProcessHelper(t *testing.T) {
|
||||
if os.Getenv("XRAY_PROCESS_HELPER") != "1" {
|
||||
return
|
||||
}
|
||||
|
||||
mode := ""
|
||||
for i, arg := range os.Args {
|
||||
if arg == "--" && i+1 < len(os.Args) {
|
||||
mode = os.Args[i+1]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case "delayed-term":
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGTERM)
|
||||
markProcessHelperReady(t)
|
||||
<-sigCh
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
os.Exit(0)
|
||||
case "default-term":
|
||||
markProcessHelperReady(t)
|
||||
select {}
|
||||
case "ignore-term":
|
||||
signal.Ignore(syscall.SIGTERM)
|
||||
markProcessHelperReady(t)
|
||||
select {}
|
||||
default:
|
||||
t.Fatalf("unknown helper mode %q", mode)
|
||||
}
|
||||
}
|
||||
|
||||
func markProcessHelperReady(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
readyPath := os.Getenv("XRAY_PROCESS_READY")
|
||||
if readyPath == "" {
|
||||
t.Fatal("XRAY_PROCESS_READY is not set")
|
||||
}
|
||||
if err := os.WriteFile(readyPath, []byte("ready"), 0644); err != nil {
|
||||
t.Fatalf("write helper ready file: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user