mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-12 16:15:02 +03:00
Compare commits
161 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b01390e124 | |||
| e535df0374 | |||
| 510bee2f30 | |||
| b54325dcc9 | |||
| f4030c889c | |||
| dbb6212988 | |||
| e4935be861 | |||
| cd176faf4e | |||
| 2541080c91 | |||
| 83ea59fb69 | |||
| 19f2f21d81 | |||
| fee7b1621d | |||
| e093854f5b | |||
| 728c2fb94f | |||
| 129e4f5292 | |||
| eae4725d99 | |||
| 1796352600 | |||
| 54a509fd67 | |||
| 09799ff734 | |||
| 121123eaaa | |||
| 43af7ada96 | |||
| 81059edc91 | |||
| a712bf1413 | |||
| 77b56a2178 | |||
| 6d9e836d61 | |||
| 4d583cebf3 | |||
| d2450e103c | |||
| 4180899f15 | |||
| 4cc742bf6f | |||
| ce44d2e772 | |||
| 34a762f7c8 | |||
| 05199dda00 | |||
| 14f2bb5e2c | |||
| 1bdb517b88 | |||
| cbf596d889 | |||
| d94a63d8bc | |||
| 6f37433d43 | |||
| 757444d505 | |||
| 62d27d54d5 | |||
| 87749c1ee4 | |||
| ba27b74be8 | |||
| 7982794291 | |||
| e789583b63 | |||
| 72c9092d4a | |||
| 9938d0b7a6 | |||
| 4dfd008edb | |||
| 99f386820d | |||
| 8f3dd70db4 | |||
| 846dfe3d74 | |||
| 54339661a6 | |||
| e02f51320a | |||
| d63583caaf | |||
| 72fb4d9358 | |||
| 6c74fccbf7 | |||
| 8b525b5be6 | |||
| 3471cecbda | |||
| 5a6d40e3ff | |||
| 2e41f908db | |||
| cc3aff2434 | |||
| 4d8e2b48ff | |||
| 8bbfda0510 | |||
| 06cb1742a1 | |||
| f99c6c664d | |||
| 93d1e63554 | |||
| 1d373cf67e | |||
| 828855cdb5 | |||
| 4cddcff12b | |||
| 255c658468 | |||
| 0394996a30 | |||
| 9c0bf6938c | |||
| 5c6cbb5680 | |||
| 082d7ac091 | |||
| ecd817658c | |||
| efaa73db3a | |||
| 7163c6e521 | |||
| dda44c1e72 | |||
| 878d37c2fc | |||
| 1507fd4edf | |||
| fac9009398 | |||
| a9191cb468 | |||
| 0972ac31eb | |||
| f8ceede46f | |||
| bd36053356 | |||
| 2024d34928 | |||
| 7f64a67ba5 | |||
| 67820dc20d | |||
| 30f17ef5a3 | |||
| 6dbd219a14 | |||
| 2467b9d542 | |||
| fc8efc0745 | |||
| f9f21fd8a4 | |||
| 3526a65f5d | |||
| 1c748adadf | |||
| 9a95153f0e | |||
| 2b9adaa6ff | |||
| 6a0c983c89 | |||
| c2250dbe57 | |||
| bebe1b911d | |||
| c92c7718bf | |||
| 68e06a174b | |||
| 152c84efa2 | |||
| 776ca554bd | |||
| c3a32c8343 | |||
| 07bbf4788a | |||
| c0bd572085 | |||
| bc2c64d901 | |||
| 1810996db6 | |||
| 5e6ffebdf2 | |||
| 05abd00bf6 | |||
| d3df3e9ea7 | |||
| daf5b16eda | |||
| d114845e44 | |||
| 376ab34c0f | |||
| 7907843941 | |||
| f66e369e41 | |||
| e1950b7575 | |||
| f988e9677f | |||
| ac896a9f95 | |||
| 0ea11e1643 | |||
| 53ee53ed9c | |||
| 6d90dcfe0c | |||
| e51a32b045 | |||
| 838923058b | |||
| 5cf2ab4f69 | |||
| 425a2bbf08 | |||
| dcdc56c64b | |||
| d69b924baf | |||
| 351a01ff0c | |||
| 47e9ef1a07 | |||
| fe5d704d67 | |||
| 44a502a407 | |||
| a69f2dee13 | |||
| 4d27313c5e | |||
| d005d89a8b | |||
| 469758837a | |||
| f67111a08a | |||
| a203056949 | |||
| 8ca321138b | |||
| fd3fa86982 | |||
| d4b80b5d80 | |||
| 87f3f54ea6 | |||
| 9cf8c87e8d | |||
| 5a1ea42785 | |||
| 08482154c2 | |||
| f1e1b0fb0c | |||
| e3736564a3 | |||
| cea69a4117 | |||
| b1108a9e81 | |||
| ce532e8df7 | |||
| 2e14a2a51a | |||
| 8e8541a5d8 | |||
| 661a169045 | |||
| 8359a0f72e | |||
| 534afe13ab | |||
| 3cdb7e2b49 | |||
| 61ee202750 | |||
| db20e491e6 | |||
| f1444bec3f | |||
| 2d72d9c715 | |||
| 1fa8cc785d | |||
| 4167597a15 |
@@ -9,6 +9,8 @@ on:
|
||||
- "frontend/**"
|
||||
- ".nvmrc"
|
||||
- "Makefile"
|
||||
- "install.sh"
|
||||
- "deploy/test/installer-panel-port.sh"
|
||||
- ".github/workflows/ci.yml"
|
||||
push:
|
||||
branches:
|
||||
@@ -20,6 +22,8 @@ on:
|
||||
- "frontend/**"
|
||||
- ".nvmrc"
|
||||
- "Makefile"
|
||||
- "install.sh"
|
||||
- "deploy/test/installer-panel-port.sh"
|
||||
- ".github/workflows/ci.yml"
|
||||
|
||||
permissions:
|
||||
@@ -71,12 +75,12 @@ jobs:
|
||||
- name: PostgreSQL durable-first tests
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test ./internal/web/service -run 'PostgresCommitFailure|^TestSyncInboundClientsOnPostgres$|^TestSetClientLimitHwidIsSerializedWithSyncInbound$' -count=1 -v | tee /tmp/postgres-durable-first.log
|
||||
go test ./internal/web/service -run 'PostgresCommitFailure|^TestSyncInboundClientsOnPostgres$|^TestSetClientLimitHwidIsSerializedWithSyncInbound$|^TestVlessManagedFallbackConflictPostgres$' -count=1 -v | tee /tmp/postgres-durable-first.log
|
||||
# Count passes rather than assert no SKIP: a renamed or deleted test
|
||||
# prints "no tests to run" and exits 0, leaving the step green for nothing.
|
||||
passed=$(grep -c -- '^--- PASS' /tmp/postgres-durable-first.log || true)
|
||||
if [ "$passed" -lt 4 ]; then
|
||||
echo "expected at least 4 passing durable-first tests, got $passed" >&2
|
||||
if [ "$passed" -lt 5 ]; then
|
||||
echo "expected at least 5 passing durable-first tests, got $passed" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -191,6 +195,15 @@ jobs:
|
||||
echo "expected at least 4 passing PostgreSQL migration tests, got $passed" >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: SQLite import host settings
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test ./internal/web/service -run '^TestImportSQLiteIntoPostgresHostSettings$' -count=1 -v | tee /tmp/postgres-import-host-settings.log
|
||||
passed=$(grep -c -- '^--- PASS: TestImportSQLiteIntoPostgresHostSettings ' /tmp/postgres-import-host-settings.log || true)
|
||||
if [ "$passed" -ne 1 ]; then
|
||||
echo "expected the SQLite import integration test to pass, got $passed" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
codegen:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -314,8 +327,10 @@ jobs:
|
||||
run: sudo apt-get update && sudo apt-get install -y shellcheck
|
||||
- name: shellcheck
|
||||
run: |
|
||||
shellcheck --severity=style DockerInit.sh DockerEntrypoint.sh deploy/test/smoke-noninteractive.sh
|
||||
shellcheck --severity=style DockerInit.sh DockerEntrypoint.sh deploy/test/smoke-noninteractive.sh deploy/test/installer-panel-port.sh
|
||||
shellcheck --severity=error install.sh update.sh x-ui.sh
|
||||
- name: Installer panel port regression tests
|
||||
run: bash deploy/test/installer-panel-port.sh
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -350,6 +365,15 @@ jobs:
|
||||
- name: Build Storybook
|
||||
run: npm run build-storybook
|
||||
working-directory: frontend
|
||||
- name: Inbound keyboard and accessibility tree
|
||||
working-directory: frontend
|
||||
run: |
|
||||
set -euo pipefail
|
||||
./node_modules/.bin/vite preview --outDir storybook-static --host 127.0.0.1 --port 6006 > /tmp/inbound-preview.log 2>&1 &
|
||||
preview_pid=$!
|
||||
trap 'kill "$preview_pid" 2>/dev/null || true' EXIT
|
||||
curl --retry 30 --retry-connrefused --retry-delay 1 --retry-max-time 45 -fsS http://127.0.0.1:6006/iframe.html > /dev/null
|
||||
node tools/check-inbound-keyboard.mjs http://127.0.0.1:6006
|
||||
- name: Audit
|
||||
run: npm run audit
|
||||
working-directory: frontend
|
||||
|
||||
@@ -115,7 +115,7 @@ jobs:
|
||||
cd x-ui/bin
|
||||
|
||||
# Download dependencies
|
||||
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.10.21/"
|
||||
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.10.31/"
|
||||
if [ "${{ matrix.platform }}" == "amd64" ]; then
|
||||
fetch ${Xray_URL}Xray-linux-64.zip
|
||||
unzip Xray-linux-64.zip
|
||||
@@ -283,7 +283,7 @@ jobs:
|
||||
cd x-ui\bin
|
||||
|
||||
# Download Xray for Windows
|
||||
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.10.21/"
|
||||
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.10.31/"
|
||||
Invoke-WebRequest @retry -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
|
||||
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
|
||||
Remove-Item "Xray-windows-64.zip"
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ if [ -z "$MTG_MULTI_VER" ]; then
|
||||
fi
|
||||
mkdir -p build/bin
|
||||
cd build/bin
|
||||
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.10.21/Xray-linux-${XRAY_ZIP}.zip"
|
||||
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.10.31/Xray-linux-${XRAY_ZIP}.zip"
|
||||
unzip "Xray-linux-${XRAY_ZIP}.zip"
|
||||
rm -f "Xray-linux-${XRAY_ZIP}.zip" geoip.dat geosite.dat
|
||||
mv xray "${XRAY_BIN}"
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
#!/usr/bin/env bash
|
||||
# Exercise only installer function definitions, never its installation entry point.
|
||||
set -euo pipefail
|
||||
|
||||
repo_root=$(cd "$(dirname "$0")/../.." && pwd)
|
||||
installer=${INSTALLER_UNDER_TEST:-"$repo_root/install.sh"}
|
||||
test_dir=$(mktemp -d)
|
||||
trap 'rm -rf -- "$test_dir"' EXIT
|
||||
|
||||
load_function() {
|
||||
local definition
|
||||
definition=$(awk -v name="$1" '
|
||||
$0 == name "() {" {copy = 1}
|
||||
copy {print}
|
||||
copy && /^}$/ {exit}
|
||||
' "$installer")
|
||||
[[ -n "$definition" ]] || { echo "Missing installer function: $1" >&2; return 1; }
|
||||
eval "$definition"
|
||||
}
|
||||
|
||||
load_function is_port_in_use
|
||||
load_function config_after_install
|
||||
# The original installer has no selection helpers; its integration tests still run.
|
||||
if grep -q '^select_panel_port() {' "$installer"; then
|
||||
load_function normalize_panel_port
|
||||
load_function select_panel_port
|
||||
fi
|
||||
|
||||
# These commands are invoked by the extracted installer functions in a subshell.
|
||||
# shellcheck disable=SC2317,SC2329
|
||||
curl() { printf '192.0.2.1\n200\n'; }
|
||||
# shellcheck disable=SC2317,SC2329
|
||||
gen_random_string() { printf 'test-credential-%s\n' "$1"; }
|
||||
# shellcheck disable=SC2317,SC2329
|
||||
prompt_and_setup_ssl() {
|
||||
echo ssl >> "$CASE_DIR/events"
|
||||
export SSL_SCHEME=http SSL_HOST=192.0.2.1
|
||||
}
|
||||
# shellcheck disable=SC2317,SC2329
|
||||
write_install_result() { printf '%s\n' "$@" > "$CASE_DIR/result"; }
|
||||
# shellcheck disable=SC2317,SC2329
|
||||
read() {
|
||||
echo read >> "$CASE_DIR/events"
|
||||
[[ "$NONINTERACTIVE" != 1 ]] || return 90
|
||||
builtin read -r "$@"
|
||||
}
|
||||
# shellcheck disable=SC2317,SC2329
|
||||
shuf() {
|
||||
echo random >> "$CASE_DIR/events"
|
||||
local count=0
|
||||
[[ ! -f "$CASE_DIR/random-count" ]] || count=$(cat "$CASE_DIR/random-count")
|
||||
echo "$((count + 1))" > "$CASE_DIR/random-count"
|
||||
[[ "${SHUF_FAIL:-0}" == 0 ]] || return 1
|
||||
if ((count > 0)) && [[ -n "${RANDOM_NEXT:-}" ]]; then
|
||||
printf '%s\n' "$RANDOM_NEXT"
|
||||
else
|
||||
printf '54321\n'
|
||||
fi
|
||||
}
|
||||
# Select the availability backend deterministically, regardless of host packages.
|
||||
# shellcheck disable=SC2317,SC2329
|
||||
command() {
|
||||
if [[ "${1:-}" == -v && "${2:-}" =~ ^(ss|netstat|lsof)$ ]]; then
|
||||
[[ "$2" == "${PORT_BACKEND:-ss}" ]]
|
||||
else
|
||||
builtin command "$@"
|
||||
fi
|
||||
}
|
||||
# shellcheck disable=SC2317,SC2329
|
||||
ss() {
|
||||
printf 'State Recv-Q Send-Q Local\ Address:Port Peer\ Address:Port\n'
|
||||
if [[ -n "${BUSY_PORT:-}" ]]; then
|
||||
printf 'LISTEN 0 128 0.0.0.0:%s 0.0.0.0:*\n' "$BUSY_PORT"
|
||||
printf 'LISTEN 0 128 [::]:%s [::]:*\n' "$BUSY_PORT"
|
||||
fi
|
||||
}
|
||||
# shellcheck disable=SC2317,SC2329
|
||||
netstat() {
|
||||
if [[ -n "${BUSY_PORT:-}" ]]; then
|
||||
printf 'tcp 0 0 0.0.0.0:%s 0.0.0.0:* LISTEN\n' "$BUSY_PORT"
|
||||
printf 'tcp6 0 0 :::%s :::* LISTEN\n' "$BUSY_PORT"
|
||||
fi
|
||||
}
|
||||
# shellcheck disable=SC2317,SC2329
|
||||
lsof() { [[ -n "${BUSY_PORT:-}" && "$*" == *"-iTCP:$BUSY_PORT "* ]]; }
|
||||
|
||||
new_case() {
|
||||
CASE_DIR=$(mktemp -d "$test_dir/case.XXXXXXXX")
|
||||
export CASE_DIR
|
||||
: > "$CASE_DIR/events"
|
||||
xui_folder="$CASE_DIR/bin"
|
||||
mkdir -p "$xui_folder"
|
||||
cat > "$xui_folder/x-ui" <<'MOCK'
|
||||
#!/usr/bin/env bash
|
||||
set -eu
|
||||
if [[ "${1:-}" == migrate ]]; then
|
||||
echo migrate >> "$CASE_DIR/events"
|
||||
exit 0
|
||||
fi
|
||||
case "${2:-}" in
|
||||
-show) printf 'hasDefaultCredential: true\nwebBasePath: /\nport: 2053\n' ;;
|
||||
-getCert) printf 'cert: \n' ;;
|
||||
-getApiToken) printf 'apiToken: test-token\n' ;;
|
||||
-username)
|
||||
printf '%s\n' "$@" > "$CASE_DIR/settings-args"
|
||||
case "${SETTING_FAILURE:-}" in
|
||||
exit) echo 'controlled settings failure' >&2; exit 23 ;;
|
||||
zero) echo 'Database initialization failed: controlled failure'; exit 0 ;;
|
||||
esac
|
||||
while (($#)); do
|
||||
case "$1" in -port) port=$2 ;; esac
|
||||
shift
|
||||
done
|
||||
printf 'Port set successfully: %s\n' "$port"
|
||||
[[ "${SETTING_FAILURE:-}" != partial ]] || exit 0
|
||||
printf 'Username and password updated successfully\nBase URI path set successfully\n'
|
||||
;;
|
||||
*) echo 'Unexpected configuration command' >&2; exit 1 ;;
|
||||
esac
|
||||
MOCK
|
||||
chmod +x "$xui_folder/x-ui"
|
||||
export red='' green='' blue='' yellow='' plain='' release=ubuntu
|
||||
export XUI_USERNAME=test-user XUI_PASSWORD=test-pass XUI_WEB_BASE_PATH=test-path
|
||||
unset XUI_PANEL_PORT SETTING_FAILURE BUSY_PORT RANDOM_NEXT SHUF_FAIL
|
||||
PORT_BACKEND=ss
|
||||
NONINTERACTIVE=0
|
||||
}
|
||||
|
||||
run_config() {
|
||||
local input=$1
|
||||
# The initial newline selects the existing SQLite default. All later input
|
||||
# belongs to the port prompts; SSL and persistence are side-effect-free mocks.
|
||||
printf '\n%s' "$input" > "$CASE_DIR/input"
|
||||
status=0
|
||||
(config_after_install) < "$CASE_DIR/input" > "$CASE_DIR/output" 2>&1 || status=$?
|
||||
}
|
||||
|
||||
expect_port() {
|
||||
[[ "$status" == 0 ]] || { cat "$CASE_DIR/output"; return 1; }
|
||||
local actual
|
||||
actual=$(awk 'last == "-port" {print; exit} {last = $0}' "$CASE_DIR/settings-args")
|
||||
[[ "$actual" == "$1" ]] || { echo "Port $actual, expected $1"; return 1; }
|
||||
grep -Fxq 'Panel Installation Complete!' < <(sed 's/^[[:space:]]*//;s/[[:space:]]*$//' "$CASE_DIR/output")
|
||||
[[ -f "$CASE_DIR/result" ]] || return 1
|
||||
[[ $(sed -n '3p' "$CASE_DIR/result") == "$1" ]] || return 1
|
||||
[[ $(sed -n '1p' "$CASE_DIR/result") == test-user ]] || return 1
|
||||
[[ $(sed -n '2p' "$CASE_DIR/result") == test-pass ]] || return 1
|
||||
[[ $(sed -n '4p' "$CASE_DIR/result") == test-path ]] || return 1
|
||||
}
|
||||
|
||||
expect_failure() {
|
||||
[[ "$status" != 0 ]] || { echo 'Configuration unexpectedly succeeded'; return 1; }
|
||||
[[ ! -f "$CASE_DIR/result" ]] || return 1
|
||||
! grep -q 'Panel Installation Complete!\|SSL Certificate Setup\|^ssl$' "$CASE_DIR/output" "$CASE_DIR/events"
|
||||
}
|
||||
|
||||
affirmative() {
|
||||
new_case
|
||||
run_config "$1"$'\n2053\n'
|
||||
expect_port 2053
|
||||
! grep -q '^random$' "$CASE_DIR/events"
|
||||
}
|
||||
negative() {
|
||||
new_case
|
||||
run_config "$1"$'\n'
|
||||
expect_port 54321
|
||||
[[ $(grep -c '^random$' "$CASE_DIR/events") == 1 ]]
|
||||
}
|
||||
invalid_port() {
|
||||
new_case
|
||||
run_config $'y\n'"$1"$'\n2053\n'
|
||||
expect_port 2053
|
||||
grep -q 'Invalid port' "$CASE_DIR/output"
|
||||
! grep -q '^random$' "$CASE_DIR/events"
|
||||
}
|
||||
valid_port() {
|
||||
new_case
|
||||
run_config $'y\n'"$1"$'\n'
|
||||
expect_port "$2"
|
||||
}
|
||||
confirmation_retry() {
|
||||
new_case
|
||||
run_config $'maybe\nyes please\nYES\n2053\n'
|
||||
expect_port 2053
|
||||
grep -q 'Please answer yes or no' "$CASE_DIR/output"
|
||||
! grep -q '^random$' "$CASE_DIR/events"
|
||||
}
|
||||
busy_port() {
|
||||
new_case
|
||||
BUSY_PORT=2053
|
||||
PORT_BACKEND=$1
|
||||
run_config $'y\n2053\n2054\n'
|
||||
expect_port 2054
|
||||
grep -q 'already in use' "$CASE_DIR/output"
|
||||
}
|
||||
random_retry() {
|
||||
new_case
|
||||
BUSY_PORT=54321 RANDOM_NEXT=54322
|
||||
run_config $'no\n'
|
||||
expect_port 54322
|
||||
[[ $(grep -c '^random$' "$CASE_DIR/events") == 2 ]]
|
||||
}
|
||||
random_failure() {
|
||||
new_case
|
||||
SHUF_FAIL=1
|
||||
run_config $'no\n'
|
||||
expect_failure
|
||||
[[ ! -f "$CASE_DIR/settings-args" ]]
|
||||
}
|
||||
random_exhaustion() {
|
||||
new_case
|
||||
BUSY_PORT=54321
|
||||
run_config $'no\n'
|
||||
expect_failure
|
||||
[[ ! -f "$CASE_DIR/settings-args" ]]
|
||||
[[ $(grep -c '^random$' "$CASE_DIR/events") == 100 ]]
|
||||
}
|
||||
noninteractive() {
|
||||
new_case
|
||||
NONINTERACTIVE=1
|
||||
case "$1" in unset) ;; *) export XUI_PANEL_PORT=$1 ;; esac
|
||||
run_config ''
|
||||
expect_port "$2"
|
||||
! grep -q '^read$' "$CASE_DIR/events"
|
||||
}
|
||||
noninteractive_invalid() {
|
||||
new_case
|
||||
NONINTERACTIVE=1
|
||||
export XUI_PANEL_PORT=$1
|
||||
run_config ''
|
||||
expect_failure
|
||||
[[ ! -f "$CASE_DIR/settings-args" ]]
|
||||
! grep -q '^read$\|^random$' "$CASE_DIR/events"
|
||||
}
|
||||
noninteractive_busy() {
|
||||
new_case
|
||||
NONINTERACTIVE=1 BUSY_PORT=2053
|
||||
export XUI_PANEL_PORT=2053
|
||||
run_config ''
|
||||
expect_failure
|
||||
[[ ! -f "$CASE_DIR/settings-args" ]]
|
||||
! grep -q '^read$\|^random$' "$CASE_DIR/events"
|
||||
}
|
||||
settings_failure() {
|
||||
new_case
|
||||
export SETTING_FAILURE=$1
|
||||
run_config $'y\n2053\n'
|
||||
expect_failure
|
||||
[[ -f "$CASE_DIR/settings-args" ]]
|
||||
grep -q 'Failed to configure panel settings' "$CASE_DIR/output"
|
||||
}
|
||||
input_eof() {
|
||||
new_case
|
||||
run_config "$1"
|
||||
expect_failure
|
||||
[[ ! -f "$CASE_DIR/settings-args" ]]
|
||||
}
|
||||
|
||||
passed=0 failed=0
|
||||
check() {
|
||||
local name=$1
|
||||
shift
|
||||
# Keep the test body out of an if/|| condition: those disable errexit
|
||||
# throughout called functions and can hide a failed intermediate assertion.
|
||||
set +e
|
||||
(set -e; "$@")
|
||||
local result=$?
|
||||
set -e
|
||||
if [[ "$result" == 0 ]]; then
|
||||
printf 'PASS %s\n' "$name"
|
||||
passed=$((passed + 1))
|
||||
else
|
||||
printf 'FAIL %s\n' "$name" >&2
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
for answer in y Y yes Yes YES ' yEs ' $'\tYES\t'; do check "affirmative [$answer]" affirmative "$answer"; done
|
||||
for answer in n N no No NO '' ' nO ' $' \t \r '; do check "negative [$answer]" negative "$answer"; done
|
||||
check 'invalid confirmation retries' confirmation_retry
|
||||
for port in '' abc 0 -1 65536 00065536 70000 1.5 +2053 '20 53' 0x805 999999999999999999999999 00000 '1+2' 'a[0]'; do
|
||||
check "invalid port [$port]" invalid_port "$port"
|
||||
done
|
||||
for port in 1 2053 65535; do check "valid port [$port]" valid_port "$port" "$port"; done
|
||||
check 'leading zeros use decimal' valid_port 00002053 2053
|
||||
check 'leading zeros containing eight use decimal' valid_port 008080 8080
|
||||
check 'leading-zero boundary uses decimal' valid_port 065535 65535
|
||||
for backend in ss netstat lsof; do check "busy port via $backend" busy_port "$backend"; done
|
||||
check 'busy random port retried' random_retry
|
||||
check 'random generator failure' random_failure
|
||||
check 'random search exhaustion' random_exhaustion
|
||||
check 'noninteractive supplied port' noninteractive 2053 2053
|
||||
check 'noninteractive decimal normalization' noninteractive 00002053 2053
|
||||
check 'noninteractive random default' noninteractive unset 54321
|
||||
for port in '' abc 0 -1 65536 1.5 999999999999999999999; do
|
||||
check "noninteractive invalid [$port]" noninteractive_invalid "$port"
|
||||
done
|
||||
check 'noninteractive busy port fails' noninteractive_busy
|
||||
for failure in exit zero partial; do check "settings failure [$failure]" settings_failure "$failure"; done
|
||||
check 'EOF at confirmation' input_eof ''
|
||||
check 'EOF at custom port' input_eof $'y\n'
|
||||
|
||||
printf '%s passed, %s failed\n' "$passed" "$failed"
|
||||
[[ "$failed" == 0 ]]
|
||||
@@ -23,7 +23,7 @@ network writes its own settings key on the wire (`tcpSettings`, `kcpSettings`,
|
||||
| **HTTPUpgrade** | `httpupgradeSettings` | CDN-friendly HTTP/1.1 `Upgrade`; lighter than full WebSocket. |
|
||||
| **XHTTP** | `xhttpSettings` | Modern stream-multiplexed HTTP transport; CDN-friendly and REALITY-capable. |
|
||||
| **Hysteria** | `hysteriaSettings` | QUIC-based transport — only for the **Hysteria2** protocol. |
|
||||
| **XDRIVE** | `xdriveSettings` | Tunnels the stream through files in shared cloud storage (Google Drive, a local folder or any HTTP storage API). |
|
||||
| **XDRIVE** | `xdriveSettings` | Tunnels the stream through files in shared cloud storage (Google Drive, S3, a local folder or any HTTP storage API). |
|
||||
| **MASQUE** | `masqueSettings` | CONNECT-IP over HTTP/3 or HTTP/2 — only for the **MASQUE** protocol. See [MASQUE](/docs/config/masque). |
|
||||
|
||||
<Callout type="info">
|
||||
@@ -132,10 +132,11 @@ enabled. The panel offers XDRIVE only for VLESS, refuses to save an inbound with
|
||||
|
||||
| Field | Default | Meaning |
|
||||
| -------------- | -------------- | ----------------------------------------------------------------------- |
|
||||
| `service` | `google-drive` | `google-drive`, `local` (a folder on disk) or `template` (any HTTP storage API). The old `Google Drive` spelling is still read. |
|
||||
| `service` | `google-drive` | `google-drive`, `local` (a folder on disk) `template` (any HTTP storage API) or `s3` (S3-compatible storage). The old `Google Drive` spelling is still read. |
|
||||
| `remoteFolder` | — | Folder that holds the session files. |
|
||||
| `secrets` | — | Google Drive: ClientID, ClientSecret, RefreshToken — exactly three, in that order. Template: values read as `{secret0}`, `{secret1}`, …. |
|
||||
| `secrets` | — | Google Drive: ClientID, ClientSecret, RefreshToken — exactly three, in that order. Template: values read as `{secret0}`, `{secret1}`, …. S3: AccessKeyID, SecretAccessKey and an optional SessionToken. |
|
||||
| `template` | — | Template service only: the storage API's `auth`, `put`, `get`, `list` and `delete` operations. |
|
||||
| `s3` | — | S3 service only: `endpoint` (an http(s) URL), `region`, `bucket` and `pathStyle` (default `true`; `false` puts the bucket in the host name). `remoteFolder` is the object prefix. |
|
||||
| tuning | core defaults | `segmentBytes` (512 KiB), `flushIntervalMs` (20), `pollIntervalMs`/`maxPollIntervalMs` (50/500), `eagerWindowMs` (2000), `holeTimeoutMs` (30000), `sessionTtlSeconds` (300), `concurrency` (8). |
|
||||
|
||||
The optional **Front address/port** (the stream-level `address`/`port`) is a domain
|
||||
|
||||
@@ -76,8 +76,11 @@ admins are notified when one runs out.
|
||||
|
||||
Admins also get inline-button flows for server usage, sorted traffic reports,
|
||||
resetting traffic, DB backups, ban logs, listing inbounds/clients, online
|
||||
clients, "depleting soon", and a full **add-client** wizard. Regular users get
|
||||
buttons for their own usage, subscription links, individual links, and QR codes.
|
||||
clients, "depleting soon", and a full **add-client** wizard. The admin's
|
||||
**client card** carries the client's subscription link, individual links and QR
|
||||
code, so a config can be handed to a client who cannot reach Telegram yet.
|
||||
Regular users get buttons for their own usage, subscription links, individual
|
||||
links, and QR codes.
|
||||
|
||||
## Reports & alerts
|
||||
|
||||
|
||||
@@ -28,6 +28,10 @@ _openapi:
|
||||
title: Fetch one client by email, including the inbound IDs and external config
|
||||
IDs it is attached to.
|
||||
url: '#fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to'
|
||||
- depth: 2
|
||||
title: Fetch one client by email, including the inbound IDs and external config
|
||||
IDs it is attached to.
|
||||
url: '#fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to-1'
|
||||
- depth: 2
|
||||
title: Fetch clients by subscription ID.
|
||||
url: '#fetch-clients-by-subscription-id'
|
||||
@@ -44,14 +48,25 @@ _openapi:
|
||||
title: Preview client auto-renewal dates without saving or resetting anything.
|
||||
url: '#preview-client-auto-renewal-dates-without-saving-or-resetting-anything'
|
||||
- depth: 2
|
||||
title: Update an existing client by email. Changes propagate to every attached
|
||||
inbound. Body is the JSON client payload — supply the full set of fields
|
||||
you want to keep (the server replaces the row, it does not patch).
|
||||
url: '#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch'
|
||||
title: 'Update an existing client by email. Changes propagate to every attached
|
||||
inbound. Body is the JSON client payload: omitted fields keep their
|
||||
current values, and an explicit zero, empty or null value clears a
|
||||
field, except as noted below.'
|
||||
url: '#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below'
|
||||
- depth: 2
|
||||
title: 'Update an existing client by email. Changes propagate to every attached
|
||||
inbound. Body is the JSON client payload: omitted fields keep their
|
||||
current values, and an explicit zero, empty or null value clears a
|
||||
field, except as noted below.'
|
||||
url: '#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below-1'
|
||||
- depth: 2
|
||||
title: Delete a client by email. Removes it from every attached inbound and
|
||||
drops its traffic record unless keepTraffic=1 is passed.
|
||||
url: '#delete-a-client-by-email-removes-it-from-every-attached-inbound-and-drops-its-traffic-record-unless-keeptraffic1-is-passed'
|
||||
- depth: 2
|
||||
title: Delete a client by email. Removes it from every attached inbound and
|
||||
drops its traffic record unless keepTraffic=1 is passed.
|
||||
url: '#delete-a-client-by-email-removes-it-from-every-attached-inbound-and-drops-its-traffic-record-unless-keeptraffic1-is-passed-1'
|
||||
- depth: 2
|
||||
title: Attach an existing client to one or more additional inbounds. Body is
|
||||
JSON.
|
||||
@@ -182,10 +197,11 @@ _openapi:
|
||||
url: '#mirror-of-bulkattach-detach-many-existing-clients-from-many-inbounds-in-one-call-for-each-email-intersects-the-clients-current-inbounds-with-the-requested-set-and-detaches-from-those-only-email-inbound-pairs-where-the-client-is-not-currently-attached-are-silently-no-ops-emails-not-attached-to-any-of-the-requested-inbounds-are-reported-under-skipped-client-records-are-kept-even-if-they-become-orphaned--use-bulkdel-for-full-removal-returns-per-email-detachedskippederrors-lists-and-triggers-a-single-xray-restart-if-any-target-inbound-was-running'
|
||||
- depth: 2
|
||||
title: Zero up/down counters for many clients in one call. Loops the
|
||||
single-reset path so each client is re-enabled across its attached
|
||||
inbounds and pushed to Xray/remote nodes. Returns the count of
|
||||
successfully reset clients.
|
||||
url: '#zero-updown-counters-for-many-clients-in-one-call-loops-the-single-reset-path-so-each-client-is-re-enabled-across-its-attached-inbounds-and-pushed-to-xrayremote-nodes-returns-the-count-of-successfully-reset-clients'
|
||||
single-reset path so each client its quota had disabled is re-enabled
|
||||
across its attached inbounds and pushed to Xray/remote nodes; a client
|
||||
disabled by hand stays disabled. Returns the count of successfully reset
|
||||
clients.
|
||||
url: '#zero-updown-counters-for-many-clients-in-one-call-loops-the-single-reset-path-so-each-client-its-quota-had-disabled-is-re-enabled-across-its-attached-inbounds-and-pushed-to-xrayremote-nodes-a-client-disabled-by-hand-stays-disabled-returns-the-count-of-successfully-reset-clients'
|
||||
- depth: 2
|
||||
title: List all client groups with their member counts. Merges persisted groups
|
||||
(rows in client_groups, including empty placeholders) with the distinct
|
||||
@@ -223,10 +239,11 @@ _openapi:
|
||||
client_groups row if the group exists only as a derived label.
|
||||
url: '#reset-only-the-group-level-traffic-counter-shown-on-the-groups-page-snapshots-the-current-updown-sum-of-the-groups-members-as-a-baseline-so-the-group-total-reads-zero-while-leaving-each-clients-own-counters-and-their-quotas-untouched-no-xray-restart-is-triggered-creates-the-client_groups-row-if-the-group-exists-only-as-a-derived-label'
|
||||
- depth: 2
|
||||
title: Zero out a single client’s up/down counters. Re-enables the client across
|
||||
every attached inbound and pushes the change to Xray (or the remote
|
||||
node) so depleted users can connect again immediately.
|
||||
url: '#zero-out-a-single-clients-updown-counters-re-enables-the-client-across-every-attached-inbound-and-pushes-the-change-to-xray-or-the-remote-node-so-depleted-users-can-connect-again-immediately'
|
||||
title: Zero out a single client’s up/down counters. Re-enables a client its
|
||||
quota had disabled across every attached inbound and pushes the change
|
||||
to Xray (or the remote node) so depleted users can connect again
|
||||
immediately; a client disabled by hand stays disabled.
|
||||
url: '#zero-out-a-single-clients-updown-counters-re-enables-a-client-its-quota-had-disabled-across-every-attached-inbound-and-pushes-the-change-to-xray-or-the-remote-node-so-depleted-users-can-connect-again-immediately-a-client-disabled-by-hand-stays-disabled'
|
||||
- depth: 2
|
||||
title: Manually adjust a client’s upload + download counters. Useful for
|
||||
migrations from external accounting systems.
|
||||
@@ -322,6 +339,9 @@ _openapi:
|
||||
- content: Fetch one client by email, including the inbound IDs and external
|
||||
config IDs it is attached to.
|
||||
id: fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to
|
||||
- content: Fetch one client by email, including the inbound IDs and external
|
||||
config IDs it is attached to.
|
||||
id: fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to-1
|
||||
- content: Fetch clients by subscription ID.
|
||||
id: fetch-clients-by-subscription-id
|
||||
- content: Fetch clients by Telegram user ID. Returns an array since multiple
|
||||
@@ -333,14 +353,22 @@ _openapi:
|
||||
id: create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
|
||||
- content: Preview client auto-renewal dates without saving or resetting anything.
|
||||
id: preview-client-auto-renewal-dates-without-saving-or-resetting-anything
|
||||
- content: Update an existing client by email. Changes propagate to every attached
|
||||
inbound. Body is the JSON client payload — supply the full set of
|
||||
fields you want to keep (the server replaces the row, it does not
|
||||
patch).
|
||||
id: update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
|
||||
- content: 'Update an existing client by email. Changes propagate to every
|
||||
attached inbound. Body is the JSON client payload: omitted fields keep
|
||||
their current values, and an explicit zero, empty or null value clears
|
||||
a field, except as noted below.'
|
||||
id: update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
|
||||
- content: 'Update an existing client by email. Changes propagate to every
|
||||
attached inbound. Body is the JSON client payload: omitted fields keep
|
||||
their current values, and an explicit zero, empty or null value clears
|
||||
a field, except as noted below.'
|
||||
id: update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below-1
|
||||
- content: Delete a client by email. Removes it from every attached inbound and
|
||||
drops its traffic record unless keepTraffic=1 is passed.
|
||||
id: delete-a-client-by-email-removes-it-from-every-attached-inbound-and-drops-its-traffic-record-unless-keeptraffic1-is-passed
|
||||
- content: Delete a client by email. Removes it from every attached inbound and
|
||||
drops its traffic record unless keepTraffic=1 is passed.
|
||||
id: delete-a-client-by-email-removes-it-from-every-attached-inbound-and-drops-its-traffic-record-unless-keeptraffic1-is-passed-1
|
||||
- content: Attach an existing client to one or more additional inbounds. Body is
|
||||
JSON.
|
||||
id: attach-an-existing-client-to-one-or-more-additional-inbounds-body-is-json
|
||||
@@ -457,10 +485,11 @@ _openapi:
|
||||
any target inbound was running."
|
||||
id: mirror-of-bulkattach-detach-many-existing-clients-from-many-inbounds-in-one-call-for-each-email-intersects-the-clients-current-inbounds-with-the-requested-set-and-detaches-from-those-only-email-inbound-pairs-where-the-client-is-not-currently-attached-are-silently-no-ops-emails-not-attached-to-any-of-the-requested-inbounds-are-reported-under-skipped-client-records-are-kept-even-if-they-become-orphaned--use-bulkdel-for-full-removal-returns-per-email-detachedskippederrors-lists-and-triggers-a-single-xray-restart-if-any-target-inbound-was-running
|
||||
- content: Zero up/down counters for many clients in one call. Loops the
|
||||
single-reset path so each client is re-enabled across its attached
|
||||
inbounds and pushed to Xray/remote nodes. Returns the count of
|
||||
successfully reset clients.
|
||||
id: zero-updown-counters-for-many-clients-in-one-call-loops-the-single-reset-path-so-each-client-is-re-enabled-across-its-attached-inbounds-and-pushed-to-xrayremote-nodes-returns-the-count-of-successfully-reset-clients
|
||||
single-reset path so each client its quota had disabled is re-enabled
|
||||
across its attached inbounds and pushed to Xray/remote nodes; a client
|
||||
disabled by hand stays disabled. Returns the count of successfully
|
||||
reset clients.
|
||||
id: zero-updown-counters-for-many-clients-in-one-call-loops-the-single-reset-path-so-each-client-its-quota-had-disabled-is-re-enabled-across-its-attached-inbounds-and-pushed-to-xrayremote-nodes-a-client-disabled-by-hand-stays-disabled-returns-the-count-of-successfully-reset-clients
|
||||
- content: List all client groups with their member counts. Merges persisted
|
||||
groups (rows in client_groups, including empty placeholders) with the
|
||||
distinct group_name values currently set on clients. Sorted
|
||||
@@ -492,10 +521,11 @@ _openapi:
|
||||
Creates the client_groups row if the group exists only as a derived
|
||||
label.
|
||||
id: reset-only-the-group-level-traffic-counter-shown-on-the-groups-page-snapshots-the-current-updown-sum-of-the-groups-members-as-a-baseline-so-the-group-total-reads-zero-while-leaving-each-clients-own-counters-and-their-quotas-untouched-no-xray-restart-is-triggered-creates-the-client_groups-row-if-the-group-exists-only-as-a-derived-label
|
||||
- content: Zero out a single client’s up/down counters. Re-enables the client
|
||||
across every attached inbound and pushes the change to Xray (or the
|
||||
remote node) so depleted users can connect again immediately.
|
||||
id: zero-out-a-single-clients-updown-counters-re-enables-the-client-across-every-attached-inbound-and-pushes-the-change-to-xray-or-the-remote-node-so-depleted-users-can-connect-again-immediately
|
||||
- content: Zero out a single client’s up/down counters. Re-enables a client its
|
||||
quota had disabled across every attached inbound and pushes the change
|
||||
to Xray (or the remote node) so depleted users can connect again
|
||||
immediately; a client disabled by hand stays disabled.
|
||||
id: zero-out-a-single-clients-updown-counters-re-enables-a-client-its-quota-had-disabled-across-every-attached-inbound-and-pushes-the-change-to-xray-or-the-remote-node-so-depleted-users-can-connect-again-immediately-a-client-disabled-by-hand-stays-disabled
|
||||
- content: Manually adjust a client’s upload + download counters. Useful for
|
||||
migrations from external accounting systems.
|
||||
id: manually-adjust-a-clients-upload--download-counters-useful-for-migrations-from-external-accounting-systems
|
||||
@@ -560,6 +590,9 @@ _openapi:
|
||||
tunnel) contribute nothing.'
|
||||
id: return-every-url-for-one-client-across-all-attached-inbounds-one-per-advertised-endpoint-the-managed-hosts-of-the-inbound-else-its-streamsettingsexternalproxy-entries-else-its-own-address-supported-protocols-vmess-vless-trojan-shadowsocks-hysteria-mtproto-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing
|
||||
contents:
|
||||
- content: Query-parameter alias for the path endpoint. Use email=. or email=.. to
|
||||
manage legacy reserved names without URL dot-segment normalization.
|
||||
heading: fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to-1
|
||||
- content: Matches the exact subscription ID and orders results by client ID.
|
||||
Returns an array of the same payloads as GET /clients/get/:email,
|
||||
since multiple clients can share a subscription ID. Includes disabled,
|
||||
@@ -568,11 +601,14 @@ _openapi:
|
||||
email lookup. Requires an admin token or a panel login session.
|
||||
heading: fetch-clients-by-subscription-id
|
||||
- content: >-
|
||||
Fields the server fills in when they are omitted — a valid value sent
|
||||
by the caller is never overwritten. Re-adding an email that already
|
||||
exists, with its stored `subId`, reuses the stored `id`, `password`,
|
||||
`auth` and `secret` instead of minting new ones, so the identity stays
|
||||
in sync across its inbounds.
|
||||
Client email must not be exactly `.` or `..`. Existing reserved-name
|
||||
clients can be read, renamed to a valid name, or deleted using the
|
||||
query-parameter /get, /update and /del aliases. Fields the server
|
||||
fills in when they are omitted — a valid value sent by the caller is
|
||||
never overwritten. Re-adding an email that already exists, with its
|
||||
stored `subId`, reuses the stored `id`, `password`, `auth` and
|
||||
`secret` instead of minting new ones, so the identity stays in sync
|
||||
across its inbounds.
|
||||
|
||||
|
||||
- **VLESS / VMess** — `id`, a fresh UUID
|
||||
@@ -609,9 +645,11 @@ _openapi:
|
||||
different client of that same inbound holds, or `... used by a client
|
||||
on <inbound>` when the holder sits on another WireGuard or AmneziaWG
|
||||
inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides
|
||||
with `10.0.0.5/32`; a `0.0.0.0/0` or `::/0` default route claims no
|
||||
address. Allocation likewise skips every address inside a prefix
|
||||
another client holds. The same validation runs on POST
|
||||
with `10.0.0.5/32`; a `/0` entry collides with every address of its
|
||||
family another client holds, and only a `0.0.0.0/0` or `::/0` default
|
||||
route that is already stored (legacy peers) keeps nobody out.
|
||||
Allocation likewise skips every address inside a prefix another client
|
||||
holds. The same validation runs on POST
|
||||
/panel/api/clients/{email}/attach, where a client that already carries
|
||||
an address brings it along.
|
||||
|
||||
@@ -635,7 +673,11 @@ _openapi:
|
||||
preview is informational and does not reserve an allowance or
|
||||
guarantee node availability.
|
||||
heading: preview-client-auto-renewal-dates-without-saving-or-resetting-anything
|
||||
- content: 'The inbounds are applied concurrently and independently: one that
|
||||
- content: 'An omitted `flow` leaves every attached inbound on its own flow. Send
|
||||
`"reverse": null` to remove the reverse tag. Credentials (`id`,
|
||||
`password`, `auth`, `secret`, `subId`) and WireGuard/AmneziaWG
|
||||
`allowedIPs` are never cleared: an empty value keeps the stored one.
|
||||
The inbounds are applied concurrently and independently: one that
|
||||
fails no longer stops the others. Every inbound error names the
|
||||
inbound it came from (`inbound 7: <message>`), and several failures
|
||||
are reported together, one per line. So a `success:false` response can
|
||||
@@ -643,7 +685,22 @@ _openapi:
|
||||
record is written after the inbounds, so a failure there is reported
|
||||
without an `inbound <id>:` prefix and leaves the inbound edits in
|
||||
place.'
|
||||
heading: update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
|
||||
heading: update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
|
||||
- content: 'Query-parameter alias for the path endpoint. Use email=. or email=..
|
||||
to manage legacy reserved names without URL dot-segment normalization.
|
||||
An omitted `flow` leaves every attached inbound on its own flow. Send
|
||||
`"reverse": null` to remove the reverse tag. Credentials (`id`,
|
||||
`password`, `auth`, `secret`, `subId`) and WireGuard/AmneziaWG
|
||||
`allowedIPs` are never cleared: an empty value keeps the stored one.
|
||||
The inbounds are applied concurrently and independently: one that
|
||||
fails no longer stops the others. Every inbound error names the
|
||||
inbound it came from (`inbound 7: <message>`), and several failures
|
||||
are reported together, one per line. So a `success:false` response can
|
||||
still have applied the edit to the remaining inbounds. The client
|
||||
record is written after the inbounds, so a failure there is reported
|
||||
without an `inbound <id>:` prefix and leaves the inbound edits in
|
||||
place.'
|
||||
heading: update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below-1
|
||||
- content: 'The inbounds are applied concurrently and independently: one that
|
||||
fails no longer stops the others. Every inbound error names the
|
||||
inbound it came from (`inbound 7: <message>`), and several failures
|
||||
@@ -654,6 +711,18 @@ _openapi:
|
||||
inbounds, so a failure there is reported without an `inbound <id>:`
|
||||
prefix and leaves the client already removed from every inbound.'
|
||||
heading: delete-a-client-by-email-removes-it-from-every-attached-inbound-and-drops-its-traffic-record-unless-keeptraffic1-is-passed
|
||||
- content: 'Query-parameter alias for the path endpoint. Use email=. or email=..
|
||||
to manage legacy reserved names without URL dot-segment normalization.
|
||||
The inbounds are applied concurrently and independently: one that
|
||||
fails no longer stops the others. Every inbound error names the
|
||||
inbound it came from (`inbound 7: <message>`), and several failures
|
||||
are reported together, one per line. So a `success:false` response can
|
||||
still have removed the client from the remaining inbounds; the client
|
||||
record is kept in that case, so re-running the call retries exactly
|
||||
the leftovers. The record and traffic rows are dropped after the
|
||||
inbounds, so a failure there is reported without an `inbound <id>:`
|
||||
prefix and leaves the client already removed from every inbound.'
|
||||
heading: delete-a-client-by-email-removes-it-from-every-attached-inbound-and-drops-its-traffic-record-unless-keeptraffic1-is-passed-1
|
||||
- content: 'A WireGuard client brings its stored `allowedIPs` into the new inbound
|
||||
instead of being given a fresh address, so the call fails with
|
||||
`inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address>
|
||||
@@ -679,7 +748,7 @@ export default function Layout(props) {
|
||||
return (
|
||||
<>
|
||||
{props.children}
|
||||
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/clients/list","method":"get"},{"path":"/panel/api/clients/list/paged","method":"get"},{"path":"/panel/api/clients/get/{email}","method":"get"},{"path":"/panel/api/clients/get/subId/{subId}","method":"get"},{"path":"/panel/api/clients/get/tgId/{tgId}","method":"get"},{"path":"/panel/api/clients/add","method":"post"},{"path":"/panel/api/clients/renewalPreview","method":"post"},{"path":"/panel/api/clients/update/{email}","method":"post"},{"path":"/panel/api/clients/del/{email}","method":"post"},{"path":"/panel/api/clients/{email}/attach","method":"post"},{"path":"/panel/api/clients/{email}/detach","method":"post"},{"path":"/panel/api/clients/{email}/externalLinks","method":"post"},{"path":"/panel/api/clients/resetAllTraffics","method":"post"},{"path":"/panel/api/clients/delDepleted","method":"post"},{"path":"/panel/api/clients/delOrphans","method":"post"},{"path":"/panel/api/clients/export","method":"get"},{"path":"/panel/api/clients/import","method":"post"},{"path":"/panel/api/clients/bulkAdjust","method":"post"},{"path":"/panel/api/clients/bulkEnable","method":"post"},{"path":"/panel/api/clients/bulkDisable","method":"post"},{"path":"/panel/api/clients/bulkDel","method":"post"},{"path":"/panel/api/clients/bulkCreate","method":"post"},{"path":"/panel/api/clients/groups/bulkAdd","method":"post"},{"path":"/panel/api/clients/groups/bulkRemove","method":"post"},{"path":"/panel/api/clients/bulkAttach","method":"post"},{"path":"/panel/api/clients/bulkDetach","method":"post"},{"path":"/panel/api/clients/bulkResetTraffic","method":"post"},{"path":"/panel/api/clients/groups","method":"get"},{"path":"/panel/api/clients/groups/{name}/emails","method":"get"},{"path":"/panel/api/clients/groups/create","method":"post"},{"path":"/panel/api/clients/groups/rename","method":"post"},{"path":"/panel/api/clients/groups/delete","method":"post"},{"path":"/panel/api/clients/groups/resetTraffic","method":"post"},{"path":"/panel/api/clients/resetTraffic/{email}","method":"post"},{"path":"/panel/api/clients/updateTraffic/{email}","method":"post"},{"path":"/panel/api/clients/ips/{email}","method":"post"},{"path":"/panel/api/clients/clearIps/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"delete"},{"path":"/panel/api/clients/hwids/{email}/{id}","method":"delete"},{"path":"/panel/api/clients/onlines","method":"post"},{"path":"/panel/api/clients/onlinesByGuid","method":"post"},{"path":"/panel/api/clients/clientIpsByGuid","method":"post"},{"path":"/panel/api/clients/activeInbounds","method":"post"},{"path":"/panel/api/clients/lastOnline","method":"post"},{"path":"/panel/api/clients/traffic/{email}","method":"get"},{"path":"/panel/api/clients/subLinks/{subId}","method":"get"},{"path":"/panel/api/clients/happLink/{id}","method":"post"},{"path":"/panel/api/clients/links/{email}","method":"get"}]} showTitle />
|
||||
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/clients/list","method":"get"},{"path":"/panel/api/clients/list/paged","method":"get"},{"path":"/panel/api/clients/get/{email}","method":"get"},{"path":"/panel/api/clients/get","method":"get"},{"path":"/panel/api/clients/get/subId/{subId}","method":"get"},{"path":"/panel/api/clients/get/tgId/{tgId}","method":"get"},{"path":"/panel/api/clients/add","method":"post"},{"path":"/panel/api/clients/renewalPreview","method":"post"},{"path":"/panel/api/clients/update/{email}","method":"post"},{"path":"/panel/api/clients/update","method":"post"},{"path":"/panel/api/clients/del/{email}","method":"post"},{"path":"/panel/api/clients/del","method":"post"},{"path":"/panel/api/clients/{email}/attach","method":"post"},{"path":"/panel/api/clients/{email}/detach","method":"post"},{"path":"/panel/api/clients/{email}/externalLinks","method":"post"},{"path":"/panel/api/clients/resetAllTraffics","method":"post"},{"path":"/panel/api/clients/delDepleted","method":"post"},{"path":"/panel/api/clients/delOrphans","method":"post"},{"path":"/panel/api/clients/export","method":"get"},{"path":"/panel/api/clients/import","method":"post"},{"path":"/panel/api/clients/bulkAdjust","method":"post"},{"path":"/panel/api/clients/bulkEnable","method":"post"},{"path":"/panel/api/clients/bulkDisable","method":"post"},{"path":"/panel/api/clients/bulkDel","method":"post"},{"path":"/panel/api/clients/bulkCreate","method":"post"},{"path":"/panel/api/clients/groups/bulkAdd","method":"post"},{"path":"/panel/api/clients/groups/bulkRemove","method":"post"},{"path":"/panel/api/clients/bulkAttach","method":"post"},{"path":"/panel/api/clients/bulkDetach","method":"post"},{"path":"/panel/api/clients/bulkResetTraffic","method":"post"},{"path":"/panel/api/clients/groups","method":"get"},{"path":"/panel/api/clients/groups/{name}/emails","method":"get"},{"path":"/panel/api/clients/groups/create","method":"post"},{"path":"/panel/api/clients/groups/rename","method":"post"},{"path":"/panel/api/clients/groups/delete","method":"post"},{"path":"/panel/api/clients/groups/resetTraffic","method":"post"},{"path":"/panel/api/clients/resetTraffic/{email}","method":"post"},{"path":"/panel/api/clients/updateTraffic/{email}","method":"post"},{"path":"/panel/api/clients/ips/{email}","method":"post"},{"path":"/panel/api/clients/clearIps/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"delete"},{"path":"/panel/api/clients/hwids/{email}/{id}","method":"delete"},{"path":"/panel/api/clients/onlines","method":"post"},{"path":"/panel/api/clients/onlinesByGuid","method":"post"},{"path":"/panel/api/clients/clientIpsByGuid","method":"post"},{"path":"/panel/api/clients/activeInbounds","method":"post"},{"path":"/panel/api/clients/lastOnline","method":"post"},{"path":"/panel/api/clients/traffic/{email}","method":"get"},{"path":"/panel/api/clients/subLinks/{subId}","method":"get"},{"path":"/panel/api/clients/happLink/{id}","method":"post"},{"path":"/panel/api/clients/links/{email}","method":"get"}]} showTitle />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -56,8 +56,10 @@ _openapi:
|
||||
title: Create a new inbound. Send the full inbound payload (protocol, port,
|
||||
settings, streamSettings, sniffing, remark, expiryTime, total, enable).
|
||||
settings, streamSettings, and sniffing may be sent as nested JSON
|
||||
objects (preferred) or as JSON-encoded strings (legacy).
|
||||
url: '#create-a-new-inbound-send-the-full-inbound-payload-protocol-port-settings-streamsettings-sniffing-remark-expirytime-total-enable-settings-streamsettings-and-sniffing-may-be-sent-as-nested-json-objects-preferred-or-as-json-encoded-strings-legacy'
|
||||
objects (preferred) or as JSON-encoded strings (legacy). VLESS empty
|
||||
fallback arrays are normalized away; nonempty fallbacks cannot be
|
||||
combined with active server decryption.
|
||||
url: '#create-a-new-inbound-send-the-full-inbound-payload-protocol-port-settings-streamsettings-sniffing-remark-expirytime-total-enable-settings-streamsettings-and-sniffing-may-be-sent-as-nested-json-objects-preferred-or-as-json-encoded-strings-legacy-vless-empty-fallback-arrays-are-normalized-away-nonempty-fallbacks-cannot-be-combined-with-active-server-decryption'
|
||||
- depth: 2
|
||||
title: Delete an inbound by ID. Also removes its associated client stats rows.
|
||||
url: '#delete-an-inbound-by-id-also-removes-its-associated-client-stats-rows'
|
||||
@@ -70,8 +72,10 @@ _openapi:
|
||||
title: 'Replace an inbound’s configuration. Body shape mirrors /add, but the
|
||||
inbound keeps its stored client list and enable flag: settings.clients
|
||||
and enable in the body are ignored. Manage clients through the
|
||||
/panel/api/clients endpoints and toggle the inbound with /setEnable.'
|
||||
url: '#replace-an-inbounds-configuration-body-shape-mirrors-add-but-the-inbound-keeps-its-stored-client-list-and-enable-flag-settingsclients-and-enable-in-the-body-are-ignored-manage-clients-through-the-panelapiclients-endpoints-and-toggle-the-inbound-with-setenable'
|
||||
/panel/api/clients endpoints and toggle the inbound with /setEnable.
|
||||
Remove effective fallback rules before enabling VLESS server
|
||||
decryption.'
|
||||
url: '#replace-an-inbounds-configuration-body-shape-mirrors-add-but-the-inbound-keeps-its-stored-client-list-and-enable-flag-settingsclients-and-enable-in-the-body-are-ignored-manage-clients-through-the-panelapiclients-endpoints-and-toggle-the-inbound-with-setenable-remove-effective-fallback-rules-before-enabling-vless-server-decryption'
|
||||
- depth: 2
|
||||
title: Toggle only the enable flag without serialising the whole settings JSON.
|
||||
Recommended for UI switches on large inbounds.
|
||||
@@ -96,8 +100,9 @@ _openapi:
|
||||
url: '#reset-upload--download-counters-on-every-inbound-destructive--accounting-history-is-lost'
|
||||
- depth: 2
|
||||
title: Bulk-import an inbound from a JSON blob (e.g. one exported via the UI).
|
||||
The body uses form encoding with a single "data" field.
|
||||
url: '#bulk-import-an-inbound-from-a-json-blob-eg-one-exported-via-the-ui-the-body-uses-form-encoding-with-a-single-data-field'
|
||||
The body uses form encoding with a single "data" field. VLESS fallback
|
||||
validation is identical to /add.
|
||||
url: '#bulk-import-an-inbound-from-a-json-blob-eg-one-exported-via-the-ui-the-body-uses-form-encoding-with-a-single-data-field-vless-fallback-validation-is-identical-to-add'
|
||||
- depth: 2
|
||||
title: Receive a master panel's aggregated per-client usage, keyed by the
|
||||
master's GUID. Stored in a side table used only for the UI display
|
||||
@@ -113,8 +118,10 @@ _openapi:
|
||||
url: '#list-the-fallback-rules-attached-to-a-master-vlesstrojan-tcp-tls-inbound-each-rule-links-one-child-inbound-the-dest-to-optional-snialpnpathdestxver-match-criteria-when-dest-is-empty-the-child-inbounds-listenport-is-used'
|
||||
- depth: 2
|
||||
title: Replace the entire fallback list for a master inbound. Body is JSON.
|
||||
Triggers an Xray restart.
|
||||
url: '#replace-the-entire-fallback-list-for-a-master-inbound-body-is-json-triggers-an-xray-restart'
|
||||
Nonempty lists require TCP with TLS/REALITY and no active VLESS server
|
||||
decryption. An empty list can remove rules even from an incompatible
|
||||
inbound. Triggers an Xray restart.
|
||||
url: '#replace-the-entire-fallback-list-for-a-master-inbound-body-is-json-nonempty-lists-require-tcp-with-tlsreality-and-no-active-vless-server-decryption-an-empty-list-can-remove-rules-even-from-an-incompatible-inbound-triggers-an-xray-restart'
|
||||
structuredData:
|
||||
headings:
|
||||
- content: List every inbound owned by the authenticated user, including each
|
||||
@@ -157,8 +164,10 @@ _openapi:
|
||||
- content: Create a new inbound. Send the full inbound payload (protocol, port,
|
||||
settings, streamSettings, sniffing, remark, expiryTime, total,
|
||||
enable). settings, streamSettings, and sniffing may be sent as nested
|
||||
JSON objects (preferred) or as JSON-encoded strings (legacy).
|
||||
id: create-a-new-inbound-send-the-full-inbound-payload-protocol-port-settings-streamsettings-sniffing-remark-expirytime-total-enable-settings-streamsettings-and-sniffing-may-be-sent-as-nested-json-objects-preferred-or-as-json-encoded-strings-legacy
|
||||
JSON objects (preferred) or as JSON-encoded strings (legacy). VLESS
|
||||
empty fallback arrays are normalized away; nonempty fallbacks cannot
|
||||
be combined with active server decryption.
|
||||
id: create-a-new-inbound-send-the-full-inbound-payload-protocol-port-settings-streamsettings-sniffing-remark-expirytime-total-enable-settings-streamsettings-and-sniffing-may-be-sent-as-nested-json-objects-preferred-or-as-json-encoded-strings-legacy-vless-empty-fallback-arrays-are-normalized-away-nonempty-fallbacks-cannot-be-combined-with-active-server-decryption
|
||||
- content: Delete an inbound by ID. Also removes its associated client stats rows.
|
||||
id: delete-an-inbound-by-id-also-removes-its-associated-client-stats-rows
|
||||
- content: Delete many inbounds in one call. Processes the list sequentially;
|
||||
@@ -168,8 +177,10 @@ _openapi:
|
||||
- content: 'Replace an inbound’s configuration. Body shape mirrors /add, but the
|
||||
inbound keeps its stored client list and enable flag: settings.clients
|
||||
and enable in the body are ignored. Manage clients through the
|
||||
/panel/api/clients endpoints and toggle the inbound with /setEnable.'
|
||||
id: replace-an-inbounds-configuration-body-shape-mirrors-add-but-the-inbound-keeps-its-stored-client-list-and-enable-flag-settingsclients-and-enable-in-the-body-are-ignored-manage-clients-through-the-panelapiclients-endpoints-and-toggle-the-inbound-with-setenable
|
||||
/panel/api/clients endpoints and toggle the inbound with /setEnable.
|
||||
Remove effective fallback rules before enabling VLESS server
|
||||
decryption.'
|
||||
id: replace-an-inbounds-configuration-body-shape-mirrors-add-but-the-inbound-keeps-its-stored-client-list-and-enable-flag-settingsclients-and-enable-in-the-body-are-ignored-manage-clients-through-the-panelapiclients-endpoints-and-toggle-the-inbound-with-setenable-remove-effective-fallback-rules-before-enabling-vless-server-decryption
|
||||
- content: Toggle only the enable flag without serialising the whole settings
|
||||
JSON. Recommended for UI switches on large inbounds.
|
||||
id: toggle-only-the-enable-flag-without-serialising-the-whole-settings-json-recommended-for-ui-switches-on-large-inbounds
|
||||
@@ -188,8 +199,9 @@ _openapi:
|
||||
accounting history is lost.
|
||||
id: reset-upload--download-counters-on-every-inbound-destructive--accounting-history-is-lost
|
||||
- content: Bulk-import an inbound from a JSON blob (e.g. one exported via the UI).
|
||||
The body uses form encoding with a single "data" field.
|
||||
id: bulk-import-an-inbound-from-a-json-blob-eg-one-exported-via-the-ui-the-body-uses-form-encoding-with-a-single-data-field
|
||||
The body uses form encoding with a single "data" field. VLESS fallback
|
||||
validation is identical to /add.
|
||||
id: bulk-import-an-inbound-from-a-json-blob-eg-one-exported-via-the-ui-the-body-uses-form-encoding-with-a-single-data-field-vless-fallback-validation-is-identical-to-add
|
||||
- content: Receive a master panel's aggregated per-client usage, keyed by the
|
||||
master's GUID. Stored in a side table used only for the UI display
|
||||
overlay and local quota enforcement — never folded into the local
|
||||
@@ -202,8 +214,10 @@ _openapi:
|
||||
inbound's listen+port is used.
|
||||
id: list-the-fallback-rules-attached-to-a-master-vlesstrojan-tcp-tls-inbound-each-rule-links-one-child-inbound-the-dest-to-optional-snialpnpathdestxver-match-criteria-when-dest-is-empty-the-child-inbounds-listenport-is-used
|
||||
- content: Replace the entire fallback list for a master inbound. Body is JSON.
|
||||
Triggers an Xray restart.
|
||||
id: replace-the-entire-fallback-list-for-a-master-inbound-body-is-json-triggers-an-xray-restart
|
||||
Nonempty lists require TCP with TLS/REALITY and no active VLESS server
|
||||
decryption. An empty list can remove rules even from an incompatible
|
||||
inbound. Triggers an Xray restart.
|
||||
id: replace-the-entire-fallback-list-for-a-master-inbound-body-is-json-nonempty-lists-require-tcp-with-tlsreality-and-no-active-vless-server-decryption-an-empty-list-can-remove-rules-even-from-an-incompatible-inbound-triggers-an-xray-restart
|
||||
contents: []
|
||||
---
|
||||
|
||||
|
||||
@@ -133,10 +133,11 @@ inbound به مقدار `decryption` نیاز دارد و outbound به `encrypt
|
||||
|
||||
| فیلد | پیشفرض | معنا |
|
||||
| -------------- | -------------- | ---- |
|
||||
| `service` | `google-drive` | `google-drive`، `local` (پوشهای روی دیسک) یا `template` (هر API ذخیرهسازی HTTP). نگارش قدیمی `Google Drive` همچنان خوانده میشود. |
|
||||
| `service` | `google-drive` | `google-drive`، `local` (پوشهای روی دیسک)، `template` (هر API ذخیرهسازی HTTP) یا `s3` (ذخیرهساز سازگار با S3). نگارش قدیمی `Google Drive` همچنان خوانده میشود. |
|
||||
| `remoteFolder` | — | پوشهای که فایلهای نشست در آن قرار میگیرند. |
|
||||
| `secrets` | — | Google Drive: دقیقاً سه مقدار ClientID، ClientSecret و RefreshToken به همین ترتیب. Template: مقادیری که با `{secret0}`، `{secret1}`، … خوانده میشوند. |
|
||||
| `secrets` | — | Google Drive: دقیقاً سه مقدار ClientID، ClientSecret و RefreshToken به همین ترتیب. Template: مقادیری که با `{secret0}`، `{secret1}`، … خوانده میشوند. S3: AccessKeyID، SecretAccessKey و در صورت نیاز SessionToken. |
|
||||
| `template` | — | فقط برای سرویس template: عملیات `auth`، `put`، `get`، `list` و `delete` در API ذخیرهساز. |
|
||||
| `s3` | — | فقط برای سرویس s3: `endpoint` (یک URL از نوع http(s))، `region`، `bucket` و `pathStyle` (پیشفرض `true`؛ با `false` نام باکت در نام میزبان میآید). `remoteFolder` پیشوند اشیاء است. |
|
||||
| تنظیمات دقیق | پیشفرض هسته | `segmentBytes` (512 KiB)، `flushIntervalMs` (20)، `pollIntervalMs`/`maxPollIntervalMs` (50/500)، `eagerWindowMs` (2000)، `holeTimeoutMs` (30000)، `sessionTtlSeconds` (300)، `concurrency` (8). |
|
||||
|
||||
**آدرس/پورت Front** اختیاری (کلیدهای `address`/`port` در سطح stream) یک domain front
|
||||
|
||||
@@ -77,6 +77,8 @@ icon: Send
|
||||
ادمینها همچنین جریانهای دکمهی درونخطی برای مصرف سرور، گزارشهای ترافیک مرتبشده،
|
||||
بازنشانی ترافیک، پشتیبانگیری از DB، گزارشهای مسدودسازی، فهرست کردن ورودیها/کلاینتها،
|
||||
کلاینتهای آنلاین، «بهزودی تمامشونده» و یک جادوگر کامل **افزودن کلاینت** را در اختیار دارند.
|
||||
کارت **کلاینت** در اختیار ادمین است و لینک اشتراک، لینکهای منفرد و کد QR کلاینت را
|
||||
نشان میدهد، تا کانفیگ را بتوان به کلاینتی که هنوز به Telegram دسترسی ندارد هم داد.
|
||||
کاربران عادی دکمههایی برای مصرف خودشان، لینکهای اشتراک، لینکهای منفرد و کدهای QR دارند.
|
||||
|
||||
## گزارشها و هشدارها
|
||||
|
||||
@@ -44,10 +44,11 @@ _openapi:
|
||||
- depth: 2
|
||||
title: >-
|
||||
Update an existing client by email. Changes propagate to every attached
|
||||
inbound. Body is the JSON client payload — supply the full set of fields
|
||||
you want to keep (the server replaces the row, it does not patch).
|
||||
inbound. Body is the JSON client payload: omitted fields keep their
|
||||
current values, and an explicit zero, empty or null value clears a
|
||||
field, except as noted below.
|
||||
url: >-
|
||||
#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
|
||||
#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
|
||||
- depth: 2
|
||||
title: >-
|
||||
Delete a client by email. Removes it from every attached inbound and
|
||||
@@ -358,11 +359,11 @@ _openapi:
|
||||
create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
|
||||
- content: >-
|
||||
Update an existing client by email. Changes propagate to every
|
||||
attached inbound. Body is the JSON client payload — supply the full
|
||||
set of fields you want to keep (the server replaces the row, it does
|
||||
not patch).
|
||||
attached inbound. Body is the JSON client payload: omitted fields keep
|
||||
their current values, and an explicit zero, empty or null value clears
|
||||
a field, except as noted below.
|
||||
id: >-
|
||||
update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
|
||||
update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
|
||||
- content: >-
|
||||
Delete a client by email. Removes it from every attached inbound and
|
||||
drops its traffic record unless keepTraffic=1 is passed.
|
||||
|
||||
@@ -135,10 +135,11 @@ Mux. Панель предлагает XDRIVE только для VLESS, не с
|
||||
|
||||
| Поле | По умолчанию | Значение |
|
||||
| -------------- | -------------- | -------- |
|
||||
| `service` | `google-drive` | `google-drive`, `local` (папка на диске) или `template` (любой HTTP API хранилища). Старое написание `Google Drive` по-прежнему читается. |
|
||||
| `service` | `google-drive` | `google-drive`, `local` (папка на диске), `template` (любой HTTP API хранилища) или `s3` (S3-совместимое хранилище). Старое написание `Google Drive` по-прежнему читается. |
|
||||
| `remoteFolder` | — | Папка с файлами сессий. |
|
||||
| `secrets` | — | Google Drive: ровно три значения — ClientID, ClientSecret, RefreshToken, в этом порядке. Template: значения, доступные как `{secret0}`, `{secret1}`, …. |
|
||||
| `secrets` | — | Google Drive: ровно три значения — ClientID, ClientSecret, RefreshToken, в этом порядке. Template: значения, доступные как `{secret0}`, `{secret1}`, …. S3: AccessKeyID, SecretAccessKey и при необходимости SessionToken. |
|
||||
| `template` | — | Только для template: операции `auth`, `put`, `get`, `list` и `delete` API хранилища. |
|
||||
| `s3` | — | Только для s3: `endpoint` (URL http(s)), `region`, `bucket` и `pathStyle` (по умолчанию `true`; `false` переносит бакет в имя хоста). `remoteFolder` — префикс объектов. |
|
||||
| тонкая настройка | значения ядра | `segmentBytes` (512 KiB), `flushIntervalMs` (20), `pollIntervalMs`/`maxPollIntervalMs` (50/500), `eagerWindowMs` (2000), `holeTimeoutMs` (30000), `sessionTtlSeconds` (300), `concurrency` (8). |
|
||||
|
||||
Необязательные **адрес/порт фронтинга** (`address`/`port` на уровне stream) — домен-фронт,
|
||||
|
||||
@@ -80,9 +80,11 @@ ID темы после двоеточия, например `-1001234567890:42`.
|
||||
Администраторам также доступны сценарии со встроенными кнопками: использование
|
||||
сервера, отсортированные отчёты по трафику, сброс трафика, резервные копии БД,
|
||||
журналы блокировок, список входящих подключений/клиентов, онлайн-клиенты,
|
||||
«скоро исчерпают» и полноценный мастер **добавления клиента**. Обычным
|
||||
пользователям доступны кнопки для своего расхода, ссылок на подписку, отдельных
|
||||
ссылок и QR-кодов.
|
||||
«скоро исчерпают» и полноценный мастер **добавления клиента**. В карточке
|
||||
клиента администратор получает ссылку на подписку, отдельные ссылки и QR-код —
|
||||
конфигурацию можно передать клиенту, который ещё не может открыть Telegram.
|
||||
Обычным пользователям доступны кнопки для своего расхода, ссылок на подписку,
|
||||
отдельных ссылок и QR-кодов.
|
||||
|
||||
## Отчёты и оповещения
|
||||
|
||||
|
||||
@@ -44,10 +44,11 @@ _openapi:
|
||||
- depth: 2
|
||||
title: >-
|
||||
Update an existing client by email. Changes propagate to every attached
|
||||
inbound. Body is the JSON client payload — supply the full set of fields
|
||||
you want to keep (the server replaces the row, it does not patch).
|
||||
inbound. Body is the JSON client payload: omitted fields keep their
|
||||
current values, and an explicit zero, empty or null value clears a
|
||||
field, except as noted below.
|
||||
url: >-
|
||||
#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
|
||||
#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
|
||||
- depth: 2
|
||||
title: >-
|
||||
Delete a client by email. Removes it from every attached inbound and
|
||||
@@ -358,11 +359,11 @@ _openapi:
|
||||
create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
|
||||
- content: >-
|
||||
Update an existing client by email. Changes propagate to every
|
||||
attached inbound. Body is the JSON client payload — supply the full
|
||||
set of fields you want to keep (the server replaces the row, it does
|
||||
not patch).
|
||||
attached inbound. Body is the JSON client payload: omitted fields keep
|
||||
their current values, and an explicit zero, empty or null value clears
|
||||
a field, except as noted below.
|
||||
id: >-
|
||||
update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
|
||||
update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
|
||||
- content: >-
|
||||
Delete a client by email. Removes it from every attached inbound and
|
||||
drops its traffic record unless keepTraffic=1 is passed.
|
||||
|
||||
@@ -124,10 +124,11 @@ XDRIVE 把数据流作为文件存放在双方都能访问的文件夹中:服
|
||||
|
||||
| 字段 | 默认值 | 含义 |
|
||||
| -------------- | -------------- | ---- |
|
||||
| `service` | `google-drive` | `google-drive`、`local`(磁盘上的文件夹)或 `template`(任意 HTTP 存储 API)。旧写法 `Google Drive` 仍可读取。 |
|
||||
| `service` | `google-drive` | `google-drive`、`local`(磁盘上的文件夹)、`template`(任意 HTTP 存储 API)或 `s3`(S3 兼容存储)。旧写法 `Google Drive` 仍可读取。 |
|
||||
| `remoteFolder` | — | 存放会话文件的文件夹。 |
|
||||
| `secrets` | — | Google Drive:恰好三项,依次为 ClientID、ClientSecret、RefreshToken。Template:以 `{secret0}`、`{secret1}`… 引用的值。 |
|
||||
| `secrets` | — | Google Drive:恰好三项,依次为 ClientID、ClientSecret、RefreshToken。Template:以 `{secret0}`、`{secret1}`… 引用的值。S3:AccessKeyID、SecretAccessKey,以及可选的 SessionToken。 |
|
||||
| `template` | — | 仅用于 template 服务:存储 API 的 `auth`、`put`、`get`、`list` 和 `delete` 操作。 |
|
||||
| `s3` | — | 仅用于 s3 服务:`endpoint`(http(s) URL)、`region`、`bucket` 和 `pathStyle`(默认 `true`;为 `false` 时存储桶名放在主机名中)。`remoteFolder` 为对象前缀。 |
|
||||
| 调优参数 | 内核默认值 | `segmentBytes`(512 KiB)、`flushIntervalMs`(20)、`pollIntervalMs`/`maxPollIntervalMs`(50/500)、`eagerWindowMs`(2000)、`holeTimeoutMs`(30000)、`sessionTtlSeconds`(300)、`concurrency`(8)。 |
|
||||
|
||||
可选的**前置地址/端口**(stream 级的 `address`/`port`)是连接存储 API 时使用的域前置;TLS 中仍使用 API 主机名。
|
||||
|
||||
@@ -71,7 +71,9 @@ ID 的所有客户端。订阅 ID 就是邀请码,因此请保持其足够长
|
||||
|
||||
管理员还可通过内联按钮使用一系列功能:服务器用量、按流量排序的报告、
|
||||
重置流量、数据库备份、封禁日志、列出入站/客户端、在线客户端、
|
||||
“即将耗尽”,以及完整的**添加客户端**向导。普通用户则可以使用按钮查看
|
||||
“即将耗尽”,以及完整的**添加客户端**向导。管理员的**客户端卡片**上还带有该客户端的
|
||||
订阅链接、单条链接和 QR 码,因此可以把配置交给暂时还无法打开 Telegram 的客户端。
|
||||
普通用户则可以使用按钮查看
|
||||
自己的用量、订阅链接、单条链接和 QR 码。
|
||||
|
||||
## 报告与告警
|
||||
|
||||
@@ -35,10 +35,10 @@ _openapi:
|
||||
#create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
|
||||
- depth: 2
|
||||
title: >-
|
||||
按 email 更新现有客户端。变更会传播到每个挂载的入站。请求体为 JSON 客户端载荷——
|
||||
请提供你希望保留的完整字段集(服务端会替换整条记录,而非局部更新)。
|
||||
按 email 更新现有客户端。变更会传播到每个挂载的入站。请求体为 JSON 客户端载荷:
|
||||
省略的字段保留当前值,显式的零值、空值或 null 会清除该字段(例外见下文)。
|
||||
url: >-
|
||||
#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
|
||||
#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
|
||||
- depth: 2
|
||||
title: >-
|
||||
按 email 删除客户端。将其从每个挂载的入站中移除,并删除其流量记录,除非传入
|
||||
@@ -292,10 +292,10 @@ _openapi:
|
||||
id: >-
|
||||
create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
|
||||
- content: >-
|
||||
按 email 更新现有客户端。变更会传播到每个挂载的入站。请求体为 JSON 客户端载荷——
|
||||
请提供你希望保留的完整字段集(服务端会替换整条记录,而非局部更新)。
|
||||
按 email 更新现有客户端。变更会传播到每个挂载的入站。请求体为 JSON 客户端载荷:
|
||||
省略的字段保留当前值,显式的零值、空值或 null 会清除该字段(例外见下文)。
|
||||
id: >-
|
||||
update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
|
||||
update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
|
||||
- content: >-
|
||||
按 email 删除客户端。将其从每个挂载的入站中移除,并删除其流量记录,除非传入
|
||||
keepTraffic=1。
|
||||
|
||||
+9
-9
@@ -18,12 +18,12 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"fumadocs-core": "^16.17.8",
|
||||
"fumadocs-core": "^16.17.9",
|
||||
"fumadocs-docgen": "^3.2.0",
|
||||
"fumadocs-mdx": "^15.4.10",
|
||||
"fumadocs-openapi": "^12.5.5",
|
||||
"fumadocs-ui": "^16.17.8",
|
||||
"lucide-react": "^1.61.0",
|
||||
"fumadocs-openapi": "^12.5.7",
|
||||
"fumadocs-ui": "^16.17.9",
|
||||
"lucide-react": "^1.66.0",
|
||||
"mermaid": "^12.2.0",
|
||||
"next": "16.4.3",
|
||||
"next-themes": "^0.4.6",
|
||||
@@ -37,15 +37,15 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@types/mdx": "^2.0.14",
|
||||
"@types/node": "^26.6.5",
|
||||
"@types/node": "^26.6.6",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"oxfmt": "0.74.0",
|
||||
"oxlint": "1.89.0",
|
||||
"postcss": "^8.5.31",
|
||||
"oxfmt": "0.75.0",
|
||||
"oxlint": "1.90.0",
|
||||
"postcss": "^8.5.32",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "7.0.2",
|
||||
"vitest": "^5.0.5"
|
||||
},
|
||||
"packageManager": "pnpm@12.15.0"
|
||||
"packageManager": "pnpm@12.16.1"
|
||||
}
|
||||
|
||||
Generated
+288
-288
File diff suppressed because it is too large
Load Diff
@@ -11,8 +11,6 @@ overrides:
|
||||
'lightningcss@<1.33.0': '^1.33.0'
|
||||
minimumReleaseAgeExcludePrune: true
|
||||
minimumReleaseAgeExclude:
|
||||
- fumadocs-core@16.17.8
|
||||
- fumadocs-ui@16.17.8
|
||||
- '@fumadocs/json-schema@0.3.5'
|
||||
- '@next/env@16.4.3'
|
||||
- '@next/swc-darwin-arm64@16.4.3'
|
||||
@@ -24,5 +22,4 @@ minimumReleaseAgeExclude:
|
||||
- '@next/swc-win32-arm64-msvc@16.4.3'
|
||||
- '@next/swc-win32-x64-msvc@16.4.3'
|
||||
- fumadocs-mdx@15.4.10
|
||||
- fumadocs-openapi@12.5.5
|
||||
- next@16.4.3
|
||||
|
||||
+174
-14
@@ -1785,7 +1785,8 @@
|
||||
"type": "string"
|
||||
},
|
||||
"forwardedPorts": {
|
||||
"description": "AmneziaWG per-client port-forwarding spec, e.g. \"80,443,8000-8100\"",
|
||||
"description": "AmneziaWG port-forwarding spec, e.g. \"80,443,8000-8100\"; \"\" withdraws the forwards, omit to keep them",
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"group": {
|
||||
@@ -5650,7 +5651,7 @@
|
||||
"tags": [
|
||||
"Inbounds"
|
||||
],
|
||||
"summary": "Create a new inbound. Send the full inbound payload (protocol, port, settings, streamSettings, sniffing, remark, expiryTime, total, enable). settings, streamSettings, and sniffing may be sent as nested JSON objects (preferred) or as JSON-encoded strings (legacy).",
|
||||
"summary": "Create a new inbound. Send the full inbound payload (protocol, port, settings, streamSettings, sniffing, remark, expiryTime, total, enable). settings, streamSettings, and sniffing may be sent as nested JSON objects (preferred) or as JSON-encoded strings (legacy). VLESS empty fallback arrays are normalized away; nonempty fallbacks cannot be combined with active server decryption.",
|
||||
"operationId": "post_panel_api_inbounds_add",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -5674,8 +5675,7 @@
|
||||
"email": "user1"
|
||||
}
|
||||
],
|
||||
"decryption": "none",
|
||||
"fallbacks": []
|
||||
"decryption": "none"
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "tcp",
|
||||
@@ -5846,7 +5846,7 @@
|
||||
"tags": [
|
||||
"Inbounds"
|
||||
],
|
||||
"summary": "Replace an inbound’s configuration. Body shape mirrors /add, but the inbound keeps its stored client list and enable flag: settings.clients and enable in the body are ignored. Manage clients through the /panel/api/clients endpoints and toggle the inbound with /setEnable.",
|
||||
"summary": "Replace an inbound’s configuration. Body shape mirrors /add, but the inbound keeps its stored client list and enable flag: settings.clients and enable in the body are ignored. Manage clients through the /panel/api/clients endpoints and toggle the inbound with /setEnable. Remove effective fallback rules before enabling VLESS server decryption.",
|
||||
"operationId": "post_panel_api_inbounds_update_id",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -5881,8 +5881,7 @@
|
||||
"email": "user1"
|
||||
}
|
||||
],
|
||||
"decryption": "none",
|
||||
"fallbacks": []
|
||||
"decryption": "none"
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "tcp",
|
||||
@@ -6157,7 +6156,7 @@
|
||||
"tags": [
|
||||
"Inbounds"
|
||||
],
|
||||
"summary": "Bulk-import an inbound from a JSON blob (e.g. one exported via the UI). The body uses form encoding with a single \"data\" field.",
|
||||
"summary": "Bulk-import an inbound from a JSON blob (e.g. one exported via the UI). The body uses form encoding with a single \"data\" field. VLESS fallback validation is identical to /add.",
|
||||
"operationId": "post_panel_api_inbounds_import",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -6331,7 +6330,7 @@
|
||||
"tags": [
|
||||
"Inbounds"
|
||||
],
|
||||
"summary": "Replace the entire fallback list for a master inbound. Body is JSON. Triggers an Xray restart.",
|
||||
"summary": "Replace the entire fallback list for a master inbound. Body is JSON. Nonempty lists require TCP with TLS/REALITY and no active VLESS server decryption. An empty list can remove rules even from an incompatible inbound. Triggers an Xray restart.",
|
||||
"operationId": "post_panel_api_inbounds_id_fallbacks",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -8723,6 +8722,48 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/clients/get": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Clients"
|
||||
],
|
||||
"summary": "Fetch one client by email, including the inbound IDs and external config IDs it is attached to.",
|
||||
"operationId": "get_panel_api_clients_get",
|
||||
"description": "Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "email",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"description": "Client email (unique identifier).",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/clients/get/subId/{subId}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -8846,7 +8887,7 @@
|
||||
],
|
||||
"summary": "Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets are generated server-side when omitted, so callers can send only the universal fields.",
|
||||
"operationId": "post_panel_api_clients_add",
|
||||
"description": "Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `0.0.0.0/0` or `::/0` default route claims no address. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.",
|
||||
"description": "Client email must not be exactly `.` or `..`. Existing reserved-name clients can be read, renamed to a valid name, or deleted using the query-parameter /get, /update and /del aliases. Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `/0` entry collides with every address of its family another client holds, and only a `0.0.0.0/0` or `::/0` default route that is already stored (legacy peers) keeps nobody out. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
@@ -9012,9 +9053,9 @@
|
||||
"tags": [
|
||||
"Clients"
|
||||
],
|
||||
"summary": "Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload — supply the full set of fields you want to keep (the server replaces the row, it does not patch).",
|
||||
"summary": "Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload: omitted fields keep their current values, and an explicit zero, empty or null value clears a field, except as noted below.",
|
||||
"operationId": "post_panel_api_clients_update_email",
|
||||
"description": "The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.",
|
||||
"description": "An omitted `flow` leaves every attached inbound on its own flow. Send `\"reverse\": null` to remove the reverse tag. Credentials (`id`, `password`, `auth`, `secret`, `subId`) and WireGuard/AmneziaWG `allowedIPs` are never cleared: an empty value keeps the stored one. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "email",
|
||||
@@ -9071,6 +9112,70 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/clients/update": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Clients"
|
||||
],
|
||||
"summary": "Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload: omitted fields keep their current values, and an explicit zero, empty or null value clears a field, except as noted below.",
|
||||
"operationId": "post_panel_api_clients_update",
|
||||
"description": "Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization. An omitted `flow` leaves every attached inbound on its own flow. Send `\"reverse\": null` to remove the reverse tag. Credentials (`id`, `password`, `auth`, `secret`, `subId`) and WireGuard/AmneziaWG `allowedIPs` are never cleared: an empty value keeps the stored one. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "email",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"description": "Current client email (unique identifier).",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
},
|
||||
"example": {
|
||||
"email": "alice@example.com",
|
||||
"totalGB": 107374182400,
|
||||
"expiryTime": 1767225600000,
|
||||
"limitHwid": 2,
|
||||
"tgId": 123456789,
|
||||
"enable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"success": true,
|
||||
"msg": "Client updated"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/clients/del/{email}": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -9126,6 +9231,61 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/clients/del": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Clients"
|
||||
],
|
||||
"summary": "Delete a client by email. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.",
|
||||
"operationId": "post_panel_api_clients_del",
|
||||
"description": "Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have removed the client from the remaining inbounds; the client record is kept in that case, so re-running the call retries exactly the leftovers. The record and traffic rows are dropped after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the client already removed from every inbound.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "email",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"description": "Client email (unique identifier).",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "keepTraffic",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"description": "Pass 1 to retain the xray_client_traffic row after deletion.",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"success": true,
|
||||
"msg": "Client deleted"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/clients/{email}/attach": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -10163,7 +10323,7 @@
|
||||
"tags": [
|
||||
"Clients"
|
||||
],
|
||||
"summary": "Zero up/down counters for many clients in one call. Loops the single-reset path so each client is re-enabled across its attached inbounds and pushed to Xray/remote nodes. Returns the count of successfully reset clients.",
|
||||
"summary": "Zero up/down counters for many clients in one call. Loops the single-reset path so each client its quota had disabled is re-enabled across its attached inbounds and pushed to Xray/remote nodes; a client disabled by hand stays disabled. Returns the count of successfully reset clients.",
|
||||
"operationId": "post_panel_api_clients_bulkResetTraffic",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -10504,7 +10664,7 @@
|
||||
"tags": [
|
||||
"Clients"
|
||||
],
|
||||
"summary": "Zero out a single client’s up/down counters. Re-enables the client across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately.",
|
||||
"summary": "Zero out a single client’s up/down counters. Re-enables a client its quota had disabled across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately; a client disabled by hand stays disabled.",
|
||||
"operationId": "post_panel_api_clients_resetTraffic_email",
|
||||
"parameters": [
|
||||
{
|
||||
|
||||
+11
-4
@@ -11,7 +11,8 @@
|
||||
"jsPlugins": [
|
||||
"./tools/oxlint/input-number-guard.mjs",
|
||||
"./tools/oxlint/control-label-guard.mjs",
|
||||
"./tools/oxlint/icon-a11y-guard.mjs"
|
||||
"./tools/oxlint/icon-a11y-guard.mjs",
|
||||
"./tools/oxlint/antd-a11y-guard.mjs"
|
||||
],
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
@@ -52,7 +53,9 @@
|
||||
"input-number/no-synthetic-clear": "off",
|
||||
"jsx-a11y/prefer-tag-over-role": "off",
|
||||
"control-label/has-name": "off",
|
||||
"antd-icon/explicit-a11y": "off"
|
||||
"antd-icon/explicit-a11y": "off",
|
||||
"antd-a11y/spin-content-not-live": "off",
|
||||
"antd-a11y/select-tag-remove": "off"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
@@ -78,7 +81,9 @@
|
||||
],
|
||||
"rules": {
|
||||
"control-label/has-name": "error",
|
||||
"antd-icon/explicit-a11y": "error"
|
||||
"antd-icon/explicit-a11y": "error",
|
||||
"antd-a11y/spin-content-not-live": "error",
|
||||
"antd-a11y/select-tag-remove": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -88,7 +93,9 @@
|
||||
],
|
||||
"rules": {
|
||||
"control-label/has-name": "off",
|
||||
"antd-icon/explicit-a11y": "off"
|
||||
"antd-icon/explicit-a11y": "off",
|
||||
"antd-a11y/spin-content-not-live": "off",
|
||||
"antd-a11y/select-tag-remove": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -5,6 +5,7 @@ import i18next from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import { buildAntdThemeConfig } from '@/hooks/useTheme';
|
||||
import { INTERPOLATION } from '@/i18n/react';
|
||||
import enUS from '../../internal/web/translation/en-US.json';
|
||||
|
||||
if (!i18next.isInitialized) {
|
||||
@@ -12,7 +13,7 @@ if (!i18next.isInitialized) {
|
||||
lng: 'en-US',
|
||||
fallbackLng: 'en-US',
|
||||
resources: { 'en-US': { translation: enUS } },
|
||||
interpolation: { escapeValue: false, prefix: '{', suffix: '}' },
|
||||
interpolation: INTERPOLATION,
|
||||
returnNull: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -47,6 +47,33 @@ production-style links work without round-tripping through Go.
|
||||
CI runs `typecheck`, `lint`, `format:check`, `test`, `build`, and
|
||||
`build-storybook` on every PR (see `../.github/workflows/ci.yml`).
|
||||
|
||||
### Inbound accessibility verification
|
||||
|
||||
`npm test` includes the real inbound form and native-select regression fixtures.
|
||||
After `npm run build-storybook`, serve the built stories with
|
||||
`npx vite preview --outDir storybook-static --host 127.0.0.1 --port 6006` and, in
|
||||
another terminal, run `node tools/check-inbound-keyboard.mjs`. CI runs this check
|
||||
too. It uses real Chromium Tab/Arrow/Enter input, checks native selected values
|
||||
in the accessibility tree, captures create/edit requests, and verifies focus
|
||||
containment and return. Its HTTP boundary is stubbed; backend configuration
|
||||
generation and server validation are unchanged.
|
||||
|
||||
Mobile viewport emulation and accessibility-tree checks do not prove TalkBack
|
||||
or NVDA compatibility. On actual Android TalkBack/Chrome and Windows NVDA:
|
||||
|
||||
1. Open Add Inbound, explore Protocol, open the native picker, choose Shadowsocks
|
||||
and confirm the control announces its selected value. Cancel another picker
|
||||
and confirm the value and focus stay unchanged.
|
||||
2. Visit Protocol, select an encryption method, then Stream; choose RAW,
|
||||
WebSocket, gRPC and XHTTP and verify the dependent fields and their labels.
|
||||
3. Check Security's None/TLS/REALITY radios; try certificate/uTLS selections and
|
||||
the ALPN multi-select, searchable node/fallback fields, clearing and custom tags.
|
||||
4. Trigger a custom-share-address validation error from another tab. Confirm
|
||||
the form reveals/focuses the field and reads its hint/error. Correct it and save.
|
||||
5. Edit the inbound, confirm Protocol remains disabled, save a transport change,
|
||||
and close/reopen with keyboard and touch exploration. Focus must return to
|
||||
the opener and Tab/Shift+Tab must stay inside the open dialog.
|
||||
|
||||
### One-off: scan for deprecated APIs
|
||||
|
||||
Run this command to sweep the codebase for usages of APIs marked
|
||||
|
||||
Generated
+606
-591
File diff suppressed because it is too large
Load Diff
+22
-18
@@ -38,30 +38,30 @@
|
||||
"@ant-design/icons": "^6.3.4",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@hookform/resolvers": "^6.0.0",
|
||||
"@hookform/resolvers": "^7.0.1",
|
||||
"@noble/hashes": "^2.4.0",
|
||||
"@tanstack/react-query": "^5.104.5",
|
||||
"@tanstack/react-query-devtools": "^5.104.5",
|
||||
"antd": "^6.6.7",
|
||||
"@tanstack/react-query": "^5.105.1",
|
||||
"@tanstack/react-query-devtools": "^5.105.1",
|
||||
"antd": "^6.6.8",
|
||||
"codemirror": "^6.0.2",
|
||||
"dayjs": "^1.11.23",
|
||||
"i18next": "^26.5.6",
|
||||
"i18next": "^26.6.0",
|
||||
"otpauth": "^9.5.2",
|
||||
"persian-calendar-suite": "^1.5.6",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-hook-form": "^7.90.0",
|
||||
"react-i18next": "^17.1.3",
|
||||
"react-router": "^8.5.0",
|
||||
"swagger-ui-react": "^5.34.0",
|
||||
"react-hook-form": "^7.91.0",
|
||||
"react-i18next": "^18.0.1",
|
||||
"react-router": "^8.5.1",
|
||||
"swagger-ui-react": "^5.34.1",
|
||||
"uplot": "^1.6.32",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/addon-a11y": "^10.6.3",
|
||||
"@storybook/addon-docs": "^10.6.3",
|
||||
"@storybook/addon-vitest": "^10.6.3",
|
||||
"@storybook/react-vite": "^10.6.3",
|
||||
"@storybook/addon-a11y": "^10.6.5",
|
||||
"@storybook/addon-docs": "^10.6.5",
|
||||
"@storybook/addon-vitest": "^10.6.5",
|
||||
"@storybook/react-vite": "^10.6.5",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@types/react": "^19.3.0",
|
||||
@@ -74,11 +74,11 @@
|
||||
"jsdom": "^30.1.3",
|
||||
"lint-staged": "^17.6.0",
|
||||
"msw": "^3.0.3",
|
||||
"oxfmt": "0.74.0",
|
||||
"oxlint": "1.89.0",
|
||||
"oxlint-tsgolint": "^7.0.2004",
|
||||
"oxfmt": "0.75.0",
|
||||
"oxlint": "1.90.0",
|
||||
"oxlint-tsgolint": "^7.0.2005",
|
||||
"playwright": "^1.64.1",
|
||||
"storybook": "^10.6.3",
|
||||
"storybook": "^10.6.5",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.3.5",
|
||||
"vitest": "^5.0.5"
|
||||
@@ -94,7 +94,11 @@
|
||||
},
|
||||
"swagger-ui-react": {
|
||||
"js-yaml": "^4.3.2",
|
||||
"brace-expansion": "^5.0.9"
|
||||
"brace-expansion": "^5.0.9",
|
||||
"minimatch": "^11.0.3"
|
||||
},
|
||||
"@joshwooding/vite-plugin-react-docgen-typescript": {
|
||||
"glob": "^14.0.3"
|
||||
},
|
||||
"@typeschema/valibot": {
|
||||
"valibot": "^1.1.0"
|
||||
|
||||
+174
-14
@@ -1785,7 +1785,8 @@
|
||||
"type": "string"
|
||||
},
|
||||
"forwardedPorts": {
|
||||
"description": "AmneziaWG per-client port-forwarding spec, e.g. \"80,443,8000-8100\"",
|
||||
"description": "AmneziaWG port-forwarding spec, e.g. \"80,443,8000-8100\"; \"\" withdraws the forwards, omit to keep them",
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"group": {
|
||||
@@ -5650,7 +5651,7 @@
|
||||
"tags": [
|
||||
"Inbounds"
|
||||
],
|
||||
"summary": "Create a new inbound. Send the full inbound payload (protocol, port, settings, streamSettings, sniffing, remark, expiryTime, total, enable). settings, streamSettings, and sniffing may be sent as nested JSON objects (preferred) or as JSON-encoded strings (legacy).",
|
||||
"summary": "Create a new inbound. Send the full inbound payload (protocol, port, settings, streamSettings, sniffing, remark, expiryTime, total, enable). settings, streamSettings, and sniffing may be sent as nested JSON objects (preferred) or as JSON-encoded strings (legacy). VLESS empty fallback arrays are normalized away; nonempty fallbacks cannot be combined with active server decryption.",
|
||||
"operationId": "post_panel_api_inbounds_add",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -5674,8 +5675,7 @@
|
||||
"email": "user1"
|
||||
}
|
||||
],
|
||||
"decryption": "none",
|
||||
"fallbacks": []
|
||||
"decryption": "none"
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "tcp",
|
||||
@@ -5846,7 +5846,7 @@
|
||||
"tags": [
|
||||
"Inbounds"
|
||||
],
|
||||
"summary": "Replace an inbound’s configuration. Body shape mirrors /add, but the inbound keeps its stored client list and enable flag: settings.clients and enable in the body are ignored. Manage clients through the /panel/api/clients endpoints and toggle the inbound with /setEnable.",
|
||||
"summary": "Replace an inbound’s configuration. Body shape mirrors /add, but the inbound keeps its stored client list and enable flag: settings.clients and enable in the body are ignored. Manage clients through the /panel/api/clients endpoints and toggle the inbound with /setEnable. Remove effective fallback rules before enabling VLESS server decryption.",
|
||||
"operationId": "post_panel_api_inbounds_update_id",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -5881,8 +5881,7 @@
|
||||
"email": "user1"
|
||||
}
|
||||
],
|
||||
"decryption": "none",
|
||||
"fallbacks": []
|
||||
"decryption": "none"
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "tcp",
|
||||
@@ -6157,7 +6156,7 @@
|
||||
"tags": [
|
||||
"Inbounds"
|
||||
],
|
||||
"summary": "Bulk-import an inbound from a JSON blob (e.g. one exported via the UI). The body uses form encoding with a single \"data\" field.",
|
||||
"summary": "Bulk-import an inbound from a JSON blob (e.g. one exported via the UI). The body uses form encoding with a single \"data\" field. VLESS fallback validation is identical to /add.",
|
||||
"operationId": "post_panel_api_inbounds_import",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -6331,7 +6330,7 @@
|
||||
"tags": [
|
||||
"Inbounds"
|
||||
],
|
||||
"summary": "Replace the entire fallback list for a master inbound. Body is JSON. Triggers an Xray restart.",
|
||||
"summary": "Replace the entire fallback list for a master inbound. Body is JSON. Nonempty lists require TCP with TLS/REALITY and no active VLESS server decryption. An empty list can remove rules even from an incompatible inbound. Triggers an Xray restart.",
|
||||
"operationId": "post_panel_api_inbounds_id_fallbacks",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -8723,6 +8722,48 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/clients/get": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Clients"
|
||||
],
|
||||
"summary": "Fetch one client by email, including the inbound IDs and external config IDs it is attached to.",
|
||||
"operationId": "get_panel_api_clients_get",
|
||||
"description": "Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "email",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"description": "Client email (unique identifier).",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/clients/get/subId/{subId}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -8846,7 +8887,7 @@
|
||||
],
|
||||
"summary": "Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets are generated server-side when omitted, so callers can send only the universal fields.",
|
||||
"operationId": "post_panel_api_clients_add",
|
||||
"description": "Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `0.0.0.0/0` or `::/0` default route claims no address. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.",
|
||||
"description": "Client email must not be exactly `.` or `..`. Existing reserved-name clients can be read, renamed to a valid name, or deleted using the query-parameter /get, /update and /del aliases. Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `/0` entry collides with every address of its family another client holds, and only a `0.0.0.0/0` or `::/0` default route that is already stored (legacy peers) keeps nobody out. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
@@ -9012,9 +9053,9 @@
|
||||
"tags": [
|
||||
"Clients"
|
||||
],
|
||||
"summary": "Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload — supply the full set of fields you want to keep (the server replaces the row, it does not patch).",
|
||||
"summary": "Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload: omitted fields keep their current values, and an explicit zero, empty or null value clears a field, except as noted below.",
|
||||
"operationId": "post_panel_api_clients_update_email",
|
||||
"description": "The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.",
|
||||
"description": "An omitted `flow` leaves every attached inbound on its own flow. Send `\"reverse\": null` to remove the reverse tag. Credentials (`id`, `password`, `auth`, `secret`, `subId`) and WireGuard/AmneziaWG `allowedIPs` are never cleared: an empty value keeps the stored one. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "email",
|
||||
@@ -9071,6 +9112,70 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/clients/update": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Clients"
|
||||
],
|
||||
"summary": "Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload: omitted fields keep their current values, and an explicit zero, empty or null value clears a field, except as noted below.",
|
||||
"operationId": "post_panel_api_clients_update",
|
||||
"description": "Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization. An omitted `flow` leaves every attached inbound on its own flow. Send `\"reverse\": null` to remove the reverse tag. Credentials (`id`, `password`, `auth`, `secret`, `subId`) and WireGuard/AmneziaWG `allowedIPs` are never cleared: an empty value keeps the stored one. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "email",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"description": "Current client email (unique identifier).",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
},
|
||||
"example": {
|
||||
"email": "alice@example.com",
|
||||
"totalGB": 107374182400,
|
||||
"expiryTime": 1767225600000,
|
||||
"limitHwid": 2,
|
||||
"tgId": 123456789,
|
||||
"enable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"success": true,
|
||||
"msg": "Client updated"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/clients/del/{email}": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -9126,6 +9231,61 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/clients/del": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Clients"
|
||||
],
|
||||
"summary": "Delete a client by email. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.",
|
||||
"operationId": "post_panel_api_clients_del",
|
||||
"description": "Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have removed the client from the remaining inbounds; the client record is kept in that case, so re-running the call retries exactly the leftovers. The record and traffic rows are dropped after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the client already removed from every inbound.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "email",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"description": "Client email (unique identifier).",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "keepTraffic",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"description": "Pass 1 to retain the xray_client_traffic row after deletion.",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"success": true,
|
||||
"msg": "Client deleted"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/clients/{email}/attach": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -10163,7 +10323,7 @@
|
||||
"tags": [
|
||||
"Clients"
|
||||
],
|
||||
"summary": "Zero up/down counters for many clients in one call. Loops the single-reset path so each client is re-enabled across its attached inbounds and pushed to Xray/remote nodes. Returns the count of successfully reset clients.",
|
||||
"summary": "Zero up/down counters for many clients in one call. Loops the single-reset path so each client its quota had disabled is re-enabled across its attached inbounds and pushed to Xray/remote nodes; a client disabled by hand stays disabled. Returns the count of successfully reset clients.",
|
||||
"operationId": "post_panel_api_clients_bulkResetTraffic",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -10504,7 +10664,7 @@
|
||||
"tags": [
|
||||
"Clients"
|
||||
],
|
||||
"summary": "Zero out a single client’s up/down counters. Re-enables the client across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately.",
|
||||
"summary": "Zero out a single client’s up/down counters. Re-enables a client its quota had disabled across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately; a client disabled by hand stays disabled.",
|
||||
"operationId": "post_panel_api_clients_resetTraffic_email",
|
||||
"parameters": [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export function clientManagementPath(action: 'get' | 'update' | 'del', email: string): string {
|
||||
const base = `/panel/api/clients/${action}`;
|
||||
const encoded = encodeURIComponent(email);
|
||||
return email === '.' || email === '..' ? `${base}?email=${encoded}` : `${base}/${encoded}`;
|
||||
}
|
||||
@@ -20,14 +20,19 @@ export function useHostMutations() {
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ groupId, payload }: { groupId: string; payload: BulkAddHostValues }) =>
|
||||
HttpUtil.post(`/panel/api/hosts/update/${groupId}`, payload, JSON_HEADERS),
|
||||
HttpUtil.post(
|
||||
`/panel/api/hosts/update/${encodeURIComponent(groupId)}`,
|
||||
payload,
|
||||
JSON_HEADERS,
|
||||
),
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: (groupId: string) => HttpUtil.post(`/panel/api/hosts/del/${groupId}`),
|
||||
mutationFn: (groupId: string) =>
|
||||
HttpUtil.post(`/panel/api/hosts/del/${encodeURIComponent(groupId)}`),
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidate();
|
||||
},
|
||||
@@ -35,7 +40,7 @@ export function useHostMutations() {
|
||||
|
||||
const setEnableMut = useMutation({
|
||||
mutationFn: ({ groupId, enable }: { groupId: string; enable: boolean }) =>
|
||||
HttpUtil.post(`/panel/api/hosts/setEnable/${groupId}`, { enable }),
|
||||
HttpUtil.post(`/panel/api/hosts/setEnable/${encodeURIComponent(groupId)}`, { enable }),
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidate();
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { SelectProps } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { TLS_CIPHER_OPTION, TLS_CIPHER_PRESETS } from '@/schemas/primitives';
|
||||
import { selectTagRender } from './SelectTag';
|
||||
|
||||
const CIPHER_SUITE_OPTIONS = Object.values(TLS_CIPHER_OPTION).map((v) => ({ value: v, label: v }));
|
||||
|
||||
@@ -46,6 +47,7 @@ export default function CipherSuitesSelect({
|
||||
tokenSeparators={[':', ',']}
|
||||
{...rest}
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
options={[
|
||||
{ label: t('pages.inbounds.form.presets'), options: PRESET_OPTIONS },
|
||||
{ label: t('pages.inbounds.form.cipherSuites'), options: CIPHER_SUITE_OPTIONS },
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
.native-select {
|
||||
padding-inline: 11px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.native-select:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.native-select:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, within } from 'storybook/test';
|
||||
|
||||
import { NativeSelect } from './NativeSelect';
|
||||
|
||||
const meta = {
|
||||
title: 'Form/NativeSelect',
|
||||
component: NativeSelect,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Native single selection for short, fixed lists. Labels, values, keyboard interaction and the mobile picker belong to the browser. Index keys keep number and string values distinct in onChange; searchable, clearable and multi-select fields continue using Ant Design.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
options: {
|
||||
description: 'Ordered labels, typed string/number values and optional disabled choices.',
|
||||
},
|
||||
value: {
|
||||
description: 'Controlled form value. Saved values absent from the list remain visible.',
|
||||
},
|
||||
onChange: {
|
||||
description: 'Receives the original typed option value for RHF or Ant Design Form.',
|
||||
},
|
||||
placeholder: { description: 'Shown until the form has a value.' },
|
||||
disabled: {
|
||||
description: 'Uses native disabled behavior and preserves the controlled form value.',
|
||||
},
|
||||
size: {
|
||||
description: 'Ant Design token size, without turning the control into a multi-row listbox.',
|
||||
},
|
||||
},
|
||||
args: {
|
||||
'aria-label': 'Protocol',
|
||||
options: [
|
||||
{ value: 'vless', label: 'VLESS' },
|
||||
{ value: 'shadowsocks', label: 'Shadowsocks' },
|
||||
{ value: 'tuic', label: 'TUIC', disabled: true },
|
||||
],
|
||||
},
|
||||
} satisfies Meta<typeof NativeSelect>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const SingleSelection: Story = {
|
||||
render: function Render(args) {
|
||||
const [value, setValue] = useState<string | number>('vless');
|
||||
return <NativeSelect {...args} value={value} onChange={setValue} />;
|
||||
},
|
||||
play: async ({ canvas, userEvent }) => {
|
||||
const select = canvas.getByRole('combobox', { name: 'Protocol' });
|
||||
await expect(select).toHaveDisplayValue('VLESS');
|
||||
await userEvent.selectOptions(
|
||||
select,
|
||||
within(select).getByRole('option', { name: 'Shadowsocks' }),
|
||||
);
|
||||
await expect(select).toHaveDisplayValue('Shadowsocks');
|
||||
await expect(within(select).getByRole('option', { name: 'Shadowsocks' })).toHaveProperty(
|
||||
'selected',
|
||||
true,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { ComponentPropsWithRef } from 'react';
|
||||
import { theme } from 'antd';
|
||||
|
||||
import './NativeSelect.css';
|
||||
|
||||
export interface NativeSelectProps<Value extends string | number> extends Omit<
|
||||
ComponentPropsWithRef<'select'>,
|
||||
'value' | 'defaultValue' | 'onChange' | 'multiple' | 'children' | 'size'
|
||||
> {
|
||||
value?: Value | null;
|
||||
onChange?: (value: Value) => void;
|
||||
options: readonly { value: Value; label: string | number; disabled?: boolean }[];
|
||||
placeholder?: string;
|
||||
size?: 'small' | 'middle' | 'large';
|
||||
}
|
||||
|
||||
export function NativeSelect<Value extends string | number>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled,
|
||||
className,
|
||||
style,
|
||||
size,
|
||||
...props
|
||||
}: NativeSelectProps<Value>) {
|
||||
const { token } = theme.useToken();
|
||||
const selected = options.findIndex((option) => Object.is(option.value, value));
|
||||
const hasUnlistedValue = selected < 0 && value != null;
|
||||
return (
|
||||
<select
|
||||
{...props}
|
||||
className={['native-select', className].filter(Boolean).join(' ')}
|
||||
disabled={disabled}
|
||||
value={selected < 0 ? '' : String(selected)}
|
||||
onChange={(event) => {
|
||||
const option = options[Number(event.target.value)];
|
||||
if (event.target.value !== '' && option && !option.disabled) onChange?.(option.value);
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
minWidth: 0,
|
||||
height:
|
||||
size === 'small'
|
||||
? token.controlHeightSM
|
||||
: size === 'large'
|
||||
? token.controlHeightLG
|
||||
: token.controlHeight,
|
||||
border: `1px solid ${props['aria-invalid'] === true || props['aria-invalid'] === 'true' ? token.colorError : token.colorBorder}`,
|
||||
borderRadius: token.borderRadius,
|
||||
backgroundColor: disabled ? token.colorBgContainerDisabled : token.colorBgContainer,
|
||||
color: disabled ? token.colorTextDisabled : token.colorText,
|
||||
fontSize: token.fontSize,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{selected < 0 && (
|
||||
<option value="" disabled={!hasUnlistedValue} hidden={!hasUnlistedValue}>
|
||||
{hasUnlistedValue ? String(value) : (placeholder ?? '')}
|
||||
</option>
|
||||
)}
|
||||
{options.map((option, index) => (
|
||||
<option key={String(index)} value={String(index)} disabled={option.disabled}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { expect } from 'storybook/test';
|
||||
import { Select } from 'antd';
|
||||
|
||||
import SelectAllClearButtons from './SelectAllClearButtons';
|
||||
import { selectTagRender } from './SelectTag';
|
||||
|
||||
const inboundOptions: Array<{ value: number; label: string }> = [
|
||||
{ value: 1, label: 'VLESS Reality — 443' },
|
||||
@@ -64,13 +65,13 @@ function InboundPickerDemo() {
|
||||
<SelectAllClearButtons options={inboundOptions} value={selected} onChange={setSelected} />
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
value={selected}
|
||||
onChange={setSelected}
|
||||
options={inboundOptions}
|
||||
placeholder="Select inbounds"
|
||||
aria-label="Select inbounds"
|
||||
maxTagCount="responsive"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -89,13 +90,13 @@ function ClientEmailsDemo() {
|
||||
/>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
value={selected}
|
||||
onChange={setSelected}
|
||||
options={clientEmailOptions}
|
||||
placeholder="Select clients"
|
||||
aria-label="Select clients"
|
||||
maxTagCount="responsive"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
.select-tag-remove {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select-tag-remove:focus-visible {
|
||||
outline: 2px solid var(--ant-color-primary);
|
||||
outline-offset: 1px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, waitFor } from 'storybook/test';
|
||||
import { Select } from 'antd';
|
||||
|
||||
import { selectTagRender } from './SelectTag';
|
||||
|
||||
const meta = {
|
||||
title: 'Form/SelectTag',
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Tag renderer for every `mode="tags"` / `mode="multiple"` Select. Ant Design draws the tag "x" as an aria-hidden, unfocusable span; `tagRender={selectTagRender}` swaps in a real button named "Remove <value>", so keyboard and screen-reader users can remove any tag, and focus moves to the next one.',
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
function SniDemo() {
|
||||
const [value, setValue] = useState(['www.microsoft.com', 'www.apple.com', 'dl.google.com']);
|
||||
return (
|
||||
<div style={{ maxWidth: 420 }}>
|
||||
<Select
|
||||
aria-label="SNI"
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',']}
|
||||
style={{ width: '100%' }}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InboundDemo() {
|
||||
const options = [
|
||||
{ value: 1, label: 'VLESS Reality — 443' },
|
||||
{ value: 2, label: 'VMess WS — 8443' },
|
||||
{ value: 3, label: 'Trojan TCP — 2053' },
|
||||
];
|
||||
const [value, setValue] = useState([1, 2, 3]);
|
||||
return (
|
||||
<div style={{ maxWidth: 420 }}>
|
||||
<Select
|
||||
aria-label="Inbounds"
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const FreeTextTags: Story = {
|
||||
render: () => <SniDemo />,
|
||||
play: async ({ canvas, userEvent }) => {
|
||||
await userEvent.tab();
|
||||
await userEvent.tab();
|
||||
await expect(canvas.getByRole('button', { name: 'Remove www.apple.com' })).toHaveFocus();
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await expect(canvas.queryByText('www.apple.com')).toBeNull();
|
||||
await waitFor(() =>
|
||||
expect(canvas.getByRole('button', { name: 'Remove dl.google.com' })).toHaveFocus(),
|
||||
);
|
||||
await expect(canvas.getByRole('combobox', { name: 'SNI' })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false',
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const OptionLabels: Story = {
|
||||
render: () => <InboundDemo />,
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useContext } from 'react';
|
||||
import type { KeyboardEvent, MouseEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import type { SelectProps } from 'antd';
|
||||
import { CloseOutlined } from '@ant-design/icons';
|
||||
import './SelectTag.css';
|
||||
|
||||
type SelectTagProps = Parameters<NonNullable<SelectProps['tagRender']>>[0];
|
||||
|
||||
const REMOVE_CLASS = 'select-tag-remove';
|
||||
|
||||
// Stops rc-select from opening the dropdown or eating Enter/Space before the click fires.
|
||||
function keepFromSelect(e: KeyboardEvent | MouseEvent) {
|
||||
if ('key' in e && e.key === 'Tab') return;
|
||||
e.stopPropagation();
|
||||
if (e.type === 'mousedown') e.preventDefault();
|
||||
}
|
||||
|
||||
function SelectTag({ label, value, closable, onClose }: SelectTagProps) {
|
||||
const { t } = useTranslation();
|
||||
const { getPrefixCls } = useContext(ConfigProvider.ConfigContext);
|
||||
const selectCls = getPrefixCls('select');
|
||||
const itemCls = `${selectCls}-selection-item`;
|
||||
const text =
|
||||
typeof label === 'string' || typeof label === 'number' ? String(label) : String(value);
|
||||
|
||||
const remove = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
const button = e.currentTarget;
|
||||
const root = button.closest(`.${selectCls}`);
|
||||
const hadFocus = document.activeElement === button;
|
||||
const index = root ? [...root.querySelectorAll(`.${REMOVE_CLASS}`)].indexOf(button) : -1;
|
||||
onClose(e);
|
||||
if (!root || !hadFocus) return;
|
||||
// Keyboard users would otherwise land on <body> once this tag unmounts.
|
||||
requestAnimationFrame(() => {
|
||||
const rest = root.querySelectorAll<HTMLElement>(`.${REMOVE_CLASS}`);
|
||||
const next = rest[Math.min(index, rest.length - 1)] ?? root.querySelector('input');
|
||||
next?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={itemCls} title={text}>
|
||||
<span className={`${itemCls}-content`}>{label}</span>
|
||||
{closable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${itemCls}-remove ${REMOVE_CLASS}`}
|
||||
aria-label={t('removeItem', { item: text })}
|
||||
onMouseDown={keepFromSelect}
|
||||
onKeyDown={keepFromSelect}
|
||||
onClick={remove}
|
||||
>
|
||||
<CloseOutlined aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Ant Design renders each tag's "x" as an aria-hidden, unfocusable span, so screen-reader
|
||||
// and keyboard users cannot remove a tag; this swaps in a real, named button.
|
||||
export function selectTagRender(props: SelectTagProps) {
|
||||
return <SelectTag {...props} />;
|
||||
}
|
||||
@@ -7,3 +7,4 @@ export { default as CipherSuitesSelect } from './CipherSuitesSelect';
|
||||
export { default as RemarkTemplateField } from './RemarkTemplateField';
|
||||
export { default as RemarkVarPicker } from './RemarkVarPicker';
|
||||
export { default as CustomSockoptList } from '../../lib/xray/forms/transport/CustomSockoptList';
|
||||
export { selectTagRender } from './SelectTag';
|
||||
|
||||
@@ -221,6 +221,9 @@ export const ValidationErrors: Story = {
|
||||
play: async ({ canvas, userEvent }) => {
|
||||
const remarkError = await canvas.findByText('Remark is required');
|
||||
await waitFor(() => expect(remarkError).toBeVisible());
|
||||
await expect(canvas.getByRole('textbox', { name: 'Remark' })).toHaveAccessibleDescription(
|
||||
'Remark is required',
|
||||
);
|
||||
await waitFor(() => expect(canvas.getByText('Port must be between 1 and 65535')).toBeVisible());
|
||||
await userEvent.type(canvas.getByPlaceholderText('vless-reality-443'), 'vless-reality-443');
|
||||
await userEvent.click(canvas.getByRole('button', { name: 'Validate' }));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cloneElement, useId } from 'react';
|
||||
import type { CSSProperties, ReactElement, ReactNode } from 'react';
|
||||
import type { AriaAttributes, CSSProperties, ReactElement, ReactNode } from 'react';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import type { Control, ControllerProps, FieldValues, Path } from 'react-hook-form';
|
||||
import { Form } from 'antd';
|
||||
@@ -50,12 +50,10 @@ export function FormField<T extends FieldValues = FieldValues>({
|
||||
const dottedName = toDotted(name) as Path<T>;
|
||||
const generatedId = useId();
|
||||
const hintId = useId();
|
||||
const childOwnProps = (children as ReactElement<{ id?: string; 'aria-describedby'?: string }>)
|
||||
.props;
|
||||
const errorId = `${generatedId}-error`;
|
||||
const extraId = `${generatedId}-extra`;
|
||||
const childOwnProps = (children as ReactElement<{ id?: string } & AriaAttributes>).props;
|
||||
const fieldId = childOwnProps.id ?? generatedId;
|
||||
const describedBy = tooltip
|
||||
? [childOwnProps['aria-describedby'], hintId].filter(Boolean).join(' ')
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
@@ -67,8 +65,14 @@ export function FormField<T extends FieldValues = FieldValues>({
|
||||
const help = fieldState.error?.message
|
||||
? t(fieldState.error.message, { defaultValue: fieldState.error.message })
|
||||
: undefined;
|
||||
const describedBy =
|
||||
[childOwnProps['aria-describedby'], tooltip && hintId, extra && extraId, help && errorId]
|
||||
.filter(Boolean)
|
||||
.join(' ') || undefined;
|
||||
const childProps: Record<string, unknown> = {
|
||||
id: fieldId,
|
||||
'aria-describedby': describedBy,
|
||||
'aria-invalid': fieldState.invalid || childOwnProps['aria-invalid'],
|
||||
[valueProp]: displayValue,
|
||||
onChange: (...args: unknown[]) => {
|
||||
const raw = normalizeAntdOnChange(args, valueProp);
|
||||
@@ -79,16 +83,15 @@ export function FormField<T extends FieldValues = FieldValues>({
|
||||
onBlur: field.onBlur,
|
||||
ref: field.ref,
|
||||
};
|
||||
if (describedBy) childProps['aria-describedby'] = describedBy;
|
||||
return (
|
||||
<Form.Item
|
||||
label={label}
|
||||
htmlFor={label ? fieldId : undefined}
|
||||
tooltip={tooltip}
|
||||
extra={extra}
|
||||
extra={extra ? <span id={extraId}>{extra}</span> : extra}
|
||||
required={required}
|
||||
validateStatus={fieldState.error ? 'error' : undefined}
|
||||
help={help}
|
||||
help={help ? <span id={errorId}>{help}</span> : undefined}
|
||||
noStyle={noStyle}
|
||||
className={className}
|
||||
style={style}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { lazy, useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Alert, Button, Card, Skeleton, Space, Switch, Tag, Typography } from 'antd';
|
||||
import { Alert, Button, Card, Layout, Modal, Skeleton, Space, Switch, Tag, Typography } from 'antd';
|
||||
import { expect, userEvent, within } from 'storybook/test';
|
||||
|
||||
import LazyMount from './LazyMount';
|
||||
|
||||
@@ -21,7 +22,10 @@ const meta = {
|
||||
when: {
|
||||
description: 'Children mount the first time this becomes true and stay mounted afterwards.',
|
||||
},
|
||||
fallback: { description: 'Suspense fallback shown while a React.lazy child is still loading.' },
|
||||
fallback: {
|
||||
description:
|
||||
'Suspense fallback shown while a React.lazy child is loading. The default fullscreen spinner does not occupy space in the surrounding layout; custom fallbacks render as supplied.',
|
||||
},
|
||||
children: { description: 'Content to mount on demand, typically a lazily imported modal.' },
|
||||
},
|
||||
} satisfies Meta<typeof LazyMount>;
|
||||
@@ -145,3 +149,53 @@ export const MountedImmediately: Story = {
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
function LazyModalInLayoutDemo() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [SlowModal] = useState(() =>
|
||||
lazy(
|
||||
() =>
|
||||
new Promise<{ default: typeof Modal }>((resolve) => {
|
||||
setTimeout(() => resolve({ default: Modal }), 1200);
|
||||
}),
|
||||
),
|
||||
);
|
||||
return (
|
||||
<Layout hasSider style={{ minHeight: 240 }}>
|
||||
<Layout.Sider width={96} style={{ color: '#fff' }}>
|
||||
Navigation
|
||||
</Layout.Sider>
|
||||
<Layout.Content data-testid="lazy-modal-content" style={{ padding: 16 }}>
|
||||
<Button onClick={() => setOpen(true)}>Open lazy dialog</Button>
|
||||
<Typography.Paragraph>
|
||||
The content width stays fixed while the dialog loads.
|
||||
</Typography.Paragraph>
|
||||
</Layout.Content>
|
||||
<LazyMount when={open}>
|
||||
<SlowModal title="Lazy dialog" open={open} onCancel={() => setOpen(false)} footer={null}>
|
||||
Dialog loaded without resizing the page.
|
||||
</SlowModal>
|
||||
</LazyMount>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export const LazyModalInLayout: Story = {
|
||||
args: placeholderArgs,
|
||||
render: () => <LazyModalInLayoutDemo />,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const content = canvas.getByTestId('lazy-modal-content');
|
||||
const width = content.getBoundingClientRect().width;
|
||||
await userEvent.click(canvas.getByRole('button', { name: 'Open lazy dialog' }));
|
||||
const widths: number[] = [];
|
||||
for (let frame = 0; frame < 12; frame++) {
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
widths.push(content.getBoundingClientRect().width);
|
||||
}
|
||||
expect(Math.min(...widths)).toBeCloseTo(width, 1);
|
||||
const page = within(canvasElement.ownerDocument.body);
|
||||
await page.findByRole('dialog', { name: 'Lazy dialog' }, { timeout: 5000 });
|
||||
await userEvent.click(page.getByRole('button', { name: 'Close' }));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -11,7 +11,11 @@ interface LazyMountProps {
|
||||
// thereafter, so React.lazy modals get loaded on demand but their close
|
||||
// animations still play out. Pair with `lazy(() => import(...))` modal imports
|
||||
// on heavy list pages to keep the initial bundle small.
|
||||
export default function LazyMount({ when, fallback = <Spin />, children }: LazyMountProps) {
|
||||
export default function LazyMount({
|
||||
when,
|
||||
fallback = <Spin fullscreen />,
|
||||
children,
|
||||
}: LazyMountProps) {
|
||||
const [mounted, setMounted] = useState(when);
|
||||
if (when && !mounted) setMounted(true);
|
||||
if (!mounted) return null;
|
||||
|
||||
@@ -431,7 +431,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"enable": false,
|
||||
"expiryTime": 0,
|
||||
"flow": "",
|
||||
"forwardedPorts": "",
|
||||
"forwardedPorts": null,
|
||||
"group": "",
|
||||
"id": "",
|
||||
"keepAlive": 0,
|
||||
|
||||
@@ -1759,7 +1759,8 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"type": "string"
|
||||
},
|
||||
"forwardedPorts": {
|
||||
"description": "AmneziaWG per-client port-forwarding spec, e.g. \"80,443,8000-8100\"",
|
||||
"description": "AmneziaWG port-forwarding spec, e.g. \"80,443,8000-8100\"; \"\" withdraws the forwards, omit to keep them",
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"group": {
|
||||
|
||||
@@ -426,7 +426,7 @@ export interface Client {
|
||||
enable: boolean;
|
||||
expiryTime: number;
|
||||
flow?: string;
|
||||
forwardedPorts?: string;
|
||||
forwardedPorts?: string | null;
|
||||
group?: string;
|
||||
id?: string;
|
||||
keepAlive?: number | string;
|
||||
|
||||
@@ -450,7 +450,7 @@ export const ClientSchema = z.object({
|
||||
enable: z.boolean(),
|
||||
expiryTime: z.number().int(),
|
||||
flow: z.string().optional(),
|
||||
forwardedPorts: z.string().optional(),
|
||||
forwardedPorts: z.string().nullable().optional(),
|
||||
group: z.string().optional(),
|
||||
id: z.string().optional(),
|
||||
keepAlive: z.union([z.number().int(), z.string()]).optional(),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tansta
|
||||
import { HttpUtil, Msg } from '@/utils';
|
||||
import { parseMsg } from '@/utils/zodValidate';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
import { clientManagementPath } from '@/api/client-management';
|
||||
import { markLocalInvalidate } from '@/api/invalidationTracker';
|
||||
import {
|
||||
ClientHydrateSchema,
|
||||
@@ -343,7 +344,7 @@ export function useClients(options: UseClientsOptions = {}) {
|
||||
|
||||
const hydrate = useCallback(async (email: string): Promise<ClientHydrate | null> => {
|
||||
if (!email) return null;
|
||||
const msg = await HttpUtil.get(`/panel/api/clients/get/${encodeURIComponent(email)}`);
|
||||
const msg = await HttpUtil.get(clientManagementPath('get', email));
|
||||
if (!msg?.success || !msg.obj) return null;
|
||||
const validated = parseMsg(msg, ClientHydrateSchema, 'clients/get');
|
||||
return validated.obj;
|
||||
@@ -375,7 +376,7 @@ export function useClients(options: UseClientsOptions = {}) {
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ email, client }: { email: string; client: unknown }) =>
|
||||
HttpUtil.post(`/panel/api/clients/update/${encodeURIComponent(email)}`, client, JSON_HEADERS),
|
||||
HttpUtil.post(clientManagementPath('update', email), client, JSON_HEADERS),
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidateAll();
|
||||
},
|
||||
@@ -383,9 +384,8 @@ export function useClients(options: UseClientsOptions = {}) {
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: ({ email, keepTraffic }: { email: string; keepTraffic?: boolean }) => {
|
||||
const url = keepTraffic
|
||||
? `/panel/api/clients/del/${encodeURIComponent(email)}?keepTraffic=1`
|
||||
: `/panel/api/clients/del/${encodeURIComponent(email)}`;
|
||||
let url = clientManagementPath('del', email);
|
||||
if (keepTraffic) url += `${url.includes('?') ? '&' : '?'}keepTraffic=1`;
|
||||
return HttpUtil.post(url);
|
||||
},
|
||||
onSuccess: (msg) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import i18next from 'i18next';
|
||||
import type { InterpolationOptions } from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import { LanguageManager } from '@/utils';
|
||||
@@ -8,6 +9,16 @@ import enUS from '../../../internal/web/translation/en-US.json';
|
||||
|
||||
const FALLBACK = 'en-US';
|
||||
|
||||
// No locale uses $t(...) nesting, and its regex backtracks exponentially on an unclosed
|
||||
// "$t(" in untrusted text (node-reported names, remarks); no off switch, so never match.
|
||||
export const INTERPOLATION: InterpolationOptions = {
|
||||
escapeValue: false,
|
||||
prefix: '{',
|
||||
suffix: '}',
|
||||
nestingPrefix: '',
|
||||
nestingPrefixEscaped: '(?!)',
|
||||
};
|
||||
|
||||
const lazyModules = import.meta.glob([
|
||||
'../../../internal/web/translation/*.json',
|
||||
'!../../../internal/web/translation/en-US.json',
|
||||
@@ -30,7 +41,7 @@ export async function readyI18n(scope: LanguageScope = 'panel') {
|
||||
lng: active,
|
||||
fallbackLng: FALLBACK,
|
||||
resources: { [FALLBACK]: { translation: enUS } },
|
||||
interpolation: { escapeValue: false, prefix: '{', suffix: '}' },
|
||||
interpolation: INTERPOLATION,
|
||||
returnNull: false,
|
||||
});
|
||||
if (active !== FALLBACK) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Form, Select, Switch } from 'antd';
|
||||
import type { FormInstance } from 'antd/es/form';
|
||||
|
||||
import { selectTagRender } from '@/components/form/SelectTag';
|
||||
import { SNIFFING_OPTION } from '@/schemas/primitives';
|
||||
|
||||
const DEST_OPTIONS = Object.entries(SNIFFING_OPTION).map(([label, value]) => ({ value, label }));
|
||||
@@ -36,6 +37,7 @@ export default function SniffingFields({ name, form, enableLabel }: SniffingFiel
|
||||
<Form.Item name={[...name, 'destOverride']} wrapperCol={{ md: { span: 14, offset: 8 } }}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
className="sniffing-options"
|
||||
aria-label={t('pages.inbounds.sniffingDestOverride')}
|
||||
options={DEST_OPTIONS}
|
||||
@@ -61,6 +63,7 @@ export default function SniffingFields({ name, form, enableLabel }: SniffingFiel
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',']}
|
||||
placeholder="IP/CIDR/geoip:*/ext:*"
|
||||
style={{ width: '100%' }}
|
||||
@@ -72,6 +75,7 @@ export default function SniffingFields({ name, form, enableLabel }: SniffingFiel
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',']}
|
||||
placeholder="domain:*/ext:*"
|
||||
style={{ width: '100%' }}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { NativeSelect } from '@/components/form/NativeSelect';
|
||||
import { Button, Divider, Form, Input, Select } from 'antd';
|
||||
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -68,7 +69,7 @@ export default function CustomSockoptList({
|
||||
<Input placeholder="decimal, e.g. 19" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Type" name={[field.name, 'type']}>
|
||||
<Select options={TYPE_OPTIONS} />
|
||||
<NativeSelect options={TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item label="Value" name={[field.name, 'value']}>
|
||||
<Input placeholder="value" />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { NativeSelect } from '@/components/form/NativeSelect';
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import {
|
||||
AutoComplete,
|
||||
@@ -16,6 +17,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import type { FormInstance } from 'antd/es/form';
|
||||
import type { NamePath } from 'antd/es/form/interface';
|
||||
|
||||
import { selectTagRender } from '@/components/form/SelectTag';
|
||||
import { RandomUtil } from '@/utils';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import { OutboundProtocols, UTLS_FINGERPRINT } from '@/schemas/primitives';
|
||||
@@ -406,7 +408,7 @@ function TcpMasksList({
|
||||
}
|
||||
/>
|
||||
{allowPresets && (
|
||||
<Select
|
||||
<NativeSelect
|
||||
id={`${uid}-tcp-presets`}
|
||||
size="small"
|
||||
style={{ minWidth: 160 }}
|
||||
@@ -477,7 +479,7 @@ function TcpMaskItem({
|
||||
</Divider>
|
||||
|
||||
<Form.Item label="Type" name={[fieldName, 'type']}>
|
||||
<Select
|
||||
<NativeSelect
|
||||
onChange={(v) =>
|
||||
form.setFieldValue([...absolutePath, 'settings'], defaultTcpMaskSettings(v))
|
||||
}
|
||||
@@ -608,6 +610,7 @@ function FilterMaskSettings({ fieldName }: { fieldName: number }) {
|
||||
<Select
|
||||
aria-describedby={`${uid}-allowed-hint`}
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={[',', ' ']}
|
||||
placeholder="10.0.0.0/8, geoip:ir"
|
||||
@@ -624,6 +627,7 @@ function FilterMaskSettings({ fieldName }: { fieldName: number }) {
|
||||
<Select
|
||||
aria-describedby={`${uid}-blocked-hint`}
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={[',', ' ']}
|
||||
placeholder="203.0.113.7, geoip:cn"
|
||||
@@ -649,7 +653,12 @@ function SudokuSettings({ fieldName }: { fieldName: number }) {
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="Custom Tables" name={[fieldName, 'settings', 'customTables']}>
|
||||
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={[',']}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Padding Min" name={[fieldName, 'settings', 'paddingMin']}>
|
||||
<InputNumber min={0} />
|
||||
@@ -1115,7 +1124,7 @@ function UdpMaskItem({
|
||||
</Divider>
|
||||
|
||||
<Form.Item label="Type" name={[fieldName, 'type']}>
|
||||
<Select onChange={onTypeChange} options={options} />
|
||||
<NativeSelect onChange={onTypeChange} options={options} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
@@ -1139,7 +1148,7 @@ function UdpMaskItem({
|
||||
return (
|
||||
<>
|
||||
<Form.Item label="Header" name={[fieldName, 'settings', 'header']}>
|
||||
<Select
|
||||
<NativeSelect
|
||||
options={[
|
||||
{ value: '', label: 'Original / AES-128-GCM' },
|
||||
{ value: 'dns', label: 'DNS' },
|
||||
@@ -1175,7 +1184,12 @@ function UdpMaskItem({
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label="IPs" name={[fieldName, 'settings', 'ips']}>
|
||||
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={[',']}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
@@ -1189,6 +1203,7 @@ function UdpMaskItem({
|
||||
<Form.Item label="STUN Servers" name={[fieldName, 'settings', 'stunServers']}>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={[',']}
|
||||
placeholder="host:port"
|
||||
@@ -1236,6 +1251,7 @@ function UdpMaskItem({
|
||||
<Form.Item label="ALPN" name={[fieldName, 'settings', 'tlsConfig', 'alpn']}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
options={[
|
||||
{ value: 'h3', label: 'h3' },
|
||||
@@ -1312,7 +1328,7 @@ function SalamanderUdpMaskSettings({
|
||||
: 'Scrambles each packet into random-looking bytes.'
|
||||
}
|
||||
>
|
||||
<Select
|
||||
<NativeSelect
|
||||
id={`${uid}-salamander-mode`}
|
||||
value={mode}
|
||||
onChange={(next) => {
|
||||
@@ -1529,6 +1545,7 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
|
||||
<Form.Item label="Names" name={[domain.name, 'names']}>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={[',']}
|
||||
placeholder="t.example.com"
|
||||
@@ -1537,6 +1554,7 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
|
||||
<Form.Item label="Record Types" name={[domain.name, 'types']}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
options={XDNS_RECORD_TYPE_OPTIONS}
|
||||
placeholder="TXT (client), all (server)"
|
||||
/>
|
||||
@@ -1583,6 +1601,7 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
|
||||
<Form.Item label="Addresses" name={[resolver.name, 'addrs']}>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={[',', ' ']}
|
||||
placeholder="udp://8.8.8.8:53, https://dns.google/dns-query"
|
||||
@@ -1670,7 +1689,7 @@ function UdpHopSettings({ udpFieldName }: { udpFieldName: number }) {
|
||||
normalize={(value: string[]) => value.join(',')}
|
||||
rules={[{ required: true, message: 'pick at least one mode' }]}
|
||||
>
|
||||
<Select mode="multiple" options={UDP_HOP_MODES} />
|
||||
<Select mode="multiple" tagRender={selectTagRender} options={UDP_HOP_MODES} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Interval (s)"
|
||||
@@ -1683,7 +1702,12 @@ function UdpHopSettings({ udpFieldName }: { udpFieldName: number }) {
|
||||
<Input placeholder="20000-50000" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Remote IPs" name={[udpFieldName, 'settings', 'remoteIPs']}>
|
||||
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',', ' ']} />
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={[',', ' ']}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
@@ -1924,7 +1948,7 @@ function ItemEditor({
|
||||
<>
|
||||
{allowVariables && (
|
||||
<Form.Item label="Kind" htmlFor={`${uid}-kind`}>
|
||||
<Select
|
||||
<NativeSelect
|
||||
id={`${uid}-kind`}
|
||||
value={kind}
|
||||
onChange={onKindChange}
|
||||
@@ -1934,7 +1958,7 @@ function ItemEditor({
|
||||
)}
|
||||
{kind === 'bytes' && (
|
||||
<Form.Item label="Type" name={[fieldName, 'type']}>
|
||||
<Select
|
||||
<NativeSelect
|
||||
onChange={onTypeChange}
|
||||
options={[
|
||||
{ value: 'array', label: 'Array' },
|
||||
@@ -2099,7 +2123,7 @@ function QuicParamsForm({
|
||||
return (
|
||||
<>
|
||||
<Form.Item label="Congestion" name={[...base, 'congestion']}>
|
||||
<Select
|
||||
<NativeSelect
|
||||
options={[
|
||||
{ value: 'reno', label: 'Reno' },
|
||||
{ value: 'bbr', label: 'BBR' },
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { NativeSelect } from '@/components/form/NativeSelect';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Input, InputNumber, Select, Typography } from 'antd';
|
||||
import { Input, InputNumber, Select, Switch, Typography } from 'antd';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { JsonEditor } from '@/components/form';
|
||||
import { selectTagRender } from '@/components/form/SelectTag';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { XDriveServiceSchema } from '@/schemas/protocols/stream/xdrive';
|
||||
|
||||
@@ -13,7 +15,7 @@ const SERVICE_LABEL_KEYS: Partial<Record<string, string>> = {
|
||||
local: 'pages.inbounds.form.xdrive.serviceLocal',
|
||||
template: 'pages.inbounds.form.xdrive.serviceTemplate',
|
||||
};
|
||||
const SERVICE_NAMES: Partial<Record<string, string>> = { 'google-drive': 'Google Drive' };
|
||||
const SERVICE_NAMES: Partial<Record<string, string>> = { 'google-drive': 'Google Drive', s3: 'S3' };
|
||||
|
||||
// Core defaults (transport/internet/xdrive/params.go), shown as placeholders so an
|
||||
// empty field keeps meaning "use the core's value".
|
||||
@@ -29,6 +31,11 @@ const TUNING_FIELDS = [
|
||||
] as const;
|
||||
|
||||
const GOOGLE_DRIVE_SECRETS = ['clientId', 'clientSecret', 'refreshToken'] as const;
|
||||
const S3_SECRETS = [
|
||||
['accessKeyId', true],
|
||||
['secretAccessKey', true],
|
||||
['sessionToken', false],
|
||||
] as const;
|
||||
|
||||
function TemplateEditor({
|
||||
value,
|
||||
@@ -83,14 +90,18 @@ export default function XDriveForm() {
|
||||
return (
|
||||
<>
|
||||
<FormField label={t('pages.inbounds.form.xdrive.service')} name={[...BASE, 'service']}>
|
||||
<Select options={serviceOptions} />
|
||||
<NativeSelect options={serviceOptions} />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.xdrive.remoteFolder')}
|
||||
name={[...BASE, 'remoteFolder']}
|
||||
required
|
||||
>
|
||||
<Input placeholder={service === 'local' ? '/var/lib/xdrive' : 'xray-tunnel'} />
|
||||
<Input
|
||||
placeholder={
|
||||
service === 'local' ? '/var/lib/xdrive' : service === 's3' ? 'xdrive' : 'xray-tunnel'
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
{service === 'google-drive' &&
|
||||
GOOGLE_DRIVE_SECRETS.map((key, index) => (
|
||||
@@ -103,6 +114,48 @@ export default function XDriveForm() {
|
||||
{key === 'clientId' ? <Input /> : <Input.Password />}
|
||||
</FormField>
|
||||
))}
|
||||
{service === 's3' && (
|
||||
<>
|
||||
{S3_SECRETS.map(([key, required], index) => (
|
||||
<FormField
|
||||
key={key}
|
||||
label={t(`pages.inbounds.form.xdrive.${key}`)}
|
||||
name={[...BASE, 'secrets', index]}
|
||||
required={required}
|
||||
>
|
||||
{key === 'accessKeyId' ? <Input /> : <Input.Password />}
|
||||
</FormField>
|
||||
))}
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.xdrive.s3Endpoint')}
|
||||
name={[...BASE, 's3', 'endpoint']}
|
||||
required
|
||||
>
|
||||
<Input placeholder="https://s3.amazonaws.com" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.xdrive.s3Region')}
|
||||
name={[...BASE, 's3', 'region']}
|
||||
>
|
||||
<Input placeholder="us-east-1" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.xdrive.s3Bucket')}
|
||||
name={[...BASE, 's3', 'bucket']}
|
||||
required
|
||||
>
|
||||
<Input />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.xdrive.s3PathStyle')}
|
||||
tooltip={t('pages.inbounds.form.xdrive.s3PathStyleDesc')}
|
||||
name={[...BASE, 's3', 'pathStyle']}
|
||||
transform={{ input: (value) => value !== false }}
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
{service === 'template' && (
|
||||
<>
|
||||
<FormField
|
||||
@@ -110,7 +163,7 @@ export default function XDriveForm() {
|
||||
tooltip={t('pages.inbounds.form.xdrive.secretsDesc')}
|
||||
name={[...BASE, 'secrets']}
|
||||
>
|
||||
<Select mode="tags" tokenSeparators={[',']} open={false} />
|
||||
<Select mode="tags" tagRender={selectTagRender} tokenSeparators={[',']} open={false} />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.xdrive.template')}
|
||||
|
||||
@@ -859,12 +859,31 @@ export function parseWireguardLink(link: string): Raw | null {
|
||||
export function parseOutboundLink(link: string): Raw | null {
|
||||
const trimmed = link.trim();
|
||||
if (!trimmed) return null;
|
||||
return (
|
||||
const outbound =
|
||||
parseVmessLink(trimmed) ??
|
||||
parseVlessLink(trimmed) ??
|
||||
parseTrojanLink(trimmed) ??
|
||||
parseShadowsocksLink(trimmed) ??
|
||||
parseHysteria2Link(trimmed) ??
|
||||
parseWireguardLink(trimmed)
|
||||
);
|
||||
parseWireguardLink(trimmed);
|
||||
if (outbound) dropHostLocalKeys(outbound);
|
||||
return outbound;
|
||||
}
|
||||
|
||||
// Keys that redirect the core's dial or name files on the importing host; extra=, fm= and
|
||||
// vmess JSON copy provider objects verbatim, and the core binds keys case-folded.
|
||||
const HOST_LOCAL_KEYS = new Set(['dialerproxy', 'certificatefile', 'keyfile', 'masterkeylog']);
|
||||
|
||||
function dropHostLocalKeys(value: unknown): void {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(dropHostLocalKeys);
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== 'object') return;
|
||||
const obj = value as Raw;
|
||||
for (const key of Object.keys(obj)) {
|
||||
// Upper-casing first folds the long s and the Kelvin sign the way Go's decoder does.
|
||||
if (HOST_LOCAL_KEYS.has(key.toUpperCase().toLowerCase())) delete obj[key];
|
||||
else dropHostLocalKeys(obj[key]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export interface Section {
|
||||
|
||||
// /inbounds/update replaces the whole row, so it takes the same payload as /add.
|
||||
const inboundBody =
|
||||
'{\n "enable": true,\n "remark": "VLESS-443",\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "expiryTime": 0,\n "total": 0,\n "settings": {\n "clients": [{ "id": "...", "email": "user1" }],\n "decryption": "none",\n "fallbacks": []\n },\n "streamSettings": {\n "network": "tcp",\n "security": "reality",\n "realitySettings": { "show": false, "dest": "..." }\n },\n "sniffing": {\n "enabled": true,\n "destOverride": ["http", "tls"]\n }\n}';
|
||||
'{\n "enable": true,\n "remark": "VLESS-443",\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "expiryTime": 0,\n "total": 0,\n "settings": {\n "clients": [{ "id": "...", "email": "user1" }],\n "decryption": "none"\n },\n "streamSettings": {\n "network": "tcp",\n "security": "reality",\n "realitySettings": { "show": false, "dest": "..." }\n },\n "sniffing": {\n "enabled": true,\n "destOverride": ["http", "tls"]\n }\n}';
|
||||
|
||||
const outboundSubscriptionBodyParams: EndpointParam[] = [
|
||||
{
|
||||
@@ -309,7 +309,7 @@ export const sections: readonly Section[] = [
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/add',
|
||||
summary:
|
||||
'Create a new inbound. Send the full inbound payload (protocol, port, settings, streamSettings, sniffing, remark, expiryTime, total, enable). settings, streamSettings, and sniffing may be sent as nested JSON objects (preferred) or as JSON-encoded strings (legacy).',
|
||||
'Create a new inbound. Send the full inbound payload (protocol, port, settings, streamSettings, sniffing, remark, expiryTime, total, enable). settings, streamSettings, and sniffing may be sent as nested JSON objects (preferred) or as JSON-encoded strings (legacy). VLESS empty fallback arrays are normalized away; nonempty fallbacks cannot be combined with active server decryption.',
|
||||
body: inboundBody,
|
||||
errorResponse: '{\n "success": false,\n "msg": "Port 443 is already in use"\n}',
|
||||
},
|
||||
@@ -332,7 +332,7 @@ export const sections: readonly Section[] = [
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/update/:id',
|
||||
summary:
|
||||
'Replace an inbound’s configuration. Body shape mirrors /add, but the inbound keeps its stored client list and enable flag: settings.clients and enable in the body are ignored. Manage clients through the /panel/api/clients endpoints and toggle the inbound with /setEnable.',
|
||||
'Replace an inbound’s configuration. Body shape mirrors /add, but the inbound keeps its stored client list and enable flag: settings.clients and enable in the body are ignored. Manage clients through the /panel/api/clients endpoints and toggle the inbound with /setEnable. Remove effective fallback rules before enabling VLESS server decryption.',
|
||||
params: [{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' }],
|
||||
body: inboundBody,
|
||||
},
|
||||
@@ -377,7 +377,7 @@ export const sections: readonly Section[] = [
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/import',
|
||||
summary:
|
||||
'Bulk-import an inbound from a JSON blob (e.g. one exported via the UI). The body uses form encoding with a single "data" field.',
|
||||
'Bulk-import an inbound from a JSON blob (e.g. one exported via the UI). The body uses form encoding with a single "data" field. VLESS fallback validation is identical to /add.',
|
||||
params: [
|
||||
{
|
||||
name: 'data',
|
||||
@@ -422,7 +422,7 @@ export const sections: readonly Section[] = [
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/:id/fallbacks',
|
||||
summary:
|
||||
'Replace the entire fallback list for a master inbound. Body is JSON. Triggers an Xray restart.',
|
||||
'Replace the entire fallback list for a master inbound. Body is JSON. Nonempty lists require TCP with TLS/REALITY and no active VLESS server decryption. An empty list can remove rules even from an incompatible inbound. Triggers an Xray restart.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Master inbound ID.' },
|
||||
{
|
||||
@@ -1149,6 +1149,19 @@ export const sections: readonly Section[] = [
|
||||
response:
|
||||
'{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5],\n "externalLinks": [\n { "id": 11, "kind": "link", "value": "vless://...", "remark": "DE", "enable": true, "expiryTime": 0 },\n { "id": 12, "kind": "subscription", "value": "https://provider.example/sub/abc", "remark": "Provider", "enable": false, "expiryTime": 1767225600000, "namePrefix": "[zjh] ", "lastFetchAt": 1767220000000, "lastFetchError": "" }\n ]\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/clients/get',
|
||||
summary:
|
||||
'Fetch one client by email, including the inbound IDs and external config IDs it is attached to.',
|
||||
description:
|
||||
'Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization.',
|
||||
params: [
|
||||
{ name: 'email', in: 'query', type: 'string', desc: 'Client email (unique identifier).' },
|
||||
],
|
||||
response:
|
||||
'{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5],\n "externalLinks": [\n { "id": 11, "kind": "link", "value": "vless://...", "remark": "DE", "enable": true, "expiryTime": 0 },\n { "id": 12, "kind": "subscription", "value": "https://provider.example/sub/abc", "remark": "Provider", "enable": false, "expiryTime": 1767225600000, "namePrefix": "[zjh] ", "lastFetchAt": 1767220000000, "lastFetchError": "" }\n ]\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/clients/get/subId/:subId',
|
||||
@@ -1195,7 +1208,7 @@ export const sections: readonly Section[] = [
|
||||
summary:
|
||||
'Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets are generated server-side when omitted, so callers can send only the universal fields.',
|
||||
description:
|
||||
'Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `0.0.0.0/0` or `::/0` default route claims no address. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.',
|
||||
'Client email must not be exactly `.` or `..`. Existing reserved-name clients can be read, renamed to a valid name, or deleted using the query-parameter /get, /update and /del aliases. Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `/0` entry collides with every address of its family another client holds, and only a `0.0.0.0/0` or `::/0` default route that is already stored (legacy peers) keeps nobody out. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.',
|
||||
params: [
|
||||
{
|
||||
name: 'client',
|
||||
@@ -1263,9 +1276,9 @@ export const sections: readonly Section[] = [
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/update/:email',
|
||||
summary:
|
||||
'Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload — supply the full set of fields you want to keep (the server replaces the row, it does not patch).',
|
||||
'Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload: omitted fields keep their current values, and an explicit zero, empty or null value clears a field, except as noted below.',
|
||||
description:
|
||||
'The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.',
|
||||
'An omitted `flow` leaves every attached inbound on its own flow. Send `"reverse": null` to remove the reverse tag. Credentials (`id`, `password`, `auth`, `secret`, `subId`) and WireGuard/AmneziaWG `allowedIPs` are never cleared: an empty value keeps the stored one. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.',
|
||||
params: [
|
||||
{
|
||||
name: 'email',
|
||||
@@ -1277,6 +1290,24 @@ export const sections: readonly Section[] = [
|
||||
body: '{\n "email": "alice@example.com",\n "totalGB": 107374182400,\n "expiryTime": 1767225600000,\n "limitHwid": 2,\n "tgId": 123456789,\n "enable": true\n}',
|
||||
response: '{\n "success": true,\n "msg": "Client updated"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/update',
|
||||
summary:
|
||||
'Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload: omitted fields keep their current values, and an explicit zero, empty or null value clears a field, except as noted below.',
|
||||
description:
|
||||
'Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization. An omitted `flow` leaves every attached inbound on its own flow. Send `"reverse": null` to remove the reverse tag. Credentials (`id`, `password`, `auth`, `secret`, `subId`) and WireGuard/AmneziaWG `allowedIPs` are never cleared: an empty value keeps the stored one. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.',
|
||||
params: [
|
||||
{
|
||||
name: 'email',
|
||||
in: 'query',
|
||||
type: 'string',
|
||||
desc: 'Current client email (unique identifier).',
|
||||
},
|
||||
],
|
||||
body: '{\n "email": "alice@example.com",\n "totalGB": 107374182400,\n "expiryTime": 1767225600000,\n "limitHwid": 2,\n "tgId": 123456789,\n "enable": true\n}',
|
||||
response: '{\n "success": true,\n "msg": "Client updated"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/del/:email',
|
||||
@@ -1295,6 +1326,24 @@ export const sections: readonly Section[] = [
|
||||
],
|
||||
response: '{\n "success": true,\n "msg": "Client deleted"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/del',
|
||||
summary:
|
||||
'Delete a client by email. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.',
|
||||
description:
|
||||
'Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have removed the client from the remaining inbounds; the client record is kept in that case, so re-running the call retries exactly the leftovers. The record and traffic rows are dropped after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the client already removed from every inbound.',
|
||||
params: [
|
||||
{ name: 'email', in: 'query', type: 'string', desc: 'Client email (unique identifier).' },
|
||||
{
|
||||
name: 'keepTraffic',
|
||||
in: 'query',
|
||||
type: 'integer',
|
||||
desc: 'Pass 1 to retain the xray_client_traffic row after deletion.',
|
||||
},
|
||||
],
|
||||
response: '{\n "success": true,\n "msg": "Client deleted"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/:email/attach',
|
||||
@@ -1497,7 +1546,7 @@ export const sections: readonly Section[] = [
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/bulkResetTraffic',
|
||||
summary:
|
||||
'Zero up/down counters for many clients in one call. Loops the single-reset path so each client is re-enabled across its attached inbounds and pushed to Xray/remote nodes. Returns the count of successfully reset clients.',
|
||||
'Zero up/down counters for many clients in one call. Loops the single-reset path so each client its quota had disabled is re-enabled across its attached inbounds and pushed to Xray/remote nodes; a client disabled by hand stays disabled. Returns the count of successfully reset clients.',
|
||||
body: '{\n "emails": ["alice", "bob"]\n}',
|
||||
response: '{\n "success": true,\n "obj": {\n "affected": 2\n }\n}',
|
||||
},
|
||||
@@ -1553,7 +1602,7 @@ export const sections: readonly Section[] = [
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/resetTraffic/:email',
|
||||
summary:
|
||||
'Zero out a single client’s up/down counters. Re-enables the client across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately.',
|
||||
'Zero out a single client’s up/down counters. Re-enables a client its quota had disabled across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately; a client disabled by hand stays disabled.',
|
||||
params: [{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' }],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Modal, Select, Typography, message } from 'antd';
|
||||
|
||||
import { SelectAllClearButtons } from '@/components/form';
|
||||
import { SelectAllClearButtons, selectTagRender } from '@/components/form';
|
||||
import type { InboundOption } from '@/hooks/useClients';
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
import type { BulkAttachResult } from '@/schemas/client';
|
||||
@@ -106,6 +106,7 @@ export default function BulkAttachInboundsModal({
|
||||
/>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
value={targetIds}
|
||||
onChange={setTargetIds}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Modal, Select, Typography, message } from 'antd';
|
||||
|
||||
import { SelectAllClearButtons } from '@/components/form';
|
||||
import { SelectAllClearButtons, selectTagRender } from '@/components/form';
|
||||
import type { InboundOption } from '@/hooks/useClients';
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
import type { BulkDetachResult } from '@/schemas/client';
|
||||
@@ -106,6 +106,7 @@ export default function BulkDetachInboundsModal({
|
||||
/>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
value={targetIds}
|
||||
onChange={setTargetIds}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
import { RandomUtil, SizeFormatter } from '@/utils';
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
import { TLS_FLOW_CONTROL, TRAFFIC_RESETS } from '@/schemas/primitives';
|
||||
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
|
||||
import { DateTimePicker, SelectAllClearButtons, selectTagRender } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { useClients, type InboundOption } from '@/hooks/useClients';
|
||||
import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
|
||||
@@ -268,6 +268,7 @@ export default function ClientBulkAddModal({
|
||||
<Select
|
||||
id={`${uid}-inbounds`}
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
value={inboundIds}
|
||||
onChange={(v) => methods.setValue('inboundIds', v)}
|
||||
options={inboundOptions}
|
||||
|
||||
@@ -95,7 +95,7 @@ export default function ClientBulkAdjustModal({
|
||||
<Modal
|
||||
open={open}
|
||||
title={t('pages.clients.bulkAdjustTitle', { count })}
|
||||
okText={t('apply')}
|
||||
okText={t('confirm')}
|
||||
cancelText={t('cancel')}
|
||||
confirmLoading={submitting}
|
||||
onOk={handleOk}
|
||||
|
||||
@@ -38,7 +38,7 @@ import { resolveExternalLinkExpiry } from '@/lib/clients/external-link';
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
import { useClientHwids } from '@/hooks/useClientHwids';
|
||||
import { useRawHostsQuery } from '@/api/queries/useRawHostsQuery';
|
||||
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
|
||||
import { DateTimePicker, SelectAllClearButtons, selectTagRender } from '@/components/form';
|
||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import ClientHwidListModal from '@/components/clients/ClientHwidList';
|
||||
import { TLS_FLOW_CONTROL, TRAFFIC_RESETS } from '@/schemas/primitives';
|
||||
@@ -798,10 +798,10 @@ export default function ClientFormModal({
|
||||
comment: values.comment,
|
||||
enable: !!values.enable,
|
||||
};
|
||||
// Sent even when empty, so clearing the tag never rests on how the server
|
||||
// reads an omitted key.
|
||||
const reverseTagValue = showReverseTag ? (values.reverseTag || '').trim() : '';
|
||||
if (reverseTagValue) {
|
||||
clientPayload.reverse = { tag: reverseTagValue };
|
||||
}
|
||||
clientPayload.reverse = reverseTagValue ? { tag: reverseTagValue } : null;
|
||||
|
||||
if (showWireguard || showAmneziawg) {
|
||||
// AmneziaWG peers are wire-identical to WireGuard peers (same
|
||||
@@ -1168,11 +1168,11 @@ export default function ClientFormModal({
|
||||
<Select
|
||||
id={`${uid}-host-rule-override`}
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
value={clientHostRuleIds || []}
|
||||
onChange={(v) => methods.setValue('clientHostRuleIds', v)}
|
||||
options={hostRuleOptions}
|
||||
placeholder={t('pages.clients.hostRuleOverridePlaceholder')}
|
||||
maxTagCount="responsive"
|
||||
placement="topLeft"
|
||||
listHeight={220}
|
||||
allowClear
|
||||
@@ -1226,11 +1226,11 @@ export default function ClientFormModal({
|
||||
<Select
|
||||
id={`${uid}-inbounds`}
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
value={inboundIds}
|
||||
onChange={handleInboundChange}
|
||||
options={inboundOptions}
|
||||
placeholder={t('pages.clients.selectInbound')}
|
||||
maxTagCount="responsive"
|
||||
placement="topLeft"
|
||||
listHeight={220}
|
||||
showSearch={{
|
||||
|
||||
@@ -163,7 +163,7 @@ function SubscriptionQrPresentation({
|
||||
showIcon
|
||||
title={t('pages.clients.happLinkDisclosure')}
|
||||
/>
|
||||
<Spin spinning={happLoading}>
|
||||
<Spin aria-live="off" spinning={happLoading}>
|
||||
<div style={{ minHeight: happLoading ? 48 : undefined }}>
|
||||
{happLink ? (
|
||||
<>
|
||||
@@ -563,7 +563,7 @@ function ClientQrModalContent({
|
||||
centered
|
||||
onCancel={() => onOpenChange(false)}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Spin aria-live="off" spinning={loading}>
|
||||
{!client?.subId && !loading && (
|
||||
<div style={{ padding: 24, textAlign: 'center', opacity: 0.6 }}>
|
||||
{t('pages.clients.noSubId')}
|
||||
|
||||
@@ -1305,6 +1305,7 @@ export default function ClientsPage() {
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content id="content-layout" className="content-area">
|
||||
<Spin
|
||||
aria-live="off"
|
||||
spinning={!fetched || !hostsFetched}
|
||||
delay={200}
|
||||
description={t('loading')}
|
||||
@@ -1724,7 +1725,7 @@ export default function ClientsPage() {
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Spin spinning={transitioning}>
|
||||
<Spin aria-live="off" spinning={transitioning}>
|
||||
<div className="client-cards">
|
||||
{filteredClients.length > 0 && (
|
||||
<div className="card-bulk-bar">
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import dayjs from 'dayjs';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import type { InboundOption } from '@/hooks/useClients';
|
||||
import type { NodeRecord } from '@/schemas/node';
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
@@ -118,24 +119,24 @@ export default function FilterDrawer({
|
||||
<Select
|
||||
id={`${uid}-protocols`}
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
value={filters.protocols}
|
||||
onChange={(v) => patch('protocols', v as string[])}
|
||||
options={protocolOptions}
|
||||
placeholder={t('pages.inbounds.protocol')}
|
||||
maxTagCount="responsive"
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('inbounds')} htmlFor={`${uid}-inbounds`}>
|
||||
<Form.Item label={t('pages.clients.attachedInbounds')} htmlFor={`${uid}-inbounds`}>
|
||||
<Select
|
||||
id={`${uid}-inbounds`}
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
value={filters.inboundIds}
|
||||
onChange={(v) => patch('inboundIds', v as number[])}
|
||||
options={inboundOptions}
|
||||
placeholder={t('inbounds')}
|
||||
maxTagCount="responsive"
|
||||
placeholder={t('pages.clients.attachedInbounds')}
|
||||
allowClear
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
listHeight={220}
|
||||
@@ -147,11 +148,11 @@ export default function FilterDrawer({
|
||||
<Select
|
||||
id={`${uid}-nodes`}
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
value={filters.nodeIds}
|
||||
onChange={(v) => patch('nodeIds', v as number[])}
|
||||
options={nodeOptions}
|
||||
placeholder={t('pages.clients.filters.nodes')}
|
||||
maxTagCount="responsive"
|
||||
allowClear
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
listHeight={220}
|
||||
@@ -163,11 +164,11 @@ export default function FilterDrawer({
|
||||
<Select
|
||||
id={`${uid}-groups`}
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
value={filters.groups}
|
||||
onChange={(v) => patch('groups', v as string[])}
|
||||
options={groupOptions}
|
||||
placeholder={t('pages.clients.groupPlaceholder')}
|
||||
maxTagCount="responsive"
|
||||
allowClear
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
listHeight={220}
|
||||
|
||||
@@ -496,7 +496,13 @@ export default function GroupsPage() {
|
||||
<AppSidebar />
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content id="content-layout" className="content-area">
|
||||
<Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
|
||||
<Spin
|
||||
aria-live="off"
|
||||
spinning={!fetched}
|
||||
delay={200}
|
||||
description={t('loading')}
|
||||
size="large"
|
||||
>
|
||||
{!fetched ? (
|
||||
<div className="loading-spacer" />
|
||||
) : fetchError ? (
|
||||
|
||||
@@ -28,7 +28,7 @@ import type { HostRecord } from '@/api/queries/useHostsQuery';
|
||||
import { BulkAddHostSchema, type BulkAddHostValues } from '@/schemas/api/host';
|
||||
import type { InboundOption } from '@/schemas/client';
|
||||
import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
|
||||
import { CipherSuitesSelect } from '@/components/form';
|
||||
import { CipherSuitesSelect, selectTagRender } from '@/components/form';
|
||||
import DynamicEchHelper from '@/components/form/DynamicEchHelper';
|
||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import { useNodesQuery } from '@/api/queries/useNodesQuery';
|
||||
@@ -291,6 +291,7 @@ export default function HostFormModal({
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
options={inboundSelectOptions}
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
placeholder={t('pages.hosts.selectInbound')}
|
||||
@@ -304,6 +305,7 @@ export default function HostFormModal({
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
options={hostOptions}
|
||||
tokenSeparators={[',', ';', ' ']}
|
||||
placeholder="cdn.example.com, cdn2.example.com:443"
|
||||
@@ -326,7 +328,12 @@ export default function HostFormModal({
|
||||
label={t('pages.hosts.fields.tags')}
|
||||
tooltip={t('pages.hosts.hints.tags')}
|
||||
>
|
||||
<Select mode="tags" allowClear tokenSeparators={[',']} />
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
allowClear
|
||||
tokenSeparators={[',']}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
name="nodeGuids"
|
||||
@@ -335,6 +342,7 @@ export default function HostFormModal({
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
allowClear
|
||||
options={nodeSelectOptions}
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
@@ -377,6 +385,7 @@ export default function HostFormModal({
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
options={sniOptions}
|
||||
tokenSeparators={[',', ';', ' ']}
|
||||
placeholder="sni.example.com, sni2.example.com"
|
||||
@@ -413,7 +422,12 @@ export default function HostFormModal({
|
||||
{showTlsExtras && (
|
||||
<>
|
||||
<FormField name="alpn" label={t('pages.hosts.fields.alpn')}>
|
||||
<Select mode="multiple" allowClear options={alpnOptions} />
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
allowClear
|
||||
options={alpnOptions}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
name="cipherSuites"
|
||||
@@ -425,7 +439,12 @@ export default function HostFormModal({
|
||||
/>
|
||||
</FormField>
|
||||
<FormField name="pinnedPeerCertSha256" label={t('pages.hosts.fields.pins')}>
|
||||
<Select mode="tags" allowClear tokenSeparators={[',']} />
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
allowClear
|
||||
tokenSeparators={[',']}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
name="verifyPeerCertByName"
|
||||
@@ -519,6 +538,7 @@ export default function HostFormModal({
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
allowClear
|
||||
options={['raw', 'json', 'clash'].map((v) => ({
|
||||
value: v,
|
||||
|
||||
@@ -180,7 +180,7 @@ export default function HostsPage() {
|
||||
<AppSidebar />
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content id="content-layout" className="content-area">
|
||||
<Spin spinning={!fetched} delay={200} size="large">
|
||||
<Spin aria-live="off" spinning={!fetched} delay={200} size="large">
|
||||
{!fetched ? (
|
||||
<div className="loading-spacer" />
|
||||
) : fetchError ? (
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Modal, Select, Typography, message } from 'antd';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { SelectAllClearButtons } from '@/components/form';
|
||||
import { SelectAllClearButtons, selectTagRender } from '@/components/form';
|
||||
import { buildClonePayload, pickClonePort } from '@/lib/xray/inbound-clone';
|
||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||
import type { DBInbound } from '@/models/dbinbound';
|
||||
@@ -132,6 +132,7 @@ export default function CloneInboundModal({
|
||||
<Select
|
||||
aria-label={t('pages.inbounds.deployTo')}
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
value={targets}
|
||||
onChange={setTargets}
|
||||
|
||||
@@ -294,6 +294,7 @@ export default function InboundsPage() {
|
||||
if (!dbInbound?.listen?.startsWith?.('@')) return dbInbound;
|
||||
for (const candidate of dbInbounds) {
|
||||
if (candidate.id === dbInbound.id) continue;
|
||||
if ((candidate.nodeId ?? 0) !== (dbInbound.nodeId ?? 0)) continue;
|
||||
if (!['trojan', 'vless'].includes(candidate.protocol)) continue;
|
||||
const candStream = coerceInboundJsonField(candidate.streamSettings) as { network?: string };
|
||||
if (candStream.network !== 'tcp') continue;
|
||||
@@ -719,6 +720,7 @@ export default function InboundsPage() {
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content id="content-layout" className="content-area">
|
||||
<Spin
|
||||
aria-live="off"
|
||||
spinning={!fetched || !hostsFetched}
|
||||
delay={200}
|
||||
description={t('loading')}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Input, Modal, Select, Space, Table, Tag, Typography, message } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
|
||||
@@ -207,6 +208,7 @@ export default function AttachClientsModal({
|
||||
) : (
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
aria-label={t('pages.inbounds.attachClientsTargets')}
|
||||
style={{ width: '100%' }}
|
||||
value={targetIds}
|
||||
|
||||
@@ -200,7 +200,7 @@ export default function AttachExistingClientsModal({
|
||||
{noClients ? (
|
||||
<Alert type="info" showIcon title={t('pages.inbounds.attachExistingNoClients')} />
|
||||
) : (
|
||||
<Spin spinning={loading}>
|
||||
<Spin aria-live="off" spinning={loading}>
|
||||
<Space orientation="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
|
||||
<Space wrap>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import type { FallbackRow } from '@/schemas/forms/inbound-form';
|
||||
|
||||
interface FallbacksCardProps {
|
||||
canAdd?: boolean;
|
||||
fallbacks: FallbackRow[];
|
||||
fallbackChildOptions: { label: string; value: number }[];
|
||||
addFallback: () => void;
|
||||
@@ -20,6 +21,7 @@ interface FallbacksCardProps {
|
||||
}
|
||||
|
||||
export default function FallbacksCard({
|
||||
canAdd = true,
|
||||
fallbacks,
|
||||
fallbackChildOptions,
|
||||
addFallback,
|
||||
@@ -38,6 +40,7 @@ export default function FallbacksCard({
|
||||
size="small"
|
||||
icon={<PlusOutlined aria-hidden />}
|
||||
onClick={addFallback}
|
||||
disabled={!canAdd}
|
||||
>
|
||||
{t('pages.inbounds.fallbacks.add') || 'Add fallback'}
|
||||
</Button>
|
||||
@@ -45,7 +48,9 @@ export default function FallbacksCard({
|
||||
size="small"
|
||||
onClick={addAllFallbacks}
|
||||
disabled={
|
||||
fallbackChildOptions.length === 0 || fallbacks.length >= fallbackChildOptions.length
|
||||
!canAdd ||
|
||||
fallbackChildOptions.length === 0 ||
|
||||
fallbacks.length >= fallbackChildOptions.length
|
||||
}
|
||||
title={t('pages.inbounds.form.addAllFallbackTooltip')}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, within, waitFor } from 'storybook/test';
|
||||
import { Button } from 'antd';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import { AntdRootConfig } from '@/i18n/AntdRootConfig';
|
||||
import { DBInbound } from '@/models/dbinbound';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import { setDatepicker } from '@/hooks/useDatepicker';
|
||||
|
||||
import InboundFormModal from './InboundFormModal';
|
||||
|
||||
const meta = {
|
||||
title: 'Inbounds/InboundFormModal',
|
||||
component: InboundFormModal,
|
||||
tags: ['autodocs'],
|
||||
beforeEach: () => {
|
||||
setDatepicker('gregorian');
|
||||
},
|
||||
args: {
|
||||
open: false,
|
||||
mode: 'add',
|
||||
dbInbound: null,
|
||||
dbInbounds: [],
|
||||
onClose: () => {},
|
||||
onSaved: () => {},
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'The real inbound form. Exercise named options, keyboard navigation, validation and modal focus here; verify TalkBack/Chrome and NVDA separately before claiming screen-reader compatibility.',
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof InboundFormModal>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
function InboundDemo({ edit = false }: { edit?: boolean }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [queryClient] = useState(
|
||||
() => new QueryClient({ defaultOptions: { queries: { retry: false } } }),
|
||||
);
|
||||
const [inbound] = useState(
|
||||
() =>
|
||||
new DBInbound({
|
||||
id: 41,
|
||||
protocol: 'shadowsocks',
|
||||
enable: true,
|
||||
port: 14443,
|
||||
settings: {
|
||||
method: 'chacha20-ietf-poly1305',
|
||||
password: 'example-password',
|
||||
network: 'tcp,udp',
|
||||
clients: [],
|
||||
},
|
||||
streamSettings: { network: 'tcp', security: 'none', tcpSettings: {} },
|
||||
}),
|
||||
);
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AntdRootConfig>
|
||||
<ThemeProvider>
|
||||
<Button onClick={() => setOpen(true)}>{edit ? 'Edit inbound' : 'Add inbound'}</Button>
|
||||
<InboundFormModal
|
||||
open={open}
|
||||
mode={edit ? 'edit' : 'add'}
|
||||
dbInbound={edit ? inbound : null}
|
||||
dbInbounds={[]}
|
||||
availableNodes={[]}
|
||||
onClose={() => setOpen(false)}
|
||||
onSaved={() => {}}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</AntdRootConfig>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export const Create: Story = {
|
||||
render: () => <InboundDemo />,
|
||||
};
|
||||
|
||||
export const SelectableOptions: Story = {
|
||||
...Create,
|
||||
play: async ({ canvas, canvasElement, userEvent }) => {
|
||||
const page = within(canvasElement.ownerDocument.body);
|
||||
const opener = canvas.getByRole('button', { name: 'Add inbound' });
|
||||
await userEvent.click(opener);
|
||||
const dialog = await page.findByRole('dialog', { name: 'Add Inbound' });
|
||||
const protocol = within(dialog).getByRole('combobox', { name: 'Protocol' });
|
||||
await userEvent.selectOptions(
|
||||
protocol,
|
||||
within(protocol).getByRole('option', { name: 'shadowsocks' }),
|
||||
);
|
||||
await expect(protocol).toHaveDisplayValue('shadowsocks');
|
||||
await expect(protocol).toHaveFocus();
|
||||
await userEvent.click(within(dialog).getByRole('tab', { name: 'Protocol' }));
|
||||
const cipher = within(dialog).getByRole('combobox', { name: 'Encryption method' });
|
||||
await userEvent.selectOptions(
|
||||
cipher,
|
||||
within(cipher).getByRole('option', { name: 'chacha20-ietf-poly1305' }),
|
||||
);
|
||||
await userEvent.click(within(dialog).getByRole('tab', { name: 'Stream' }));
|
||||
const transport = within(dialog).getByRole('combobox', { name: 'Transmission' });
|
||||
await userEvent.selectOptions(
|
||||
transport,
|
||||
within(transport).getByRole('option', { name: 'WebSocket' }),
|
||||
);
|
||||
await expect(within(dialog).getByRole('textbox', { name: 'Path' })).toHaveValue('/');
|
||||
await userEvent.click(within(dialog).getAllByRole('button', { name: 'Close' })[0]);
|
||||
await waitFor(() => expect(opener).toHaveFocus());
|
||||
},
|
||||
};
|
||||
|
||||
export const Edit: Story = {
|
||||
render: () => <InboundDemo edit />,
|
||||
};
|
||||
@@ -1,8 +1,22 @@
|
||||
import { NativeSelect } from '@/components/form/NativeSelect';
|
||||
import { useEffect, useRef, useState, useId } from 'react';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import { Alert, Form, Input, InputNumber, Modal, Radio, Select, Switch, Tabs, message } from 'antd';
|
||||
import {
|
||||
Alert,
|
||||
ConfigProvider,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Radio,
|
||||
Select,
|
||||
Switch,
|
||||
Tabs,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
|
||||
import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils';
|
||||
@@ -92,6 +106,28 @@ const SHARE_ADDR_STRATEGIES = ['node', 'listen', 'custom'] as const;
|
||||
const SHARE_ADDR_HOSTNAME_RE =
|
||||
/^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/;
|
||||
|
||||
function inboundPopupContainer(trigger?: HTMLElement): HTMLElement {
|
||||
return (
|
||||
trigger?.closest<HTMLElement>('.ant-modal-body') ?? trigger?.parentElement ?? document.body
|
||||
);
|
||||
}
|
||||
|
||||
function loopInboundFocus(event: KeyboardEvent<HTMLDivElement>) {
|
||||
if (event.key !== 'Tab') return;
|
||||
const first = event.currentTarget.querySelector<HTMLButtonElement>(
|
||||
'.ant-modal-close:not(:disabled)',
|
||||
);
|
||||
const last = Array.from(
|
||||
event.currentTarget.querySelectorAll<HTMLButtonElement>(
|
||||
'.ant-modal-footer button:not(:disabled)',
|
||||
),
|
||||
).at(-1);
|
||||
if (first && last && event.target === (event.shiftKey ? first : last)) {
|
||||
event.preventDefault();
|
||||
(event.shiftKey ? last : first).focus();
|
||||
}
|
||||
}
|
||||
|
||||
function isValidShareAddrInput(value: string): boolean {
|
||||
const v = value.trim();
|
||||
if (v.length === 0) return true;
|
||||
@@ -226,11 +262,14 @@ export default function InboundFormModal({
|
||||
const getV = methods.getValues as unknown as (name?: string) => unknown;
|
||||
const control = methods.control;
|
||||
const [saving, setSaving] = useState(false);
|
||||
const savedInboundId = useRef<number | null>(null);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [scanResult, setScanResult] = useState<RealityScanResult | null>(null);
|
||||
const [activeTab, setActiveTab] = useState('basic');
|
||||
const {
|
||||
fallbacks,
|
||||
hasStoredFallbacks,
|
||||
fallbacksLoaded,
|
||||
fallbackChildOptions,
|
||||
loadFallbacks,
|
||||
saveFallbacks,
|
||||
@@ -250,6 +289,7 @@ export default function InboundFormModal({
|
||||
* always empty. Offer it only then; `listen`/`custom` work for local inbounds.
|
||||
*/
|
||||
const nodeShareOptionAvailable = selectableNodes.length > 0 && isNodeEligible;
|
||||
const vlessDecryption = useWatch({ control, name: 'settings.decryption' }) ?? '';
|
||||
const vlessEncryption = useWatch({ control, name: 'settings.encryption' }) ?? '';
|
||||
const ssMethod = useWatch({ control, name: 'settings.method' });
|
||||
const isSSWith2022 = isSS2022({
|
||||
@@ -295,7 +335,10 @@ export default function InboundFormModal({
|
||||
streamSettings: { network },
|
||||
settings: { network: wSsNetwork, allowedNetwork: wTunnelNetwork, udp: mixedUdpOn },
|
||||
});
|
||||
const hasVlessDecryption =
|
||||
protocol === Protocols.VLESS && vlessDecryption !== '' && vlessDecryption !== 'none';
|
||||
const isFallbackHost =
|
||||
!hasVlessDecryption &&
|
||||
(protocol === Protocols.VLESS || protocol === Protocols.TROJAN) &&
|
||||
network === 'tcp' &&
|
||||
(security === 'tls' || security === 'reality');
|
||||
@@ -437,6 +480,7 @@ export default function InboundFormModal({
|
||||
? withoutClients(rawInboundToFormValues(dbInbound))
|
||||
: buildAddModeValues();
|
||||
methods.reset(initial);
|
||||
savedInboundId.current = null;
|
||||
setScanResult(null);
|
||||
setActiveTab('basic');
|
||||
const initialTag = (initial.tag ?? '') as string;
|
||||
@@ -573,19 +617,46 @@ export default function InboundFormModal({
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (saving) return;
|
||||
if (!fallbacksLoaded) {
|
||||
messageApi.error(t('pages.inbounds.fallbacks.saveFailed'));
|
||||
return;
|
||||
}
|
||||
if (!isFallbackHost && fallbacks.length > 0) {
|
||||
setActiveTab('protocol');
|
||||
messageApi.error(
|
||||
t(
|
||||
hasVlessDecryption
|
||||
? 'pages.inbounds.fallbacks.decryptionConflict'
|
||||
: 'pages.inbounds.fallbacks.needsTls',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = formValuesToWirePayload(parsed.data, { omitClients: mode === 'edit' });
|
||||
const url =
|
||||
mode === 'edit' && dbInbound
|
||||
? `/panel/api/inbounds/update/${dbInbound.id}`
|
||||
: '/panel/api/inbounds/add';
|
||||
// Persist an explicit removal before the backend checks the new decryption setting.
|
||||
if (!isFallbackHost && hasStoredFallbacks && dbInbound) {
|
||||
if (!(await saveFallbacks(dbInbound.id))) {
|
||||
messageApi.error(t('pages.inbounds.fallbacks.saveFailed'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
const existingId = mode === 'edit' ? dbInbound?.id : savedInboundId.current;
|
||||
const payload = formValuesToWirePayload(parsed.data, { omitClients: !!existingId });
|
||||
const url = existingId
|
||||
? `/panel/api/inbounds/update/${existingId}`
|
||||
: '/panel/api/inbounds/add';
|
||||
const msg = await HttpUtil.post(url, payload);
|
||||
if (msg?.success) {
|
||||
if (isFallbackHost) {
|
||||
const obj = msg.obj as { id?: number; Id?: number } | null;
|
||||
const masterId = mode === 'edit' ? dbInbound!.id : (obj?.id ?? obj?.Id ?? 0);
|
||||
if (masterId) await saveFallbacks(masterId);
|
||||
const masterId = existingId ?? obj?.id ?? obj?.Id ?? 0;
|
||||
if (masterId) savedInboundId.current = masterId;
|
||||
if (masterId && !(await saveFallbacks(masterId))) {
|
||||
messageApi.error(t('pages.inbounds.fallbacks.saveFailed'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
@@ -642,7 +713,7 @@ export default function InboundFormModal({
|
||||
)}
|
||||
|
||||
<FormField name="protocol" label={t('pages.inbounds.protocol')}>
|
||||
<Select id="protocol" disabled={mode === 'edit'} options={PROTOCOL_OPTIONS} />
|
||||
<NativeSelect id="protocol" disabled={mode === 'edit'} options={PROTOCOL_OPTIONS} />
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
@@ -660,7 +731,7 @@ export default function InboundFormModal({
|
||||
label={t('pages.inbounds.form.shareAddrStrategy')}
|
||||
tooltip={t('pages.inbounds.form.shareAddrStrategyHelp')}
|
||||
>
|
||||
<Select
|
||||
<NativeSelect
|
||||
options={SHARE_ADDR_STRATEGIES.filter(
|
||||
(strategy) => strategy !== 'node' || nodeShareOptionAvailable,
|
||||
).map((strategy) => ({
|
||||
@@ -745,7 +816,7 @@ export default function InboundFormModal({
|
||||
</Form.Item>
|
||||
|
||||
<FormField name="trafficReset" label={t('pages.inbounds.periodicTrafficResetTitle')}>
|
||||
<Select
|
||||
<NativeSelect
|
||||
options={TRAFFIC_RESETS.map((r) => ({
|
||||
value: r,
|
||||
label: t(`pages.inbounds.periodicTrafficReset.${r}`),
|
||||
@@ -783,6 +854,7 @@ export default function InboundFormModal({
|
||||
|
||||
const fallbacksCard = (
|
||||
<FallbacksCard
|
||||
canAdd={isFallbackHost && fallbacksLoaded}
|
||||
fallbacks={fallbacks}
|
||||
fallbackChildOptions={fallbackChildOptions}
|
||||
addFallback={addFallback}
|
||||
@@ -834,9 +906,18 @@ export default function InboundFormModal({
|
||||
/>
|
||||
)}
|
||||
|
||||
{isFallbackHost && fallbacksCard}
|
||||
{(isFallbackHost || fallbacks.length > 0) && fallbacksCard}
|
||||
{hasVlessDecryption && (
|
||||
<Alert
|
||||
className="mt-12"
|
||||
type="warning"
|
||||
showIcon
|
||||
title={t('pages.inbounds.fallbacks.decryptionConflict')}
|
||||
/>
|
||||
)}
|
||||
{(protocol === Protocols.VLESS || protocol === Protocols.TROJAN) &&
|
||||
network === 'tcp' &&
|
||||
!hasVlessDecryption &&
|
||||
!isFallbackHost && (
|
||||
<Alert
|
||||
className="mt-12"
|
||||
@@ -913,7 +994,7 @@ export default function InboundFormModal({
|
||||
<>
|
||||
{hasSelectableTransport && (
|
||||
<Form.Item htmlFor={`${uid}-network`} label={t('transmission')}>
|
||||
<Select
|
||||
<NativeSelect
|
||||
id={`${uid}-network`}
|
||||
style={{ width: '75%' }}
|
||||
value={network}
|
||||
@@ -989,8 +1070,11 @@ export default function InboundFormModal({
|
||||
|
||||
const securityTab = (
|
||||
<>
|
||||
<Form.Item label={t('pages.inbounds.securityTab')}>
|
||||
<Form.Item
|
||||
label={<span id={`${uid}-security-label`}>{t('pages.inbounds.securityTab')}</span>}
|
||||
>
|
||||
<Radio.Group
|
||||
aria-labelledby={`${uid}-security-label`}
|
||||
value={security}
|
||||
buttonStyle="solid"
|
||||
disabled={!tlsOk}
|
||||
@@ -1129,7 +1213,7 @@ export default function InboundFormModal({
|
||||
const sniffingTab = <SniffingTab />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfigProvider virtual={false} getPopupContainer={inboundPopupContainer}>
|
||||
{messageContextHolder}
|
||||
{modalContextHolder}
|
||||
<Modal
|
||||
@@ -1143,6 +1227,11 @@ export default function InboundFormModal({
|
||||
onOk={submit}
|
||||
onCancel={onClose}
|
||||
destroyOnHidden
|
||||
modalRender={(content) => (
|
||||
<div role="presentation" onKeyDown={loopInboundFocus}>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<FormProvider {...methods}>
|
||||
<Form
|
||||
@@ -1175,7 +1264,9 @@ export default function InboundFormModal({
|
||||
Protocols.TUIC,
|
||||
Protocols.MASQUE,
|
||||
] as string[]
|
||||
).includes(protocol) || isFallbackHost
|
||||
).includes(protocol) ||
|
||||
isFallbackHost ||
|
||||
fallbacks.length > 0
|
||||
? [
|
||||
{
|
||||
key: 'protocol',
|
||||
@@ -1226,6 +1317,6 @@ export default function InboundFormModal({
|
||||
</Form>
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
</>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ export default function AmneziawgFields({
|
||||
<Alert
|
||||
type={kernelModeOn ? 'warning' : 'info'}
|
||||
showIcon
|
||||
message={t('pages.xray.amneziawg.kernelUnavailable')}
|
||||
title={t('pages.xray.amneziawg.kernelUnavailable')}
|
||||
description={
|
||||
<>
|
||||
<ul style={{ margin: '0 0 8px', paddingInlineStart: 20 }}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NativeSelect } from '@/components/form/NativeSelect';
|
||||
import { useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input, InputNumber, Select, Switch } from 'antd';
|
||||
import { Form, Input, InputNumber, Switch } from 'antd';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { HeaderMapEditor } from '@/components/form';
|
||||
@@ -60,7 +61,7 @@ export default function HysteriaFields() {
|
||||
{masq && (
|
||||
<>
|
||||
<FormField label={t('pages.inbounds.form.type')} name={[...MASQ_PATH, 'type']}>
|
||||
<Select
|
||||
<NativeSelect
|
||||
options={[
|
||||
{ value: '', label: 'default (404 page)' },
|
||||
{ value: 'proxy', label: 'proxy (reverse proxy)' },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Input, InputNumber, Select } from 'antd';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { MASQUE_DEFAULT_PATH } from '@/schemas/protocols/stream/masque';
|
||||
|
||||
@@ -14,7 +15,7 @@ export function MasqueSettingsFields() {
|
||||
name={['settings', 'address']}
|
||||
required
|
||||
>
|
||||
<Select mode="tags" tokenSeparators={[',', ' ']} open={false} />
|
||||
<Select mode="tags" tagRender={selectTagRender} tokenSeparators={[',', ' ']} open={false} />
|
||||
</FormField>
|
||||
<FormField label={t('pages.inbounds.info.mtu')} name={['settings', 'mtu']}>
|
||||
<InputNumber min={1280} max={65535} placeholder="1280" style={{ width: '100%' }} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NativeSelect } from '@/components/form/NativeSelect';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Input, Select, Switch } from 'antd';
|
||||
import { Input, Switch } from 'antd';
|
||||
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import AccountsList from './accounts-list';
|
||||
@@ -10,7 +11,7 @@ export default function MixedFields({ mixedUdpOn }: { mixedUdpOn: boolean }) {
|
||||
<>
|
||||
<AccountsList />
|
||||
<FormField name={['settings', 'auth']} label={t('pages.inbounds.info.auth')}>
|
||||
<Select
|
||||
<NativeSelect
|
||||
options={[
|
||||
{ value: 'noauth', label: 'noauth' },
|
||||
{ value: 'password', label: 'password' },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NativeSelect } from '@/components/form/NativeSelect';
|
||||
import { useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Input, Select, Space, Switch } from 'antd';
|
||||
import { Button, Form, Input, Space, Switch } from 'antd';
|
||||
import { ReloadOutlined } from '@ant-design/icons';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
@@ -25,7 +26,9 @@ export default function ShadowsocksFields({ isSSWith2022 }: ShadowsocksFieldsPro
|
||||
setValue('settings.password', RandomUtil.randomShadowsocksPassword(v as string));
|
||||
}}
|
||||
>
|
||||
<Select options={SSInboundMethodSchema.options.map((m) => ({ value: m, label: m }))} />
|
||||
<NativeSelect
|
||||
options={SSInboundMethodSchema.options.map((m) => ({ value: m, label: m }))}
|
||||
/>
|
||||
</FormField>
|
||||
{isSSWith2022 && (
|
||||
<Form.Item htmlFor={`${uid}-password`} label={t('password')}>
|
||||
@@ -48,7 +51,7 @@ export default function ShadowsocksFields({ isSSWith2022 }: ShadowsocksFieldsPro
|
||||
</Form.Item>
|
||||
)}
|
||||
<FormField name={['settings', 'network']} label={t('pages.inbounds.network')}>
|
||||
<Select
|
||||
<NativeSelect
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: 'tcp,udp', label: 'TCP, UDP' },
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { NativeSelect } from '@/components/form/NativeSelect';
|
||||
import { useState, useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
import { CloudDownloadOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
@@ -67,7 +69,7 @@ export default function TuicFields() {
|
||||
if (obj.webKeyFile) {
|
||||
setValue('settings.server.private_key', obj.webKeyFile);
|
||||
}
|
||||
messageApi.success(t('pages.inbounds.setSuccess'));
|
||||
messageApi.success(t('success'));
|
||||
} catch {
|
||||
messageApi.error(t('somethingWentWrong'));
|
||||
} finally {
|
||||
@@ -110,7 +112,7 @@ export default function TuicFields() {
|
||||
label={t('pages.xray.tuic.logLevel')}
|
||||
tooltip={t('pages.xray.tuic.logLevelHint')}
|
||||
>
|
||||
<Select
|
||||
<NativeSelect
|
||||
options={[
|
||||
{ label: 'Info', value: 'info' },
|
||||
{ label: 'Warn', value: 'warn' },
|
||||
@@ -198,7 +200,7 @@ export default function TuicFields() {
|
||||
label={t('pages.xray.tuic.congestionControl')}
|
||||
tooltip={t('pages.xray.tuic.congestionControlHint')}
|
||||
>
|
||||
<Select
|
||||
<NativeSelect
|
||||
options={[
|
||||
{ label: 'BBR', value: 'bbr' },
|
||||
{ label: 'CUBIC', value: 'cubic' },
|
||||
@@ -210,6 +212,7 @@ export default function TuicFields() {
|
||||
<FormField name={['settings', 'server', 'alpn']} label={t('pages.xray.tuic.alpn')}>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',']}
|
||||
options={[
|
||||
{ label: 'h3', value: 'h3' },
|
||||
@@ -242,7 +245,7 @@ export default function TuicFields() {
|
||||
label={t('pages.xray.tuic.udpRelayMode')}
|
||||
tooltip={t('pages.xray.tuic.udpRelayModeHint')}
|
||||
>
|
||||
<Select
|
||||
<NativeSelect
|
||||
options={[
|
||||
{ label: 'Native (Recommended)', value: 'native' },
|
||||
{ label: 'QUIC', value: 'quic' },
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
||||
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
|
||||
interface StringListProps {
|
||||
@@ -46,9 +47,10 @@ function StringList({ name, label, tooltip, placeholder }: StringListProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// xray-core 26.9.30 accepts exactly these autoSystemWfpBlockLeak values.
|
||||
// Named autoSystemWfpBlockLeak values; xray-core 26.10.31 also takes typed "tcp/<port>" or "udp/<port>".
|
||||
const WFP_BLOCK_LEAK_OPTIONS = [
|
||||
{ value: 'dns', label: 'dns' },
|
||||
{ value: 'windnsclient', label: 'windnsclient' },
|
||||
{ value: 'misconfigtun', label: 'misconfigtun' },
|
||||
];
|
||||
|
||||
@@ -110,7 +112,9 @@ export default function TunFields() {
|
||||
>
|
||||
<Select
|
||||
id="tun-wfp-block-leak"
|
||||
mode="multiple"
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',', ' ']}
|
||||
allowClear
|
||||
options={WFP_BLOCK_LEAK_OPTIONS}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NativeSelect } from '@/components/form/NativeSelect';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Input, InputNumber, Select, Switch } from 'antd';
|
||||
import { Input, InputNumber, Switch } from 'antd';
|
||||
|
||||
import { HeaderMapEditor } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
@@ -21,7 +22,7 @@ export default function TunnelFields() {
|
||||
name={['settings', 'allowedNetwork']}
|
||||
label={t('pages.inbounds.form.allowedNetwork')}
|
||||
>
|
||||
<Select
|
||||
<NativeSelect
|
||||
options={[
|
||||
{ value: 'tcp,udp', label: 'TCP, UDP' },
|
||||
{ value: 'tcp', label: 'TCP' },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NativeSelect } from '@/components/form/NativeSelect';
|
||||
import { useState, useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Input, InputNumber, Select, Space, Typography } from 'antd';
|
||||
import { Button, Form, Input, InputNumber, Space, Typography } from 'antd';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
@@ -50,7 +51,7 @@ export default function VlessFields({
|
||||
</FormField>
|
||||
<Form.Item htmlFor={`${uid}-authKind`} label={t('pages.inbounds.vlessAuthGenerate')}>
|
||||
<Space size={8} wrap>
|
||||
<Select
|
||||
<NativeSelect
|
||||
id={`${uid}-authKind`}
|
||||
value={authKind}
|
||||
onChange={(v) => setAuthKind(v)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { NativeSelect } from '@/components/form/NativeSelect';
|
||||
import { useState, useId } from 'react';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
import { RadarChartOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { UTLS_FINGERPRINT } from '@/schemas/primitives';
|
||||
import {
|
||||
@@ -111,7 +113,9 @@ export default function RealityForm({
|
||||
name={['streamSettings', 'realitySettings', 'settings', 'fingerprint']}
|
||||
label="uTLS"
|
||||
>
|
||||
<Select options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))} />
|
||||
<NativeSelect
|
||||
options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))}
|
||||
/>
|
||||
</FormField>
|
||||
<Form.Item
|
||||
htmlFor={`${uid}-target`}
|
||||
@@ -208,7 +212,12 @@ export default function RealityForm({
|
||||
</Form.Item>
|
||||
)}
|
||||
<FormField label="SNI" name={['streamSettings', 'realitySettings', 'serverNames']}>
|
||||
<Select mode="tags" tokenSeparators={[',']} style={{ width: '100%' }} />
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',']}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'realitySettings', 'maxTimediff']}
|
||||
@@ -251,6 +260,7 @@ export default function RealityForm({
|
||||
<Select
|
||||
id={`${uid}-shortIds`}
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',']}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { NativeSelect } from '@/components/form/NativeSelect';
|
||||
import { useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -20,7 +21,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { CipherSuitesSelect } from '@/components/form';
|
||||
import { CipherSuitesSelect, selectTagRender } from '@/components/form';
|
||||
import DynamicEchHelper from '@/components/form/DynamicEchHelper';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import {
|
||||
@@ -154,7 +155,7 @@ function CertRow({
|
||||
name={['streamSettings', 'tlsSettings', 'certificates', index, 'usage']}
|
||||
label={t('pages.inbounds.form.usageOption')}
|
||||
>
|
||||
<Select
|
||||
<NativeSelect
|
||||
style={{ width: '50%' }}
|
||||
options={Object.values(USAGE_OPTION).map((u) => ({ value: u, label: u }))}
|
||||
/>
|
||||
@@ -212,7 +213,7 @@ function EchSockoptSection() {
|
||||
name={['streamSettings', 'tlsSettings', 'echSockopt', 'domainStrategy']}
|
||||
label={t('pages.xray.wireguard.domainStrategy')}
|
||||
>
|
||||
<Select
|
||||
<NativeSelect
|
||||
options={Object.values(DOMAIN_STRATEGY_OPTION).map((v) => ({ value: v, label: v }))}
|
||||
/>
|
||||
</FormField>
|
||||
@@ -354,14 +355,14 @@ export default function TlsForm({
|
||||
<Form.Item htmlFor={`${uid}-minVersion`} label={t('pages.inbounds.form.minMaxVersion')}>
|
||||
<Space.Compact block>
|
||||
<FormField name={['streamSettings', 'tlsSettings', 'minVersion']} noStyle>
|
||||
<Select
|
||||
<NativeSelect
|
||||
id={`${uid}-minVersion`}
|
||||
style={{ width: '50%' }}
|
||||
options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField name={['streamSettings', 'tlsSettings', 'maxVersion']} noStyle>
|
||||
<Select
|
||||
<NativeSelect
|
||||
aria-label={t('pages.inbounds.form.maxVersion')}
|
||||
style={{ width: '50%' }}
|
||||
options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))}
|
||||
@@ -370,7 +371,7 @@ export default function TlsForm({
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<FormField name={['streamSettings', 'tlsSettings', 'settings', 'fingerprint']} label="uTLS">
|
||||
<Select
|
||||
<NativeSelect
|
||||
options={[
|
||||
{ value: '', label: 'None' },
|
||||
...Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp })),
|
||||
@@ -380,6 +381,7 @@ export default function TlsForm({
|
||||
<FormField name={['streamSettings', 'tlsSettings', 'alpn']} label="ALPN">
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',']}
|
||||
style={{ width: '100%' }}
|
||||
options={Object.values(ALPN_OPTION).map((a) => ({ value: a, label: a }))}
|
||||
@@ -392,6 +394,7 @@ export default function TlsForm({
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',', ' ']}
|
||||
style={{ width: '100%' }}
|
||||
options={['X25519MLKEM768', 'X25519', 'P-256', 'P-384', 'P-521'].map((c) => ({
|
||||
@@ -478,6 +481,7 @@ export default function TlsForm({
|
||||
id={`${uid}-pinnedPeerCertSha256`}
|
||||
aria-describedby={`${uid}-pinnedPeerCertSha256-hint`}
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',', ' ']}
|
||||
placeholder={t('pages.inbounds.form.pinnedPeerCertSha256Placeholder')}
|
||||
style={{ width: 'calc(100% - 64px)' }}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { NativeSelect } from '@/components/form/NativeSelect';
|
||||
import { useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Form, InputNumber, Segmented, Select, Switch } from 'antd';
|
||||
import { Controller, useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { SockoptCustomField } from '@/lib/xray/forms/fields';
|
||||
import { TCP_CONGESTION_OPTION } from '@/schemas/primitives';
|
||||
@@ -206,13 +208,13 @@ export default function SockoptForm({
|
||||
name={['streamSettings', 'sockopt', 'tcpcongestion']}
|
||||
label={t('pages.inbounds.form.tcpCongestion')}
|
||||
>
|
||||
<Select
|
||||
<NativeSelect
|
||||
style={{ width: '50%' }}
|
||||
options={Object.values(TCP_CONGESTION_OPTION).map((c) => ({ value: c, label: c }))}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField name={['streamSettings', 'sockopt', 'tproxy']} label="TProxy">
|
||||
<Select
|
||||
<NativeSelect
|
||||
style={{ width: '50%' }}
|
||||
options={[
|
||||
{ value: 'off', label: 'Off' },
|
||||
@@ -228,6 +230,7 @@ export default function SockoptForm({
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={[',']}
|
||||
options={[
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NativeSelect } from '@/components/form/NativeSelect';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AutoComplete, Input, InputNumber, Select, Switch } from 'antd';
|
||||
import { AutoComplete, Input, InputNumber, Switch } from 'antd';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { HeaderMapEditor } from '@/components/form';
|
||||
@@ -81,7 +82,7 @@ export default function XhttpForm() {
|
||||
name={['streamSettings', 'xhttpSettings', 'mode']}
|
||||
label={t('pages.inbounds.info.mode')}
|
||||
>
|
||||
<Select
|
||||
<NativeSelect
|
||||
style={{ width: '50%' }}
|
||||
options={(['auto', 'packet-up', 'stream-up', 'stream-one'] as const).map((m) => ({
|
||||
value: m,
|
||||
@@ -149,7 +150,7 @@ export default function XhttpForm() {
|
||||
name={['streamSettings', 'xhttpSettings', 'uplinkHTTPMethod']}
|
||||
label={t('pages.inbounds.form.uplinkHttpMethod')}
|
||||
>
|
||||
<Select
|
||||
<NativeSelect
|
||||
options={[
|
||||
{ value: '', label: 'Default (POST)' },
|
||||
{ value: 'POST', label: 'POST' },
|
||||
@@ -187,7 +188,7 @@ export default function XhttpForm() {
|
||||
name={['streamSettings', 'xhttpSettings', 'xPaddingPlacement']}
|
||||
label={t('pages.inbounds.form.paddingPlacement')}
|
||||
>
|
||||
<Select
|
||||
<NativeSelect
|
||||
options={[
|
||||
{ value: '', label: 'Default (queryInHeader)' },
|
||||
{ value: 'queryInHeader', label: 'queryInHeader' },
|
||||
@@ -201,7 +202,7 @@ export default function XhttpForm() {
|
||||
name={['streamSettings', 'xhttpSettings', 'xPaddingMethod']}
|
||||
label={t('pages.inbounds.form.paddingMethod')}
|
||||
>
|
||||
<Select
|
||||
<NativeSelect
|
||||
options={[
|
||||
{ value: '', label: 'Default (repeat-x)' },
|
||||
{ value: 'repeat-x', label: 'repeat-x' },
|
||||
@@ -215,7 +216,7 @@ export default function XhttpForm() {
|
||||
name={['streamSettings', 'xhttpSettings', 'sessionIDPlacement']}
|
||||
label={t('pages.inbounds.form.sessionPlacement')}
|
||||
>
|
||||
<Select
|
||||
<NativeSelect
|
||||
options={[
|
||||
{ value: '', label: 'Default (path)' },
|
||||
{ value: 'path', label: 'path' },
|
||||
@@ -259,7 +260,7 @@ export default function XhttpForm() {
|
||||
name={['streamSettings', 'xhttpSettings', 'seqPlacement']}
|
||||
label={t('pages.inbounds.form.sequencePlacement')}
|
||||
>
|
||||
<Select
|
||||
<NativeSelect
|
||||
options={[
|
||||
{ value: '', label: 'Default (path)' },
|
||||
{ value: 'path', label: 'path' },
|
||||
@@ -283,7 +284,7 @@ export default function XhttpForm() {
|
||||
name={['streamSettings', 'xhttpSettings', 'uplinkDataPlacement']}
|
||||
label={t('pages.inbounds.form.uplinkDataPlacement')}
|
||||
>
|
||||
<Select
|
||||
<NativeSelect
|
||||
options={[
|
||||
{ value: '', label: 'Default (auto)' },
|
||||
{ value: 'auto', label: 'auto' },
|
||||
|
||||
@@ -10,6 +10,8 @@ import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
|
||||
export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBInbound[]) {
|
||||
const fallbackKeyRef = useRef(0);
|
||||
const [fallbacks, setFallbacks] = useState<FallbackRow[]>([]);
|
||||
const [hasStoredFallbacks, setHasStoredFallbacks] = useState(false);
|
||||
const [fallbacksLoaded, setFallbacksLoaded] = useState(false);
|
||||
|
||||
const fallbackChildOptions = (dbInbounds || [])
|
||||
.filter((ib) => ib.id !== dbInbound?.id)
|
||||
@@ -19,7 +21,10 @@ export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBI
|
||||
}));
|
||||
|
||||
const loadFallbacks = async (masterId: number | null) => {
|
||||
setFallbacksLoaded(false);
|
||||
setHasStoredFallbacks(false);
|
||||
if (!masterId) {
|
||||
setFallbacksLoaded(true);
|
||||
setFallbacks([]);
|
||||
return;
|
||||
}
|
||||
@@ -28,6 +33,8 @@ export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBI
|
||||
setFallbacks([]);
|
||||
return;
|
||||
}
|
||||
setFallbacksLoaded(true);
|
||||
setHasStoredFallbacks(msg.obj.length > 0);
|
||||
setFallbacks(
|
||||
(
|
||||
msg.obj as {
|
||||
@@ -68,6 +75,7 @@ export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBI
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/${masterId}/fallbacks`, payload, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (msg?.success) setHasStoredFallbacks(payload.fallbacks.length > 0);
|
||||
return !!msg?.success;
|
||||
};
|
||||
|
||||
@@ -181,6 +189,8 @@ export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBI
|
||||
|
||||
return {
|
||||
fallbacks,
|
||||
hasStoredFallbacks,
|
||||
fallbacksLoaded,
|
||||
fallbackChildOptions,
|
||||
loadFallbacks,
|
||||
saveFallbacks,
|
||||
|
||||
@@ -69,7 +69,9 @@ export default function InboundInfoModal({
|
||||
if (!clientStats?.email) return;
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/ips/${clientStats.email}`);
|
||||
const msg = await HttpUtil.post(
|
||||
`/panel/api/clients/ips/${encodeURIComponent(clientStats.email)}`,
|
||||
);
|
||||
if (!msg?.success) {
|
||||
setClientIpsText((msg?.obj as string) || 'No IP record');
|
||||
setClientIpsArray([]);
|
||||
@@ -101,7 +103,9 @@ export default function InboundInfoModal({
|
||||
|
||||
const clearClientIps = useCallback(async () => {
|
||||
if (!clientStats?.email) return;
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/clearIps/${clientStats.email}`);
|
||||
const msg = await HttpUtil.post(
|
||||
`/panel/api/clients/clearIps/${encodeURIComponent(clientStats.email)}`,
|
||||
);
|
||||
if (msg?.success) {
|
||||
setClientIpsArray([]);
|
||||
setClientIpsText(t('tgbot.noIpRecord'));
|
||||
@@ -223,7 +227,8 @@ export default function InboundInfoModal({
|
||||
setClientIpsText('');
|
||||
|
||||
if (ipLimitEnable && (clientSet?.limitIp ?? 0) > 0 && stats?.email) {
|
||||
void HttpUtil.post(`/panel/api/clients/ips/${stats.email}`).then((msg) => {
|
||||
const email = encodeURIComponent(stats.email);
|
||||
void HttpUtil.post(`/panel/api/clients/ips/${email}`).then((msg) => {
|
||||
if (!msg?.success) {
|
||||
setClientIpsText((msg?.obj as string) || 'No IP record');
|
||||
return;
|
||||
|
||||
@@ -19,8 +19,8 @@ interface GeodataSectionProps {
|
||||
}
|
||||
|
||||
const DEFAULT_CRON = '0 4 * * *';
|
||||
// Xray resolves `file` inside its asset directory; plain file names only.
|
||||
const FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
|
||||
// Xray resolves `file` inside its asset directory; since 26.10.29 a scheduled one must be .dat.
|
||||
const FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.dat$/i;
|
||||
|
||||
function fileNameFromUrl(url: string): string {
|
||||
try {
|
||||
@@ -178,7 +178,7 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
|
||||
<div>
|
||||
{modalContextHolder}
|
||||
{messageContextHolder}
|
||||
<Spin spinning={loading}>
|
||||
<Spin aria-live="off" spinning={loading}>
|
||||
<Alert type="info" className="mb-12" title={t('pages.index.geodataHint')} showIcon />
|
||||
<Form layout="vertical">
|
||||
<Form.Item
|
||||
|
||||
@@ -174,6 +174,7 @@ export default function IndexPage() {
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content className="content-area">
|
||||
<Spin
|
||||
aria-live="off"
|
||||
spinning={loading || !fetched}
|
||||
delay={200}
|
||||
description={loading ? loadingTip : t('loading')}
|
||||
|
||||
@@ -103,7 +103,7 @@ export default function VersionModal({ open, status, onClose, onBusy }: VersionM
|
||||
return (
|
||||
<Modal open={open} title={t('pages.index.xrayUpdates')} footer={null} onCancel={onClose}>
|
||||
{modalContextHolder}
|
||||
<Spin spinning={loading}>
|
||||
<Spin aria-live="off" spinning={loading}>
|
||||
<Collapse
|
||||
accordion
|
||||
activeKey={activeKey}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useId, useMemo } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Card, theme } from 'antd';
|
||||
|
||||
@@ -31,6 +31,7 @@ export default function VitalTile({
|
||||
isMobile,
|
||||
}: VitalTileProps) {
|
||||
const { token } = theme.useToken();
|
||||
const labelId = useId();
|
||||
const meanColor = token.colorTextTertiary;
|
||||
|
||||
const referenceLines = useMemo(
|
||||
@@ -39,10 +40,18 @@ export default function VitalTile({
|
||||
);
|
||||
|
||||
return (
|
||||
<Card hoverable className="ov-tile" styles={{ body: { padding: 0 } }}>
|
||||
<Card
|
||||
hoverable
|
||||
className="ov-tile"
|
||||
styles={{ body: { padding: 0 } }}
|
||||
role="group"
|
||||
aria-labelledby={labelId}
|
||||
>
|
||||
<div className="ov-tile-head">
|
||||
<span className="ov-tile-icon">{icon}</span>
|
||||
<span className="ov-kicker">{label}</span>
|
||||
<span className="ov-kicker" id={labelId}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="ov-tile-value">
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
message,
|
||||
} from 'antd';
|
||||
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||
import type { RemoteInboundOption } from '@/api/queries/useNodeMutations';
|
||||
import type { Msg } from '@/utils';
|
||||
@@ -431,6 +432,7 @@ export default function NodeFormModal({
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
allowClear
|
||||
loading={fetchingInbounds}
|
||||
placeholder={t('pages.nodes.inboundTagsPlaceholder')}
|
||||
|
||||
@@ -127,7 +127,14 @@ function StatusLabel({ status, xrayState }: HealthProps) {
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span>{t(`pages.nodes.statusValues.${status || 'unknown'}`)}</span>;
|
||||
// A sub-node's status is free text from its parent node: show it as unknown, not as a key.
|
||||
return (
|
||||
<span>
|
||||
{t(`pages.nodes.statusValues.${status || 'unknown'}`, {
|
||||
defaultValue: t('pages.nodes.statusValues.unknown'),
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatPct(p?: number, available = true): string {
|
||||
@@ -188,11 +195,11 @@ export default function NodeList({
|
||||
const [statsNode, setStatsNode] = useState<NodeRow | null>(null);
|
||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
||||
|
||||
// Map a node GUID to its display name so a transitive sub-node can show which
|
||||
// parent it is reached through (#4983).
|
||||
// Map a direct node's GUID to its display name so a transitive sub-node can show
|
||||
// which parent it is reached through (#4983); the master walks one level only.
|
||||
const nameByGuid = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
for (const n of nodes) if (n.guid) m.set(n.guid, n.name || n.guid);
|
||||
for (const n of nodes) if (n.guid && !n.transitive) m.set(n.guid, n.name || n.guid);
|
||||
return m;
|
||||
}, [nodes]);
|
||||
|
||||
|
||||
@@ -290,7 +290,13 @@ export default function NodesPage() {
|
||||
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content id="content-layout" className="content-area">
|
||||
<Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
|
||||
<Spin
|
||||
aria-live="off"
|
||||
spinning={!fetched}
|
||||
delay={200}
|
||||
description={t('loading')}
|
||||
size="large"
|
||||
>
|
||||
{!fetched ? (
|
||||
<div className="loading-spacer" />
|
||||
) : fetchError ? (
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
SafetyCertificateOutlined,
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { isOutboundProtocol } from '@/schemas/primitives';
|
||||
import { HttpUtil, LanguageManager } from '@/utils';
|
||||
@@ -637,6 +638,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
||||
<>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
value={ldapInboundTagList}
|
||||
onChange={setLdapInboundTagList}
|
||||
style={{ width: '100%' }}
|
||||
|
||||
@@ -169,7 +169,7 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
|
||||
if (!token) return;
|
||||
const ok = await ClipboardManager.copyText(token);
|
||||
if (ok) messageApi.success(t('copySuccess'));
|
||||
else messageApi.error(t('copyFail') ?? 'Copy failed');
|
||||
else messageApi.error(t('copyFail'));
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
@@ -381,7 +381,7 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
|
||||
+ {t('pages.settings.security.apiTokenNew') || 'New token'}
|
||||
</Button>
|
||||
</div>
|
||||
<Spin spinning={apiTokensLoading}>
|
||||
<Spin aria-live="off" spinning={apiTokensLoading}>
|
||||
{!apiTokens.length && !apiTokensLoading && (
|
||||
<Empty
|
||||
description={t('pages.settings.security.apiTokenEmpty') || 'No tokens yet'}
|
||||
|
||||
@@ -242,6 +242,7 @@ export default function SettingsPage() {
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content id="content-layout" className="content-area">
|
||||
<Spin
|
||||
aria-live="off"
|
||||
spinning={spinning || !fetched}
|
||||
delay={200}
|
||||
description={t('loading')}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input, InputNumber, Modal, Select, Switch, message } from 'antd';
|
||||
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import SelectAllClearButtons from '@/components/form/SelectAllClearButtons';
|
||||
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
||||
@@ -166,8 +167,8 @@ export default function SubBalancerFormModal({
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
options={inboundOptions}
|
||||
maxTagCount="responsive"
|
||||
listHeight={220}
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
/>
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import { GoRegexInput } from '@/components/form';
|
||||
import { GoRegexInput, selectTagRender } from '@/components/form';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { catTabLabel } from './catTabLabel';
|
||||
import { sanitizePath, normalizePath } from './uriPath';
|
||||
@@ -418,6 +418,7 @@ export default function SubscriptionFormatsTab({
|
||||
<SettingListItem paddings="small" title={<>{t('pages.settings.direct')} IPs</>}>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
value={ruleValues('direct', 'ip')}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => setRuleValues('direct', 'ip', DEFAULT_DIRECT_RULES[1], v)}
|
||||
@@ -434,6 +435,7 @@ export default function SubscriptionFormatsTab({
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
value={ruleValues('direct', 'domain')}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) =>
|
||||
@@ -467,6 +469,7 @@ export default function SubscriptionFormatsTab({
|
||||
<SettingListItem paddings="small" title={t('pages.xray.blockdomains')}>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
value={ruleValues('block', 'domain')}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => setRuleValues('block', 'domain', DEFAULT_BLOCK_RULES[0], v)}
|
||||
@@ -476,6 +479,7 @@ export default function SubscriptionFormatsTab({
|
||||
<SettingListItem paddings="small" title={t('pages.xray.blockips')}>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
value={ruleValues('block', 'ip')}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => setRuleValues('block', 'ip', BLOCK_IP_RULE, v)}
|
||||
|
||||
@@ -72,7 +72,7 @@ export default function SponsorsPage() {
|
||||
<BecomeButton contact={data.contact} />
|
||||
</div>
|
||||
|
||||
<Spin spinning={!fetched} delay={200}>
|
||||
<Spin aria-live="off" spinning={!fetched} delay={200}>
|
||||
{sponsors.length === 0 ? (
|
||||
fetched && <YourBrandCard contact={data.contact} />
|
||||
) : (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user