Merge branch 'MHSanaei:main' into main

This commit is contained in:
消失的星球 2026-05-16 13:40:03 +08:00 committed by GitHub
commit 467e5049c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 794 additions and 323 deletions

View file

@ -210,9 +210,9 @@
} }
}, },
"node_modules/@codemirror/view": { "node_modules/@codemirror/view": {
"version": "6.42.1", "version": "6.43.0",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.42.1.tgz", "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz",
"integrity": "sha512-ToN3oFc0nsxNUYVF5P0ztLgbC4UPPjPtA9aKYhkOKQaZASpOUo6ISXyQLP66ctVwlDc+j6Jv0uK5IFALkiXztg==", "integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codemirror/state": "^6.6.0", "@codemirror/state": "^6.6.0",
@ -610,9 +610,9 @@
} }
}, },
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
"version": "0.129.0", "version": "0.130.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
"integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==", "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@ -620,9 +620,9 @@
} }
}, },
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
"integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==", "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -637,9 +637,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-arm64": { "node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz",
"integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==", "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -654,9 +654,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-x64": { "node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz",
"integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==", "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -671,9 +671,9 @@
} }
}, },
"node_modules/@rolldown/binding-freebsd-x64": { "node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz",
"integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==", "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -688,9 +688,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm-gnueabihf": { "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz",
"integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==", "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -705,9 +705,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-gnu": { "node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz",
"integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==", "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -725,9 +725,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-musl": { "node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz",
"integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==", "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -745,9 +745,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-ppc64-gnu": { "node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz",
"integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==", "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -765,9 +765,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-s390x-gnu": { "node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz",
"integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==", "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@ -785,9 +785,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-gnu": { "node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz",
"integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==", "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -805,9 +805,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-musl": { "node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz",
"integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==", "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -825,9 +825,9 @@
} }
}, },
"node_modules/@rolldown/binding-openharmony-arm64": { "node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz",
"integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==", "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -842,9 +842,9 @@
} }
}, },
"node_modules/@rolldown/binding-wasm32-wasi": { "node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz",
"integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==", "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
"cpu": [ "cpu": [
"wasm32" "wasm32"
], ],
@ -861,9 +861,9 @@
} }
}, },
"node_modules/@rolldown/binding-win32-arm64-msvc": { "node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz",
"integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==", "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -878,9 +878,9 @@
} }
}, },
"node_modules/@rolldown/binding-win32-x64-msvc": { "node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz",
"integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==", "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -2715,14 +2715,14 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
"integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==", "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@oxc-project/types": "=0.129.0", "@oxc-project/types": "=0.130.0",
"@rolldown/pluginutils": "1.0.0" "@rolldown/pluginutils": "^1.0.0"
}, },
"bin": { "bin": {
"rolldown": "bin/cli.mjs" "rolldown": "bin/cli.mjs"
@ -2731,27 +2731,27 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0", "@rolldown/binding-android-arm64": "1.0.1",
"@rolldown/binding-darwin-arm64": "1.0.0", "@rolldown/binding-darwin-arm64": "1.0.1",
"@rolldown/binding-darwin-x64": "1.0.0", "@rolldown/binding-darwin-x64": "1.0.1",
"@rolldown/binding-freebsd-x64": "1.0.0", "@rolldown/binding-freebsd-x64": "1.0.1",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0", "@rolldown/binding-linux-arm-gnueabihf": "1.0.1",
"@rolldown/binding-linux-arm64-gnu": "1.0.0", "@rolldown/binding-linux-arm64-gnu": "1.0.1",
"@rolldown/binding-linux-arm64-musl": "1.0.0", "@rolldown/binding-linux-arm64-musl": "1.0.1",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0", "@rolldown/binding-linux-ppc64-gnu": "1.0.1",
"@rolldown/binding-linux-s390x-gnu": "1.0.0", "@rolldown/binding-linux-s390x-gnu": "1.0.1",
"@rolldown/binding-linux-x64-gnu": "1.0.0", "@rolldown/binding-linux-x64-gnu": "1.0.1",
"@rolldown/binding-linux-x64-musl": "1.0.0", "@rolldown/binding-linux-x64-musl": "1.0.1",
"@rolldown/binding-openharmony-arm64": "1.0.0", "@rolldown/binding-openharmony-arm64": "1.0.1",
"@rolldown/binding-wasm32-wasi": "1.0.0", "@rolldown/binding-wasm32-wasi": "1.0.1",
"@rolldown/binding-win32-arm64-msvc": "1.0.0", "@rolldown/binding-win32-arm64-msvc": "1.0.1",
"@rolldown/binding-win32-x64-msvc": "1.0.0" "@rolldown/binding-win32-x64-msvc": "1.0.1"
} }
}, },
"node_modules/rolldown/node_modules/@rolldown/pluginutils": { "node_modules/rolldown/node_modules/@rolldown/pluginutils": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@ -2964,16 +2964,16 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "8.0.12", "version": "8.0.13",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
"integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==", "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
"picomatch": "^4.0.4", "picomatch": "^4.0.4",
"postcss": "^8.5.14", "postcss": "^8.5.14",
"rolldown": "1.0.0", "rolldown": "1.0.1",
"tinyglobby": "^0.2.16" "tinyglobby": "^0.2.16"
}, },
"bin": { "bin": {

View file

@ -51,7 +51,12 @@ export function setupAxios() {
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8'; axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
const basePath = window.X_UI_BASE_PATH; // Read base path from window object or fallback to meta tag (for Cloudflare Rocket Loader compatibility)
let basePath = window.X_UI_BASE_PATH;
if (!basePath) {
const metaTag = document.querySelector('meta[name="base-path"]');
basePath = metaTag ? metaTag.getAttribute('content') : null;
}
if (typeof basePath === 'string' && basePath !== '' && basePath !== '/') { if (typeof basePath === 'string' && basePath !== '' && basePath !== '/') {
axios.defaults.baseURL = basePath; axios.defaults.baseURL = basePath;
} }

View file

@ -4,7 +4,7 @@ import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js'; import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js'; import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js'; import { i18n, readyI18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils'; import { applyDocumentTitle } from '@/utils';
import ApiDocsPage from '@/pages/api-docs/ApiDocsPage.vue'; import ApiDocsPage from '@/pages/api-docs/ApiDocsPage.vue';
@ -16,4 +16,6 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer }); message.config({ getContainer: () => messageContainer });
} }
createApp(ApiDocsPage).use(Antd).use(i18n).mount('#app'); readyI18n().then(() => {
createApp(ApiDocsPage).use(Antd).use(i18n).mount('#app');
});

View file

@ -4,7 +4,7 @@ import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js'; import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js'; import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js'; import { i18n, readyI18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils'; import { applyDocumentTitle } from '@/utils';
import InboundsPage from '@/pages/inbounds/InboundsPage.vue'; import InboundsPage from '@/pages/inbounds/InboundsPage.vue';
@ -16,4 +16,6 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer }); message.config({ getContainer: () => messageContainer });
} }
createApp(InboundsPage).use(Antd).use(i18n).mount('#app'); readyI18n().then(() => {
createApp(InboundsPage).use(Antd).use(i18n).mount('#app');
});

View file

@ -6,7 +6,7 @@ import { setupAxios } from '@/api/axios-init.js';
// Importing useTheme triggers the boot side-effect that applies the // Importing useTheme triggers the boot side-effect that applies the
// stored theme to <body>/<html> before Vue mounts. // stored theme to <body>/<html> before Vue mounts.
import '@/composables/useTheme.js'; import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js'; import { i18n, readyI18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils'; import { applyDocumentTitle } from '@/utils';
import IndexPage from '@/pages/index/IndexPage.vue'; import IndexPage from '@/pages/index/IndexPage.vue';
@ -18,4 +18,6 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer }); message.config({ getContainer: () => messageContainer });
} }
createApp(IndexPage).use(Antd).use(i18n).mount('#app'); readyI18n().then(() => {
createApp(IndexPage).use(Antd).use(i18n).mount('#app');
});

View file

@ -6,18 +6,18 @@ import { setupAxios } from '@/api/axios-init.js';
// Importing this module triggers the boot side-effect that applies the // Importing this module triggers the boot side-effect that applies the
// stored theme to <body>/<html> before Vue renders anything. // stored theme to <body>/<html> before Vue renders anything.
import '@/composables/useTheme.js'; import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js'; import { i18n, readyI18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils'; import { applyDocumentTitle } from '@/utils';
import LoginPage from '@/pages/login/LoginPage.vue'; import LoginPage from '@/pages/login/LoginPage.vue';
setupAxios(); setupAxios();
applyDocumentTitle(); applyDocumentTitle();
// Toasts attach to a #message div the page provides — keeps theme
// styling in sync with the rest of the panel.
const messageContainer = document.getElementById('message'); const messageContainer = document.getElementById('message');
if (messageContainer) { if (messageContainer) {
message.config({ getContainer: () => messageContainer }); message.config({ getContainer: () => messageContainer });
} }
createApp(LoginPage).use(Antd).use(i18n).mount('#app'); readyI18n().then(() => {
createApp(LoginPage).use(Antd).use(i18n).mount('#app');
});

View file

@ -4,7 +4,7 @@ import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js'; import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js'; import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js'; import { i18n, readyI18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils'; import { applyDocumentTitle } from '@/utils';
import NodesPage from '@/pages/nodes/NodesPage.vue'; import NodesPage from '@/pages/nodes/NodesPage.vue';
@ -16,4 +16,6 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer }); message.config({ getContainer: () => messageContainer });
} }
createApp(NodesPage).use(Antd).use(i18n).mount('#app'); readyI18n().then(() => {
createApp(NodesPage).use(Antd).use(i18n).mount('#app');
});

View file

@ -6,7 +6,7 @@ import { setupAxios } from '@/api/axios-init.js';
// Importing useTheme triggers the boot side-effect that applies the // Importing useTheme triggers the boot side-effect that applies the
// stored theme to <body>/<html> before Vue mounts. // stored theme to <body>/<html> before Vue mounts.
import '@/composables/useTheme.js'; import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js'; import { i18n, readyI18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils'; import { applyDocumentTitle } from '@/utils';
import SettingsPage from '@/pages/settings/SettingsPage.vue'; import SettingsPage from '@/pages/settings/SettingsPage.vue';
@ -18,4 +18,6 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer }); message.config({ getContainer: () => messageContainer });
} }
createApp(SettingsPage).use(Antd).use(i18n).mount('#app'); readyI18n().then(() => {
createApp(SettingsPage).use(Antd).use(i18n).mount('#app');
});

View file

@ -7,7 +7,7 @@ import 'ant-design-vue/dist/reset.css';
// with the parsed traffic/quota/expiry view-model and the rendered // with the parsed traffic/quota/expiry view-model and the rendered
// share links — the SPA reads those at mount. // share links — the SPA reads those at mount.
import '@/composables/useTheme.js'; import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js'; import { i18n, readyI18n } from '@/i18n/index.js';
import SubPage from '@/pages/sub/SubPage.vue'; import SubPage from '@/pages/sub/SubPage.vue';
const messageContainer = document.getElementById('message'); const messageContainer = document.getElementById('message');
@ -15,4 +15,6 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer }); message.config({ getContainer: () => messageContainer });
} }
createApp(SubPage).use(Antd).use(i18n).mount('#app'); readyI18n().then(() => {
createApp(SubPage).use(Antd).use(i18n).mount('#app');
});

View file

@ -4,7 +4,7 @@ import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js'; import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js'; import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js'; import { i18n, readyI18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils'; import { applyDocumentTitle } from '@/utils';
import XrayPage from '@/pages/xray/XrayPage.vue'; import XrayPage from '@/pages/xray/XrayPage.vue';
@ -16,4 +16,6 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer }); message.config({ getContainer: () => messageContainer });
} }
createApp(XrayPage).use(Antd).use(i18n).mount('#app'); readyI18n().then(() => {
createApp(XrayPage).use(Antd).use(i18n).mount('#app');
});

View file

@ -1,93 +1,54 @@
// vue-i18n setup. Locale files live in web/translation/*.json — the same
// directory the Go binary embeds, so SPA + Telegram bot + subscription
// page all read from a single source.
//
// Usage in a component:
// import { useI18n } from 'vue-i18n';
// const { t } = useI18n();
// ...
// <span>{{ t('pages.inbounds.email') }}</span>
//
// Or via the global helper exposed on the app:
// <span>{{ $t('pages.inbounds.email') }}</span>
//
// The locale follows the `lang` cookie that LanguageManager already
// reads/writes — switching language anywhere in the app continues to
// trigger a full page reload (matches legacy ergonomics), so we don't
// need a runtime locale switcher here.
import { createI18n } from 'vue-i18n'; import { createI18n } from 'vue-i18n';
import { LanguageManager } from '@/utils'; import { LanguageManager } from '@/utils';
import enUS from '../../../web/translation/en-US.json';
// Lazy-loaded locales — Vite splits each one into its own chunk. We
// eager-load only the active language plus the en-US fallback so the
// initial page payload stays small (the inbounds bundle was sitting
// at ~700kB gzipped with all 13 locales eager; now ~480kB).
//
// LanguageManager.setLanguage() does a full reload on change, so
// "lazy" here effectively means "load only what this page needs for
// its lifetime."
const FALLBACK = 'en-US'; const FALLBACK = 'en-US';
const lazyModules = import.meta.glob('../../../web/translation/*.json'); const lazyModules = import.meta.glob([
const eagerModules = import.meta.glob('../../../web/translation/*.json', { eager: true }); '../../../web/translation/*.json',
'!../../../web/translation/en-US.json',
]);
function moduleKeyFor(code) { function moduleKeyFor(code) {
return `../../../web/translation/${code}.json`; return `../../../web/translation/${code}.json`;
} }
// Resolve the active locale via LanguageManager so the cookie set on
// the legacy panel keeps working after a user upgrades. Falls back
// to en-US when the cookie names a language we don't have.
let active = LanguageManager.getLanguage(); let active = LanguageManager.getLanguage();
if (!Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))) { if (active !== FALLBACK && !Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))) {
active = FALLBACK; active = FALLBACK;
} }
const messages = {};
// Eagerly include the active locale + the fallback (when distinct)
// so the very first render has strings ready. Vite still emits these
// as their own chunks so the user pays for at most two locales.
for (const code of new Set([active, FALLBACK])) {
const mod = eagerModules[moduleKeyFor(code)];
if (mod) messages[code] = mod.default || mod;
}
export const i18n = createI18n({ export const i18n = createI18n({
legacy: false, legacy: false,
// `composition` mode (legacy: false) so `useI18n()` works in
// <script setup> blocks.
globalInjection: true, globalInjection: true,
locale: active, locale: active,
fallbackLocale: FALLBACK, fallbackLocale: FALLBACK,
// Locale JSON is nested by namespace ({pages: {inbounds: {email: ...}}}) messages: { [FALLBACK]: enUS },
// so vue-i18n's default `.`-delimited lookups walk straight into it.
messages,
// The Go side sometimes interpolates `#variable#` into translated
// strings (e.g. xraySwitchVersionDialogDesc). vue-i18n's default
// expects `{var}` — disable warnings about strings that look like
// they don't use the new syntax.
warnHtmlMessage: false, warnHtmlMessage: false,
missingWarn: false, missingWarn: false,
fallbackWarn: false, fallbackWarn: false,
}); });
// Convenience export for non-component contexts (HTTP error toasts,
// stores, etc.) that need to look up a translation outside a setup
// scope.
export function t(key, params) { export function t(key, params) {
return i18n.global.t(key, params || {}); return i18n.global.t(key, params || {});
} }
// loadLocale fetches a locale module on demand and registers it with
// vue-i18n. Pages that switch language at runtime (rather than via
// LanguageManager's reload) can call this to swap strings live.
export async function loadLocale(code) { export async function loadLocale(code) {
const key = moduleKeyFor(code); if (code === FALLBACK) {
const loader = lazyModules[key]; i18n.global.locale.value = FALLBACK;
return true;
}
const loader = lazyModules[moduleKeyFor(code)];
if (!loader) return false; if (!loader) return false;
const mod = await loader(); const mod = await loader();
i18n.global.setLocaleMessage(code, mod.default || mod); i18n.global.setLocaleMessage(code, mod.default || mod);
i18n.global.locale.value = code; i18n.global.locale.value = code;
return true; return true;
} }
export async function readyI18n() {
if (active !== FALLBACK) {
await loadLocale(active);
}
return i18n;
}

View file

@ -2412,20 +2412,25 @@ export class Inbound extends XrayCommonClass {
} }
toJson() { toJson() {
let streamSettings; // Only these protocols use streamSettings
if (this.canEnableStream() || this.stream?.sockopt) { const streamProtocols = [Protocols.VLESS, Protocols.VMESS, Protocols.TROJAN, Protocols.SHADOWSOCKS, Protocols.HYSTERIA];
streamSettings = this.stream.toJson();
} const result = {
return {
port: this.port, port: this.port,
listen: this.listen, listen: this.listen,
protocol: this.protocol, protocol: this.protocol,
settings: this.settings instanceof XrayCommonClass ? this.settings.toJson() : this.settings, settings: this.settings instanceof XrayCommonClass ? this.settings.toJson() : this.settings,
streamSettings: streamSettings,
tag: this.tag, tag: this.tag,
sniffing: this.sniffing.toJson(), sniffing: this.sniffing.toJson(),
clientStats: this.clientStats clientStats: this.clientStats
}; };
// Only add streamSettings if protocol supports it
if (streamProtocols.includes(this.protocol)) {
result.streamSettings = this.stream.toJson();
}
return result;
} }
} }

View file

@ -70,8 +70,11 @@ const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
const inbound = ref(null); const inbound = ref(null);
const dbForm = ref(null); const dbForm = ref(null);
const saving = ref(false); const saving = ref(false);
const advancedJson = ref({ stream: '', sniffing: '', settings: '' }); const advancedStreamText = ref('');
const advancedSniffingText = ref('');
const advancedSettingsText = ref('');
const activeTabKey = ref('basic'); const activeTabKey = ref('basic');
const advancedSectionKey = ref('all');
// Cached default cert/key paths from /panel/setting/defaultSettings // Cached default cert/key paths from /panel/setting/defaultSettings
// powers the "Set default cert" button on the TLS form. // powers the "Set default cert" button on the TLS form.
const defaultCert = ref(''); const defaultCert = ref('');
@ -223,15 +226,7 @@ function freshDbForm() {
function primeAdvancedJson() { function primeAdvancedJson() {
if (!inbound.value) return; if (!inbound.value) return;
try { ['stream', 'sniffing', 'settings'].forEach(stampAdvancedTextFor);
advancedJson.value.stream = JSON.stringify(JSON.parse(inbound.value.stream.toString()), null, 2);
} catch (_e) { /* keep prior text */ }
try {
advancedJson.value.sniffing = JSON.stringify(JSON.parse(inbound.value.sniffing.toString()), null, 2);
} catch (_e) { /* keep prior text */ }
try {
advancedJson.value.settings = JSON.stringify(JSON.parse(inbound.value.settings.toString()), null, 2);
} catch (_e) { /* keep prior text */ }
} }
watch(() => props.open, (next) => { watch(() => props.open, (next) => {
@ -244,39 +239,28 @@ watch(() => props.open, (next) => {
primeAdvancedJson(); primeAdvancedJson();
} }
activeTabKey.value = 'basic'; activeTabKey.value = 'basic';
advancedSectionKey.value = 'all';
fetchDefaultCertSettings(); fetchDefaultCertSettings();
}); });
function applyAdvancedJsonToBasic() { function applyAdvancedJsonToBasic() {
if (!inbound.value) return true; if (!inbound.value) return true;
let parsedSettings; let settings; let streamSettings; let sniffing;
let parsedStream;
let parsedSniffing;
try { try {
parsedSettings = advancedJson.value.settings.trim() settings = parseAdvancedSliceWithLabel(advancedSettingsText.value, settingsFallback(), 'Settings');
? JSON.parse(advancedJson.value.settings) streamSettings = parseAdvancedSliceWithLabel(advancedStreamText.value, streamFallback(), 'Stream');
: inbound.value.settings?.toJson?.(); sniffing = parseAdvancedSliceWithLabel(advancedSniffingText.value, sniffingFallback(), 'Sniffing');
} catch (e) { message.error(`Settings JSON invalid: ${e.message}`); return false; } } catch (_e) { return false; }
try {
parsedStream = advancedJson.value.stream.trim()
? JSON.parse(advancedJson.value.stream)
: inbound.value.stream?.toJson?.();
} catch (e) { message.error(`Stream JSON invalid: ${e.message}`); return false; }
try {
parsedSniffing = advancedJson.value.sniffing.trim()
? JSON.parse(advancedJson.value.sniffing)
: inbound.value.sniffing?.toJson?.();
} catch (e) { message.error(`Sniffing JSON invalid: ${e.message}`); return false; }
try { try {
inbound.value = Inbound.fromJson({ inbound.value = Inbound.fromJson({
port: inbound.value.port, port: inbound.value.port,
listen: inbound.value.listen, listen: inbound.value.listen,
protocol: inbound.value.protocol, protocol: inbound.value.protocol,
settings: parsedSettings, settings,
streamSettings: parsedStream, streamSettings,
tag: inbound.value.tag, tag: inbound.value.tag,
sniffing: parsedSniffing, sniffing,
clientStats: inbound.value.clientStats, clientStats: inbound.value.clientStats,
}); });
} catch (e) { } catch (e) {
@ -324,6 +308,181 @@ function onNetworkChange(next) {
} }
} }
function parseAdvancedSliceOrFallback(rawText, fallbackValue) {
if (!rawText?.trim()) return fallbackValue;
return JSON.parse(rawText);
}
function unwrapWrappedObject(parsed, key) {
if (
parsed
&& typeof parsed === 'object'
&& !Array.isArray(parsed)
&& parsed[key] !== undefined
) {
return parsed[key];
}
return parsed;
}
const settingsFallback = () => inbound.value?.settings?.toJson?.() || {};
const sniffingFallback = () => inbound.value?.sniffing?.toJson?.() || {};
const streamFallback = () => inbound.value?.stream?.toJson?.() || {};
const advancedTextRefs = {
stream: advancedStreamText,
sniffing: advancedSniffingText,
settings: advancedSettingsText,
};
function stampAdvancedTextFor(slice) {
const textRef = advancedTextRefs[slice];
if (!textRef) return;
if (slice === 'stream' && !canEnableStream.value) {
textRef.value = '{}';
return;
}
const obj = inbound.value?.[slice];
if (!obj) return;
try {
textRef.value = JSON.stringify(JSON.parse(obj.toString()), null, 2);
} catch (_e) { /* keep prior text */ }
}
function parseAdvancedSliceWithLabel(rawText, fallback, label) {
try {
return parseAdvancedSliceOrFallback(rawText, fallback);
} catch (e) {
message.error(`${label} JSON invalid: ${e.message}`);
throw e;
}
}
function compactAdvancedJson(raw, fallback, label) {
try {
return JSON.stringify(JSON.parse(raw || fallback));
} catch (e) {
message.error(`${label} JSON invalid: ${e.message}`);
throw e;
}
}
async function withSaving(fn) {
saving.value = true;
try { return await fn(); } finally { saving.value = false; }
}
function makeWrappedAdvancedConfig({ key, textRef, getFallback, label }) {
const invalid = `${label} JSON invalid`;
return computed({
get: () => {
if (!inbound.value) return '';
try {
const value = parseAdvancedSliceOrFallback(textRef.value, getFallback());
return JSON.stringify({ [key]: value }, null, 2);
} catch (_e) {
return '';
}
},
set: (next) => {
let parsed;
try {
parsed = JSON.parse(next);
} catch (e) {
message.error(`${invalid}: ${e.message}`);
return;
}
const unwrapped = unwrapWrappedObject(parsed, key);
if (!unwrapped || typeof unwrapped !== 'object' || Array.isArray(unwrapped)) {
message.error(`${label} JSON must be an object or { ${key}: { ... } }.`);
return;
}
try {
textRef.value = JSON.stringify(unwrapped, null, 2);
} catch (e) {
message.error(`${invalid}: ${e.message}`);
}
},
});
}
const advancedAllConfig = computed({
get: () => {
if (!inbound.value) return '';
try {
const result = {
listen: inbound.value.listen,
port: inbound.value.port,
protocol: inbound.value.protocol,
settings: parseAdvancedSliceOrFallback(advancedSettingsText.value, settingsFallback()),
sniffing: parseAdvancedSliceOrFallback(advancedSniffingText.value, sniffingFallback()),
tag: inbound.value.tag,
};
if (canEnableStream.value) {
result.streamSettings = parseAdvancedSliceOrFallback(advancedStreamText.value, streamFallback());
}
return JSON.stringify(result, null, 2);
} catch (_e) {
return '';
}
},
set: (next) => {
let parsed;
try {
parsed = JSON.parse(next);
} catch (e) {
message.error(`All JSON invalid: ${e.message}`);
return;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
message.error('All JSON must be an inbound object.');
return;
}
try {
if (typeof parsed.listen === 'string') inbound.value.listen = parsed.listen;
if (parsed.port !== undefined) {
const port = Number(parsed.port);
if (Number.isFinite(port)) inbound.value.port = port;
}
if (typeof parsed.protocol === 'string' && PROTOCOLS.includes(parsed.protocol)) {
inbound.value.protocol = parsed.protocol;
}
if (typeof parsed.tag === 'string') inbound.value.tag = parsed.tag;
const existingSettings = parseAdvancedSliceOrFallback(advancedSettingsText.value, settingsFallback());
advancedSettingsText.value = JSON.stringify(parsed.settings ?? existingSettings, null, 2);
advancedSniffingText.value = JSON.stringify(parsed.sniffing ?? sniffingFallback(), null, 2);
advancedStreamText.value = canEnableStream.value
? JSON.stringify(parsed.streamSettings ?? streamFallback(), null, 2)
: '{}';
} catch (e) {
message.error(`All JSON invalid: ${e.message}`);
}
},
});
const advancedSettingsConfig = makeWrappedAdvancedConfig({
key: 'settings',
textRef: advancedSettingsText,
getFallback: settingsFallback,
label: 'Settings',
});
const advancedSniffingConfig = makeWrappedAdvancedConfig({
key: 'sniffing',
textRef: advancedSniffingText,
getFallback: sniffingFallback,
label: 'Sniffing',
});
const advancedStreamConfig = makeWrappedAdvancedConfig({
key: 'streamSettings',
textRef: advancedStreamText,
getFallback: streamFallback,
label: 'Stream',
});
// === Random helpers wired to the form's sync icons ================== // === Random helpers wired to the form's sync icons ==================
function randomEmail(target) { function randomEmail(target) {
if (target) target.email = RandomUtil.randomLowerAndNum(9); if (target) target.email = RandomUtil.randomLowerAndNum(9);
@ -356,16 +515,13 @@ function regenInboundWg() {
// === Reality keygen via existing API ================================= // === Reality keygen via existing API =================================
async function genRealityKeypair() { async function genRealityKeypair() {
saving.value = true; await withSaving(async () => {
try {
const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert'); const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert');
if (msg?.success) { if (msg?.success) {
inbound.value.stream.reality.privateKey = msg.obj.privateKey; inbound.value.stream.reality.privateKey = msg.obj.privateKey;
inbound.value.stream.reality.settings.publicKey = msg.obj.publicKey; inbound.value.stream.reality.settings.publicKey = msg.obj.publicKey;
} }
} finally { });
saving.value = false;
}
} }
function clearRealityKeypair() { function clearRealityKeypair() {
@ -375,16 +531,13 @@ function clearRealityKeypair() {
} }
async function genMldsa65() { async function genMldsa65() {
saving.value = true; await withSaving(async () => {
try {
const msg = await HttpUtil.get('/panel/api/server/getNewmldsa65'); const msg = await HttpUtil.get('/panel/api/server/getNewmldsa65');
if (msg?.success) { if (msg?.success) {
inbound.value.stream.reality.mldsa65Seed = msg.obj.seed; inbound.value.stream.reality.mldsa65Seed = msg.obj.seed;
inbound.value.stream.reality.settings.mldsa65Verify = msg.obj.verify; inbound.value.stream.reality.settings.mldsa65Verify = msg.obj.verify;
} }
} finally { });
saving.value = false;
}
} }
function clearMldsa65() { function clearMldsa65() {
@ -408,8 +561,7 @@ function randomizeShortIds() {
// === ECH cert helpers ================================================ // === ECH cert helpers ================================================
async function getNewEchCert() { async function getNewEchCert() {
if (!inbound.value?.stream?.tls) return; if (!inbound.value?.stream?.tls) return;
saving.value = true; await withSaving(async () => {
try {
const msg = await HttpUtil.post('/panel/api/server/getNewEchCert', { const msg = await HttpUtil.post('/panel/api/server/getNewEchCert', {
sni: inbound.value.stream.tls.sni, sni: inbound.value.stream.tls.sni,
}); });
@ -417,9 +569,7 @@ async function getNewEchCert() {
inbound.value.stream.tls.echServerKeys = msg.obj.echServerKeys; inbound.value.stream.tls.echServerKeys = msg.obj.echServerKeys;
inbound.value.stream.tls.settings.echConfigList = msg.obj.echConfigList; inbound.value.stream.tls.settings.echConfigList = msg.obj.echConfigList;
} }
} finally { });
saving.value = false;
}
} }
function clearEchCert() { function clearEchCert() {
@ -463,17 +613,14 @@ function matchesVlessAuth(block, authId) {
async function getNewVlessEnc(authId) { async function getNewVlessEnc(authId) {
if (!authId || !inbound.value?.settings) return; if (!authId || !inbound.value?.settings) return;
saving.value = true; await withSaving(async () => {
try {
const msg = await HttpUtil.get('/panel/api/server/getNewVlessEnc'); const msg = await HttpUtil.get('/panel/api/server/getNewVlessEnc');
if (!msg?.success) return; if (!msg?.success) return;
const block = (msg.obj?.auths || []).find((a) => matchesVlessAuth(a, authId)); const block = (msg.obj?.auths || []).find((a) => matchesVlessAuth(a, authId));
if (!block) return; if (!block) return;
inbound.value.settings.decryption = block.decryption; inbound.value.settings.decryption = block.decryption;
inbound.value.settings.encryption = block.encryption; inbound.value.settings.encryption = block.encryption;
} finally { });
saving.value = false;
}
} }
function clearVlessEnc() { function clearVlessEnc() {
@ -518,24 +665,16 @@ async function submit() {
if (!inbound.value || !dbForm.value) return; if (!inbound.value || !dbForm.value) return;
saving.value = true; saving.value = true;
try { try {
// Sniffing tab is structured; stream stays JSON for unsupported let streamSettings; let sniffing; let settings;
// transports both go to wire as serialized JSON.
let streamSettings;
let sniffing;
let settings;
try { try {
streamSettings = canEnableStream.value streamSettings = canEnableStream.value
? JSON.stringify(JSON.parse(advancedJson.value.stream)) ? compactAdvancedJson(advancedStreamText.value, '', 'Stream')
: (inbound.value.stream?.sockopt : (inbound.value.stream?.sockopt
? JSON.stringify({ sockopt: inbound.value.stream.sockopt.toJson() }) ? JSON.stringify({ sockopt: inbound.value.stream.sockopt.toJson() })
: ''); : '');
} catch (e) { message.error(`Stream JSON invalid: ${e.message}`); return; } sniffing = compactAdvancedJson(advancedSniffingText.value, inbound.value.sniffing.toString(), 'Sniffing');
try { settings = compactAdvancedJson(advancedSettingsText.value, inbound.value.settings.toString(), 'Settings');
sniffing = JSON.stringify(JSON.parse(advancedJson.value.sniffing || inbound.value.sniffing.toString())); } catch (_e) { return; }
} catch (e) { message.error(`Sniffing JSON invalid: ${e.message}`); return; }
try {
settings = JSON.stringify(JSON.parse(advancedJson.value.settings || inbound.value.settings.toString()));
} catch (e) { message.error(`Settings JSON invalid: ${e.message}`); return; }
// The structured form mutates `inbound.stream` directly when the // The structured form mutates `inbound.stream` directly when the
// user edits TCP/WS/gRPC/HTTPUpgrade fields, but if they touched // user edits TCP/WS/gRPC/HTTPUpgrade fields, but if they touched
@ -591,35 +730,15 @@ const okText = computed(() =>
// Whenever the structured form mutates stream / sniffing / settings, // Whenever the structured form mutates stream / sniffing / settings,
// refresh the matching slice of the Advanced JSON tab so the user // refresh the matching slice of the Advanced JSON tab so the user
// always sees the live state flipping a switch in Sniffing or // always sees the live state.
// editing encryption in Protocol now reflects in Advanced. ['stream', 'sniffing', 'settings'].forEach((slice) => {
watch( watch(
() => inbound.value && JSON.stringify(inbound.value.stream?.toJson?.() || {}), () => inbound.value && JSON.stringify(inbound.value[slice]?.toJson?.() || {}),
() => { () => stampAdvancedTextFor(slice),
if (!inbound.value?.stream) return; );
try { });
advancedJson.value.stream = JSON.stringify(JSON.parse(inbound.value.stream.toString()), null, 2);
} catch (_e) { /* leave as is */ } watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream'));
},
);
watch(
() => inbound.value && JSON.stringify(inbound.value.sniffing?.toJson?.() || {}),
() => {
if (!inbound.value?.sniffing) return;
try {
advancedJson.value.sniffing = JSON.stringify(JSON.parse(inbound.value.sniffing.toString()), null, 2);
} catch (_e) { /* leave as is */ }
},
);
watch(
() => inbound.value && JSON.stringify(inbound.value.settings?.toJson?.() || {}),
() => {
if (!inbound.value?.settings) return;
try {
advancedJson.value.settings = JSON.stringify(JSON.parse(inbound.value.settings.toString()), null, 2);
} catch (_e) { /* leave as is */ }
},
);
</script> </script>
<template> <template>
@ -1005,7 +1124,7 @@ watch(
<a-form-item> <a-form-item>
<template #label> <template #label>
<a-tooltip <a-tooltip
title='Physical interface for outbound traffic. Use "auto" to detect; auto-enabled when Auto system routes is set.'> title="Physical interface for outbound traffic. Use 'auto' to detect; auto-enabled when Auto system routes is set.">
Auto outbounds interface Auto outbounds interface
</a-tooltip> </a-tooltip>
</template> </template>
@ -1944,20 +2063,48 @@ watch(
<!-- ============================== ADVANCED ============================== --> <!-- ============================== ADVANCED ============================== -->
<a-tab-pane key="advanced" :tab="t('pages.xray.advancedTemplate')"> <a-tab-pane key="advanced" :tab="t('pages.xray.advancedTemplate')">
<a-alert type="info" show-icon <div class="advanced-shell">
message="Edit raw stream JSON to access advanced fields we don't yet expose through the form." <div class="advanced-panel">
class="mb-12" /> <div class="advanced-panel__header">
<a-form layout="vertical"> <div>
<a-form-item label="settings (clients, encryption, fallbacks, …)"> <div class="advanced-panel__title">Inbound JSON sections</div>
<JsonEditor v-model:value="advancedJson.settings" min-height="280px" max-height="520px" /> <div class="advanced-panel__subtitle">
</a-form-item> Full inbound JSON and focused editors for settings, sniffing, and streamSettings.
<a-form-item label="streamSettings"> </div>
<JsonEditor v-model:value="advancedJson.stream" min-height="280px" max-height="520px" /> </div>
</a-form-item> </div>
<a-form-item label="sniffing (overrides the Sniffing tab when set)">
<JsonEditor v-model:value="advancedJson.sniffing" min-height="180px" max-height="360px" /> <a-tabs v-model:active-key="advancedSectionKey" class="advanced-inner-tabs">
</a-form-item> <a-tab-pane key="all" tab="All">
</a-form> <div class="advanced-editor-meta">
Full inbound object with all fields in one editor.
</div>
<JsonEditor v-model:value="advancedAllConfig" min-height="340px" max-height="560px" />
</a-tab-pane>
<a-tab-pane key="settings" tab="Settings">
<div class="advanced-editor-meta">
Xray settings block wrapper:
<code>{ settings: { ... } }</code>.
</div>
<JsonEditor v-model:value="advancedSettingsConfig" min-height="320px" max-height="540px" />
</a-tab-pane>
<a-tab-pane key="sniffingSection" tab="Sniffing">
<div class="advanced-editor-meta">
Xray sniffing block wrapper:
<code>{ sniffing: { ... } }</code>.
</div>
<JsonEditor v-model:value="advancedSniffingConfig" min-height="240px" max-height="420px" />
</a-tab-pane>
<a-tab-pane v-if="canEnableStream" key="streamSection" tab="Stream">
<div class="advanced-editor-meta">
Xray stream block wrapper:
<code>{ streamSettings: { ... } }</code>.
</div>
<JsonEditor v-model:value="advancedStreamConfig" min-height="320px" max-height="540px" />
</a-tab-pane>
</a-tabs>
</div>
</div>
</a-tab-pane> </a-tab-pane>
</a-tabs> </a-tabs>
</a-modal> </a-modal>
@ -2033,6 +2180,66 @@ watch(
margin-top: 4px; margin-top: 4px;
} }
.advanced-shell {
display: flex;
flex-direction: column;
gap: 12px;
}
.advanced-panel {
padding: 14px;
border: 1px solid rgba(128, 128, 128, 0.18);
border-radius: 12px;
background: rgba(128, 128, 128, 0.04);
}
.advanced-panel__header {
margin-bottom: 12px;
}
.advanced-panel__title {
font-size: 14px;
font-weight: 600;
line-height: 1.4;
}
.advanced-panel__subtitle {
margin-top: 4px;
opacity: 0.7;
line-height: 1.5;
}
.advanced-inner-tabs :deep(.ant-tabs-nav) {
margin-bottom: 12px;
}
.advanced-inner-tabs :deep(.ant-tabs-tab) {
padding-inline: 14px;
}
.advanced-editor-meta {
margin-bottom: 10px;
opacity: 0.75;
line-height: 1.5;
}
@media (max-width: 768px) {
.advanced-panel {
padding: 12px;
border-radius: 10px;
}
.advanced-inner-tabs :deep(.ant-tabs-tab) {
padding-inline: 10px;
}
}
:global(body.dark) .advanced-panel,
:global(html[data-theme='ultra-dark']) .advanced-panel {
border-color: rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.03);
}
.section-heading { .section-heading {
font-weight: 500; font-weight: 500;
margin: 12px 0 6px; margin: 12px 0 6px;

View file

@ -1,6 +1,7 @@
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { message } from 'ant-design-vue';
import { import {
BarsOutlined, BarsOutlined,
ControlOutlined, ControlOutlined,
@ -18,17 +19,18 @@ import {
DesktopOutlined, DesktopOutlined,
DatabaseOutlined, DatabaseOutlined,
ForkOutlined, ForkOutlined,
CopyOutlined,
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
const { t } = useI18n(); const { t } = useI18n();
import { HttpUtil, SizeFormatter, TimeFormatter } from '@/utils'; import { HttpUtil, SizeFormatter, TimeFormatter, ClipboardManager, FileManager } from '@/utils';
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js'; import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
import { useStatus } from '@/composables/useStatus.js'; import { useStatus } from '@/composables/useStatus.js';
import { useMediaQuery } from '@/composables/useMediaQuery.js'; import { useMediaQuery } from '@/composables/useMediaQuery.js';
import AppSidebar from '@/components/AppSidebar.vue'; import AppSidebar from '@/components/AppSidebar.vue';
import CustomStatistic from '@/components/CustomStatistic.vue'; import CustomStatistic from '@/components/CustomStatistic.vue';
import TextModal from '@/components/TextModal.vue'; import JsonEditor from '@/components/JsonEditor.vue';
import StatusCard from './StatusCard.vue'; import StatusCard from './StatusCard.vue';
import XrayStatusCard from './XrayStatusCard.vue'; import XrayStatusCard from './XrayStatusCard.vue';
import PanelUpdateModal from './PanelUpdateModal.vue'; import PanelUpdateModal from './PanelUpdateModal.vue';
@ -117,7 +119,7 @@ function openTelegram() {
} }
// Legacy "Config" action fetch the rendered xray config and show // Legacy "Config" action fetch the rendered xray config and show
// it as JSON in the shared TextModal (same UX as main). // it as JSON in the config modal with syntax highlighting.
async function openConfig() { async function openConfig() {
loading.value = true; loading.value = true;
try { try {
@ -129,6 +131,17 @@ async function openConfig() {
loading.value = false; loading.value = false;
} }
} }
async function copyConfig() {
const ok = await ClipboardManager.copyText(configText.value || '');
if (ok) {
message.success('Copied');
}
}
function downloadConfig() {
FileManager.downloadTextFile(configText.value, 'config.json');
}
</script> </script>
<template> <template>
@ -360,8 +373,27 @@ async function openConfig() {
<XrayMetricsModal v-model:open="xrayMetricsOpen" /> <XrayMetricsModal v-model:open="xrayMetricsOpen" />
<XrayLogModal v-model:open="xrayLogsOpen" /> <XrayLogModal v-model:open="xrayLogsOpen" />
<VersionModal v-model:open="versionOpen" :status="status" @busy="setBusy" /> <VersionModal v-model:open="versionOpen" :status="status" @busy="setBusy" />
<TextModal v-model:open="configTextOpen" :title="t('pages.index.config')" :content="configText"
file-name="config.json" /> <a-modal v-model:open="configTextOpen" :title="t('pages.index.config')" :width="isMobile ? '100%' : '900px'"
:style="isMobile ? { top: '20px', maxWidth: 'calc(100vw - 16px)' } : {}" :closable="true">
<JsonEditor v-model:value="configText" :min-height="isMobile ? '300px' : '420px'"
:max-height="isMobile ? '500px' : '720px'" :readonly="true" />
<template #footer>
<a-button @click="downloadConfig" :size="isMobile ? 'small' : 'middle'">
<template #icon>
<CloudDownloadOutlined />
</template>
<span v-if="!isMobile">config.json</span>
<span v-else>Download</span>
</a-button>
<a-button type="primary" @click="copyConfig" :size="isMobile ? 'small' : 'middle'">
<template #icon>
<CopyOutlined />
</template>
Copy
</a-button>
</template>
</a-modal>
</a-layout> </a-layout>
</a-config-provider> </a-config-provider>
</template> </template>

View file

@ -163,9 +163,9 @@ async function onSave() {
</a-col> </a-col>
</a-row> </a-row>
<a-form-item label="Allow private address"> <a-form-item :label="t('pages.nodes.allowPrivateAddress')">
<a-switch v-model:checked="form.allowPrivateAddress" /> <a-switch v-model:checked="form.allowPrivateAddress" />
<div class="hint">Enable only for nodes on a private network or VPN.</div> <div class="hint">{{ t('pages.nodes.allowPrivateAddressHint') }}</div>
</a-form-item> </a-form-item>
<a-form-item :label="t('pages.nodes.apiToken')" required> <a-form-item :label="t('pages.nodes.apiToken')" required>

View file

@ -61,6 +61,16 @@ const isValid = computed(
() => !tagEmpty.value && !duplicateTag.value && !emptySelector.value, () => !tagEmpty.value && !duplicateTag.value && !emptySelector.value,
); );
const fallbackSupported = computed(
() => form.strategy === 'leastPing' || form.strategy === 'leastLoad',
);
watch(() => form.strategy, (next) => {
if (next !== 'leastPing' && next !== 'leastLoad') {
form.fallbackTag = '';
}
});
const tagValidateStatus = computed(() => { const tagValidateStatus = computed(() => {
if (tagEmpty.value) return 'error'; if (tagEmpty.value) return 'error';
if (duplicateTag.value) return 'warning'; if (duplicateTag.value) return 'warning';
@ -111,8 +121,9 @@ const okText = computed(() =>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item label="Fallback"> <a-form-item label="Fallback"
<a-select v-model:value="form.fallbackTag" allow-clear> :help="fallbackSupported ? '' : 'Available only with Least ping / Least load'">
<a-select v-model:value="form.fallbackTag" allow-clear :disabled="!fallbackSupported">
<a-select-option v-for="tag in ['', ...outboundTags]" :key="tag || '__empty'" :value="tag"> <a-select-option v-for="tag in ['', ...outboundTags]" :key="tag || '__empty'" :value="tag">
{{ tag || `(${t('none')})` }} {{ tag || `(${t('none')})` }}
</a-select-option> </a-select-option>

View file

@ -145,10 +145,11 @@ function syncObservatories() {
} }
function buildWireBalancer(form) { function buildWireBalancer(form) {
const supportsFallback = form.strategy === 'leastPing' || form.strategy === 'leastLoad';
const out = { const out = {
tag: form.tag, tag: form.tag,
selector: [...form.selector], selector: [...form.selector],
fallbackTag: form.fallbackTag, fallbackTag: supportsFallback ? form.fallbackTag : '',
}; };
if (form.strategy && form.strategy !== 'random') { if (form.strategy && form.strategy !== 'random') {
out.strategy = { type: form.strategy }; out.strategy = { type: form.strategy };

View file

@ -56,6 +56,7 @@ func serveDistPage(c *gin.Context, name string) {
csrfToken = "" csrfToken = ""
} }
csrfMeta := []byte(`<meta name="csrf-token" content="` + htmlpkg.EscapeString(csrfToken) + `">`) csrfMeta := []byte(`<meta name="csrf-token" content="` + htmlpkg.EscapeString(csrfToken) + `">`)
basePathMeta := []byte(`<meta name="base-path" content="` + htmlpkg.EscapeString(basePath) + `">`)
nonceAttr := "" nonceAttr := ""
if nonce := c.GetString("csp_nonce"); nonce != "" { if nonce := c.GetString("csp_nonce"); nonce != "" {
@ -69,6 +70,7 @@ func serveDistPage(c *gin.Context, name string) {
script += `;</script>` script += `;</script>`
inject := []byte(script) inject := []byte(script)
inject = append(inject, csrfMeta...) inject = append(inject, csrfMeta...)
inject = append(inject, basePathMeta...)
inject = append(inject, []byte(`</head>`)...) inject = append(inject, []byte(`</head>`)...)
out := bytes.Replace(body, []byte("</head>"), inject, 1) out := bytes.Replace(body, []byte("</head>"), inject, 1)

View file

@ -87,10 +87,6 @@ func (j *NodeTrafficSyncJob) Run() {
return return
} }
online := j.inboundService.GetOnlineClients()
if online == nil {
online = []string{}
}
lastOnline, err := j.inboundService.GetClientsLastOnline() lastOnline, err := j.inboundService.GetClientsLastOnline()
if err != nil { if err != nil {
logger.Warning("node traffic sync: get last-online failed:", err) logger.Warning("node traffic sync: get last-online failed:", err)
@ -98,6 +94,13 @@ func (j *NodeTrafficSyncJob) Run() {
if lastOnline == nil { if lastOnline == nil {
lastOnline = map[string]int64{} lastOnline = map[string]int64{}
} }
j.inboundService.RefreshOnlineClientsFromMap(lastOnline)
online := j.inboundService.GetOnlineClients()
if online == nil {
online = []string{}
}
websocket.BroadcastTraffic(map[string]any{ websocket.BroadcastTraffic(map[string]any{
"onlineClients": online, "onlineClients": online,
"lastOnlineMap": lastOnline, "lastOnlineMap": lastOnline,

View file

@ -77,10 +77,6 @@ func (j *XrayTrafficJob) Run() {
// a missing/null onlineClients field as "no update", so without this the // a missing/null onlineClients field as "no update", so without this the
// "everyone went offline" transition was silently dropped — stale online // "everyone went offline" transition was silently dropped — stale online
// users lingered in the list and the online filter kept showing them. // users lingered in the list and the online filter kept showing them.
onlineClients := j.inboundService.GetOnlineClients()
if onlineClients == nil {
onlineClients = []string{}
}
lastOnlineMap, err := j.inboundService.GetClientsLastOnline() lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
if err != nil { if err != nil {
logger.Warning("get clients last online failed:", err) logger.Warning("get clients last online failed:", err)
@ -88,6 +84,17 @@ func (j *XrayTrafficJob) Run() {
if lastOnlineMap == nil { if lastOnlineMap == nil {
lastOnlineMap = make(map[string]int64) lastOnlineMap = make(map[string]int64)
} }
// Determine online clients from lastOnline timestamps with a 5-second
// grace period instead of just the current 5-second traffic poll. This
// prevents idle-but-connected clients from randomly disappearing from
// the UI between polling windows.
j.inboundService.RefreshOnlineClientsFromMap(lastOnlineMap)
onlineClients := j.inboundService.GetOnlineClients()
if onlineClients == nil {
onlineClients = []string{}
}
websocket.BroadcastTraffic(map[string]any{ websocket.BroadcastTraffic(map[string]any{
"traffics": traffics, "traffics": traffics,
"clientTraffics": clientTraffics, "clientTraffics": clientTraffics,

View file

@ -278,11 +278,31 @@ func (s *InboundService) checkEmailsExistForClients(clients []model.Client) (str
return "", nil return "", nil
} }
// normalizeStreamSettings clears StreamSettings for protocols that don't use it.
// Only vmess, vless, trojan, shadowsocks, and hysteria protocols use streamSettings.
func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
protocolsWithStream := map[model.Protocol]bool{
model.VMESS: true,
model.VLESS: true,
model.Trojan: true,
model.Shadowsocks: true,
model.Hysteria: true,
model.Hysteria2: true,
}
if !protocolsWithStream[inbound.Protocol] {
inbound.StreamSettings = ""
}
}
// AddInbound creates a new inbound configuration. // AddInbound creates a new inbound configuration.
// It validates port uniqueness, client email uniqueness, and required fields, // It validates port uniqueness, client email uniqueness, and required fields,
// then saves the inbound to the database and optionally adds it to the running Xray instance. // then saves the inbound to the database and optionally adds it to the running Xray instance.
// Returns the created inbound, whether Xray needs restart, and any error. // Returns the created inbound, whether Xray needs restart, and any error.
func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) { func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
// Normalize streamSettings based on protocol
s.normalizeStreamSettings(inbound)
exist, err := s.checkPortConflict(inbound, 0) exist, err := s.checkPortConflict(inbound, 0)
if err != nil { if err != nil {
return inbound, false, err return inbound, false, err
@ -501,6 +521,19 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
return true, nil return true, nil
} }
// Remote nodes interpret DelInbound as a real row delete (it hits
// panel/api/inbounds/del/:id on the remote), so toggling the enable
// switch on a remote inbound used to wipe the row entirely (#4402).
// PATCH the remote row via UpdateInbound instead — preserves the
// settings/client history and just flips the enable flag.
if inbound.NodeID != nil {
if err := rt.UpdateInbound(context.Background(), inbound, inbound); err != nil {
logger.Debug("SetInboundEnable: remote UpdateInbound on", rt.Name(), "failed:", err)
return false, err
}
return false, nil
}
if err := rt.DelInbound(context.Background(), inbound); err != nil && if err := rt.DelInbound(context.Background(), inbound); err != nil &&
!strings.Contains(err.Error(), "not found") { !strings.Contains(err.Error(), "not found") {
logger.Debug("SetInboundEnable: DelInbound on", rt.Name(), "failed:", err) logger.Debug("SetInboundEnable: DelInbound on", rt.Name(), "failed:", err)
@ -510,26 +543,22 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
return needRestart, nil return needRestart, nil
} }
addTarget := inbound
if inbound.NodeID == nil {
runtimeInbound, err := s.buildRuntimeInboundForAPI(db, inbound) runtimeInbound, err := s.buildRuntimeInboundForAPI(db, inbound)
if err != nil { if err != nil {
logger.Debug("SetInboundEnable: build runtime config failed:", err) logger.Debug("SetInboundEnable: build runtime config failed:", err)
return true, nil return true, nil
} }
addTarget = runtimeInbound if err := rt.AddInbound(context.Background(), runtimeInbound); err != nil {
}
if err := rt.AddInbound(context.Background(), addTarget); err != nil {
logger.Debug("SetInboundEnable: AddInbound on", rt.Name(), "failed:", err) logger.Debug("SetInboundEnable: AddInbound on", rt.Name(), "failed:", err)
if inbound.NodeID != nil {
return false, err
}
needRestart = true needRestart = true
} }
return needRestart, nil return needRestart, nil
} }
func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) { func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
// Normalize streamSettings based on protocol
s.normalizeStreamSettings(inbound)
exist, err := s.checkPortConflict(inbound, inbound.Id) exist, err := s.checkPortConflict(inbound, inbound.Id)
if err != nil { if err != nil {
return inbound, false, err return inbound, false, err
@ -1516,6 +1545,13 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
const resetGracePeriodMs int64 = 30000 const resetGracePeriodMs int64 = 30000
// onlineGracePeriodMs must comfortably exceed the 5s traffic-poll interval —
// Xray's stats counters often report a zero delta for an active session across
// a single poll, so a 5s grace would still drop the client on the next tick.
// ~4 polls of slack keeps idle-but-connected clients visible without lingering
// long after a real disconnect.
const onlineGracePeriodMs int64 = 20000
func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot) (bool, error) { func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot) (bool, error) {
var structuralChange bool var structuralChange bool
err := submitTrafficWrite(func() error { err := submitTrafficWrite(func() error {
@ -1857,15 +1893,9 @@ func (s *InboundService) addInboundTraffic(tx *gorm.DB, traffics []*xray.Traffic
func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTraffic) (err error) { func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTraffic) (err error) {
if len(traffics) == 0 { if len(traffics) == 0 {
// Empty onlineUsers
if p != nil {
p.SetOnlineClients(make([]string, 0))
}
return nil return nil
} }
onlineClients := make([]string, 0)
emails := make([]string, 0, len(traffics)) emails := make([]string, 0, len(traffics))
for _, traffic := range traffics { for _, traffic := range traffics {
emails = append(emails, traffic.Email) emails = append(emails, traffic.Email)
@ -1908,14 +1938,10 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
dbClientTraffics[dbTraffic_index].Down += t.Down dbClientTraffics[dbTraffic_index].Down += t.Down
dbClientTraffics[dbTraffic_index].AllTime += t.Up + t.Down dbClientTraffics[dbTraffic_index].AllTime += t.Up + t.Down
if t.Up+t.Down > 0 { if t.Up+t.Down > 0 {
onlineClients = append(onlineClients, t.Email)
dbClientTraffics[dbTraffic_index].LastOnline = now dbClientTraffics[dbTraffic_index].LastOnline = now
} }
} }
// Set onlineUsers
p.SetOnlineClients(onlineClients)
err = tx.Save(dbClientTraffics).Error err = tx.Save(dbClientTraffics).Error
if err != nil { if err != nil {
logger.Warning("AddClientTraffic update data ", err) logger.Warning("AddClientTraffic update data ", err)
@ -3741,6 +3767,19 @@ func (s *InboundService) GetClientsLastOnline() (map[string]int64, error) {
return result, nil return result, nil
} }
func (s *InboundService) RefreshOnlineClientsFromMap(lastOnlineMap map[string]int64) {
now := time.Now().UnixMilli()
newOnlineClients := make([]string, 0, len(lastOnlineMap))
for email, lastOnline := range lastOnlineMap {
if now-lastOnline < onlineGracePeriodMs {
newOnlineClients = append(newOnlineClients, email)
}
}
if p != nil {
p.SetOnlineClients(newOnlineClients)
}
}
func (s *InboundService) FilterAndSortClientEmails(emails []string) ([]string, []string, error) { func (s *InboundService) FilterAndSortClientEmails(emails []string) ([]string, []string, error) {
db := database.GetDB() db := database.GetDB()

View file

@ -190,7 +190,7 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes
wg.Add(1) wg.Add(1)
go func(i int) { go func(i int) {
defer wg.Done() defer wg.Done()
results[i] = probeTCPEndpoint(endpoints[i], 5*time.Second) results[i] = probeEndpoint(endpoints[i], 5*time.Second)
}(i) }(i)
} }
wg.Wait() wg.Wait()
@ -207,7 +207,11 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes
} }
} }
out := &TestOutboundResult{Mode: "tcp", Endpoints: results} mode := "tcp"
if endpoints[0].Network == "udp" {
mode = "udp"
}
out := &TestOutboundResult{Mode: mode, Endpoints: results}
if bestDelay >= 0 { if bestDelay >= 0 {
out.Success = true out.Success = true
out.Delay = bestDelay out.Delay = bestDelay
@ -220,6 +224,22 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes
return out, nil return out, nil
} }
// outboundEndpoint is a host:port plus the transport its proxy actually
// listens on. WireGuard (and WARP, which is WireGuard) is UDP-only, so a
// TCP dial to its peer endpoint always times out — the probe must match
// the transport of the outbound being tested.
type outboundEndpoint struct {
Address string
Network string
}
func probeEndpoint(ep outboundEndpoint, timeout time.Duration) TestEndpointResult {
if ep.Network == "udp" {
return probeUDPEndpoint(ep.Address, timeout)
}
return probeTCPEndpoint(ep.Address, timeout)
}
func probeTCPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult { func probeTCPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult {
r := TestEndpointResult{Address: endpoint} r := TestEndpointResult{Address: endpoint}
start := time.Now() start := time.Now()
@ -234,18 +254,69 @@ func probeTCPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult
return r return r
} }
func extractOutboundEndpoints(ob map[string]any) []string { // probeUDPEndpoint sends a single byte and waits briefly for a reply or
// an ICMP-driven error. WireGuard won't answer an unauthenticated byte,
// so a read timeout is the normal "endpoint reachable" outcome; a
// concrete error (e.g. ECONNREFUSED, "host unreachable") fails the probe.
func probeUDPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult {
r := TestEndpointResult{Address: endpoint}
start := time.Now()
conn, err := net.DialTimeout("udp", endpoint, timeout)
if err != nil {
r.Delay = time.Since(start).Milliseconds()
r.Error = err.Error()
return r
}
defer conn.Close()
if _, werr := conn.Write([]byte{0}); werr != nil {
r.Delay = time.Since(start).Milliseconds()
r.Error = werr.Error()
return r
}
_ = conn.SetReadDeadline(time.Now().Add(timeout))
buf := make([]byte, 64)
_, rerr := conn.Read(buf)
r.Delay = time.Since(start).Milliseconds()
if rerr != nil {
if nerr, ok := rerr.(net.Error); ok && nerr.Timeout() {
r.Success = true
return r
}
r.Error = rerr.Error()
return r
}
r.Success = true
return r
}
func extractOutboundEndpoints(ob map[string]any) []outboundEndpoint {
protocol, _ := ob["protocol"].(string) protocol, _ := ob["protocol"].(string)
settings, _ := ob["settings"].(map[string]any) settings, _ := ob["settings"].(map[string]any)
if settings == nil { if settings == nil {
return nil return nil
} }
var out []string
// Hysteria (and hysteria2 over trojan) is QUIC/UDP. Detect it via the
// outer protocol or via streamSettings.network so trojan-with-hysteria2
// transport gets probed over UDP too. kcp and quic are also UDP-based.
network := "tcp"
if protocol == "hysteria" || protocol == "wireguard" {
network = "udp"
}
if stream, ok := ob["streamSettings"].(map[string]any); ok {
if n, _ := stream["network"].(string); n == "hysteria" || n == "kcp" || n == "quic" {
network = "udp"
}
}
var out []outboundEndpoint
addServer := func(addr any, port any) { addServer := func(addr any, port any) {
host, _ := addr.(string) host, _ := addr.(string)
p := numAsInt(port) p := numAsInt(port)
if host != "" && p > 0 { if host != "" && p > 0 {
out = append(out, fmt.Sprintf("%s:%d", host, p)) out = append(out, outboundEndpoint{Address: fmt.Sprintf("%s:%d", host, p), Network: network})
} }
} }
switch protocol { switch protocol {
@ -259,6 +330,8 @@ func extractOutboundEndpoints(ob map[string]any) []string {
} }
case "vless": case "vless":
addServer(settings["address"], settings["port"]) addServer(settings["address"], settings["port"])
case "hysteria":
addServer(settings["address"], settings["port"])
case "trojan", "shadowsocks", "http", "socks": case "trojan", "shadowsocks", "http", "socks":
if servers, ok := settings["servers"].([]any); ok { if servers, ok := settings["servers"].([]any); ok {
for _, sv := range servers { for _, sv := range servers {
@ -272,7 +345,7 @@ func extractOutboundEndpoints(ob map[string]any) []string {
for _, p := range peers { for _, p := range peers {
if pm, ok := p.(map[string]any); ok { if pm, ok := p.(map[string]any); ok {
if ep, _ := pm["endpoint"].(string); ep != "" { if ep, _ := pm["endpoint"].(string); ep != "" {
out = append(out, ep) out = append(out, outboundEndpoint{Address: ep, Network: network})
} }
} }
} }

View file

@ -1398,6 +1398,25 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
return return
} }
t.addClient(callbackQuery.Message.GetChat().ID, message_text, messageId)
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation"))
case "add_client_set_flow":
if dataArray[1] == "none" {
client_Flow = ""
} else {
client_Flow = dataArray[1]
}
messageId := callbackQuery.Message.GetMessageID()
inbound, err := t.inboundService.GetInbound(receiver_inbound_ID)
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
message_text, err := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol)
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
t.addClient(callbackQuery.Message.GetChat().ID, message_text, messageId) t.addClient(callbackQuery.Message.GetChat().ID, message_text, messageId)
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation")) t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation"))
case "add_client_ip_limit_in": case "add_client_ip_limit_in":
@ -1865,6 +1884,22 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
), ),
) )
t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard)
case "add_client_ch_default_flow":
inlineKeyboard := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("add_client_default_traffic_exp")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton("None").WithCallbackData(t.encodeQuery("add_client_set_flow none")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton("xtls-rprx-vision").WithCallbackData(t.encodeQuery("add_client_set_flow xtls-rprx-vision")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton("xtls-rprx-vision-udp443").WithCallbackData(t.encodeQuery("add_client_set_flow xtls-rprx-vision-udp443")),
),
)
t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard)
case "add_client_ch_default_ip_limit": case "add_client_ch_default_ip_limit":
inlineKeyboard := tu.InlineKeyboard( inlineKeyboard := tu.InlineKeyboard(
tu.InlineKeyboardRow( tu.InlineKeyboardRow(
@ -3345,6 +3380,25 @@ func (t *Tgbot) getCommonClientButtons() [][]telego.InlineKeyboardButton {
} }
} }
// inboundCanEnableTlsFlow mirrors Inbound.canEnableTlsFlow() from the frontend
// model: xtls-rprx-vision is only valid on VLESS-over-TCP with TLS or Reality.
func inboundCanEnableTlsFlow(ib *model.Inbound) bool {
if ib == nil || ib.Protocol != model.VLESS {
return false
}
var stream struct {
Network string `json:"network"`
Security string `json:"security"`
}
if err := json.Unmarshal([]byte(ib.StreamSettings), &stream); err != nil {
return false
}
if stream.Network != "tcp" {
return false
}
return stream.Security == "tls" || stream.Security == "reality"
}
// addClient handles the process of adding a new client to an inbound. // addClient handles the process of adding a new client to an inbound.
func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) { func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) {
inbound, err := t.inboundService.GetInbound(receiver_inbound_ID) inbound, err := t.inboundService.GetInbound(receiver_inbound_ID)
@ -3357,13 +3411,31 @@ func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) {
var protocolRows [][]telego.InlineKeyboardButton var protocolRows [][]telego.InlineKeyboardButton
switch protocol { switch protocol {
case model.VMESS, model.VLESS: case model.VMESS:
protocolRows = [][]telego.InlineKeyboardButton{ protocolRows = [][]telego.InlineKeyboardButton{
tu.InlineKeyboardRow( tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"), tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_id")).WithCallbackData("add_client_ch_default_id"), tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_id")).WithCallbackData("add_client_ch_default_id"),
), ),
} }
case model.VLESS:
protocolRows = [][]telego.InlineKeyboardButton{
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_id")).WithCallbackData("add_client_ch_default_id"),
),
}
if inboundCanEnableTlsFlow(inbound) {
flowLabel := t.I18nBot("tgbot.buttons.change_flow")
if client_Flow != "" {
flowLabel = flowLabel + ": " + client_Flow
}
protocolRows = append(protocolRows, tu.InlineKeyboardRow(
tu.InlineKeyboardButton(flowLabel).WithCallbackData("add_client_ch_default_flow"),
))
} else if client_Flow != "" {
client_Flow = ""
}
case model.Trojan: case model.Trojan:
protocolRows = [][]telego.InlineKeyboardButton{ protocolRows = [][]telego.InlineKeyboardButton{
tu.InlineKeyboardRow( tu.InlineKeyboardRow(

View file

@ -418,6 +418,8 @@
"apiTokenHint": "البانل البعيد بيعرض توكن API بتاعه في الإعدادات → توكن API.", "apiTokenHint": "البانل البعيد بيعرض توكن API بتاعه في الإعدادات → توكن API.",
"regenerate": "تجديد التوكن", "regenerate": "تجديد التوكن",
"regenerateConfirm": "تجديد التوكن هيلغي التوكن الحالي. أي بانل مركزي بيستخدمه هيفقد الصلاحية لحد ما تحدّث التوكن. تكمّل؟", "regenerateConfirm": "تجديد التوكن هيلغي التوكن الحالي. أي بانل مركزي بيستخدمه هيفقد الصلاحية لحد ما تحدّث التوكن. تكمّل؟",
"allowPrivateAddress": "السماح بالعنوان الخاص",
"allowPrivateAddressHint": "التفعيل فقط للعقد على شبكة خاصة أو VPN.",
"enable": "مفعل", "enable": "مفعل",
"status": "الحالة", "status": "الحالة",
"cpu": "المعالج", "cpu": "المعالج",
@ -950,6 +952,7 @@
"change_password": "⚙️🔑 كلمة السر", "change_password": "⚙️🔑 كلمة السر",
"change_email": "⚙️📧 البريد الإلكتروني", "change_email": "⚙️📧 البريد الإلكتروني",
"change_comment": "⚙️💬 تعليق", "change_comment": "⚙️💬 تعليق",
"change_flow": "⚙️🚦 التدفق",
"ResetAllTraffics": "إعادة ضبط جميع الترافيك", "ResetAllTraffics": "إعادة ضبط جميع الترافيك",
"SortedTrafficUsageReport": "تقرير استخدام الترافيك المرتب" "SortedTrafficUsageReport": "تقرير استخدام الترافيك المرتب"
}, },

View file

@ -418,6 +418,8 @@
"apiTokenHint": "The remote panel exposes its API token under Settings → API Token.", "apiTokenHint": "The remote panel exposes its API token under Settings → API Token.",
"regenerate": "Regenerate Token", "regenerate": "Regenerate Token",
"regenerateConfirm": "Regenerating invalidates the current token. Any central panel using it will lose access until updated. Continue?", "regenerateConfirm": "Regenerating invalidates the current token. Any central panel using it will lose access until updated. Continue?",
"allowPrivateAddress": "Allow private address",
"allowPrivateAddressHint": "Enable only for nodes on a private network or VPN.",
"enable": "Enabled", "enable": "Enabled",
"status": "Status", "status": "Status",
"cpu": "CPU", "cpu": "CPU",
@ -950,6 +952,7 @@
"change_password": "⚙️🔑 Password", "change_password": "⚙️🔑 Password",
"change_email": "⚙️📧 Email", "change_email": "⚙️📧 Email",
"change_comment": "⚙️💬 Comment", "change_comment": "⚙️💬 Comment",
"change_flow": "⚙️🚦 Flow",
"ResetAllTraffics": "Reset All Traffics", "ResetAllTraffics": "Reset All Traffics",
"SortedTrafficUsageReport": "Sorted Traffic Usage Report" "SortedTrafficUsageReport": "Sorted Traffic Usage Report"
}, },

View file

@ -418,6 +418,8 @@
"apiTokenHint": "El panel remoto expone su token de API en Configuración → Token de API.", "apiTokenHint": "El panel remoto expone su token de API en Configuración → Token de API.",
"regenerate": "Regenerar token", "regenerate": "Regenerar token",
"regenerateConfirm": "Regenerar invalida el token actual. Cualquier panel central que lo use perderá el acceso hasta que se actualice. ¿Continuar?", "regenerateConfirm": "Regenerar invalida el token actual. Cualquier panel central que lo use perderá el acceso hasta que se actualice. ¿Continuar?",
"allowPrivateAddress": "Permitir dirección privada",
"allowPrivateAddressHint": "Habilitar solo para nodos en una red privada o VPN.",
"enable": "Habilitado", "enable": "Habilitado",
"status": "Estado", "status": "Estado",
"cpu": "CPU", "cpu": "CPU",
@ -950,6 +952,7 @@
"change_password": "⚙️🔑 Contraseña", "change_password": "⚙️🔑 Contraseña",
"change_email": "⚙️📧 Correo electrónico", "change_email": "⚙️📧 Correo electrónico",
"change_comment": "⚙️💬 Comentario", "change_comment": "⚙️💬 Comentario",
"change_flow": "⚙️🚦 Flujo",
"ResetAllTraffics": "Reiniciar todo el tráfico", "ResetAllTraffics": "Reiniciar todo el tráfico",
"SortedTrafficUsageReport": "Informe de uso de tráfico ordenado" "SortedTrafficUsageReport": "Informe de uso de tráfico ordenado"
}, },

View file

@ -418,6 +418,8 @@
"apiTokenHint": "پنل ریموت توکن API خودش را در بخش تنظیمات → توکن API نمایش می‌دهد.", "apiTokenHint": "پنل ریموت توکن API خودش را در بخش تنظیمات → توکن API نمایش می‌دهد.",
"regenerate": "تولید مجدد توکن", "regenerate": "تولید مجدد توکن",
"regenerateConfirm": "تولید مجدد، توکن فعلی را باطل می‌کند. هر پنل مرکزی‌ای که از این توکن استفاده می‌کند تا زمان به‌روزرسانی، دسترسی‌اش قطع می‌شود. ادامه می‌دهید؟", "regenerateConfirm": "تولید مجدد، توکن فعلی را باطل می‌کند. هر پنل مرکزی‌ای که از این توکن استفاده می‌کند تا زمان به‌روزرسانی، دسترسی‌اش قطع می‌شود. ادامه می‌دهید؟",
"allowPrivateAddress": "اجازه آدرس خصوصی",
"allowPrivateAddressHint": "فقط برای نودهای روی شبکه خصوصی یا VPN فعال شود.",
"enable": "فعال", "enable": "فعال",
"status": "وضعیت", "status": "وضعیت",
"cpu": "پردازنده", "cpu": "پردازنده",
@ -950,6 +952,7 @@
"change_password": "⚙️🔑 گذرواژه", "change_password": "⚙️🔑 گذرواژه",
"change_email": "⚙️📧 ایمیل", "change_email": "⚙️📧 ایمیل",
"change_comment": "⚙️💬 نظر", "change_comment": "⚙️💬 نظر",
"change_flow": "⚙️🚦 جریان",
"ResetAllTraffics": "بازنشانی همه ترافیک‌ها", "ResetAllTraffics": "بازنشانی همه ترافیک‌ها",
"SortedTrafficUsageReport": "گزارش استفاده از ترافیک مرتب‌شده" "SortedTrafficUsageReport": "گزارش استفاده از ترافیک مرتب‌شده"
}, },

View file

@ -418,6 +418,8 @@
"apiTokenHint": "Panel jarak jauh menampilkan token API-nya di Pengaturan → Token API.", "apiTokenHint": "Panel jarak jauh menampilkan token API-nya di Pengaturan → Token API.",
"regenerate": "Buat Ulang Token", "regenerate": "Buat Ulang Token",
"regenerateConfirm": "Membuat ulang akan membatalkan token saat ini. Setiap panel pusat yang menggunakannya akan kehilangan akses sampai diperbarui. Lanjutkan?", "regenerateConfirm": "Membuat ulang akan membatalkan token saat ini. Setiap panel pusat yang menggunakannya akan kehilangan akses sampai diperbarui. Lanjutkan?",
"allowPrivateAddress": "Izinkan alamat pribadi",
"allowPrivateAddressHint": "Aktifkan hanya untuk node di jaringan pribadi atau VPN.",
"enable": "Aktif", "enable": "Aktif",
"status": "Status", "status": "Status",
"cpu": "CPU", "cpu": "CPU",
@ -950,6 +952,7 @@
"change_password": "⚙️🔑 Kata Sandi", "change_password": "⚙️🔑 Kata Sandi",
"change_email": "⚙️📧 Email", "change_email": "⚙️📧 Email",
"change_comment": "⚙️💬 Komentar", "change_comment": "⚙️💬 Komentar",
"change_flow": "⚙️🚦 Flow",
"ResetAllTraffics": "Reset Semua Lalu Lintas", "ResetAllTraffics": "Reset Semua Lalu Lintas",
"SortedTrafficUsageReport": "Laporan Penggunaan Lalu Lintas yang Terurut" "SortedTrafficUsageReport": "Laporan Penggunaan Lalu Lintas yang Terurut"
}, },

View file

@ -418,6 +418,8 @@
"apiTokenHint": "リモートパネルでは、設定 → APIトークン でAPIトークンを確認できます。", "apiTokenHint": "リモートパネルでは、設定 → APIトークン でAPIトークンを確認できます。",
"regenerate": "トークンを再生成", "regenerate": "トークンを再生成",
"regenerateConfirm": "再生成すると現在のトークンは無効になります。これを使用しているすべての中央パネルは更新されるまでアクセスできなくなります。続行しますか?", "regenerateConfirm": "再生成すると現在のトークンは無効になります。これを使用しているすべての中央パネルは更新されるまでアクセスできなくなります。続行しますか?",
"allowPrivateAddress": "プライベートアドレスを許可",
"allowPrivateAddressHint": "プライベートネットワークまたはVPN上のードにのみ有効にします。",
"enable": "有効", "enable": "有効",
"status": "ステータス", "status": "ステータス",
"cpu": "CPU", "cpu": "CPU",
@ -950,6 +952,7 @@
"change_password": "⚙️🔑 パスワード", "change_password": "⚙️🔑 パスワード",
"change_email": "⚙️📧 メールアドレス", "change_email": "⚙️📧 メールアドレス",
"change_comment": "⚙️💬 コメント", "change_comment": "⚙️💬 コメント",
"change_flow": "⚙️🚦 フロー",
"ResetAllTraffics": "すべてのトラフィックをリセット", "ResetAllTraffics": "すべてのトラフィックをリセット",
"SortedTrafficUsageReport": "ソートされたトラフィック使用レポート" "SortedTrafficUsageReport": "ソートされたトラフィック使用レポート"
}, },

View file

@ -418,6 +418,8 @@
"apiTokenHint": "O painel remoto exibe o token da API em Configurações → Token da API.", "apiTokenHint": "O painel remoto exibe o token da API em Configurações → Token da API.",
"regenerate": "Regenerar token", "regenerate": "Regenerar token",
"regenerateConfirm": "Regenerar invalida o token atual. Qualquer painel central que o utilize perderá acesso até ser atualizado. Continuar?", "regenerateConfirm": "Regenerar invalida o token atual. Qualquer painel central que o utilize perderá acesso até ser atualizado. Continuar?",
"allowPrivateAddress": "Permitir endereço privado",
"allowPrivateAddressHint": "Ativar apenas para nós em uma rede privada ou VPN.",
"enable": "Ativado", "enable": "Ativado",
"status": "Status", "status": "Status",
"cpu": "CPU", "cpu": "CPU",
@ -950,6 +952,7 @@
"change_password": "⚙️🔑 Senha", "change_password": "⚙️🔑 Senha",
"change_email": "⚙️📧 E-mail", "change_email": "⚙️📧 E-mail",
"change_comment": "⚙️💬 Comentário", "change_comment": "⚙️💬 Comentário",
"change_flow": "⚙️🚦 Fluxo",
"ResetAllTraffics": "Redefinir Todo o Tráfego", "ResetAllTraffics": "Redefinir Todo o Tráfego",
"SortedTrafficUsageReport": "Relatório de Uso de Tráfego Ordenado" "SortedTrafficUsageReport": "Relatório de Uso de Tráfego Ordenado"
}, },

View file

@ -418,6 +418,8 @@
"apiTokenHint": "Удалённая панель показывает свой токен API в разделе Настройки → Токен API.", "apiTokenHint": "Удалённая панель показывает свой токен API в разделе Настройки → Токен API.",
"regenerate": "Сгенерировать токен заново", "regenerate": "Сгенерировать токен заново",
"regenerateConfirm": "Повторная генерация аннулирует текущий токен. Любая центральная панель, использующая его, потеряет доступ до обновления. Продолжить?", "regenerateConfirm": "Повторная генерация аннулирует текущий токен. Любая центральная панель, использующая его, потеряет доступ до обновления. Продолжить?",
"allowPrivateAddress": "Разрешить частный адрес",
"allowPrivateAddressHint": "Включить только для узлов в частной сети или VPN.",
"enable": "Включён", "enable": "Включён",
"status": "Статус", "status": "Статус",
"cpu": "CPU", "cpu": "CPU",
@ -950,6 +952,7 @@
"change_password": "⚙️🔑 Пароль", "change_password": "⚙️🔑 Пароль",
"change_email": "⚙️📧 Email", "change_email": "⚙️📧 Email",
"change_comment": "⚙️💬 Комментарий", "change_comment": "⚙️💬 Комментарий",
"change_flow": "⚙️🚦 Поток",
"ResetAllTraffics": "Сбросить весь трафик", "ResetAllTraffics": "Сбросить весь трафик",
"SortedTrafficUsageReport": "Отсортированный отчет об использовании трафика" "SortedTrafficUsageReport": "Отсортированный отчет об использовании трафика"
}, },

View file

@ -418,6 +418,8 @@
"apiTokenHint": "Uzak panel API token'ını Ayarlar → API Token altında gösterir.", "apiTokenHint": "Uzak panel API token'ını Ayarlar → API Token altında gösterir.",
"regenerate": "Token'ı Yeniden Oluştur", "regenerate": "Token'ı Yeniden Oluştur",
"regenerateConfirm": "Yeniden oluşturmak mevcut token'ı geçersiz kılar. Onu kullanan tüm merkezi paneller, güncellenene kadar erişimini kaybeder. Devam edilsin mi?", "regenerateConfirm": "Yeniden oluşturmak mevcut token'ı geçersiz kılar. Onu kullanan tüm merkezi paneller, güncellenene kadar erişimini kaybeder. Devam edilsin mi?",
"allowPrivateAddress": "Özel adrese izin ver",
"allowPrivateAddressHint": "Yalnızca özel ağ veya VPN üzerindeki düğümler için etkinleştir.",
"enable": "Etkin", "enable": "Etkin",
"status": "Durum", "status": "Durum",
"cpu": "CPU", "cpu": "CPU",
@ -950,6 +952,7 @@
"change_password": "⚙️🔑 Şifre", "change_password": "⚙️🔑 Şifre",
"change_email": "⚙️📧 E-posta", "change_email": "⚙️📧 E-posta",
"change_comment": "⚙️💬 Yorum", "change_comment": "⚙️💬 Yorum",
"change_flow": "⚙️🚦 Akış",
"ResetAllTraffics": "Tüm Trafikleri Sıfırla", "ResetAllTraffics": "Tüm Trafikleri Sıfırla",
"SortedTrafficUsageReport": "Sıralı Trafik Kullanım Raporu" "SortedTrafficUsageReport": "Sıralı Trafik Kullanım Raporu"
}, },

View file

@ -418,6 +418,8 @@
"apiTokenHint": "Віддалена панель показує свій токен API в Налаштуваннях → Токен API.", "apiTokenHint": "Віддалена панель показує свій токен API в Налаштуваннях → Токен API.",
"regenerate": "Перегенерувати токен", "regenerate": "Перегенерувати токен",
"regenerateConfirm": "Перегенерація скасовує поточний токен. Будь-яка центральна панель, що його використовує, втратить доступ до оновлення. Продовжити?", "regenerateConfirm": "Перегенерація скасовує поточний токен. Будь-яка центральна панель, що його використовує, втратить доступ до оновлення. Продовжити?",
"allowPrivateAddress": "Дозволити приватну адресу",
"allowPrivateAddressHint": "Увімкнути лише для вузлів у приватній мережі або VPN.",
"enable": "Увімкнено", "enable": "Увімкнено",
"status": "Статус", "status": "Статус",
"cpu": "CPU", "cpu": "CPU",
@ -950,6 +952,7 @@
"change_password": "⚙️🔑 Пароль", "change_password": "⚙️🔑 Пароль",
"change_email": "⚙️📧 Електронна пошта", "change_email": "⚙️📧 Електронна пошта",
"change_comment": "⚙️💬 Коментар", "change_comment": "⚙️💬 Коментар",
"change_flow": "⚙️🚦 Потік",
"ResetAllTraffics": "Скинути весь трафік", "ResetAllTraffics": "Скинути весь трафік",
"SortedTrafficUsageReport": "Відсортований звіт про використання трафіку" "SortedTrafficUsageReport": "Відсортований звіт про використання трафіку"
}, },

View file

@ -418,6 +418,8 @@
"apiTokenHint": "Panel từ xa hiển thị token API tại Cài đặt → Token API.", "apiTokenHint": "Panel từ xa hiển thị token API tại Cài đặt → Token API.",
"regenerate": "Tạo lại token", "regenerate": "Tạo lại token",
"regenerateConfirm": "Tạo lại sẽ vô hiệu hóa token hiện tại. Mọi panel trung tâm dùng nó sẽ mất quyền truy cập cho đến khi được cập nhật. Tiếp tục?", "regenerateConfirm": "Tạo lại sẽ vô hiệu hóa token hiện tại. Mọi panel trung tâm dùng nó sẽ mất quyền truy cập cho đến khi được cập nhật. Tiếp tục?",
"allowPrivateAddress": "Cho phép địa chỉ riêng",
"allowPrivateAddressHint": "Chỉ bật cho các nút trên mạng riêng hoặc VPN.",
"enable": "Kích hoạt", "enable": "Kích hoạt",
"status": "Trạng thái", "status": "Trạng thái",
"cpu": "CPU", "cpu": "CPU",
@ -950,6 +952,7 @@
"change_password": "⚙️🔑 Mật Khẩu", "change_password": "⚙️🔑 Mật Khẩu",
"change_email": "⚙️📧 Email", "change_email": "⚙️📧 Email",
"change_comment": "⚙️💬 Bình Luận", "change_comment": "⚙️💬 Bình Luận",
"change_flow": "⚙️🚦 Flow",
"ResetAllTraffics": "Đặt lại tất cả lưu lượng", "ResetAllTraffics": "Đặt lại tất cả lưu lượng",
"SortedTrafficUsageReport": "Báo cáo sử dụng lưu lượng đã sắp xếp" "SortedTrafficUsageReport": "Báo cáo sử dụng lưu lượng đã sắp xếp"
}, },

View file

@ -418,6 +418,8 @@
"apiTokenHint": "远程面板在 设置 → API 令牌 中显示其 API 令牌。", "apiTokenHint": "远程面板在 设置 → API 令牌 中显示其 API 令牌。",
"regenerate": "重新生成令牌", "regenerate": "重新生成令牌",
"regenerateConfirm": "重新生成会使当前令牌失效。任何使用该令牌的中央面板都会失去访问权限,直至更新。是否继续?", "regenerateConfirm": "重新生成会使当前令牌失效。任何使用该令牌的中央面板都会失去访问权限,直至更新。是否继续?",
"allowPrivateAddress": "允许私有地址",
"allowPrivateAddressHint": "仅对私有网络或VPN上的节点启用。",
"enable": "已启用", "enable": "已启用",
"status": "状态", "status": "状态",
"cpu": "CPU", "cpu": "CPU",
@ -950,6 +952,7 @@
"change_password": "⚙️🔑 密码", "change_password": "⚙️🔑 密码",
"change_email": "⚙️📧 邮箱", "change_email": "⚙️📧 邮箱",
"change_comment": "⚙️💬 评论", "change_comment": "⚙️💬 评论",
"change_flow": "⚙️🚦 流控",
"ResetAllTraffics": "重置所有流量", "ResetAllTraffics": "重置所有流量",
"SortedTrafficUsageReport": "排序的流量使用报告" "SortedTrafficUsageReport": "排序的流量使用报告"
}, },

View file

@ -418,6 +418,8 @@
"apiTokenHint": "遠端面板在 設定 → API 權杖 中顯示其 API 權杖。", "apiTokenHint": "遠端面板在 設定 → API 權杖 中顯示其 API 權杖。",
"regenerate": "重新產生權杖", "regenerate": "重新產生權杖",
"regenerateConfirm": "重新產生會使目前的權杖失效。任何使用該權杖的中央面板將失去存取權,直到更新為止。是否繼續?", "regenerateConfirm": "重新產生會使目前的權杖失效。任何使用該權杖的中央面板將失去存取權,直到更新為止。是否繼續?",
"allowPrivateAddress": "允許私有地址",
"allowPrivateAddressHint": "僅對私有網路或VPN上的節點啟用。",
"enable": "已啟用", "enable": "已啟用",
"status": "狀態", "status": "狀態",
"cpu": "CPU", "cpu": "CPU",
@ -950,6 +952,7 @@
"change_password": "⚙️🔑 密碼", "change_password": "⚙️🔑 密碼",
"change_email": "⚙️📧 電子郵件", "change_email": "⚙️📧 電子郵件",
"change_comment": "⚙️💬 評論", "change_comment": "⚙️💬 評論",
"change_flow": "⚙️🚦 流控",
"ResetAllTraffics": "重設所有流量", "ResetAllTraffics": "重設所有流量",
"SortedTrafficUsageReport": "排序過的流量使用報告" "SortedTrafficUsageReport": "排序過的流量使用報告"
}, },

View file

@ -11,17 +11,17 @@ import (
type Config struct { type Config struct {
LogConfig json_util.RawMessage `json:"log"` LogConfig json_util.RawMessage `json:"log"`
RouterConfig json_util.RawMessage `json:"routing"` RouterConfig json_util.RawMessage `json:"routing"`
DNSConfig json_util.RawMessage `json:"dns"` DNSConfig json_util.RawMessage `json:"dns,omitempty"`
InboundConfigs []InboundConfig `json:"inbounds"` InboundConfigs []InboundConfig `json:"inbounds"`
OutboundConfigs json_util.RawMessage `json:"outbounds"` OutboundConfigs json_util.RawMessage `json:"outbounds"`
Transport json_util.RawMessage `json:"transport"` Transport json_util.RawMessage `json:"transport,omitempty"`
Policy json_util.RawMessage `json:"policy"` Policy json_util.RawMessage `json:"policy"`
API json_util.RawMessage `json:"api"` API json_util.RawMessage `json:"api"`
Stats json_util.RawMessage `json:"stats"` Stats json_util.RawMessage `json:"stats"`
Reverse json_util.RawMessage `json:"reverse"` Reverse json_util.RawMessage `json:"reverse,omitempty"`
FakeDNS json_util.RawMessage `json:"fakedns"` FakeDNS json_util.RawMessage `json:"fakedns,omitempty"`
Observatory json_util.RawMessage `json:"observatory"` Observatory json_util.RawMessage `json:"observatory,omitempty"`
BurstObservatory json_util.RawMessage `json:"burstObservatory"` BurstObservatory json_util.RawMessage `json:"burstObservatory,omitempty"`
Metrics json_util.RawMessage `json:"metrics"` Metrics json_util.RawMessage `json:"metrics"`
} }

View file

@ -13,9 +13,9 @@ type InboundConfig struct {
Port int `json:"port"` Port int `json:"port"`
Protocol string `json:"protocol"` Protocol string `json:"protocol"`
Settings json_util.RawMessage `json:"settings"` Settings json_util.RawMessage `json:"settings"`
StreamSettings json_util.RawMessage `json:"streamSettings"` StreamSettings json_util.RawMessage `json:"streamSettings,omitempty"`
Tag string `json:"tag"` Tag string `json:"tag"`
Sniffing json_util.RawMessage `json:"sniffing"` Sniffing json_util.RawMessage `json:"sniffing,omitempty"`
} }
// Equals compares two InboundConfig instances for deep equality. // Equals compares two InboundConfig instances for deep equality.