mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-26 09:52:14 +03:00
Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acbb879f80 | ||
|
|
1358f65bec | ||
|
|
f4e79e70ea | ||
|
|
edb487a005 | ||
|
|
35cf6be6f9 | ||
|
|
0f7329c3ce | ||
|
|
29557e2153 | ||
|
|
0b60154383 | ||
|
|
c3967e57dc | ||
|
|
aa60d54ea5 | ||
|
|
a652cb8cea | ||
|
|
cd674c8d4f | ||
|
|
b319dd0c3a | ||
|
|
8ef2eec3d1 | ||
|
|
941c6116a9 | ||
|
|
c77608bc47 | ||
|
|
892c06c8bc | ||
|
|
16b9b3ce1c | ||
|
|
2b1308ca29 | ||
|
|
9e117bbdd3 | ||
|
|
8cd71e07ea | ||
|
|
79e65f63df | ||
|
|
c87649d9f2 | ||
|
|
123fac222b | ||
|
|
d38c912dc1 | ||
|
|
fde66ba820 | ||
|
|
a0dec000b2 | ||
|
|
11f602fe04 | ||
|
|
c80e5e276b | ||
|
|
22ff07b24b | ||
|
|
d623410cf4 | ||
|
|
a9d5d9afdb | ||
|
|
16b2bcf9aa | ||
|
|
2f156c8eb0 | ||
|
|
455d1cd0f7 | ||
|
|
ab1a922806 | ||
|
|
5e1cb7693b | ||
|
|
28b360f2af | ||
|
|
4600771167 | ||
|
|
b97504385f | ||
|
|
8dfb639dcf | ||
|
|
444e1e5917 | ||
|
|
73b479e5a0 | ||
|
|
129f50d92a | ||
|
|
f2b17397f4 | ||
|
|
658e6ab3d3 | ||
|
|
1cfd7b49b0 | ||
|
|
ae0da4c51f | ||
|
|
65b5074b60 | ||
|
|
b18c87dc4b | ||
|
|
b11ceac18e | ||
|
|
bbc4163768 | ||
|
|
ee9a6067c2 | ||
|
|
60316c831f | ||
|
|
df3ba568d1 | ||
|
|
7078abc14a |
26
.github/workflows/ci.yml
vendored
26
.github/workflows/ci.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
@@ -74,11 +74,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Regenerate schemas, examples and OpenAPI
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
@@ -139,7 +139,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
@@ -168,12 +168,18 @@ jobs:
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
working-directory: frontend
|
||||
- name: Install Playwright Chromium (Storybook story tests)
|
||||
run: npx playwright install --with-deps chromium
|
||||
working-directory: frontend
|
||||
- name: Test
|
||||
run: npm test
|
||||
working-directory: frontend
|
||||
- name: Build
|
||||
run: npm run build
|
||||
working-directory: frontend
|
||||
- name: Build Storybook
|
||||
run: npm run build-storybook
|
||||
working-directory: frontend
|
||||
- name: Audit
|
||||
run: npm audit --audit-level=high
|
||||
run: npm audit --omit=dev --audit-level=high
|
||||
working-directory: frontend
|
||||
|
||||
792
.github/workflows/claude-bot.yml
vendored
792
.github/workflows/claude-bot.yml
vendored
File diff suppressed because it is too large
Load Diff
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@@ -49,7 +49,7 @@ jobs:
|
||||
|
||||
- name: Setup Node.js
|
||||
if: matrix.language == 'go'
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: 'npm'
|
||||
|
||||
6
.github/workflows/docs-ci.yml
vendored
6
.github/workflows/docs-ci.yml
vendored
@@ -26,11 +26,11 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 11
|
||||
package_json_file: docs/package.json
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
node-version-file: .nvmrc
|
||||
cache: pnpm
|
||||
cache-dependency-path: docs/pnpm-lock.yaml
|
||||
|
||||
|
||||
16
.github/workflows/docs-deploy.yml
vendored
16
.github/workflows/docs-deploy.yml
vendored
@@ -9,6 +9,9 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'frontend/src/components/**'
|
||||
- 'frontend/.storybook/**'
|
||||
- 'frontend/package-lock.json'
|
||||
- '.github/workflows/docs-deploy.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -32,10 +35,10 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 11
|
||||
- uses: actions/setup-node@v6
|
||||
package_json_file: docs/package.json
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
node-version-file: .nvmrc
|
||||
cache: pnpm
|
||||
cache-dependency-path: docs/pnpm-lock.yaml
|
||||
- run: pnpm install --frozen-lockfile
|
||||
@@ -52,6 +55,13 @@ jobs:
|
||||
# in place. That way both /docs/... and /en/docs/... resolve. Other
|
||||
# locales stay under /fa, /ru, /zh.
|
||||
run: cp -a out/en/. out/
|
||||
- name: Build the component Storybook (frontend/)
|
||||
working-directory: frontend
|
||||
run: |
|
||||
npm ci
|
||||
npm run build-storybook
|
||||
- name: Bundle Storybook at /storybook
|
||||
run: cp -a ../frontend/storybook-static out/storybook
|
||||
- uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: docs/out
|
||||
|
||||
2
.github/workflows/mutation.yml
vendored
2
.github/workflows/mutation.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
exclude: 'server\.go|xray\.go|inbound\.go|client_bulk\.go|inbound_traffic\.go|.*_postgres_test\.go'
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
106
.github/workflows/release.yml
vendored
106
.github/workflows/release.yml
vendored
@@ -16,6 +16,7 @@ on:
|
||||
- "x-ui.service.debian"
|
||||
- "x-ui.service.arch"
|
||||
- "x-ui.service.rhel"
|
||||
- ".github/workflows/release.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "**.go"
|
||||
@@ -26,12 +27,15 @@ on:
|
||||
- "x-ui.service.debian"
|
||||
- "x-ui.service.arch"
|
||||
- "x-ui.service.rhel"
|
||||
- ".github/workflows/release.yml"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
# One platform hitting a transient outage must not cancel the other six.
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform:
|
||||
- amd64
|
||||
@@ -47,7 +51,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
@@ -57,7 +61,7 @@ jobs:
|
||||
# at compile time. internal/web/dist/ is .gitignored, so on a fresh CI
|
||||
# checkout it doesn't exist until vite emits it.
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: 'npm'
|
||||
@@ -71,6 +75,8 @@ jobs:
|
||||
|
||||
- name: Build 3X-UI
|
||||
run: |
|
||||
CURL_RETRY="--retry 5 --retry-all-errors --retry-delay 3"
|
||||
fetch() { wget -q --tries=5 --waitretry=10 --retry-on-http-error=429,500,502,503 "$@"; }
|
||||
export CGO_ENABLED=1
|
||||
export GOOS=linux
|
||||
export GOARCH=${{ matrix.platform }}
|
||||
@@ -86,11 +92,11 @@ jobs:
|
||||
esac
|
||||
echo "Resolving Bootlin musl toolchain for arch=$BOOTLIN_ARCH (platform=${{ matrix.platform }})"
|
||||
TARBALL_BASE="https://toolchains.bootlin.com/downloads/releases/toolchains/$BOOTLIN_ARCH/tarballs/"
|
||||
TARBALL_URL=$(curl -fsSL "$TARBALL_BASE" | grep -oE "${BOOTLIN_ARCH}--musl--stable-[^\"]+\\.tar\\.xz" | sort -r | head -n1)
|
||||
TARBALL_URL=$(curl -fsSL $CURL_RETRY "$TARBALL_BASE" | grep -oE "${BOOTLIN_ARCH}--musl--stable-[^\"]+\\.tar\\.xz" | sort -r | head -n1)
|
||||
[ -z "$TARBALL_URL" ] && { echo "Failed to locate Bootlin musl toolchain for arch=$BOOTLIN_ARCH" >&2; exit 1; }
|
||||
echo "Downloading: $TARBALL_URL"
|
||||
cd /tmp
|
||||
curl -fL -sS -o "$(basename "$TARBALL_URL")" "$TARBALL_BASE/$TARBALL_URL"
|
||||
curl -fL -sS $CURL_RETRY -o "$(basename "$TARBALL_URL")" "$TARBALL_BASE/$TARBALL_URL"
|
||||
tar -xf "$(basename "$TARBALL_URL")"
|
||||
TOOLCHAIN_DIR=$(find . -maxdepth 1 -type d -name "${BOOTLIN_ARCH}--musl--stable-*" | head -n1)
|
||||
export PATH="$(realpath "$TOOLCHAIN_DIR")/bin:$PATH"
|
||||
@@ -120,53 +126,55 @@ jobs:
|
||||
# Download dependencies
|
||||
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.7.11/"
|
||||
if [ "${{ matrix.platform }}" == "amd64" ]; then
|
||||
wget -q ${Xray_URL}Xray-linux-64.zip
|
||||
fetch ${Xray_URL}Xray-linux-64.zip
|
||||
unzip Xray-linux-64.zip
|
||||
rm -f Xray-linux-64.zip
|
||||
elif [ "${{ matrix.platform }}" == "arm64" ]; then
|
||||
wget -q ${Xray_URL}Xray-linux-arm64-v8a.zip
|
||||
fetch ${Xray_URL}Xray-linux-arm64-v8a.zip
|
||||
unzip Xray-linux-arm64-v8a.zip
|
||||
rm -f Xray-linux-arm64-v8a.zip
|
||||
elif [ "${{ matrix.platform }}" == "armv7" ]; then
|
||||
wget -q ${Xray_URL}Xray-linux-arm32-v7a.zip
|
||||
fetch ${Xray_URL}Xray-linux-arm32-v7a.zip
|
||||
unzip Xray-linux-arm32-v7a.zip
|
||||
rm -f Xray-linux-arm32-v7a.zip
|
||||
elif [ "${{ matrix.platform }}" == "armv6" ]; then
|
||||
wget -q ${Xray_URL}Xray-linux-arm32-v6.zip
|
||||
fetch ${Xray_URL}Xray-linux-arm32-v6.zip
|
||||
unzip Xray-linux-arm32-v6.zip
|
||||
rm -f Xray-linux-arm32-v6.zip
|
||||
elif [ "${{ matrix.platform }}" == "386" ]; then
|
||||
wget -q ${Xray_URL}Xray-linux-32.zip
|
||||
fetch ${Xray_URL}Xray-linux-32.zip
|
||||
unzip Xray-linux-32.zip
|
||||
rm -f Xray-linux-32.zip
|
||||
elif [ "${{ matrix.platform }}" == "armv5" ]; then
|
||||
wget -q ${Xray_URL}Xray-linux-arm32-v5.zip
|
||||
fetch ${Xray_URL}Xray-linux-arm32-v5.zip
|
||||
unzip Xray-linux-arm32-v5.zip
|
||||
rm -f Xray-linux-arm32-v5.zip
|
||||
elif [ "${{ matrix.platform }}" == "s390x" ]; then
|
||||
wget -q ${Xray_URL}Xray-linux-s390x.zip
|
||||
fetch ${Xray_URL}Xray-linux-s390x.zip
|
||||
unzip Xray-linux-s390x.zip
|
||||
rm -f Xray-linux-s390x.zip
|
||||
fi
|
||||
rm -f geoip.dat geosite.dat
|
||||
wget -q https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
|
||||
wget -q https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||
wget -q -O geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat
|
||||
wget -q -O geosite_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat
|
||||
wget -q -O geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat
|
||||
wget -q -O geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||
fetch https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
|
||||
fetch https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||
fetch -O geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat
|
||||
fetch -O geosite_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat
|
||||
fetch -O geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat
|
||||
fetch -O geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||
mv xray xray-linux-${{ matrix.platform }}
|
||||
# mtg-multi (MTProto sidecar) ships prebuilt release binaries whose
|
||||
# platform labels match our matrix, so download and unpack the matching
|
||||
# archive. Only the platforms the fork publishes are packaged. The tag
|
||||
# is resolved from the fork's latest release so it never needs bumping
|
||||
# here; the token only lifts the API rate limit for a public read.
|
||||
MTG_MULTI_VER=$(curl -sfL -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" "https://api.github.com/repos/mhsanaei/mtg-multi/releases/latest" | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n 1)
|
||||
if [ -z "$MTG_MULTI_VER" ]; then echo "could not resolve the latest mtg-multi release tag"; exit 1; fi
|
||||
# archive. Only the platforms the fork publishes are packaged — the tag
|
||||
# lookup lives inside that branch so unpackaged platforms (s390x) never
|
||||
# depend on it. The tag comes from the release-page redirect on
|
||||
# github.com — the host the downloads need anyway — because api.github.com
|
||||
# has 503'd whole release runs while asset downloads kept working.
|
||||
case "${{ matrix.platform }}" in
|
||||
amd64|arm64|armv7|armv6|386)
|
||||
MTG_MULTI_VER=$(curl -sf $CURL_RETRY -o /dev/null -w '%{redirect_url}' "https://github.com/mhsanaei/mtg-multi/releases/latest" | sed -n 's#.*/releases/tag/##p')
|
||||
if [ -z "$MTG_MULTI_VER" ]; then echo "could not resolve the latest mtg-multi release tag"; exit 1; fi
|
||||
MTG_PKG="mtg-multi-${MTG_MULTI_VER#v}-linux-${{ matrix.platform }}"
|
||||
curl -sfLRO "https://github.com/mhsanaei/mtg-multi/releases/download/${MTG_MULTI_VER}/${MTG_PKG}.tar.gz"
|
||||
curl -sfLRO $CURL_RETRY "https://github.com/mhsanaei/mtg-multi/releases/download/${MTG_MULTI_VER}/${MTG_PKG}.tar.gz"
|
||||
tar -xzf "${MTG_PKG}.tar.gz"
|
||||
mv "${MTG_PKG}/mtg-multi" "mtg-linux-${{ matrix.platform }}"
|
||||
rm -rf "${MTG_PKG}" "${MTG_PKG}.tar.gz"
|
||||
@@ -211,7 +219,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
@@ -220,7 +228,7 @@ jobs:
|
||||
# Linux job above. This step is identical except npm runs on the
|
||||
# Windows runner here.
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: 'npm'
|
||||
@@ -267,6 +275,7 @@ jobs:
|
||||
- name: Copy and download resources
|
||||
shell: pwsh
|
||||
run: |
|
||||
$retry = @{ MaximumRetryCount = 5; RetryIntervalSec = 10 }
|
||||
mkdir x-ui
|
||||
Copy-Item xui-release.exe x-ui\x-ui.exe
|
||||
mkdir x-ui\bin
|
||||
@@ -274,25 +283,26 @@ jobs:
|
||||
|
||||
# Download Xray for Windows
|
||||
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.7.11/"
|
||||
Invoke-WebRequest -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
|
||||
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"
|
||||
Remove-Item geoip.dat, geosite.dat -ErrorAction SilentlyContinue
|
||||
Invoke-WebRequest -Uri "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat" -OutFile "geoip.dat"
|
||||
Invoke-WebRequest -Uri "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat" -OutFile "geosite.dat"
|
||||
Invoke-WebRequest -Uri "https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat" -OutFile "geoip_IR.dat"
|
||||
Invoke-WebRequest -Uri "https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat" -OutFile "geosite_IR.dat"
|
||||
Invoke-WebRequest -Uri "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat" -OutFile "geoip_RU.dat"
|
||||
Invoke-WebRequest -Uri "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat" -OutFile "geosite_RU.dat"
|
||||
Invoke-WebRequest @retry -Uri "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat" -OutFile "geoip.dat"
|
||||
Invoke-WebRequest @retry -Uri "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat" -OutFile "geosite.dat"
|
||||
Invoke-WebRequest @retry -Uri "https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat" -OutFile "geoip_IR.dat"
|
||||
Invoke-WebRequest @retry -Uri "https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat" -OutFile "geosite_IR.dat"
|
||||
Invoke-WebRequest @retry -Uri "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat" -OutFile "geoip_RU.dat"
|
||||
Invoke-WebRequest @retry -Uri "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat" -OutFile "geosite_RU.dat"
|
||||
Rename-Item xray.exe xray-windows-amd64.exe
|
||||
|
||||
# mtg-multi (MTProto sidecar) publishes a prebuilt Windows binary, so
|
||||
# download and unpack it instead of compiling. The tag tracks the
|
||||
# fork's latest release so it never needs bumping here.
|
||||
$MTG_MULTI_VER = (Invoke-RestMethod -Uri "https://api.github.com/repos/mhsanaei/mtg-multi/releases/latest" -Headers @{ Authorization = "Bearer ${{ secrets.GITHUB_TOKEN }}"; "User-Agent" = "x-ui-release" }).tag_name
|
||||
if (-not $MTG_MULTI_VER) { throw "could not resolve the latest mtg-multi release tag" }
|
||||
# download and unpack it instead of compiling. The tag comes from the
|
||||
# release-page redirect on github.com — not api.github.com, whose
|
||||
# outages have failed release runs while asset downloads kept working.
|
||||
$MTG_MULTI_VER = (curl.exe -sf --retry 5 --retry-all-errors --retry-delay 3 -o NUL -w '%{redirect_url}' "https://github.com/mhsanaei/mtg-multi/releases/latest") -replace '^.*/releases/tag/', ''
|
||||
if (-not $MTG_MULTI_VER -or $MTG_MULTI_VER -notmatch '^v[\d.]+$') { throw "could not resolve the latest mtg-multi release tag" }
|
||||
$MTG_PKG = "mtg-multi-$($MTG_MULTI_VER.TrimStart('v'))-windows-amd64"
|
||||
curl.exe -sfLRO "https://github.com/mhsanaei/mtg-multi/releases/download/$MTG_MULTI_VER/$MTG_PKG.zip"
|
||||
curl.exe -sfLRO --retry 5 --retry-all-errors --retry-delay 3 "https://github.com/mhsanaei/mtg-multi/releases/download/$MTG_MULTI_VER/$MTG_PKG.zip"
|
||||
Expand-Archive -Path "$MTG_PKG.zip" -DestinationPath "mtg-tmp" -Force
|
||||
Move-Item "mtg-tmp/$MTG_PKG/mtg-multi.exe" "mtg-windows-amd64.exe"
|
||||
Remove-Item -Recurse -Force "mtg-tmp", "$MTG_PKG.zip"
|
||||
@@ -359,6 +369,14 @@ jobs:
|
||||
COMMIT: ${{ github.sha }}
|
||||
run: |
|
||||
set -e
|
||||
retry() {
|
||||
for i in 1 2 3 4 5; do
|
||||
"$@" && return 0
|
||||
echo "attempt $i failed: ${*:1:3}" >&2
|
||||
sleep $((i * 5))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
short="${COMMIT::8}"
|
||||
notes="Rolling development build — installs via the panel's Dev update channel.
|
||||
|
||||
@@ -369,14 +387,14 @@ jobs:
|
||||
|
||||
# Force-move the dev-latest tag to this commit so the release tracks it.
|
||||
git tag -f dev-latest "${COMMIT}"
|
||||
git push -f origin refs/tags/dev-latest
|
||||
retry git push -f origin refs/tags/dev-latest
|
||||
|
||||
if gh release view dev-latest >/dev/null 2>&1; then
|
||||
gh release edit dev-latest --prerelease --latest=false \
|
||||
--title "Dev build ${short}" --notes "${notes}"
|
||||
else
|
||||
gh release create dev-latest --prerelease --latest=false \
|
||||
# The release exists on every run but the first; edit-first avoids an
|
||||
# existence probe that can 503 and mis-route into create (422).
|
||||
if ! retry gh release edit dev-latest --prerelease --latest=false \
|
||||
--title "Dev build ${short}" --notes "${notes}"; then
|
||||
retry gh release create dev-latest --prerelease --latest=false \
|
||||
--target "${COMMIT}" --title "Dev build ${short}" --notes "${notes}"
|
||||
fi
|
||||
|
||||
gh release upload dev-latest dev-artifacts/*.tar.gz dev-artifacts/*.zip --clobber
|
||||
retry gh release upload dev-latest dev-artifacts/*.tar.gz dev-artifacts/*.zip --clobber
|
||||
|
||||
131
.vscode/tasks.json
vendored
131
.vscode/tasks.json
vendored
@@ -96,6 +96,22 @@
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"linux": {
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"osx": {
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"problemMatcher": [
|
||||
"$go"
|
||||
]
|
||||
@@ -111,6 +127,22 @@
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"linux": {
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"osx": {
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"problemMatcher": [
|
||||
"$go"
|
||||
]
|
||||
@@ -125,6 +157,22 @@
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"linux": {
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"osx": {
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"problemMatcher": [
|
||||
"$go"
|
||||
]
|
||||
@@ -140,10 +188,93 @@
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"linux": {
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"osx": {
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"problemMatcher": [
|
||||
"$go"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "go: install golangci-lint",
|
||||
"type": "shell",
|
||||
"command": "go",
|
||||
"args": [
|
||||
"install",
|
||||
"github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"linux": {
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"osx": {
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "go: install modernize",
|
||||
"type": "shell",
|
||||
"command": "go",
|
||||
"args": [
|
||||
"install",
|
||||
"golang.org/x/tools/gopls/internal/analysis/modernize/cmd/modernize@latest"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"linux": {
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"osx": {
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "go: install tools",
|
||||
"dependsOrder": "sequence",
|
||||
"dependsOn": [
|
||||
"go: install golangci-lint",
|
||||
"go: install modernize"
|
||||
],
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "frontend: ncu -u",
|
||||
"type": "shell",
|
||||
|
||||
@@ -5,7 +5,7 @@ Thanks for taking the time to contribute to 3x-ui. This guide gets a development
|
||||
## Prerequisites
|
||||
|
||||
- **Go 1.26+** (the version pinned in `go.mod`)
|
||||
- **Node.js 22+** and npm 10+ (for the React frontend)
|
||||
- **Node.js 24 LTS** (the version pinned in `.nvmrc`) and npm 10+ (for the React frontend)
|
||||
- **Git**
|
||||
- **A C compiler** — required by the CGo SQLite driver (`github.com/mattn/go-sqlite3`). Linux and macOS already ship one; for Windows see below.
|
||||
|
||||
@@ -157,12 +157,13 @@ Panel navigation happens client-side through React Router, and per-route code is
|
||||
|
||||
Locale strings live in `internal/web/translation/<locale>.json`, **not** under `frontend/`. The Go binary embeds the same JSON and serves it to both backend templates and `react-i18next` (initialized in `src/i18n/react.ts`). When a new English key is added it must also land in **every** non-English locale — missing keys do not break the build, they just render the raw key in the UI.
|
||||
|
||||
### Two dev workflows
|
||||
### Dev workflows
|
||||
|
||||
| Goal | Command |
|
||||
|------|---------|
|
||||
| Iterate on UI changes with HMR | `cd frontend && npm run dev` (Vite on `:5173`, proxies `/panel/*` and the WebSocket to the Go panel on `:2053`). Start the Go panel first. |
|
||||
| Verify what end users actually see | `cd frontend && npm run build`, then `go run .`. The Go binary serves the built bundle — embedded in release mode, off disk in debug mode. |
|
||||
| Develop/preview a reusable component in isolation | `cd frontend && npm run storybook` (Storybook workbench + autodocs on `:6006`). |
|
||||
|
||||
The Vite dev proxy serves the admin SPA for any `/panel/*` URL — `bypassMigratedRoute` in `vite.config.js` rewrites those requests to `index.html` and lets React Router take over — while forwarding `/panel/api/*`, `/panel/api/setting/*`, `/panel/api/xray/*`, and the WebSocket to the Go panel. Because routing is now client-side, new panel routes need no proxy or allowlist changes.
|
||||
|
||||
@@ -189,6 +190,7 @@ Only a genuinely **standalone bundle** (like `login` or `subpage`, reachable wit
|
||||
- **Document new endpoints.** Every new `g.POST`/`g.GET` in `internal/web/controller/` needs a matching entry in `src/pages/api-docs/endpoints.ts` — it drives both the in-panel API docs and the generated OpenAPI/Zod (`npm run gen:api` / `gen:zod`).
|
||||
- **Do not break link generation.** Share-link logic lives in `src/lib/xray/` (`inbound-link.ts`, `outbound-link-parser.ts`, …) and is round-tripped by the golden fixture suite — run `npm run test` after any change to URL generation, defaults, or TLS/Reality handling, and regenerate snapshots (`npx vitest run -u`) only for intentional changes. Two runtime paths consume it: the **inbounds page** and the **clients page** subscription links (`/panel/api/clients/subLinks/:subId` → backend `GetSubs`); exercise both.
|
||||
- **Vite is pinned to an exact version** (no `^`) in `frontend/package.json` — read the live version there rather than trusting a number quoted here — so local, CI, and release builds resolve identically. Bump it deliberately and verify both `npm run dev` and `npm run build` afterward.
|
||||
- **Reusable components are documented in Storybook.** When you add or change a component in `frontend/src/components/`, add or update its co-located `<Component>.stories.tsx` (`tags: ['autodocs']`), documenting props via `argTypes` / `parameters.docs` string metadata rather than JSDoc. CI compile-checks every story via `npm run build-storybook` and runs each story as a headless-browser test via `@storybook/addon-vitest` (`npm run test`, needs `npx playwright install chromium`); run `npm run storybook` to preview locally.
|
||||
|
||||
### Project layout
|
||||
|
||||
@@ -277,7 +279,7 @@ CI runs this for you nightly (and on demand) via `.github/workflows/mutation.yml
|
||||
|
||||
### CI
|
||||
|
||||
`.github/workflows/ci.yml` runs per PR: `go-test` (with `-shuffle -count=1`), a `race` job (`-race -shuffle -count=1`), a `fuzz-smoke` job on the critical parsers, and the frontend `typecheck`/`lint`/`test`/`build`. Snapshots are regression guards — regenerate them (`npx vitest run -u`) only for intentional output changes, never to make a red test green.
|
||||
`.github/workflows/ci.yml` runs per PR: `go-test` (with `-shuffle -count=1`), a `race` job (`-race -shuffle -count=1`), a `fuzz-smoke` job on the critical parsers, and the frontend `typecheck`/`lint`/`test`/`build`/`build-storybook`. Snapshots are regression guards — regenerate them (`npx vitest run -u`) only for intentional output changes, never to make a red test green.
|
||||
|
||||
## Sending a pull request
|
||||
|
||||
@@ -286,7 +288,7 @@ CI runs this for you nightly (and on demand) via `.github/workflows/mutation.yml
|
||||
3. Run the relevant checks before pushing:
|
||||
- `go build ./...`
|
||||
- `go test ./...` (when Go code changed)
|
||||
- `cd frontend && npm run typecheck && npm run lint && npm run test && npm run build` (when the frontend changed; CI runs this same set on every PR via `.github/workflows/ci.yml`)
|
||||
- `cd frontend && npm run typecheck && npm run lint && npm run test && npm run build && npm run build-storybook` (when the frontend changed; CI runs this same set on every PR via `.github/workflows/ci.yml`)
|
||||
4. Commit messages follow the existing pattern in `git log` — `<area>: short imperative summary`, then a body explaining the *why*. Conventional-commit prefixes (`feat`, `fix`, `refactor`, `chore`, `style`, `docs`) are encouraged.
|
||||
5. Open the PR against `main` with a brief description of what changed and how to test it.
|
||||
|
||||
|
||||
8
Makefile
8
Makefile
@@ -68,8 +68,12 @@ build-fe: ## Build the Vite bundles into internal/web/dist
|
||||
build: build-fe ## Build the frontend then the Go binary
|
||||
go build ./...
|
||||
|
||||
.PHONY: build-storybook
|
||||
build-storybook: ## Build the static Storybook (compile-checks all stories)
|
||||
cd $(FRONTEND) && npm run build-storybook
|
||||
|
||||
# The PR gate. Matches ci.yml: codegen freshness, both linters, typecheck,
|
||||
# both test suites, and a full build.
|
||||
# both test suites, a full build, and the Storybook compile-check.
|
||||
.PHONY: verify
|
||||
verify: gen-check lint typecheck test build ## Full local gate (mirrors CI)
|
||||
verify: gen-check lint typecheck test build build-storybook ## Full local gate (mirrors CI)
|
||||
@echo "verify: OK"
|
||||
|
||||
@@ -63,7 +63,7 @@ Two key ideas that explain most of the complexity:
|
||||
**Frontend (`frontend/`):**
|
||||
- **React 19** + **Ant Design 6** + **Vite 8** + **TypeScript**.
|
||||
- Data layer: **TanStack Query** (`@tanstack/react-query`) over the native **Fetch API**; **Zod 4** schemas.
|
||||
- Router: **react-router-dom 7**. Charts: **uPlot** (`frontend/src/components/viz/Sparkline.tsx`). Editor: **CodeMirror 6**.
|
||||
- Router: **react-router 8**. Charts: **uPlot** (`frontend/src/components/viz/Sparkline.tsx`). Editor: **CodeMirror 6**.
|
||||
- **Build output goes to `internal/web/dist/`** (see `vite.config.js` → `outDir`) and is
|
||||
embedded into the Go binary with `go:embed`. Three HTML entries: `index.html` (panel SPA),
|
||||
`login.html`, `subpage.html`. The Go server serves the SPA; there is no separate frontend
|
||||
|
||||
@@ -24,6 +24,7 @@ When rendering the template, the following variables are injected into the templ
|
||||
|
||||
* `{{ .sId }}`: Subscription ID (UUID).
|
||||
* `{{ .enabled }}`: Whether the subscription/client is enabled (boolean).
|
||||
* `{{ .isOnline }}`: Whether the subscription's client has a live connection right now (boolean). Computed from the panel's online-client tracking (local Xray plus any remote nodes) at render time.
|
||||
* `{{ .download }}`: Formatted download traffic (e.g. "2.5 GB").
|
||||
* `{{ .upload }}`: Formatted upload traffic.
|
||||
* `{{ .total }}`: Formatted total traffic limit.
|
||||
@@ -40,5 +41,41 @@ When rendering the template, the following variables are injected into the templ
|
||||
* `{{ .subTitle }}`: The subscription title configured in the panel (Subscription → Information). Useful for page branding/headings. May be empty.
|
||||
* `{{ .subSupportUrl }}`: The support URL configured in the panel. Useful for a "Contact support" link. May be empty.
|
||||
* `{{ .links }}`: A list (slice) of string configurations (VMess, VLESS, etc. URLs). You can loop through them using `{{ range .links }} ... {{ end }}`.
|
||||
* `{{ .emails }}`: A list (slice) of emails related to the subscription.
|
||||
* `{{ .emails }}`: A list (slice) of client emails, parallel to `links` — the email at index *i* owns the link at index *i*. May contain duplicates when one client has several links.
|
||||
* `{{ .announce }}`: The announcement text configured in the panel (Settings → Subscription → Announce). May be empty.
|
||||
* `{{ .datepicker }}`: Current calendar format used by the panel (e.g. "gregorian" or "jalali").
|
||||
|
||||
## Live Status JSON (`?format=info`)
|
||||
|
||||
Every subscription URL also answers `GET <sub URL>?format=info` with the same view-model as JSON —
|
||||
minus `links`, and with `emails` deduplicated — so a template can poll it and update usage or
|
||||
online status live without reloading the page:
|
||||
|
||||
```json
|
||||
{
|
||||
"sId": "…",
|
||||
"enabled": true,
|
||||
"isOnline": true,
|
||||
"used": "1.2 GB",
|
||||
"remained": "8.8 GB",
|
||||
"expire": 0,
|
||||
"lastOnline": 1735680000000,
|
||||
"…": "…"
|
||||
}
|
||||
```
|
||||
|
||||
Example polling snippet for a template:
|
||||
|
||||
```html
|
||||
<span id="status"></span>
|
||||
<script>
|
||||
async function refreshStatus() {
|
||||
const res = await fetch(window.location.pathname + '?format=info');
|
||||
if (!res.ok) return;
|
||||
const info = await res.json();
|
||||
document.getElementById('status').textContent = info.isOnline ? 'Online' : 'Offline';
|
||||
}
|
||||
refreshStatus();
|
||||
setInterval(refreshStatus, 10000);
|
||||
</script>
|
||||
```
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
||||
import { Heart } from 'lucide-react';
|
||||
import { Logo } from '@/components/logo';
|
||||
import { TelegramIcon } from '@/components/icons';
|
||||
import { appName, productRepoUrl, telegramChannel, telegramChannelUrl, donateUrl } from './shared';
|
||||
import { appName, productRepoUrl, telegramChannel, telegramChannelUrl, donateUrl, siteUrl } from './shared';
|
||||
import { getSiteMessages } from './site-i18n';
|
||||
|
||||
// Build locale-aware shared layout options. With `hideLocale: 'default-locale'`,
|
||||
@@ -28,6 +28,12 @@ export function baseOptions(lang: string): BaseLayoutProps {
|
||||
url: `${prefix}/docs`,
|
||||
active: 'nested-url',
|
||||
},
|
||||
// Live component workbench built from frontend/ and published alongside the docs.
|
||||
{
|
||||
text: 'Storybook',
|
||||
url: `${siteUrl}/storybook/`,
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
type: 'icon',
|
||||
label: `Telegram channel (@${telegramChannel})`,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"postinstall": "fumadocs-mdx",
|
||||
"gen:api": "node --experimental-strip-types scripts/gen-openapi.ts",
|
||||
"gen:api": "node scripts/gen-openapi.ts",
|
||||
"typecheck": "fumadocs-mdx && next typegen && tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write .",
|
||||
@@ -19,34 +19,34 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@orama/orama": "^3.1.18",
|
||||
"fumadocs-core": "^16.10.5",
|
||||
"fumadocs-docgen": "^3.0.10",
|
||||
"fumadocs-mdx": "^15.0.12",
|
||||
"fumadocs-openapi": "^11.0.5",
|
||||
"fumadocs-ui": "^16.10.5",
|
||||
"lucide-react": "^1.21.0",
|
||||
"fumadocs-core": "^16.11.5",
|
||||
"fumadocs-docgen": "^3.1.0",
|
||||
"fumadocs-mdx": "^15.2.0",
|
||||
"fumadocs-openapi": "^11.2.2",
|
||||
"fumadocs-ui": "^16.11.5",
|
||||
"lucide-react": "^1.25.0",
|
||||
"mermaid": "^11.16.0",
|
||||
"next": "16.2.9",
|
||||
"next": "16.2.11",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-qr-code": "^2.2.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.1",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@types/mdx": "^2.0.14",
|
||||
"@types/node": "^26.0.1",
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "16.2.9",
|
||||
"postcss": "^8.5.15",
|
||||
"prettier": "^3.8.4",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-next": "16.2.11",
|
||||
"postcss": "^8.5.21",
|
||||
"prettier": "^3.9.6",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.9"
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"packageManager": "pnpm@11.9.0"
|
||||
"packageManager": "pnpm@11.15.1+sha512.81350b07e53c9538a02f1f2303b4290fa2d7be04e56e2a970c4cc4b417dc761de196edabd49d55c7dc9580db81007c44143e4e3d7e462b3000d23c255122d065"
|
||||
}
|
||||
|
||||
3504
docs/pnpm-lock.yaml
generated
3504
docs/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ allowBuilds:
|
||||
# release — fixes GHSA-qx2v-qp2m-jg93 / CVE-2026-41305 (vulnerable < 8.5.10).
|
||||
overrides:
|
||||
'postcss@<8.5.10': '^8.5.15'
|
||||
'sharp@<0.35.0': '^0.35.3'
|
||||
minimumReleaseAgeExclude:
|
||||
- '@mermaid-js/parser@1.2.0'
|
||||
- mermaid@11.16.0
|
||||
|
||||
@@ -6,7 +6,11 @@ const config: StorybookConfig = {
|
||||
options: {},
|
||||
},
|
||||
stories: ['../src/**/*.stories.@(ts|tsx)'],
|
||||
addons: ['@storybook/addon-docs', '@storybook/addon-a11y'],
|
||||
addons: [
|
||||
'@storybook/addon-docs',
|
||||
'@storybook/addon-a11y',
|
||||
'@storybook/addon-vitest'
|
||||
],
|
||||
viteFinal: (viteConfig) => {
|
||||
if (viteConfig.build) {
|
||||
viteConfig.build.outDir = undefined;
|
||||
|
||||
6
frontend/.storybook/preview-head.html
Normal file
6
frontend/.storybook/preview-head.html
Normal file
@@ -0,0 +1,6 @@
|
||||
<script>
|
||||
if (localStorage.getItem('dark-mode') === null) localStorage.setItem('dark-mode', 'false');
|
||||
if (localStorage.getItem('isUltraDarkThemeEnabled') === null) {
|
||||
localStorage.setItem('isUltraDarkThemeEnabled', 'false');
|
||||
}
|
||||
</script>
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { Decorator, Preview } from '@storybook/react-vite';
|
||||
import { ConfigProvider, theme as antdTheme } from 'antd';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import i18next from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import { buildAntdThemeConfig } from '@/hooks/useTheme';
|
||||
import enUS from '../../internal/web/translation/en-US.json';
|
||||
|
||||
if (!i18next.isInitialized) {
|
||||
@@ -19,10 +20,11 @@ if (!i18next.isInitialized) {
|
||||
const withTheme: Decorator = (Story, context) => {
|
||||
const dark = context.globals.theme === 'dark';
|
||||
useEffect(() => {
|
||||
document.body.setAttribute('class', dark ? 'dark' : 'light');
|
||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
|
||||
}, [dark]);
|
||||
return (
|
||||
<ConfigProvider theme={{ algorithm: dark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm }}>
|
||||
<ConfigProvider theme={buildAntdThemeConfig(dark, false)}>
|
||||
<div style={{ padding: 24, minWidth: 320 }}>
|
||||
<Story />
|
||||
</div>
|
||||
@@ -49,11 +51,16 @@ const preview: Preview = {
|
||||
},
|
||||
parameters: {
|
||||
controls: {
|
||||
expanded: true,
|
||||
sort: 'requiredFirst',
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
a11y: {
|
||||
test: 'error',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -63,5 +63,9 @@ Only standalone bundles (login/subpage) need a new `.html` + `src/entries/*` +
|
||||
- `npm run typecheck` / `npm run lint` / `npm run test` / `npm run build`.
|
||||
- `npm run gen` = `gen:zod` (Go → `src/generated/`) + `gen:api`
|
||||
(`build-openapi.mjs` → `public/openapi.json`).
|
||||
- `npm run storybook` (workbench on :6006) / `npm run build-storybook` (CI
|
||||
compile-checks every story). Reusable `src/components/` get a co-located
|
||||
`<Component>.stories.tsx` with `tags: ['autodocs']`; document props via
|
||||
`argTypes` / `parameters.docs` string metadata, never JSDoc.
|
||||
- After `npm run build`, RESTART `go run .` (see the XUI_DEBUG gotcha in root
|
||||
CLAUDE.md) before checking the panel.
|
||||
|
||||
@@ -36,11 +36,13 @@ production-style links work without round-tripping through Go.
|
||||
| `npm run lint` | ESLint flat config (`@typescript-eslint` + `react-hooks`) |
|
||||
| `npm run test` | Vitest single run (schema fixtures, link parsers, …) |
|
||||
| `npm run test:watch` | Vitest watch mode |
|
||||
| `npm run storybook` | Storybook dev server on `:6006` (component workbench + autodocs) |
|
||||
| `npm run build-storybook` | Static Storybook build — CI compile-checks every story |
|
||||
| `npm run gen:api` | Build `public/openapi.json` from `pages/api-docs/endpoints.ts` |
|
||||
| `npm run gen:zod` | Run the Go-side openapigen tool → `src/generated/{zod,types}.ts` |
|
||||
|
||||
CI runs `typecheck`, `lint`, `test`, and `build` on every PR
|
||||
(see `../.github/workflows/ci.yml`).
|
||||
CI runs `typecheck`, `lint`, `test`, `build`, and `build-storybook` on
|
||||
every PR (see `../.github/workflows/ci.yml`).
|
||||
|
||||
### One-off: scan for deprecated APIs
|
||||
|
||||
@@ -79,6 +81,7 @@ frontend/
|
||||
│ # usages of APIs marked with JSDoc @deprecated
|
||||
├── vitest.config.ts
|
||||
├── vite.config.js
|
||||
├── .storybook/ # Storybook config (main.ts, preview.tsx)
|
||||
├── scripts/
|
||||
│ └── build-openapi.mjs # endpoints.ts → openapi.json
|
||||
└── src/
|
||||
@@ -89,7 +92,7 @@ frontend/
|
||||
│ ├── index/, login/, inbounds/, clients/, xray/, nodes/,
|
||||
│ ├── settings/, api-docs/, sub/
|
||||
├── layouts/ # AdminLayout (sidebar + header + outlet)
|
||||
├── components/ # Cross-page React components
|
||||
├── components/ # Cross-page React components (+ co-located *.stories.tsx)
|
||||
├── hooks/ # useClients, useTheme, useWebSocket, …
|
||||
├── api/ # fetch client + CSRF handling, TanStack Query bridge,
|
||||
│ # WebSocket client + queryClient.ts
|
||||
@@ -187,6 +190,36 @@ npx vitest run -u
|
||||
Fixtures live in `src/test/golden/fixtures/` and are auto-discovered
|
||||
via `import.meta.glob`.
|
||||
|
||||
## Storybook
|
||||
|
||||
Reusable components in `src/components/` are developed and documented in
|
||||
**Storybook** (`@storybook/react-vite`). It is a component workbench, not part
|
||||
of the shipped panel — nothing here is embedded into the Go binary. The built
|
||||
Storybook is published with the docs site at
|
||||
[docs.sanaei.dev/storybook](https://docs.sanaei.dev/storybook/) by
|
||||
`.github/workflows/docs-deploy.yml`.
|
||||
|
||||
```sh
|
||||
npm run storybook # dev server on http://localhost:6006
|
||||
npm run build-storybook # static build; CI runs this to compile-check every story
|
||||
```
|
||||
|
||||
Addons: `@storybook/addon-docs` renders an autodocs page per component,
|
||||
`@storybook/addon-a11y` flags accessibility issues in the canvas, and
|
||||
`@storybook/addon-vitest` runs every story as a headless-browser test under
|
||||
`npm run test` (Playwright/Chromium — run `npx playwright install chromium` once
|
||||
locally). The `.storybook/preview.tsx` decorator wraps every story in the AntD
|
||||
`ConfigProvider` and adds a light/dark theme toggle to the toolbar.
|
||||
|
||||
Conventions for a story:
|
||||
|
||||
- Co-locate it with its component as `<Component>.stories.tsx`.
|
||||
- Set `tags: ['autodocs']` so it gets a generated docs page.
|
||||
- Document props via story metadata, not JSDoc (the repo bans `//` comments): a
|
||||
component summary in `parameters.docs.description.component` and per-prop text
|
||||
in `argTypes[prop].description`. `satisfies Meta<typeof Component>` keeps the
|
||||
metadata type-checked.
|
||||
|
||||
## Adding a new page
|
||||
|
||||
Most new routes go inside the admin SPA (`index.html`) via
|
||||
|
||||
1779
frontend/package-lock.json
generated
1779
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
|
||||
"engines": {
|
||||
"node": ">=22.0.0",
|
||||
"node": ">=24.0.0",
|
||||
"npm": ">=10.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -19,7 +19,7 @@
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
"gen": "npm run gen:zod && npm run gen:api",
|
||||
"gen:api": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/build-openapi.mjs",
|
||||
"gen:api": "node scripts/build-openapi.mjs",
|
||||
"gen:zod": "cd .. && go run ./tools/openapigen",
|
||||
"prepare": "cd .. && husky frontend/.husky || true"
|
||||
},
|
||||
@@ -30,49 +30,52 @@
|
||||
"@ant-design/icons": "^6.3.2",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@hookform/resolvers": "^5.4.3",
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"@tanstack/react-query-devtools": "^5.101.2",
|
||||
"antd": "^6.5.0",
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"@tanstack/react-query-devtools": "^5.101.4",
|
||||
"antd": "^6.5.2",
|
||||
"codemirror": "^6.0.2",
|
||||
"dayjs": "^1.11.21",
|
||||
"i18next": "^26.3.6",
|
||||
"otpauth": "^9.5.1",
|
||||
"persian-calendar-suite": "^1.5.5",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-hook-form": "^7.81.0",
|
||||
"react-i18next": "^17.0.9",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"swagger-ui-react": "^5.32.8",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-hook-form": "^7.83.0",
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-router": "^8.3.0",
|
||||
"swagger-ui-react": "^5.32.11",
|
||||
"uplot": "^1.6.32",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@storybook/addon-a11y": "^10.5.0",
|
||||
"@storybook/addon-docs": "^10.5.0",
|
||||
"@storybook/react-vite": "^10.5.0",
|
||||
"@storybook/addon-a11y": "^10.5.4",
|
||||
"@storybook/addon-docs": "^10.5.4",
|
||||
"@storybook/addon-vitest": "^10.5.4",
|
||||
"@storybook/react-vite": "^10.5.4",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/swagger-ui-react": "^5.18.0",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"@vitejs/plugin-react": "^6.0.4",
|
||||
"@vitest/browser-playwright": "4.1.10",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"eslint": "^10.7.0",
|
||||
"eslint": "^10.8.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"globals": "^17.7.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^29.1.1",
|
||||
"lint-staged": "^17.0.8",
|
||||
"lint-staged": "^17.2.0",
|
||||
"msw": "^2.15.0",
|
||||
"storybook": "^10.5.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.63.0",
|
||||
"vite": "8.1.4",
|
||||
"playwright": "^1.62.0",
|
||||
"storybook": "^10.5.4",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "^8.65.0",
|
||||
"vite": "8.1.5",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"overrides": {
|
||||
@@ -87,6 +90,9 @@
|
||||
},
|
||||
"swagger-ui-react": {
|
||||
"js-yaml": "^4.2.0"
|
||||
},
|
||||
"@typeschema/valibot": {
|
||||
"valibot": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"allowScripts": {
|
||||
|
||||
@@ -109,6 +109,11 @@
|
||||
"ldapVlessField": {
|
||||
"type": "string"
|
||||
},
|
||||
"outboundDownThreshold": {
|
||||
"maximum": 100,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"pageSize": {
|
||||
"maximum": 1000,
|
||||
"minimum": 0,
|
||||
@@ -142,6 +147,12 @@
|
||||
"smtpEncryptionType": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpFrom": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpFromName": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpHost": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -170,6 +181,9 @@
|
||||
"subCertFile": {
|
||||
"type": "string"
|
||||
},
|
||||
"subClashAutoDetect": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subClashEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -185,6 +199,9 @@
|
||||
"subClashURI": {
|
||||
"type": "string"
|
||||
},
|
||||
"subClashUserAgentRegex": {
|
||||
"type": "string"
|
||||
},
|
||||
"subDomain": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -206,6 +223,12 @@
|
||||
"subIncyRoutingRules": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonAlwaysArray": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subJsonAutoDetect": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subJsonEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -224,6 +247,9 @@
|
||||
"subJsonURI": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonUserAgentRegex": {
|
||||
"type": "string"
|
||||
},
|
||||
"subKeyFile": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -366,6 +392,7 @@
|
||||
"ldapUserAttr",
|
||||
"ldapUserFilter",
|
||||
"ldapVlessField",
|
||||
"outboundDownThreshold",
|
||||
"pageSize",
|
||||
"panelOutbound",
|
||||
"remarkTemplate",
|
||||
@@ -375,6 +402,8 @@
|
||||
"smtpEnable",
|
||||
"smtpEnabledEvents",
|
||||
"smtpEncryptionType",
|
||||
"smtpFrom",
|
||||
"smtpFromName",
|
||||
"smtpHost",
|
||||
"smtpMemory",
|
||||
"smtpPassword",
|
||||
@@ -383,11 +412,13 @@
|
||||
"smtpUsername",
|
||||
"subAnnounce",
|
||||
"subCertFile",
|
||||
"subClashAutoDetect",
|
||||
"subClashEnable",
|
||||
"subClashEnableRouting",
|
||||
"subClashPath",
|
||||
"subClashRules",
|
||||
"subClashURI",
|
||||
"subClashUserAgentRegex",
|
||||
"subDomain",
|
||||
"subEnable",
|
||||
"subEnableRouting",
|
||||
@@ -395,12 +426,15 @@
|
||||
"subHideSettings",
|
||||
"subIncyEnableRouting",
|
||||
"subIncyRoutingRules",
|
||||
"subJsonAlwaysArray",
|
||||
"subJsonAutoDetect",
|
||||
"subJsonEnable",
|
||||
"subJsonFinalMask",
|
||||
"subJsonMux",
|
||||
"subJsonPath",
|
||||
"subJsonRules",
|
||||
"subJsonURI",
|
||||
"subJsonUserAgentRegex",
|
||||
"subKeyFile",
|
||||
"subListen",
|
||||
"subPath",
|
||||
@@ -542,6 +576,11 @@
|
||||
"ldapVlessField": {
|
||||
"type": "string"
|
||||
},
|
||||
"outboundDownThreshold": {
|
||||
"maximum": 100,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"pageSize": {
|
||||
"maximum": 1000,
|
||||
"minimum": 0,
|
||||
@@ -575,6 +614,12 @@
|
||||
"smtpEncryptionType": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpFrom": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpFromName": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpHost": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -603,6 +648,9 @@
|
||||
"subCertFile": {
|
||||
"type": "string"
|
||||
},
|
||||
"subClashAutoDetect": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subClashEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -618,6 +666,9 @@
|
||||
"subClashURI": {
|
||||
"type": "string"
|
||||
},
|
||||
"subClashUserAgentRegex": {
|
||||
"type": "string"
|
||||
},
|
||||
"subDomain": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -639,6 +690,12 @@
|
||||
"subIncyRoutingRules": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonAlwaysArray": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subJsonAutoDetect": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subJsonEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -657,6 +714,9 @@
|
||||
"subJsonURI": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonUserAgentRegex": {
|
||||
"type": "string"
|
||||
},
|
||||
"subKeyFile": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -806,6 +866,7 @@
|
||||
"ldapUserAttr",
|
||||
"ldapUserFilter",
|
||||
"ldapVlessField",
|
||||
"outboundDownThreshold",
|
||||
"pageSize",
|
||||
"panelOutbound",
|
||||
"remarkTemplate",
|
||||
@@ -815,6 +876,8 @@
|
||||
"smtpEnable",
|
||||
"smtpEnabledEvents",
|
||||
"smtpEncryptionType",
|
||||
"smtpFrom",
|
||||
"smtpFromName",
|
||||
"smtpHost",
|
||||
"smtpMemory",
|
||||
"smtpPassword",
|
||||
@@ -823,11 +886,13 @@
|
||||
"smtpUsername",
|
||||
"subAnnounce",
|
||||
"subCertFile",
|
||||
"subClashAutoDetect",
|
||||
"subClashEnable",
|
||||
"subClashEnableRouting",
|
||||
"subClashPath",
|
||||
"subClashRules",
|
||||
"subClashURI",
|
||||
"subClashUserAgentRegex",
|
||||
"subDomain",
|
||||
"subEnable",
|
||||
"subEnableRouting",
|
||||
@@ -835,12 +900,15 @@
|
||||
"subHideSettings",
|
||||
"subIncyEnableRouting",
|
||||
"subIncyRoutingRules",
|
||||
"subJsonAlwaysArray",
|
||||
"subJsonAutoDetect",
|
||||
"subJsonEnable",
|
||||
"subJsonFinalMask",
|
||||
"subJsonMux",
|
||||
"subJsonPath",
|
||||
"subJsonRules",
|
||||
"subJsonURI",
|
||||
"subJsonUserAgentRegex",
|
||||
"subKeyFile",
|
||||
"subListen",
|
||||
"subPath",
|
||||
@@ -1990,10 +2058,6 @@
|
||||
"allowPrivateAddress": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"apiToken": {
|
||||
"example": "abcdef0123456789",
|
||||
"type": "string"
|
||||
},
|
||||
"basePath": {
|
||||
"example": "/",
|
||||
"type": "string"
|
||||
@@ -2164,7 +2228,6 @@
|
||||
"activeCount",
|
||||
"address",
|
||||
"allowPrivateAddress",
|
||||
"apiToken",
|
||||
"basePath",
|
||||
"clientCount",
|
||||
"configDirty",
|
||||
@@ -2203,6 +2266,315 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"NodeMutationRequest": {
|
||||
"description": "NodeMutationRequest is the node write/probe contract. ApiToken is accepted\nonly as input. On update, nil means keep the stored token; replacement and\nclearing are explicit and mutually exclusive.",
|
||||
"properties": {
|
||||
"address": {
|
||||
"type": "string"
|
||||
},
|
||||
"allowPrivateAddress": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"apiToken": {
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"basePath": {
|
||||
"type": "string"
|
||||
},
|
||||
"clearApiToken": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"enable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"inboundSyncMode": {
|
||||
"enum": [
|
||||
"all",
|
||||
"selected"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"inboundTags": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"outboundTag": {
|
||||
"type": "string"
|
||||
},
|
||||
"pinnedCertSha256": {
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"maximum": 65535,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"remark": {
|
||||
"type": "string"
|
||||
},
|
||||
"scheme": {
|
||||
"enum": [
|
||||
"http",
|
||||
"https"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"tlsVerifyMode": {
|
||||
"enum": [
|
||||
"verify",
|
||||
"skip",
|
||||
"pin",
|
||||
"mtls"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"address",
|
||||
"allowPrivateAddress",
|
||||
"basePath",
|
||||
"enable",
|
||||
"id",
|
||||
"inboundSyncMode",
|
||||
"inboundTags",
|
||||
"name",
|
||||
"outboundTag",
|
||||
"pinnedCertSha256",
|
||||
"port",
|
||||
"remark",
|
||||
"scheme",
|
||||
"tlsVerifyMode"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"NodeView": {
|
||||
"description": "NodeView is the browser/API read contract for nodes. Credentials are\nwrite-only: responses expose only whether a node has a token configured.",
|
||||
"properties": {
|
||||
"activeCount": {
|
||||
"example": 20,
|
||||
"type": "integer"
|
||||
},
|
||||
"address": {
|
||||
"example": "node.example.com",
|
||||
"type": "string"
|
||||
},
|
||||
"allowPrivateAddress": {
|
||||
"example": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"basePath": {
|
||||
"example": "/",
|
||||
"type": "string"
|
||||
},
|
||||
"clientCount": {
|
||||
"example": 25,
|
||||
"type": "integer"
|
||||
},
|
||||
"configDirty": {
|
||||
"example": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"configDirtyAt": {
|
||||
"example": 0,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"cpuPct": {
|
||||
"example": 12.5,
|
||||
"type": "number"
|
||||
},
|
||||
"createdAt": {
|
||||
"example": 1700000000,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"depletedCount": {
|
||||
"example": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"disabledCount": {
|
||||
"example": 2,
|
||||
"type": "integer"
|
||||
},
|
||||
"enable": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"guid": {
|
||||
"example": "node-guid",
|
||||
"type": "string"
|
||||
},
|
||||
"hasApiToken": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"example": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"inboundCount": {
|
||||
"example": 3,
|
||||
"type": "integer"
|
||||
},
|
||||
"inboundSyncMode": {
|
||||
"example": "all",
|
||||
"type": "string"
|
||||
},
|
||||
"inboundTags": {
|
||||
"example": [
|
||||
"in-443-tcp"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"lastError": {
|
||||
"type": "string"
|
||||
},
|
||||
"lastHeartbeat": {
|
||||
"example": 1700000000,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"latencyMs": {
|
||||
"example": 42,
|
||||
"type": "integer"
|
||||
},
|
||||
"memPct": {
|
||||
"example": 45.2,
|
||||
"type": "number"
|
||||
},
|
||||
"name": {
|
||||
"example": "edge-1",
|
||||
"type": "string"
|
||||
},
|
||||
"netDown": {
|
||||
"example": 1048576,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"netUp": {
|
||||
"example": 2097152,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"onlineCount": {
|
||||
"example": 5,
|
||||
"type": "integer"
|
||||
},
|
||||
"outboundTag": {
|
||||
"example": "direct",
|
||||
"type": "string"
|
||||
},
|
||||
"panelVersion": {
|
||||
"example": "v3.x.x",
|
||||
"type": "string"
|
||||
},
|
||||
"parentGuid": {
|
||||
"type": "string"
|
||||
},
|
||||
"pinnedCertSha256": {
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"example": 2053,
|
||||
"type": "integer"
|
||||
},
|
||||
"remark": {
|
||||
"example": "Primary edge",
|
||||
"type": "string"
|
||||
},
|
||||
"scheme": {
|
||||
"example": "https",
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"example": "online",
|
||||
"type": "string"
|
||||
},
|
||||
"tlsVerifyMode": {
|
||||
"example": "verify",
|
||||
"type": "string"
|
||||
},
|
||||
"transitive": {
|
||||
"example": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"updatedAt": {
|
||||
"example": 1700003600,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"uptimeSecs": {
|
||||
"example": 86400,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"xrayError": {
|
||||
"type": "string"
|
||||
},
|
||||
"xrayState": {
|
||||
"example": "running",
|
||||
"type": "string"
|
||||
},
|
||||
"xrayVersion": {
|
||||
"example": "25.10.31",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeCount",
|
||||
"address",
|
||||
"allowPrivateAddress",
|
||||
"basePath",
|
||||
"clientCount",
|
||||
"configDirty",
|
||||
"configDirtyAt",
|
||||
"cpuPct",
|
||||
"createdAt",
|
||||
"depletedCount",
|
||||
"disabledCount",
|
||||
"enable",
|
||||
"guid",
|
||||
"hasApiToken",
|
||||
"id",
|
||||
"inboundCount",
|
||||
"inboundSyncMode",
|
||||
"inboundTags",
|
||||
"lastError",
|
||||
"lastHeartbeat",
|
||||
"latencyMs",
|
||||
"memPct",
|
||||
"name",
|
||||
"netDown",
|
||||
"netUp",
|
||||
"onlineCount",
|
||||
"outboundTag",
|
||||
"panelVersion",
|
||||
"pinnedCertSha256",
|
||||
"port",
|
||||
"remark",
|
||||
"scheme",
|
||||
"status",
|
||||
"tlsVerifyMode",
|
||||
"updatedAt",
|
||||
"uptimeSecs",
|
||||
"xrayError",
|
||||
"xrayState",
|
||||
"xrayVersion"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"OutboundTraffics": {
|
||||
"description": "OutboundTraffics tracks traffic statistics for Xray outbound connections.",
|
||||
"properties": {
|
||||
@@ -7452,7 +7824,7 @@
|
||||
"obj": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Node"
|
||||
"$ref": "#/components/schemas/NodeView"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7461,48 +7833,48 @@
|
||||
"success": true,
|
||||
"obj": [
|
||||
{
|
||||
"activeCount": 23,
|
||||
"address": "node1.example.com",
|
||||
"activeCount": 20,
|
||||
"address": "node.example.com",
|
||||
"allowPrivateAddress": false,
|
||||
"apiToken": "abcdef0123456789",
|
||||
"basePath": "/",
|
||||
"clientCount": 27,
|
||||
"clientCount": 25,
|
||||
"configDirty": false,
|
||||
"configDirtyAt": 0,
|
||||
"cpuPct": 23.5,
|
||||
"cpuPct": 12.5,
|
||||
"createdAt": 1700000000,
|
||||
"depletedCount": 1,
|
||||
"disabledCount": 3,
|
||||
"disabledCount": 2,
|
||||
"enable": true,
|
||||
"guid": "",
|
||||
"guid": "node-guid",
|
||||
"hasApiToken": true,
|
||||
"id": 1,
|
||||
"inboundCount": 5,
|
||||
"inboundCount": 3,
|
||||
"inboundSyncMode": "all",
|
||||
"inboundTags": [
|
||||
""
|
||||
"in-443-tcp"
|
||||
],
|
||||
"lastError": "",
|
||||
"lastHeartbeat": 1700000000,
|
||||
"latencyMs": 42,
|
||||
"memPct": 45.1,
|
||||
"name": "de-fra-1",
|
||||
"netDown": 2097152,
|
||||
"netUp": 1048576,
|
||||
"onlineCount": 3,
|
||||
"outboundTag": "",
|
||||
"memPct": 45.2,
|
||||
"name": "edge-1",
|
||||
"netDown": 1048576,
|
||||
"netUp": 2097152,
|
||||
"onlineCount": 5,
|
||||
"outboundTag": "direct",
|
||||
"panelVersion": "v3.x.x",
|
||||
"parentGuid": "",
|
||||
"pinnedCertSha256": "",
|
||||
"port": 2053,
|
||||
"remark": "",
|
||||
"remark": "Primary edge",
|
||||
"scheme": "https",
|
||||
"status": "online",
|
||||
"tlsVerifyMode": "verify",
|
||||
"transitive": false,
|
||||
"updatedAt": 1700000000,
|
||||
"updatedAt": 1700003600,
|
||||
"uptimeSecs": 86400,
|
||||
"xrayError": "",
|
||||
"xrayState": "",
|
||||
"xrayState": "running",
|
||||
"xrayVersion": "25.10.31"
|
||||
}
|
||||
]
|
||||
@@ -7624,7 +7996,57 @@
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {}
|
||||
"obj": {
|
||||
"$ref": "#/components/schemas/NodeView"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"success": true,
|
||||
"obj": {
|
||||
"activeCount": 20,
|
||||
"address": "node.example.com",
|
||||
"allowPrivateAddress": false,
|
||||
"basePath": "/",
|
||||
"clientCount": 25,
|
||||
"configDirty": false,
|
||||
"configDirtyAt": 0,
|
||||
"cpuPct": 12.5,
|
||||
"createdAt": 1700000000,
|
||||
"depletedCount": 1,
|
||||
"disabledCount": 2,
|
||||
"enable": true,
|
||||
"guid": "node-guid",
|
||||
"hasApiToken": true,
|
||||
"id": 1,
|
||||
"inboundCount": 3,
|
||||
"inboundSyncMode": "all",
|
||||
"inboundTags": [
|
||||
"in-443-tcp"
|
||||
],
|
||||
"lastError": "",
|
||||
"lastHeartbeat": 1700000000,
|
||||
"latencyMs": 42,
|
||||
"memPct": 45.2,
|
||||
"name": "edge-1",
|
||||
"netDown": 1048576,
|
||||
"netUp": 2097152,
|
||||
"onlineCount": 5,
|
||||
"outboundTag": "direct",
|
||||
"panelVersion": "v3.x.x",
|
||||
"parentGuid": "",
|
||||
"pinnedCertSha256": "",
|
||||
"port": 2053,
|
||||
"remark": "Primary edge",
|
||||
"scheme": "https",
|
||||
"status": "online",
|
||||
"tlsVerifyMode": "verify",
|
||||
"transitive": false,
|
||||
"updatedAt": 1700003600,
|
||||
"uptimeSecs": 86400,
|
||||
"xrayError": "",
|
||||
"xrayState": "running",
|
||||
"xrayVersion": "25.10.31"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7686,7 +8108,7 @@
|
||||
"tags": [
|
||||
"Nodes"
|
||||
],
|
||||
"summary": "Register a new remote node. Provide its URL, apiToken, and optional remark / allowPrivateAddress flag.",
|
||||
"summary": "Register a new remote node. Provide its URL, write-only apiToken, and optional remark / allowPrivateAddress flag. Responses expose hasApiToken only.",
|
||||
"operationId": "post_panel_api_nodes_add",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -7703,6 +8125,7 @@
|
||||
"port": 2053,
|
||||
"basePath": "/",
|
||||
"apiToken": "abcdef...",
|
||||
"clearApiToken": false,
|
||||
"enable": true,
|
||||
"allowPrivateAddress": false
|
||||
}
|
||||
@@ -7723,7 +8146,57 @@
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {}
|
||||
"obj": {
|
||||
"$ref": "#/components/schemas/NodeView"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"success": true,
|
||||
"obj": {
|
||||
"activeCount": 20,
|
||||
"address": "node.example.com",
|
||||
"allowPrivateAddress": false,
|
||||
"basePath": "/",
|
||||
"clientCount": 25,
|
||||
"configDirty": false,
|
||||
"configDirtyAt": 0,
|
||||
"cpuPct": 12.5,
|
||||
"createdAt": 1700000000,
|
||||
"depletedCount": 1,
|
||||
"disabledCount": 2,
|
||||
"enable": true,
|
||||
"guid": "node-guid",
|
||||
"hasApiToken": true,
|
||||
"id": 1,
|
||||
"inboundCount": 3,
|
||||
"inboundSyncMode": "all",
|
||||
"inboundTags": [
|
||||
"in-443-tcp"
|
||||
],
|
||||
"lastError": "",
|
||||
"lastHeartbeat": 1700000000,
|
||||
"latencyMs": 42,
|
||||
"memPct": 45.2,
|
||||
"name": "edge-1",
|
||||
"netDown": 1048576,
|
||||
"netUp": 2097152,
|
||||
"onlineCount": 5,
|
||||
"outboundTag": "direct",
|
||||
"panelVersion": "v3.x.x",
|
||||
"parentGuid": "",
|
||||
"pinnedCertSha256": "",
|
||||
"port": 2053,
|
||||
"remark": "Primary edge",
|
||||
"scheme": "https",
|
||||
"status": "online",
|
||||
"tlsVerifyMode": "verify",
|
||||
"transitive": false,
|
||||
"updatedAt": 1700003600,
|
||||
"uptimeSecs": 86400,
|
||||
"xrayError": "",
|
||||
"xrayState": "running",
|
||||
"xrayVersion": "25.10.31"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7737,7 +8210,7 @@
|
||||
"tags": [
|
||||
"Nodes"
|
||||
],
|
||||
"summary": "Replace a node’s connection details. Same body shape as /add.",
|
||||
"summary": "Replace a node’s connection details. apiToken is write-only: omit it or send an empty string to keep the stored token; set clearApiToken=true to clear it.",
|
||||
"operationId": "post_panel_api_nodes_update_id",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -7764,7 +8237,8 @@
|
||||
"address": "node1.example.com",
|
||||
"port": 2053,
|
||||
"basePath": "/",
|
||||
"apiToken": "abcdef...",
|
||||
"apiToken": "",
|
||||
"clearApiToken": false,
|
||||
"enable": true,
|
||||
"allowPrivateAddress": false
|
||||
}
|
||||
@@ -9247,6 +9721,53 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/setting/validateRegex": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Settings"
|
||||
],
|
||||
"summary": "Validate any regular expression with the backend Go RE2 compiler without saving it.",
|
||||
"operationId": "post_panel_api_setting_validateRegex",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
},
|
||||
"example": {
|
||||
"regex": "(?m)^general-purpose$"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"success": true,
|
||||
"msg": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/setting/updateUser": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -10460,7 +10981,7 @@
|
||||
"tags": [
|
||||
"Subscription Server"
|
||||
],
|
||||
"summary": "Return base64-encoded subscription links for all enabled clients matching the subscription ID. When the request has an Accept: text/html header or ?html=1, renders a styled info page instead. Default path: /sub/:subid.",
|
||||
"summary": "Return base64-encoded subscription links for all enabled clients matching the subscription ID. When the request has an Accept: text/html header or ?html=1, renders a styled info page instead. With ?format=info, returns the page view-model as JSON (traffic, expiry, online status; no links) for live polling. Default path: /sub/:subid.",
|
||||
"operationId": "get_subPath_subid",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -10472,6 +10993,15 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "format",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Set to \"info\" to get the subscription status view-model as JSON instead of the links.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "subPath",
|
||||
"in": "path",
|
||||
|
||||
@@ -152,8 +152,11 @@ export async function httpRequest(
|
||||
|
||||
if (res.status === 403 && !SAFE_METHODS.has(method.toUpperCase())) {
|
||||
csrfToken = null;
|
||||
const fresh = await ensureCsrfToken();
|
||||
if (fresh) res = await performFetch(method, url, data, options, fresh);
|
||||
const fresh = await fetchCsrfToken();
|
||||
if (fresh) {
|
||||
csrfToken = fresh;
|
||||
res = await performFetch(method, url, data, options, fresh);
|
||||
}
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
|
||||
@@ -190,3 +190,12 @@ export class WebSocketClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sharedClient: WebSocketClient | null = null;
|
||||
|
||||
export function getSharedWebSocketClient(): WebSocketClient {
|
||||
if (sharedClient) return sharedClient;
|
||||
const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
|
||||
sharedClient = new WebSocketClient(basePath);
|
||||
return sharedClient;
|
||||
}
|
||||
|
||||
@@ -1,34 +1,19 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { WebSocketClient } from '@/api/websocket';
|
||||
import { getSharedWebSocketClient } from '@/api/websocket';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
import { isRecentLocalInvalidate } from '@/api/invalidationTracker';
|
||||
|
||||
type Handler = (payload: unknown) => void;
|
||||
|
||||
interface SharedClient {
|
||||
connect(): void;
|
||||
on(event: string, fn: Handler): void;
|
||||
off(event: string, fn: Handler): void;
|
||||
}
|
||||
|
||||
let sharedClient: SharedClient | null = null;
|
||||
|
||||
function getSharedClient(): SharedClient {
|
||||
if (sharedClient) return sharedClient;
|
||||
const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
|
||||
sharedClient = new WebSocketClient(basePath) as SharedClient;
|
||||
return sharedClient;
|
||||
}
|
||||
|
||||
let invalidateTimer: number | null = null;
|
||||
|
||||
export function useWebSocketBridge() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
const client = getSharedClient();
|
||||
const client = getSharedWebSocketClient();
|
||||
|
||||
const onInvalidate: Handler = (payload) => {
|
||||
const p = payload as { type?: string } | undefined;
|
||||
@@ -46,6 +31,7 @@ export function useWebSocketBridge() {
|
||||
};
|
||||
|
||||
const onOutbounds: Handler = (payload) => {
|
||||
if (!Array.isArray(payload)) return;
|
||||
queryClient.setQueryData(keys.xray.outboundsTraffic(), payload);
|
||||
};
|
||||
|
||||
|
||||
14
frontend/src/components/clients/ClientCardComment.tsx
Normal file
14
frontend/src/components/clients/ClientCardComment.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
type ClientCardCommentProps = {
|
||||
comment?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function ClientCardComment({ comment, className = 'client-card-comment' }: ClientCardCommentProps) {
|
||||
if (!comment) return null;
|
||||
|
||||
return (
|
||||
<span className={className} title={comment}>
|
||||
{comment}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,17 @@ const meta = {
|
||||
title: 'Clients/ClientSpeedTag',
|
||||
component: ClientSpeedTag,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Blue tag showing a live upload/download speed for one client, formatted for readability. Shown next to online clients that have active traffic.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
speed: { description: 'Live upload/download rate in bytes per second (`{ up, down }`).' },
|
||||
},
|
||||
} satisfies Meta<typeof ClientSpeedTag>;
|
||||
|
||||
export default meta;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Tag } from 'antd';
|
||||
|
||||
import { SizeFormatter } from '@/utils';
|
||||
import type { ClientSpeedEntry } from '@/hooks/useClients';
|
||||
import { SPEED_TAG_CLASS_NAME, SPEED_TAG_STYLE } from '@/components/utility/speedTagStyle';
|
||||
|
||||
export type { ClientSpeedEntry };
|
||||
|
||||
@@ -11,11 +12,16 @@ export function isActiveSpeed(speed?: ClientSpeedEntry): speed is ClientSpeedEnt
|
||||
|
||||
interface ClientSpeedTagProps {
|
||||
speed: ClientSpeedEntry;
|
||||
tableCell?: boolean;
|
||||
}
|
||||
|
||||
export function ClientSpeedTag({ speed }: ClientSpeedTagProps) {
|
||||
export function ClientSpeedTag({ speed, tableCell = false }: ClientSpeedTagProps) {
|
||||
return (
|
||||
<Tag color="blue">
|
||||
<Tag
|
||||
color="blue"
|
||||
className={tableCell ? SPEED_TAG_CLASS_NAME : undefined}
|
||||
style={tableCell ? SPEED_TAG_STYLE : undefined}
|
||||
>
|
||||
↑ {SizeFormatter.speedFormat(speed.up)}
|
||||
{' / '}
|
||||
↓ {SizeFormatter.speedFormat(speed.down)}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
|
||||
import ClientTrafficCell from './ClientTrafficCell';
|
||||
|
||||
const GiB = 1024 ** 3;
|
||||
|
||||
const meta = {
|
||||
title: 'Clients/ClientTrafficCell',
|
||||
component: ClientTrafficCell,
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<ThemeProvider>
|
||||
<Story />
|
||||
</ThemeProvider>
|
||||
),
|
||||
],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Traffic usage cell for the clients table: used bytes, a color-coded progress bar, and the quota (or an infinity icon for unlimited clients), with an upload/download/remaining breakdown in a hover popover.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
up: { description: 'Uploaded bytes counted against the client.' },
|
||||
down: { description: 'Downloaded bytes counted against the client.' },
|
||||
total: { description: 'Traffic quota in bytes; 0 or less renders as unlimited.' },
|
||||
enabled: { description: 'Grays the bar out when the client is disabled.' },
|
||||
trafficDiff: { description: 'Headroom in bytes below the quota at which the bar shifts from green to orange.' },
|
||||
compact: { description: 'Smaller bar and tighter layout for dense table rows.' },
|
||||
},
|
||||
} satisfies Meta<typeof ClientTrafficCell>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
up: 6 * GiB,
|
||||
down: 35 * GiB,
|
||||
total: 100 * GiB,
|
||||
trafficDiff: 5 * GiB,
|
||||
},
|
||||
};
|
||||
|
||||
export const Unlimited: Story = {
|
||||
args: {
|
||||
up: 87 * GiB,
|
||||
down: 940 * GiB,
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
|
||||
export const Depleted: Story = {
|
||||
args: {
|
||||
up: 9 * GiB,
|
||||
down: 42 * GiB,
|
||||
total: 50 * GiB,
|
||||
trafficDiff: 5 * GiB,
|
||||
},
|
||||
};
|
||||
|
||||
export const DisabledCompact: Story = {
|
||||
args: {
|
||||
up: 2 * GiB,
|
||||
down: 11 * GiB,
|
||||
total: 40 * GiB,
|
||||
enabled: false,
|
||||
compact: true,
|
||||
},
|
||||
};
|
||||
@@ -64,6 +64,7 @@ export default function ClientTrafficCell({
|
||||
<span className="client-traffic-cell-used">{SizeFormatter.sizeFormat(display.used)}</span>
|
||||
<Progress
|
||||
className="client-traffic-cell-bar"
|
||||
aria-label={`${SizeFormatter.sizeFormat(display.used)} / ${display.isUnlimited ? t('subscription.unlimited') : SizeFormatter.sizeFormat(total)}`}
|
||||
percent={display.percent}
|
||||
showInfo={false}
|
||||
strokeColor={display.strokeColor}
|
||||
@@ -72,7 +73,7 @@ export default function ClientTrafficCell({
|
||||
/>
|
||||
<span className="client-traffic-cell-limit">
|
||||
{display.isUnlimited ? (
|
||||
<span className="client-traffic-cell-infinity" aria-label={t('subscription.unlimited')}>
|
||||
<span className="client-traffic-cell-infinity" role="img" aria-label={t('subscription.unlimited')}>
|
||||
<InfinityIcon />
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
body.light .config-block .ant-tag.ant-tag-filled.ant-tag-gold {
|
||||
color: #874d00;
|
||||
}
|
||||
|
||||
.config-block .ant-collapse-extra {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -34,6 +38,7 @@
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
padding: 6px 8px;
|
||||
color: var(--ant-color-text);
|
||||
background: var(--ant-color-fill-tertiary);
|
||||
border-radius: 4px;
|
||||
user-select: all;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, waitFor } from 'storybook/test';
|
||||
|
||||
import ConfigBlock from './ConfigBlock';
|
||||
|
||||
@@ -6,7 +7,24 @@ const meta = {
|
||||
title: 'Clients/ConfigBlock',
|
||||
component: ConfigBlock,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'padded' },
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Collapsible panel that displays a client config or share link with copy, download, and QR-code actions. Used on the clients and inbounds pages to present generated links.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
label: { description: 'Protocol/type badge shown on the panel header (e.g. `vless`, `trojan`).' },
|
||||
text: { description: 'The config or share-link text to display, copy, download, and encode as a QR code.' },
|
||||
fileName: { description: 'File name used when downloading the text.' },
|
||||
qrRemark: { description: 'Optional remark embedded in the QR panel; falls back to `label`.' },
|
||||
showQr: { description: 'Whether to show the QR-code action button.' },
|
||||
tagColor: { description: 'Ant Design tag color for the header badge.' },
|
||||
defaultOpen: { description: 'Whether the panel starts expanded.' },
|
||||
},
|
||||
} satisfies Meta<typeof ConfigBlock>;
|
||||
|
||||
export default meta;
|
||||
@@ -18,6 +36,15 @@ const sampleLink = 'vless://11112222-3333-4444-5555-666677778888@panel.example.c
|
||||
|
||||
export const Collapsed: Story = {
|
||||
args: { label: 'vless', text: sampleLink, fileName: 'client-config.txt' },
|
||||
play: async ({ canvas, userEvent }) => {
|
||||
await expect(canvas.queryByText(/vless:\/\/11112222/)).not.toBeInTheDocument();
|
||||
await userEvent.click(canvas.getByText('vless'));
|
||||
const configText = await canvas.findByText(/vless:\/\/11112222/);
|
||||
await waitFor(() => expect(configText).toBeVisible());
|
||||
await expect(canvas.getByRole('button', { name: 'Copy' })).toBeVisible();
|
||||
await expect(canvas.getByRole('button', { name: 'Download' })).toBeVisible();
|
||||
await expect(canvas.getByRole('button', { name: 'QR Code' })).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const Expanded: Story = {
|
||||
|
||||
@@ -68,6 +68,7 @@ export default function ConfigBlock({
|
||||
{messageContextHolder}
|
||||
<Collapse
|
||||
className="config-block"
|
||||
collapsible="header"
|
||||
defaultActiveKey={defaultOpen ? ['cfg'] : []}
|
||||
items={[{
|
||||
key: 'cfg',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, waitFor, within } from 'storybook/test';
|
||||
import { Button } from 'antd';
|
||||
|
||||
import PromptModal from './PromptModal';
|
||||
@@ -8,6 +9,25 @@ const meta = {
|
||||
title: 'Feedback/PromptModal',
|
||||
component: PromptModal,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Modal that prompts for a single value — a plain input, a multi-line textarea, or a JSON editor. Confirms on Enter (input) or Ctrl+S (textarea) and returns the entered text.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
open: { description: 'Whether the modal is visible.' },
|
||||
title: { description: 'Modal title text.' },
|
||||
okText: { description: 'Confirm button label; defaults to the translated "confirm".' },
|
||||
type: { description: 'Editor variant: single-line `input` or multi-line `textarea`.' },
|
||||
initialValue: { description: 'Value pre-filled when the modal opens.' },
|
||||
loading: { description: 'Shows a loading state on the confirm button.' },
|
||||
json: { description: 'Render a JSON editor instead of a plain field.' },
|
||||
onConfirm: { description: 'Called with the entered value on confirm.' },
|
||||
onClose: { description: 'Called when the modal is dismissed.' },
|
||||
},
|
||||
} satisfies Meta<typeof PromptModal>;
|
||||
|
||||
export default meta;
|
||||
@@ -62,9 +82,25 @@ const placeholderArgs = {
|
||||
export const Input: Story = {
|
||||
args: placeholderArgs,
|
||||
render: () => <InputDemo />,
|
||||
play: async ({ canvas, canvasElement, userEvent }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
await userEvent.click(canvas.getByRole('button', { name: 'Rename client' }));
|
||||
const input = await body.findByRole('textbox');
|
||||
await userEvent.type(input, 'new-name');
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await expect(await canvas.findByText(/Last confirmed: new-name/)).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const Textarea: Story = {
|
||||
args: placeholderArgs,
|
||||
render: () => <TextareaDemo />,
|
||||
play: async ({ canvas, canvasElement, userEvent }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
await userEvent.click(canvas.getByRole('button', { name: 'Edit note' }));
|
||||
const textarea = await body.findByRole('textbox');
|
||||
await expect(textarea).toHaveValue('line one\nline two');
|
||||
await userEvent.click(body.getByRole('button', { name: 'Confirm' }));
|
||||
await waitFor(() => expect(body.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
},
|
||||
};
|
||||
|
||||
@@ -72,6 +72,7 @@ export default function PromptModal({
|
||||
) : type === 'textarea' ? (
|
||||
<Input.TextArea
|
||||
ref={(el) => { textareaRef.current = (el as unknown as { resizableTextArea?: { textArea: HTMLTextAreaElement } })?.resizableTextArea?.textArea ?? null; }}
|
||||
aria-label={title}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
autoSize={{ minRows: 10, maxRows: 20 }}
|
||||
@@ -80,6 +81,7 @@ export default function PromptModal({
|
||||
) : (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
aria-label={title}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={onKeydown}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, waitFor, within } from 'storybook/test';
|
||||
import { Button } from 'antd';
|
||||
|
||||
import TextModal from './TextModal';
|
||||
@@ -8,6 +9,23 @@ const meta = {
|
||||
title: 'Feedback/TextModal',
|
||||
component: TextModal,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Read-only modal for viewing generated text or JSON — a client config, subscription, or exported settings — with copy and optional download actions, plus optional tabs for multiple documents.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
open: { description: 'Whether the modal is visible.' },
|
||||
title: { description: 'Modal title text.' },
|
||||
content: { description: 'Text shown when no `tabs` are provided.' },
|
||||
fileName: { description: 'When set, adds a download button that saves the active content under this name.' },
|
||||
json: { description: 'Render the content in a read-only JSON editor with syntax highlighting.' },
|
||||
tabs: { description: 'Optional list of `{ key, label, content }` documents shown as tabs.' },
|
||||
onClose: { description: 'Called when the modal is dismissed.' },
|
||||
},
|
||||
} satisfies Meta<typeof TextModal>;
|
||||
|
||||
export default meta;
|
||||
@@ -43,6 +61,13 @@ const placeholderArgs = {
|
||||
export const PlainText: Story = {
|
||||
args: placeholderArgs,
|
||||
render: () => <Demo fileName="client.txt" />,
|
||||
play: async ({ canvas, canvasElement, userEvent }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
await userEvent.click(canvas.getByRole('button', { name: 'Show config' }));
|
||||
const content = await body.findByDisplayValue(/vless:\/\/uuid@example\.com/);
|
||||
await waitFor(() => expect(content).toBeVisible());
|
||||
await expect(body.getByRole('button', { name: /client\.txt/ })).toBeEnabled();
|
||||
},
|
||||
};
|
||||
|
||||
export const Json: Story = {
|
||||
|
||||
@@ -75,6 +75,7 @@ export default function TextModal({ open, onClose, title, content, fileName = ''
|
||||
<JsonEditor value={activeContent} readOnly minHeight="240px" maxHeight="60vh" />
|
||||
) : (
|
||||
<Input.TextArea
|
||||
aria-label={title}
|
||||
value={activeContent}
|
||||
readOnly
|
||||
autoSize={{ minRows: 10, maxRows: 20 }}
|
||||
|
||||
96
frontend/src/components/form/DateTimePicker.stories.tsx
Normal file
96
frontend/src/components/form/DateTimePicker.stories.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Typography } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import { setDatepicker } from '@/hooks/useDatepicker';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
|
||||
import DateTimePicker from './DateTimePicker';
|
||||
|
||||
setDatepicker('gregorian');
|
||||
|
||||
function ClientExpiryDemo() {
|
||||
const [value, setValue] = useState<Dayjs | null>(dayjs('2026-12-31 23:59:59'));
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<DateTimePicker value={value} onChange={setValue} placeholder="Expiry date" />
|
||||
<Typography.Text type="secondary">
|
||||
{value ? `user1@node-de expiryTime: ${value.valueOf()}` : 'user1@node-de expiryTime: 0 (never expires)'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JalaliDemo() {
|
||||
const [value, setValue] = useState<Dayjs | null>(dayjs('2026-12-31 23:59:59'));
|
||||
useEffect(() => {
|
||||
setDatepicker('jalalian');
|
||||
return () => setDatepicker('gregorian');
|
||||
}, []);
|
||||
return <DateTimePicker value={value} onChange={setValue} placeholder="Expiry date" />;
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Form/DateTimePicker',
|
||||
component: DateTimePicker,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Calendar-aware date/time picker used for client and inbound expiry dates. Renders an AntD DatePicker by default and switches to a Persian (Jalali) calendar — with theme-matched colors and an overlaid clear button — when the panel datepicker setting is jalalian.',
|
||||
},
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<ThemeProvider>
|
||||
<Story />
|
||||
</ThemeProvider>
|
||||
),
|
||||
],
|
||||
argTypes: {
|
||||
value: { description: 'Selected moment as a Dayjs instance, or null when unset.' },
|
||||
onChange: { description: 'Called with the picked Dayjs value, or null when cleared.' },
|
||||
showTime: { description: 'Include an hour/minute/second selector alongside the date.' },
|
||||
format: { description: 'Display format for the Gregorian picker input.' },
|
||||
placeholder: { description: 'Input placeholder shown while no value is set.' },
|
||||
disabled: { description: 'Disables the input and hides the clear button.' },
|
||||
},
|
||||
} satisfies Meta<typeof DateTimePicker>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
value: null,
|
||||
onChange: () => undefined,
|
||||
placeholder: 'Leave blank to never expire',
|
||||
},
|
||||
};
|
||||
|
||||
export const ClientExpiry: Story = {
|
||||
args: { value: null, onChange: () => undefined },
|
||||
render: () => <ClientExpiryDemo />,
|
||||
};
|
||||
|
||||
export const DateOnly: Story = {
|
||||
args: {
|
||||
value: dayjs('2026-08-01'),
|
||||
onChange: () => undefined,
|
||||
showTime: false,
|
||||
format: 'YYYY-MM-DD',
|
||||
placeholder: 'Start date',
|
||||
},
|
||||
};
|
||||
|
||||
export const JalaliCalendar: Story = {
|
||||
args: { value: null, onChange: () => undefined },
|
||||
parameters: { docs: { disable: true } },
|
||||
render: () => <JalaliDemo />,
|
||||
};
|
||||
63
frontend/src/components/form/GoRegexInput.tsx
Normal file
63
frontend/src/components/form/GoRegexInput.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Input, Typography } from 'antd';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
interface GoRegexInputProps {
|
||||
value: string;
|
||||
ariaLabel?: string;
|
||||
placeholder?: string;
|
||||
maxLength?: number;
|
||||
onChange: (value: string) => void;
|
||||
externalError?: string;
|
||||
}
|
||||
|
||||
export async function validateGoRegex(value: string): Promise<string> {
|
||||
const result = await HttpUtil.post(
|
||||
'/panel/api/setting/validateRegex',
|
||||
{ regex: value },
|
||||
{ silent: true },
|
||||
);
|
||||
return result.success ? '' : result.msg || 'Invalid Go RE2 regular expression';
|
||||
}
|
||||
|
||||
export default function GoRegexInput({
|
||||
value,
|
||||
ariaLabel,
|
||||
placeholder,
|
||||
maxLength = 2048,
|
||||
onChange,
|
||||
externalError,
|
||||
}: GoRegexInputProps) {
|
||||
const [error, setError] = useState('');
|
||||
const validationSequence = useRef(0);
|
||||
|
||||
async function validate() {
|
||||
const sequence = ++validationSequence.current;
|
||||
const nextError = await validateGoRegex(value);
|
||||
if (sequence === validationSequence.current) {
|
||||
setError(nextError);
|
||||
}
|
||||
}
|
||||
|
||||
const displayError = externalError ?? error;
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%' }}>
|
||||
<Input
|
||||
value={value}
|
||||
aria-label={ariaLabel}
|
||||
placeholder={placeholder}
|
||||
maxLength={maxLength}
|
||||
status={displayError ? 'error' : undefined}
|
||||
onChange={(event) => {
|
||||
validationSequence.current += 1;
|
||||
setError('');
|
||||
onChange(event.target.value);
|
||||
}}
|
||||
onBlur={() => void validate()}
|
||||
/>
|
||||
{displayError && <Typography.Text type="danger">{displayError}</Typography.Text>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
frontend/src/components/form/HeaderMapEditor.stories.tsx
Normal file
76
frontend/src/components/form/HeaderMapEditor.stories.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import HeaderMapEditor, { type HeaderMapValue } from './HeaderMapEditor';
|
||||
|
||||
const meta = {
|
||||
title: 'Form/HeaderMapEditor',
|
||||
component: HeaderMapEditor,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Row-based editor for Xray HTTP header maps, used in the inbound/outbound stream forms. Mode `v1` emits one string per header name (WS / HTTPUpgrade / Hysteria masquerade); mode `v2` emits string arrays so headers can repeat (TCP HTTP camouflage request/response).',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
mode: { description: 'Wire shape: `v1` = string per name, `v2` = string[] per name (repeatable headers).' },
|
||||
value: { description: 'Header map in the wire shape matching `mode`; converted to editable rows internally.' },
|
||||
onChange: { description: 'Called with the rebuilt wire-shape map after every row edit, add, or remove.' },
|
||||
},
|
||||
} satisfies Meta<typeof HeaderMapEditor>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Empty: Story = {
|
||||
args: { mode: 'v1', onChange: () => undefined },
|
||||
};
|
||||
|
||||
export const WsHostHeaders: Story = {
|
||||
args: {
|
||||
mode: 'v1',
|
||||
value: {
|
||||
Host: 'cdn.example.com',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
},
|
||||
onChange: () => undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const TcpCamouflageRequest: Story = {
|
||||
args: {
|
||||
mode: 'v2',
|
||||
value: {
|
||||
Accept: ['text/html,application/xhtml+xml', 'application/json'],
|
||||
'Accept-Encoding': ['gzip, deflate'],
|
||||
Connection: ['keep-alive'],
|
||||
Pragma: ['no-cache'],
|
||||
},
|
||||
onChange: () => undefined,
|
||||
},
|
||||
};
|
||||
|
||||
function WireShapeDemo() {
|
||||
const [value, setValue] = useState<HeaderMapValue>({
|
||||
Accept: ['text/html', 'application/json'],
|
||||
'X-Forwarded-For': ['203.0.113.7'],
|
||||
});
|
||||
return (
|
||||
<div style={{ maxWidth: 560 }}>
|
||||
<HeaderMapEditor mode="v2" value={value} onChange={setValue} />
|
||||
<pre style={{ marginTop: 16, padding: 12, borderRadius: 8, background: 'rgba(128, 128, 128, 0.12)' }}>
|
||||
{JSON.stringify(value ?? {}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const LiveWireShape: Story = {
|
||||
args: { mode: 'v2', onChange: () => undefined },
|
||||
render: () => <WireShapeDemo />,
|
||||
};
|
||||
132
frontend/src/components/form/JsonEditor.stories.tsx
Normal file
132
frontend/src/components/form/JsonEditor.stories.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Typography } from 'antd';
|
||||
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
|
||||
import JsonEditor from './JsonEditor';
|
||||
|
||||
const warpOutbound = JSON.stringify(
|
||||
{
|
||||
tag: 'warp-out',
|
||||
protocol: 'wireguard',
|
||||
settings: {
|
||||
secretKey: 'yFXfmXX3Zn5tnpNJ7HAcbLvqcMVioqPDGV1GXn2FeV0=',
|
||||
address: ['172.16.0.2/32', '2606:4700:110:8f81::2/128'],
|
||||
peers: [
|
||||
{
|
||||
publicKey: 'bmXOC+F1FxEMF9dyiK2H5/1SUtzH0JuVo51h2wPfgyo=',
|
||||
allowedIPs: ['0.0.0.0/0', '::/0'],
|
||||
endpoint: 'engage.cloudflareclient.com:2408',
|
||||
},
|
||||
],
|
||||
mtu: 1280,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
const realityStreamSettings = JSON.stringify(
|
||||
{
|
||||
network: 'tcp',
|
||||
security: 'reality',
|
||||
realitySettings: {
|
||||
show: false,
|
||||
dest: 'yahoo.com:443',
|
||||
xver: 0,
|
||||
serverNames: ['yahoo.com', 'www.yahoo.com'],
|
||||
privateKey: 'wLc4dpQvRt8mK1nS9jH2fXaU7yEoB3iZ6vNqTgCkW5A',
|
||||
shortIds: ['6ba85179e30d4fc2'],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
const brokenInboundSettings = [
|
||||
'{',
|
||||
' "clients": [',
|
||||
' {',
|
||||
' "id": "9f4c3a2b-7d61-4e8a-b5c0-1f2e3d4a5b6c",',
|
||||
' "email": "user1@node-de",',
|
||||
' "flow": "xtls-rprx-vision",',
|
||||
' }',
|
||||
' ],',
|
||||
' "decryption": "none"',
|
||||
].join('\n');
|
||||
|
||||
function ControlledDemo() {
|
||||
const [value, setValue] = useState(warpOutbound);
|
||||
let parseError = '';
|
||||
try {
|
||||
JSON.parse(value);
|
||||
} catch (err) {
|
||||
parseError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<JsonEditor value={value} onChange={setValue} minHeight="220px" maxHeight="360px" />
|
||||
<Typography.Text type={parseError ? 'danger' : 'success'}>
|
||||
{parseError ? `Parse error: ${parseError}` : `Valid JSON (${value.length} chars)`}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Form/JsonEditor',
|
||||
component: JsonEditor,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'CodeMirror-based JSON editor with syntax highlighting, live parse linting, and theme-aware styling. The panel uses it for raw xray config snippets — inbound settings, stream settings, and outbound JSON in the modals and settings pages.',
|
||||
},
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<ThemeProvider>
|
||||
<Story />
|
||||
</ThemeProvider>
|
||||
),
|
||||
],
|
||||
argTypes: {
|
||||
value: { description: 'JSON document text; the editor resyncs when this prop changes.' },
|
||||
onChange: { description: 'Called with the full document text on every edit.' },
|
||||
minHeight: { description: 'CSS min-height of the scrollable editor area.' },
|
||||
maxHeight: { description: 'CSS max-height before the editor scrolls internally.' },
|
||||
readOnly: { description: 'Disables editing while keeping selection and scrolling.' },
|
||||
},
|
||||
} satisfies Meta<typeof JsonEditor>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: { value: warpOutbound },
|
||||
};
|
||||
|
||||
export const ReadOnly: Story = {
|
||||
args: { value: realityStreamSettings, readOnly: true, minHeight: '200px' },
|
||||
};
|
||||
|
||||
export const LintErrors: Story = {
|
||||
args: { value: brokenInboundSettings, minHeight: '220px' },
|
||||
};
|
||||
|
||||
export const Controlled: Story = {
|
||||
args: { value: '' },
|
||||
parameters: {
|
||||
a11y: {
|
||||
config: {
|
||||
rules: [{ id: 'scrollable-region-focusable', enabled: false }],
|
||||
},
|
||||
},
|
||||
},
|
||||
render: () => <ControlledDemo />,
|
||||
};
|
||||
@@ -120,6 +120,7 @@ const JsonEditor = forwardRef<JsonEditorHandle, JsonEditorProps>(function JsonEd
|
||||
doc: value,
|
||||
extensions: [
|
||||
basicSetup,
|
||||
EditorView.contentAttributes.of({ 'aria-label': t('jsonEditor') }),
|
||||
keymap.of([indentWithTab]),
|
||||
json(),
|
||||
linter(jsonParseLinter()),
|
||||
|
||||
68
frontend/src/components/form/RemarkTemplateField.stories.tsx
Normal file
68
frontend/src/components/form/RemarkTemplateField.stories.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import RemarkTemplateField from './RemarkTemplateField';
|
||||
|
||||
const meta = {
|
||||
title: 'Form/RemarkTemplateField',
|
||||
component: RemarkTemplateField,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Text input augmented with a {{VAR}} token picker (insert-at-caret) and a live sample-based preview of the expanded remark. The panel uses it in subscription settings for the global Remark Template.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
value: { description: 'Current template string; any {{VAR}} token enables the live preview below the input.' },
|
||||
onChange: { description: 'Called with the updated template on typing or token insertion.' },
|
||||
maxLength: { description: 'Maximum template length; picker insertions are clamped to it.' },
|
||||
placeholder: { description: 'Placeholder shown while the template is empty.' },
|
||||
},
|
||||
} satisfies Meta<typeof RemarkTemplateField>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
function InteractiveDemo() {
|
||||
const [value, setValue] = useState('{{STATUS_EMOJI}} {{INBOUND}}-{{EMAIL}} | {{TRAFFIC_LEFT}}');
|
||||
return <RemarkTemplateField value={value} onChange={setValue} maxLength={256} placeholder="{{INBOUND}}-{{EMAIL}}" />;
|
||||
}
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
value: '',
|
||||
onChange: () => undefined,
|
||||
placeholder: '{{INBOUND}}-{{EMAIL}}',
|
||||
},
|
||||
};
|
||||
|
||||
export const TokenTemplate: Story = {
|
||||
args: {
|
||||
value: '{{INBOUND}}-{{EMAIL}} | {{TRAFFIC_LEFT}} left | {{DAYS_LEFT}}d',
|
||||
onChange: () => undefined,
|
||||
maxLength: 256,
|
||||
placeholder: '{{INBOUND}}-{{EMAIL}}',
|
||||
},
|
||||
};
|
||||
|
||||
export const PlainRemark: Story = {
|
||||
args: {
|
||||
value: 'Germany CDN node',
|
||||
onChange: () => undefined,
|
||||
maxLength: 256,
|
||||
placeholder: '{{INBOUND}}-{{EMAIL}}',
|
||||
},
|
||||
};
|
||||
|
||||
export const Interactive: Story = {
|
||||
args: {
|
||||
value: '',
|
||||
onChange: () => undefined,
|
||||
},
|
||||
render: () => <InteractiveDemo />,
|
||||
};
|
||||
78
frontend/src/components/form/RemarkVarPicker.stories.tsx
Normal file
78
frontend/src/components/form/RemarkVarPicker.stories.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Button, Input, Popover, Typography } from 'antd';
|
||||
|
||||
import { previewRemark, wrapToken } from '@/lib/remark/remarkVariables';
|
||||
|
||||
import RemarkVarPicker from './RemarkVarPicker';
|
||||
|
||||
const meta = {
|
||||
title: 'Form/RemarkVarPicker',
|
||||
component: RemarkVarPicker,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Grouped, tooltipped chip list of the {{VAR}} tokens the backend substitutes per client in subscription remarks. The hosts page shows it in a popover beside the remark-template field so operators can insert placeholders like {{EMAIL}} or {{TRAFFIC_LEFT}}.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
onPick: { description: 'Called with the bare token (e.g. "EMAIL") when a chip is clicked or activated via keyboard.' },
|
||||
},
|
||||
} satisfies Meta<typeof RemarkVarPicker>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
function TemplateBuilderDemo() {
|
||||
const [template, setTemplate] = useState('{{INBOUND}}-{{EMAIL}} {{STATUS_EMOJI}} {{TRAFFIC_LEFT}} left');
|
||||
return (
|
||||
<div style={{ maxWidth: 520 }}>
|
||||
<Input
|
||||
value={template}
|
||||
onChange={(e) => setTemplate(e.target.value)}
|
||||
aria-label="Remark template"
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
/>
|
||||
<Typography.Paragraph type="secondary" style={{ margin: '8px 0 16px' }}>
|
||||
Preview: {previewRemark(template)}
|
||||
</Typography.Paragraph>
|
||||
<RemarkVarPicker onPick={(token) => setTemplate((prev) => `${prev}${wrapToken(token)}`)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverDemo() {
|
||||
const [lastPicked, setLastPicked] = useState('');
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
content={<RemarkVarPicker onPick={(token) => setLastPicked(wrapToken(token))} />}
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
title="Remark variables"
|
||||
>
|
||||
<Button>Insert variable</Button>
|
||||
</Popover>
|
||||
<div style={{ marginTop: 12 }}>Last picked: {lastPicked || '—'}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const Default: Story = {
|
||||
args: { onPick: () => undefined },
|
||||
};
|
||||
|
||||
export const TemplateBuilder: Story = {
|
||||
args: { onPick: () => undefined },
|
||||
render: () => <TemplateBuilderDemo />,
|
||||
};
|
||||
|
||||
export const InsidePopover: Story = {
|
||||
args: { onPick: () => undefined },
|
||||
render: () => <PopoverDemo />,
|
||||
};
|
||||
132
frontend/src/components/form/SelectAllClearButtons.stories.tsx
Normal file
132
frontend/src/components/form/SelectAllClearButtons.stories.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect } from 'storybook/test';
|
||||
import { Select } from 'antd';
|
||||
|
||||
import SelectAllClearButtons from './SelectAllClearButtons';
|
||||
|
||||
const inboundOptions: Array<{ value: number; label: string }> = [
|
||||
{ value: 1, label: 'VLESS Reality — 443' },
|
||||
{ value: 2, label: 'VMess WS — 8443' },
|
||||
{ value: 3, label: 'Trojan TCP — 2053' },
|
||||
{ value: 4, label: 'Shadowsocks — 8388' },
|
||||
];
|
||||
|
||||
const clientEmailOptions: Array<{ value: string; label: string }> = [
|
||||
{ value: 'ava@corp.example', label: 'ava@corp.example' },
|
||||
{ value: 'reza.mobile', label: 'reza.mobile' },
|
||||
{ value: 'office-tv', label: 'office-tv' },
|
||||
{ value: 'guest-42', label: 'guest-42' },
|
||||
];
|
||||
|
||||
const meta = {
|
||||
title: 'Form/SelectAllClearButtons',
|
||||
component: SelectAllClearButtons,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Small "Select all" / "Clear all" button pair rendered above a multi-select. The panel places it over the attached-inbounds picker in the client form and the bulk attach/detach modals.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
options: { description: 'Option list whose values define the "all" set; matches the AntD Select option shape.' },
|
||||
value: { description: 'Currently selected values (controlled).' },
|
||||
onChange: { description: 'Called with the union of the current selection and every option value, or with an empty array on clear.' },
|
||||
selectAllLabel: { description: 'Override for the "Select all" button text; defaults to the translated inbound copy.' },
|
||||
clearLabel: { description: 'Override for the "Clear all" button text; defaults to the translated inbound copy.' },
|
||||
},
|
||||
} satisfies Meta<typeof SelectAllClearButtons>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
function InboundPickerDemo() {
|
||||
const [selected, setSelected] = useState<number[]>([1]);
|
||||
return (
|
||||
<div style={{ maxWidth: 360 }}>
|
||||
<SelectAllClearButtons options={inboundOptions} value={selected} onChange={setSelected} />
|
||||
<Select
|
||||
mode="multiple"
|
||||
style={{ width: '100%' }}
|
||||
value={selected}
|
||||
onChange={setSelected}
|
||||
options={inboundOptions}
|
||||
placeholder="Select inbounds"
|
||||
aria-label="Select inbounds"
|
||||
maxTagCount="responsive"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientEmailsDemo() {
|
||||
const [selected, setSelected] = useState<string[]>(['ava@corp.example']);
|
||||
return (
|
||||
<div style={{ maxWidth: 360 }}>
|
||||
<SelectAllClearButtons
|
||||
options={clientEmailOptions}
|
||||
value={selected}
|
||||
onChange={setSelected}
|
||||
selectAllLabel="Select all clients"
|
||||
clearLabel="Deselect clients"
|
||||
/>
|
||||
<Select
|
||||
mode="multiple"
|
||||
style={{ width: '100%' }}
|
||||
value={selected}
|
||||
onChange={setSelected}
|
||||
options={clientEmailOptions}
|
||||
placeholder="Select clients"
|
||||
aria-label="Select clients"
|
||||
maxTagCount="responsive"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const placeholderArgs = {
|
||||
options: [],
|
||||
value: [],
|
||||
onChange: () => undefined,
|
||||
};
|
||||
|
||||
export const PartiallySelected: Story = {
|
||||
args: {
|
||||
options: [{ value: 1 }, { value: 2 }, { value: 3 }, { value: 4 }],
|
||||
value: [1, 3],
|
||||
onChange: () => undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const AllSelected: Story = {
|
||||
args: {
|
||||
options: [{ value: 1 }, { value: 2 }, { value: 3 }],
|
||||
value: [1, 2, 3],
|
||||
onChange: () => undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithInboundSelect: Story = {
|
||||
args: placeholderArgs,
|
||||
render: () => <InboundPickerDemo />,
|
||||
};
|
||||
|
||||
export const CustomLabels: Story = {
|
||||
args: placeholderArgs,
|
||||
render: () => <ClientEmailsDemo />,
|
||||
play: async ({ canvas, userEvent }) => {
|
||||
const selectAll = canvas.getByRole('button', { name: 'Select all clients' });
|
||||
const clearAll = canvas.getByRole('button', { name: 'Deselect clients' });
|
||||
await expect(selectAll).toBeEnabled();
|
||||
await userEvent.click(selectAll);
|
||||
await expect(selectAll).toBeDisabled();
|
||||
await userEvent.click(clearAll);
|
||||
await expect(clearAll).toBeDisabled();
|
||||
await expect(selectAll).toBeEnabled();
|
||||
},
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
export { default as DateTimePicker } from './DateTimePicker';
|
||||
export { default as JsonEditor } from './JsonEditor';
|
||||
export { default as HeaderMapEditor } from './HeaderMapEditor';
|
||||
export { default as GoRegexInput, validateGoRegex } from './GoRegexInput';
|
||||
export { default as SelectAllClearButtons } from './SelectAllClearButtons';
|
||||
export { default as RemarkTemplateField } from './RemarkTemplateField';
|
||||
export { default as RemarkVarPicker } from './RemarkVarPicker';
|
||||
|
||||
210
frontend/src/components/form/rhf/FormField.stories.tsx
Normal file
210
frontend/src/components/form/rhf/FormField.stories.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, waitFor } from 'storybook/test';
|
||||
import { Button, Form, Input, InputNumber, Select, Switch, Typography } from 'antd';
|
||||
import { FormProvider } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FormField } from './FormField';
|
||||
import { useZodForm } from './useZodForm';
|
||||
|
||||
const GB = 1024 * 1024 * 1024;
|
||||
|
||||
const meta = {
|
||||
title: 'Form/RHF/FormField',
|
||||
component: FormField,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Bridges one Ant Design control into react-hook-form: wraps the child in a Controller plus Form.Item, normalizes the onChange payload, and surfaces resolver errors as translated help text. Every RHF panel form (client, inbound, outbound, and host modals) builds its fields with it.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
name: { description: 'Field path — a dotted string or an array of segments joined with dots.' },
|
||||
control: { description: 'Optional react-hook-form control; falls back to the surrounding FormProvider.' },
|
||||
label: { description: 'Form.Item label.' },
|
||||
tooltip: { description: 'Form.Item tooltip shown next to the label.' },
|
||||
extra: { description: 'Helper text rendered below the input.' },
|
||||
valueProp: { description: 'Prop the child receives the value on: `value` (default) or `checked` for switches.' },
|
||||
transform: { description: 'Optional input/output mappers, e.g. bytes stored in the form but GB shown in the input.' },
|
||||
onAfterChange: { description: 'Called with the stored value after every change.' },
|
||||
rules: { description: 'Controller-level validation rules applied on top of the form resolver.' },
|
||||
required: { description: 'Marks the label with the required asterisk.' },
|
||||
noStyle: { description: 'Render the bare input without Form.Item chrome.' },
|
||||
children: { description: 'The single Ant Design control to wire up.' },
|
||||
},
|
||||
} satisfies Meta<typeof FormField>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const ClientSchema = z.object({
|
||||
email: z.string(),
|
||||
flow: z.string(),
|
||||
enable: z.boolean(),
|
||||
});
|
||||
|
||||
function ClientDemo() {
|
||||
const methods = useZodForm(ClientSchema, {
|
||||
defaultValues: { email: 'user1@example.com', flow: 'xtls-rprx-vision', enable: true },
|
||||
});
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<Form layout="vertical" style={{ maxWidth: 360 }}>
|
||||
<FormField name="email" label="Email" tooltip="Unique identifier used to match client traffic" required>
|
||||
<Input placeholder="user1@example.com" />
|
||||
</FormField>
|
||||
<FormField name="flow" label="Flow" extra="Only applies to VLESS over raw TLS">
|
||||
<Select
|
||||
options={[
|
||||
{ label: 'none', value: '' },
|
||||
{ label: 'xtls-rprx-vision', value: 'xtls-rprx-vision' },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField name="enable" label="Enable" valueProp="checked">
|
||||
<Switch />
|
||||
</FormField>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const TrafficSchema = z.object({
|
||||
totalBytes: z.number(),
|
||||
});
|
||||
|
||||
function TrafficDemo() {
|
||||
const methods = useZodForm(TrafficSchema, { defaultValues: { totalBytes: 50 * GB } });
|
||||
const totalBytes = methods.watch('totalBytes');
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<Form layout="vertical" style={{ maxWidth: 360 }}>
|
||||
<FormField
|
||||
name="totalBytes"
|
||||
label="Total traffic (GB)"
|
||||
extra="Stored on the client as bytes; 0 means unlimited"
|
||||
transform={{
|
||||
input: (value) => (typeof value === 'number' ? value / GB : value),
|
||||
output: (value) => (typeof value === 'number' ? value * GB : 0),
|
||||
}}
|
||||
>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</FormField>
|
||||
<Typography.Text type="secondary">Form state: {totalBytes.toLocaleString()} bytes</Typography.Text>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const InboundSchema = z.object({
|
||||
remark: z.string().min(1, 'Remark is required'),
|
||||
port: z
|
||||
.number()
|
||||
.min(1, 'Port must be between 1 and 65535')
|
||||
.max(65535, 'Port must be between 1 and 65535'),
|
||||
});
|
||||
|
||||
function ValidationDemo() {
|
||||
const methods = useZodForm(InboundSchema, { defaultValues: { remark: '', port: 0 } });
|
||||
useEffect(() => {
|
||||
void methods.trigger();
|
||||
}, [methods]);
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<Form layout="vertical" style={{ maxWidth: 360 }}>
|
||||
<FormField name="remark" label="Remark" required>
|
||||
<Input placeholder="vless-reality-443" />
|
||||
</FormField>
|
||||
<FormField name="port" label="Port" required>
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
</FormField>
|
||||
<Button onClick={() => void methods.trigger()}>Validate</Button>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const RealitySchema = z.object({
|
||||
streamSettings: z.object({
|
||||
realitySettings: z.object({
|
||||
dest: z.string(),
|
||||
serverNames: z.string(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
function NestedDemo() {
|
||||
const methods = useZodForm(RealitySchema, {
|
||||
defaultValues: {
|
||||
streamSettings: {
|
||||
realitySettings: { dest: 'yahoo.com:443', serverNames: 'yahoo.com,www.yahoo.com' },
|
||||
},
|
||||
},
|
||||
});
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<Form layout="vertical" style={{ maxWidth: 360 }}>
|
||||
<FormField
|
||||
name={['streamSettings', 'realitySettings', 'dest']}
|
||||
label="Dest"
|
||||
tooltip="Camouflage target the REALITY handshake is proxied to"
|
||||
>
|
||||
<Input />
|
||||
</FormField>
|
||||
<FormField
|
||||
name="streamSettings.realitySettings.serverNames"
|
||||
label="Server names"
|
||||
extra="Comma-separated SNI list offered to clients"
|
||||
>
|
||||
<Input />
|
||||
</FormField>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const placeholderArgs = {
|
||||
name: 'email',
|
||||
children: <Input />,
|
||||
};
|
||||
|
||||
export const ClientFields: Story = {
|
||||
args: placeholderArgs,
|
||||
render: () => <ClientDemo />,
|
||||
};
|
||||
|
||||
export const TrafficTransform: Story = {
|
||||
args: placeholderArgs,
|
||||
render: () => <TrafficDemo />,
|
||||
play: async ({ canvas, userEvent }) => {
|
||||
const input = canvas.getByRole('spinbutton');
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, '100');
|
||||
await expect(await canvas.findByText(/107,374,182,400 bytes/)).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const ValidationErrors: Story = {
|
||||
args: placeholderArgs,
|
||||
render: () => <ValidationDemo />,
|
||||
play: async ({ canvas, userEvent }) => {
|
||||
const remarkError = await canvas.findByText('Remark is required');
|
||||
await waitFor(() => expect(remarkError).toBeVisible());
|
||||
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' }));
|
||||
await waitFor(() => expect(canvas.queryByText('Remark is required')).not.toBeInTheDocument());
|
||||
await expect(canvas.getByText('Port must be between 1 and 65535')).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const NestedNames: Story = {
|
||||
args: placeholderArgs,
|
||||
render: () => <NestedDemo />,
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cloneElement } from 'react';
|
||||
import { cloneElement, useId } from 'react';
|
||||
import type { CSSProperties, ReactElement, ReactNode } from 'react';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import type { Control, ControllerProps, FieldValues, Path } from 'react-hook-form';
|
||||
@@ -48,6 +48,9 @@ export function FormField<T extends FieldValues = FieldValues>({
|
||||
}: FormFieldProps<T>) {
|
||||
const { t } = useTranslation();
|
||||
const dottedName = toDotted(name) as Path<T>;
|
||||
const generatedId = useId();
|
||||
const explicitId = (children as ReactElement<{ id?: string }>).props.id;
|
||||
const fieldId = explicitId ?? generatedId;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
@@ -60,6 +63,7 @@ export function FormField<T extends FieldValues = FieldValues>({
|
||||
? t(fieldState.error.message, { defaultValue: fieldState.error.message })
|
||||
: undefined;
|
||||
const childProps: Record<string, unknown> = {
|
||||
id: fieldId,
|
||||
[valueProp]: displayValue,
|
||||
onChange: (...args: unknown[]) => {
|
||||
const raw = normalizeAntdOnChange(args, valueProp);
|
||||
@@ -73,6 +77,7 @@ export function FormField<T extends FieldValues = FieldValues>({
|
||||
return (
|
||||
<Form.Item
|
||||
label={label}
|
||||
htmlFor={label ? fieldId : undefined}
|
||||
tooltip={tooltip}
|
||||
extra={extra}
|
||||
required={required}
|
||||
|
||||
@@ -6,6 +6,18 @@ const meta = {
|
||||
title: 'UI/InfinityIcon',
|
||||
component: InfinityIcon,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Inline SVG infinity glyph used to denote an unlimited value (e.g. unlimited traffic or no expiry). Inherits the current text color.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
width: { description: 'Icon width in pixels or any CSS length.' },
|
||||
height: { description: 'Icon height in pixels or any CSS length.' },
|
||||
},
|
||||
} satisfies Meta<typeof InfinityIcon>;
|
||||
|
||||
export default meta;
|
||||
|
||||
@@ -7,6 +7,21 @@ const meta = {
|
||||
title: 'UI/InputAddon',
|
||||
component: InputAddon,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Prefix/suffix addon styled to sit flush against an Ant Design input. Becomes a keyboard-accessible button (role, tabIndex, Enter/Space) when `onClick` is provided.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
children: { description: 'Addon content (text or an icon).' },
|
||||
onClick: { description: 'When set, the addon becomes an activatable button.' },
|
||||
ariaLabel: { description: 'Accessible label; used only when `onClick` is set.' },
|
||||
className: { description: 'Extra CSS class appended to the addon.' },
|
||||
style: { description: 'Inline styles for the addon element.' },
|
||||
},
|
||||
} satisfies Meta<typeof InputAddon>;
|
||||
|
||||
export default meta;
|
||||
@@ -26,7 +41,7 @@ export const BesideInput: Story = {
|
||||
render: () => (
|
||||
<Space.Compact>
|
||||
<InputAddon>https://</InputAddon>
|
||||
<Input defaultValue="panel.example.com" style={{ width: 220 }} />
|
||||
<Input defaultValue="panel.example.com" aria-label="Panel host" style={{ width: 220 }} />
|
||||
</Space.Compact>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -7,7 +7,22 @@ const meta = {
|
||||
title: 'UI/SettingListItem',
|
||||
component: SettingListItem,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'padded' },
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Two-column settings row: a title and description on the left, and a control (Switch, InputNumber, …) on the right. Associates the title with the control via `aria-labelledby` for accessibility.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
title: { description: 'Setting name shown on the left.' },
|
||||
description: { description: 'Secondary help text under the title.' },
|
||||
control: { description: 'The control rendered on the right (Switch, InputNumber, …).' },
|
||||
children: { description: 'Alternative to `control`; used when no explicit control is passed.' },
|
||||
paddings: { description: 'Row density: `default` or the tighter `small`.' },
|
||||
},
|
||||
} satisfies Meta<typeof SettingListItem>;
|
||||
|
||||
export default meta;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { AllSetting } from '@/models/setting';
|
||||
import { EmailNotifications } from './EmailNotifications';
|
||||
|
||||
const meta = {
|
||||
title: 'UI/Notifications/EmailNotifications',
|
||||
component: EmailNotifications,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Grid of grouped event checkboxes on the settings page that picks which panel events (outbound/node health, Xray crashes, CPU/RAM thresholds, login attempts) trigger an SMTP email, stored as a comma-separated list in smtpEnabledEvents.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
allSetting: {
|
||||
description:
|
||||
'Panel settings snapshot; smtpEnabledEvents holds the selected event keys and smtpCpu/smtpMemory the alert threshold percentages.',
|
||||
},
|
||||
updateSetting: {
|
||||
description: 'Receives a partial settings patch when an event is toggled or a threshold input changes.',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof EmailNotifications>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
function StatefulDemo({ initial }: { initial: AllSetting }) {
|
||||
const [settings, setSettings] = useState(initial);
|
||||
return (
|
||||
<EmailNotifications
|
||||
allSetting={settings}
|
||||
updateSetting={(patch) => setSettings((prev) => new AllSetting({ ...prev, ...patch }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const placeholderArgs = {
|
||||
allSetting: new AllSetting(),
|
||||
updateSetting: () => undefined,
|
||||
};
|
||||
|
||||
export const NothingSelected: Story = {
|
||||
args: placeholderArgs,
|
||||
render: () => <StatefulDemo initial={new AllSetting()} />,
|
||||
};
|
||||
|
||||
export const SystemThresholdAlerts: Story = {
|
||||
args: placeholderArgs,
|
||||
render: () => (
|
||||
<StatefulDemo
|
||||
initial={new AllSetting({ smtpEnabledEvents: 'cpu.high,memory.high', smtpCpu: 85, smtpMemory: 90 })}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const InfrastructureOnly: Story = {
|
||||
args: placeholderArgs,
|
||||
render: () => (
|
||||
<StatefulDemo initial={new AllSetting({ smtpEnabledEvents: 'outbound.down,node.down,node.up,xray.crash' })} />
|
||||
),
|
||||
};
|
||||
|
||||
export const AllEventsEnabled: Story = {
|
||||
args: placeholderArgs,
|
||||
render: () => (
|
||||
<StatefulDemo
|
||||
initial={
|
||||
new AllSetting({
|
||||
smtpEnabledEvents:
|
||||
'outbound.down,outbound.up,xray.crash,node.down,node.up,cpu.high,memory.high,login.attempt',
|
||||
smtpCpu: 80,
|
||||
smtpMemory: 80,
|
||||
})
|
||||
}
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -10,7 +10,14 @@ const GROUPS: NotificationGroupConfig[] = [
|
||||
icon: <CloudServerOutlined />,
|
||||
title: 'eventGroupOutbound',
|
||||
events: [
|
||||
{ key: 'outbound.down', label: 'eventOutboundDown', settingKey: '' },
|
||||
{
|
||||
key: 'outbound.down',
|
||||
label: 'eventOutboundDown',
|
||||
settingKey: 'outboundDownThreshold',
|
||||
extra: ({ value, onChange, ariaLabel }) => (
|
||||
<InputNumber size="small" min={1} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
|
||||
),
|
||||
},
|
||||
{ key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' },
|
||||
],
|
||||
},
|
||||
@@ -37,16 +44,16 @@ const GROUPS: NotificationGroupConfig[] = [
|
||||
key: 'cpu.high',
|
||||
label: 'eventCPUHigh',
|
||||
settingKey: 'smtpCpu',
|
||||
extra: ({ value, onChange }) => (
|
||||
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} style={{ width: 80 }} />
|
||||
extra: ({ value, onChange, ariaLabel }) => (
|
||||
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'memory.high',
|
||||
label: 'eventMemoryHigh',
|
||||
settingKey: 'smtpMemory',
|
||||
extra: ({ value, onChange }) => (
|
||||
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} style={{ width: 80 }} />
|
||||
extra: ({ value, onChange, ariaLabel }) => (
|
||||
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
|
||||
),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -8,7 +8,21 @@ const meta = {
|
||||
title: 'UI/Notifications/NotificationCard',
|
||||
component: NotificationCard,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'padded' },
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Small outlined card that groups a notification channel — an icon and title in the header, a control in the top-right `extra` slot (typically a toggle), and the channel settings as its body.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
icon: { description: 'Leading icon shown before the title.' },
|
||||
title: { description: 'Channel name shown in the header.' },
|
||||
extra: { description: 'Top-right slot, typically an enable/disable Switch.' },
|
||||
children: { description: 'Card body — the channel settings.' },
|
||||
},
|
||||
} satisfies Meta<typeof NotificationCard>;
|
||||
|
||||
export default meta;
|
||||
@@ -19,7 +33,7 @@ export const Default: Story = {
|
||||
args: {
|
||||
icon: <BellOutlined />,
|
||||
title: 'Telegram',
|
||||
extra: <Switch defaultChecked />,
|
||||
extra: <Switch defaultChecked aria-label="Enable Telegram notifications" />,
|
||||
children: <span>Push a message to the configured chat when an event fires.</span>,
|
||||
},
|
||||
};
|
||||
@@ -28,7 +42,7 @@ export const Disabled: Story = {
|
||||
args: {
|
||||
icon: <BellOutlined />,
|
||||
title: 'Email',
|
||||
extra: <Switch />,
|
||||
extra: <Switch aria-label="Enable email notifications" />,
|
||||
children: <span>Email delivery is turned off for this channel.</span>,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { InputNumber } from 'antd';
|
||||
|
||||
import { NotificationEvent } from './NotificationEvent';
|
||||
|
||||
const meta = {
|
||||
title: 'UI/Notifications/NotificationEvent',
|
||||
component: NotificationEvent,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Single toggleable notification event row used inside the Telegram and email notification groups on the settings page. Renders a checkbox with a translated label and, when checked, an optional indented extra control such as a threshold input.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
label: { description: 'i18n key (or already-translated text) shown next to the checkbox.' },
|
||||
checked: { description: 'Whether the event notification is enabled.' },
|
||||
onToggle: { description: 'Called when the checkbox is clicked.' },
|
||||
children: { description: 'Extra control rendered indented below the label while checked.' },
|
||||
},
|
||||
} satisfies Meta<typeof NotificationEvent>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
function CpuThresholdDemo() {
|
||||
const [checked, setChecked] = useState(true);
|
||||
const [threshold, setThreshold] = useState(80);
|
||||
return (
|
||||
<NotificationEvent
|
||||
label="pages.settings.eventCPUHigh"
|
||||
checked={checked}
|
||||
onToggle={() => setChecked((prev) => !prev)}
|
||||
>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
max={100}
|
||||
value={threshold}
|
||||
onChange={(v) => setThreshold(v ?? 0)}
|
||||
aria-label="CPU usage threshold percent"
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
</NotificationEvent>
|
||||
);
|
||||
}
|
||||
|
||||
export const Unchecked: Story = {
|
||||
args: {
|
||||
label: 'pages.settings.eventLoginAttempt',
|
||||
checked: false,
|
||||
onToggle: () => undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const Checked: Story = {
|
||||
args: {
|
||||
label: 'pages.settings.eventXrayCrash',
|
||||
checked: true,
|
||||
onToggle: () => undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const CpuThreshold: Story = {
|
||||
args: {
|
||||
label: 'pages.settings.eventCPUHigh',
|
||||
checked: true,
|
||||
onToggle: () => undefined,
|
||||
},
|
||||
render: () => <CpuThresholdDemo />,
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { InputNumber } from 'antd';
|
||||
import { CloudServerOutlined, DashboardOutlined } from '@ant-design/icons';
|
||||
|
||||
import { AllSetting } from '@/models/setting';
|
||||
import { NotificationGroup } from './NotificationGroup';
|
||||
import type { NotificationGroupConfig } from './types';
|
||||
|
||||
const systemGroup: NotificationGroupConfig = {
|
||||
icon: <DashboardOutlined />,
|
||||
title: 'eventGroupSystem',
|
||||
events: [
|
||||
{
|
||||
key: 'cpu.high',
|
||||
label: 'eventCPUHigh',
|
||||
settingKey: 'tgCpu',
|
||||
extra: ({ value, onChange, ariaLabel }) => (
|
||||
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'memory.high',
|
||||
label: 'eventMemoryHigh',
|
||||
settingKey: 'tgMemory',
|
||||
extra: ({ value, onChange, ariaLabel }) => (
|
||||
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const outboundGroup: NotificationGroupConfig = {
|
||||
icon: <CloudServerOutlined />,
|
||||
title: 'eventGroupOutbound',
|
||||
events: [
|
||||
{ key: 'outbound.down', label: 'eventOutboundDown', settingKey: '' },
|
||||
{ key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' },
|
||||
],
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: 'UI/Notifications/NotificationGroup',
|
||||
component: NotificationGroup,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Card for one notification event group (outbound, Xray, node, system, security) with a per-group select-all checkbox, a selected-count tag, and optional per-event threshold inputs. Composed by the Telegram and email notification tabs on the settings page.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
config: { description: 'Group definition: icon, `pages.settings` title key, and the event rows to render.' },
|
||||
selected: { description: 'Enabled event keys; drives each checkbox and the header count.' },
|
||||
onToggle: { description: 'Called with the event key when a single checkbox is clicked.' },
|
||||
onToggleAll: { description: 'Called with every event key in the group when the master checkbox is clicked.' },
|
||||
allSetting: { description: 'Panel settings snapshot; threshold values such as `tgCpu` are read from it.' },
|
||||
updateSetting: { description: 'Called with a partial settings patch when a threshold input changes.' },
|
||||
},
|
||||
} satisfies Meta<typeof NotificationGroup>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
function Demo() {
|
||||
const [selected, setSelected] = useState<string[]>(['cpu.high']);
|
||||
const [settings, setSettings] = useState(new AllSetting({ tgCpu: 85, tgMemory: 90 }));
|
||||
return (
|
||||
<NotificationGroup
|
||||
config={systemGroup}
|
||||
selected={selected}
|
||||
onToggle={(key) =>
|
||||
setSelected((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]))
|
||||
}
|
||||
onToggleAll={(keys) =>
|
||||
setSelected((prev) => (keys.every((k) => prev.includes(k)) ? prev.filter((k) => !keys.includes(k)) : [...new Set([...prev, ...keys])]))
|
||||
}
|
||||
allSetting={settings}
|
||||
updateSetting={(patch) => setSettings((prev) => new AllSetting({ ...prev, ...patch }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const AllSelected: Story = {
|
||||
args: {
|
||||
config: systemGroup,
|
||||
selected: ['cpu.high', 'memory.high'],
|
||||
onToggle: () => undefined,
|
||||
onToggleAll: () => undefined,
|
||||
allSetting: new AllSetting({ tgCpu: 85, tgMemory: 90 }),
|
||||
updateSetting: () => undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const PartiallySelected: Story = {
|
||||
args: {
|
||||
config: systemGroup,
|
||||
selected: ['cpu.high'],
|
||||
onToggle: () => undefined,
|
||||
onToggleAll: () => undefined,
|
||||
allSetting: new AllSetting(),
|
||||
updateSetting: () => undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const NoneSelected: Story = {
|
||||
args: {
|
||||
config: outboundGroup,
|
||||
selected: [],
|
||||
onToggle: () => undefined,
|
||||
onToggleAll: () => undefined,
|
||||
allSetting: new AllSetting(),
|
||||
updateSetting: () => undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const Interactive: Story = {
|
||||
args: {
|
||||
config: systemGroup,
|
||||
selected: [],
|
||||
onToggle: () => undefined,
|
||||
onToggleAll: () => undefined,
|
||||
allSetting: new AllSetting(),
|
||||
updateSetting: () => undefined,
|
||||
},
|
||||
render: () => <Demo />,
|
||||
};
|
||||
@@ -51,6 +51,7 @@ export function NotificationGroup({ config, selected, onToggle, onToggleAll, all
|
||||
{event.extra?.({
|
||||
value: Number((allSetting as unknown as Record<string, unknown>)[event.settingKey]) || 0,
|
||||
onChange: (v) => updateSetting({ [event.settingKey]: v }),
|
||||
ariaLabel: t(`pages.settings.${event.label}`),
|
||||
})}
|
||||
</NotificationEvent>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Checkbox } from 'antd';
|
||||
|
||||
import { NotificationHeader } from './NotificationHeader';
|
||||
|
||||
const meta = {
|
||||
title: 'UI/Notifications/NotificationHeader',
|
||||
component: NotificationHeader,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Selection summary for a notification group header — a `count/total` tag plus a tri-state master checkbox that selects or clears every event in the group. Rendered in the `extra` slot of the Telegram/email notification cards on the settings page.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
count: { description: 'Number of events currently selected in the group.' },
|
||||
total: { description: 'Total number of events the group offers.' },
|
||||
allSelected: { description: 'Checks the master checkbox when every event is selected.' },
|
||||
indeterminate: { description: 'Shows the dash state when only some events are selected.' },
|
||||
onToggleAll: { description: 'Called when the master checkbox is clicked to select or clear all events.' },
|
||||
},
|
||||
} satisfies Meta<typeof NotificationHeader>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const NoneSelected: Story = {
|
||||
args: {
|
||||
count: 0,
|
||||
total: 6,
|
||||
allSelected: false,
|
||||
indeterminate: false,
|
||||
onToggleAll: () => undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const PartialSelection: Story = {
|
||||
args: {
|
||||
count: 3,
|
||||
total: 6,
|
||||
allSelected: false,
|
||||
indeterminate: true,
|
||||
onToggleAll: () => undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const AllSelected: Story = {
|
||||
args: {
|
||||
count: 6,
|
||||
total: 6,
|
||||
allSelected: true,
|
||||
indeterminate: false,
|
||||
onToggleAll: () => undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const events = ['Panel login', 'Xray crashed', 'CPU high', 'Client depleted'];
|
||||
|
||||
function GroupDemo() {
|
||||
const [selected, setSelected] = useState<string[]>(['Panel login', 'CPU high']);
|
||||
const count = selected.length;
|
||||
const total = events.length;
|
||||
function toggleAll() {
|
||||
setSelected(count === total ? [] : [...events]);
|
||||
}
|
||||
function toggle(name: string) {
|
||||
setSelected((prev) => (prev.includes(name) ? prev.filter((e) => e !== name) : [...prev, name]));
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxWidth: 260 }}>
|
||||
<NotificationHeader
|
||||
count={count}
|
||||
total={total}
|
||||
allSelected={count === total}
|
||||
indeterminate={count > 0 && count < total}
|
||||
onToggleAll={toggleAll}
|
||||
/>
|
||||
{events.map((name) => (
|
||||
<Checkbox key={name} checked={selected.includes(name)} onChange={() => toggle(name)}>
|
||||
{name}
|
||||
</Checkbox>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const placeholderArgs = {
|
||||
count: 0,
|
||||
total: 0,
|
||||
allSelected: false,
|
||||
indeterminate: false,
|
||||
onToggleAll: () => undefined,
|
||||
};
|
||||
|
||||
export const Interactive: Story = {
|
||||
args: placeholderArgs,
|
||||
render: () => <GroupDemo />,
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { InputNumber, Space } from 'antd';
|
||||
import {
|
||||
CloudServerOutlined,
|
||||
DashboardOutlined,
|
||||
DesktopOutlined,
|
||||
SafetyOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { NotificationLayout } from './NotificationLayout';
|
||||
import { NotificationCard } from './NotificationCard';
|
||||
import { NotificationEvent } from './NotificationEvent';
|
||||
import { NotificationHeader } from './NotificationHeader';
|
||||
|
||||
const noop = () => undefined;
|
||||
|
||||
function OutboundGroup() {
|
||||
return (
|
||||
<NotificationCard
|
||||
icon={<CloudServerOutlined />}
|
||||
title="Outbound"
|
||||
extra={<NotificationHeader count={1} total={2} allSelected={false} indeterminate onToggleAll={noop} />}
|
||||
>
|
||||
<Space orientation="vertical" size={8} style={{ width: '100%' }}>
|
||||
<NotificationEvent label="Outbound went down" checked onToggle={noop} />
|
||||
<NotificationEvent label="Outbound recovered" checked={false} onToggle={noop} />
|
||||
</Space>
|
||||
</NotificationCard>
|
||||
);
|
||||
}
|
||||
|
||||
function XrayGroup() {
|
||||
return (
|
||||
<NotificationCard
|
||||
icon={<ThunderboltOutlined />}
|
||||
title="Xray"
|
||||
extra={<NotificationHeader count={1} total={1} allSelected indeterminate={false} onToggleAll={noop} />}
|
||||
>
|
||||
<Space orientation="vertical" size={8} style={{ width: '100%' }}>
|
||||
<NotificationEvent label="Xray crashed" checked onToggle={noop} />
|
||||
</Space>
|
||||
</NotificationCard>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeGroup() {
|
||||
return (
|
||||
<NotificationCard
|
||||
icon={<DesktopOutlined />}
|
||||
title="Nodes"
|
||||
extra={<NotificationHeader count={0} total={2} allSelected={false} indeterminate={false} onToggleAll={noop} />}
|
||||
>
|
||||
<Space orientation="vertical" size={8} style={{ width: '100%' }}>
|
||||
<NotificationEvent label="Node went offline" checked={false} onToggle={noop} />
|
||||
<NotificationEvent label="Node back online" checked={false} onToggle={noop} />
|
||||
</Space>
|
||||
</NotificationCard>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemGroup() {
|
||||
return (
|
||||
<NotificationCard
|
||||
icon={<DashboardOutlined />}
|
||||
title="System"
|
||||
extra={<NotificationHeader count={2} total={2} allSelected indeterminate={false} onToggleAll={noop} />}
|
||||
>
|
||||
<Space orientation="vertical" size={8} style={{ width: '100%' }}>
|
||||
<NotificationEvent label="CPU usage above threshold (%)" checked onToggle={noop}>
|
||||
<InputNumber size="small" min={0} max={100} defaultValue={80} aria-label="CPU usage threshold percent" style={{ width: 80 }} />
|
||||
</NotificationEvent>
|
||||
<NotificationEvent label="Memory usage above threshold (%)" checked onToggle={noop}>
|
||||
<InputNumber size="small" min={0} max={100} defaultValue={90} aria-label="Memory usage threshold percent" style={{ width: 80 }} />
|
||||
</NotificationEvent>
|
||||
</Space>
|
||||
</NotificationCard>
|
||||
);
|
||||
}
|
||||
|
||||
function SecurityGroup() {
|
||||
return (
|
||||
<NotificationCard
|
||||
icon={<SafetyOutlined />}
|
||||
title="Security"
|
||||
extra={<NotificationHeader count={1} total={1} allSelected indeterminate={false} onToggleAll={noop} />}
|
||||
>
|
||||
<Space orientation="vertical" size={8} style={{ width: '100%' }}>
|
||||
<NotificationEvent label="Panel login attempt" checked onToggle={noop} />
|
||||
</Space>
|
||||
</NotificationCard>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'UI/Notifications/NotificationLayout',
|
||||
component: NotificationLayout,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Responsive auto-fit grid (min 260px columns) that arranges notification event-group cards; the Telegram and email notification tabs on the settings page render their groups inside it.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
children: { description: 'Grid items, typically one NotificationCard per event group.' },
|
||||
},
|
||||
} satisfies Meta<typeof NotificationLayout>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const AllEventGroups: Story = {
|
||||
args: {
|
||||
children: (
|
||||
<>
|
||||
<OutboundGroup />
|
||||
<XrayGroup />
|
||||
<NodeGroup />
|
||||
<SystemGroup />
|
||||
<SecurityGroup />
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const TwoGroups: Story = {
|
||||
args: {
|
||||
children: (
|
||||
<>
|
||||
<SystemGroup />
|
||||
<SecurityGroup />
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleGroup: Story = {
|
||||
args: {
|
||||
children: <OutboundGroup />,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { AllSetting } from '@/models/setting';
|
||||
import { TelegramNotifications } from './TelegramNotifications';
|
||||
|
||||
const meta = {
|
||||
title: 'UI/Notifications/TelegramNotifications',
|
||||
component: TelegramNotifications,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Grid of event-group cards (outbound, Xray, node, system, security) that pick which panel events the Telegram bot reports, with per-group select-all and CPU/RAM threshold inputs. Used on the settings page Telegram tab to edit `tgEnabledEvents`.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
allSetting: { description: 'Panel settings snapshot; reads `tgEnabledEvents` plus the `tgCpu`/`tgMemory` thresholds.' },
|
||||
updateSetting: { description: 'Called with a partial settings patch when an event toggle or threshold changes.' },
|
||||
},
|
||||
} satisfies Meta<typeof TelegramNotifications>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
function Demo({ initial }: { initial: AllSetting }) {
|
||||
const [settings, setSettings] = useState(initial);
|
||||
return (
|
||||
<TelegramNotifications
|
||||
allSetting={settings}
|
||||
updateSetting={(patch) => setSettings((prev) => new AllSetting({ ...prev, ...patch }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const placeholderArgs = {
|
||||
allSetting: new AllSetting(),
|
||||
updateSetting: () => undefined,
|
||||
};
|
||||
|
||||
export const NothingSelected: Story = {
|
||||
args: placeholderArgs,
|
||||
render: () => <Demo initial={new AllSetting()} />,
|
||||
};
|
||||
|
||||
export const TypicalMonitoring: Story = {
|
||||
args: placeholderArgs,
|
||||
render: () => (
|
||||
<Demo
|
||||
initial={
|
||||
new AllSetting({
|
||||
tgBotEnable: true,
|
||||
tgBotChatId: '123456789',
|
||||
tgEnabledEvents: 'xray.crash,node.down,cpu.high,memory.high,login.attempt',
|
||||
tgCpu: 85,
|
||||
tgMemory: 90,
|
||||
})
|
||||
}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const EverythingEnabled: Story = {
|
||||
args: placeholderArgs,
|
||||
render: () => (
|
||||
<Demo
|
||||
initial={
|
||||
new AllSetting({
|
||||
tgBotEnable: true,
|
||||
tgEnabledEvents:
|
||||
'outbound.down,outbound.up,xray.crash,node.down,node.up,cpu.high,memory.high,login.attempt',
|
||||
tgCpu: 70,
|
||||
tgMemory: 75,
|
||||
})
|
||||
}
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -10,7 +10,14 @@ const GROUPS: NotificationGroupConfig[] = [
|
||||
icon: <CloudServerOutlined />,
|
||||
title: 'eventGroupOutbound',
|
||||
events: [
|
||||
{ key: 'outbound.down', label: 'eventOutboundDown', settingKey: '' },
|
||||
{
|
||||
key: 'outbound.down',
|
||||
label: 'eventOutboundDown',
|
||||
settingKey: 'outboundDownThreshold',
|
||||
extra: ({ value, onChange, ariaLabel }) => (
|
||||
<InputNumber size="small" min={1} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
|
||||
),
|
||||
},
|
||||
{ key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' },
|
||||
],
|
||||
},
|
||||
@@ -37,16 +44,16 @@ const GROUPS: NotificationGroupConfig[] = [
|
||||
key: 'cpu.high',
|
||||
label: 'eventCPUHigh',
|
||||
settingKey: 'tgCpu',
|
||||
extra: ({ value, onChange }) => (
|
||||
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} style={{ width: 80 }} />
|
||||
extra: ({ value, onChange, ariaLabel }) => (
|
||||
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'memory.high',
|
||||
label: 'eventMemoryHigh',
|
||||
settingKey: 'tgMemory',
|
||||
extra: ({ value, onChange }) => (
|
||||
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} style={{ width: 80 }} />
|
||||
extra: ({ value, onChange, ariaLabel }) => (
|
||||
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
|
||||
),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -4,7 +4,7 @@ export interface NotificationEventConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
settingKey: string;
|
||||
extra?: (props: { value: number; onChange: (v: number | null) => void }) => ReactNode;
|
||||
extra?: (props: { value: number; onChange: (v: number | null) => void; ariaLabel: string }) => ReactNode;
|
||||
}
|
||||
|
||||
export interface NotificationGroupConfig {
|
||||
|
||||
138
frontend/src/components/utility/LazyMount.stories.tsx
Normal file
138
frontend/src/components/utility/LazyMount.stories.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
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 LazyMount from './LazyMount';
|
||||
|
||||
const meta = {
|
||||
title: 'Utility/LazyMount',
|
||||
component: LazyMount,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Mounts its children the first time `when` becomes true and keeps them mounted afterwards, wrapped in Suspense. The panel pairs it with React.lazy modal imports on heavy list pages so modals load on demand while their close animations still play.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
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.' },
|
||||
children: { description: 'Content to mount on demand, typically a lazily imported modal.' },
|
||||
},
|
||||
} satisfies Meta<typeof LazyMount>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
function MountBadge() {
|
||||
const [mountedAt] = useState(() => new Date().toLocaleTimeString());
|
||||
return <Tag color="green">mounted at {mountedAt}</Tag>;
|
||||
}
|
||||
|
||||
function OnDemandDemo() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
return (
|
||||
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Space>
|
||||
<Switch checked={visible} onChange={setVisible} aria-label="Mount the client card" />
|
||||
<Typography.Text>when = {String(visible)}</Typography.Text>
|
||||
</Space>
|
||||
<LazyMount when={visible}>
|
||||
<Card size="small" title="alice@corp.example" style={{ maxWidth: 480 }}>
|
||||
<Space orientation="vertical" size={4}>
|
||||
<MountBadge />
|
||||
<Typography.Text type="secondary">Traffic: 42.7 GB of 100 GB</Typography.Text>
|
||||
<Typography.Text code copyable style={{ fontSize: 12 }}>
|
||||
vless://b831381d-6324-4d53-ad4f-8cda48b30811@vpn.example.com:443?type=tcp&security=reality&fp=chrome&sni=yahoo.com#alice
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</LazyMount>
|
||||
<Typography.Text type="secondary">
|
||||
The card mounts the first time the switch turns on and stays mounted after turning it off; the mount time never changes.
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
const xrayConfigSnippet = JSON.stringify(
|
||||
{
|
||||
inbounds: [
|
||||
{
|
||||
tag: 'inbound-443',
|
||||
protocol: 'vless',
|
||||
port: 443,
|
||||
settings: {
|
||||
clients: [{ id: 'b831381d-6324-4d53-ad4f-8cda48b30811', email: 'alice@corp.example', flow: 'xtls-rprx-vision' }],
|
||||
decryption: 'none',
|
||||
},
|
||||
streamSettings: { network: 'tcp', security: 'reality' },
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
function XrayConfigPreview() {
|
||||
return (
|
||||
<Card size="small" title="Generated xray config">
|
||||
<pre style={{ margin: 0, overflowX: 'auto', fontSize: 12 }}>{xrayConfigSnippet}</pre>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const SlowXrayConfigPreview = lazy(
|
||||
() =>
|
||||
new Promise<{ default: typeof XrayConfigPreview }>((resolve) => {
|
||||
setTimeout(() => resolve({ default: XrayConfigPreview }), 1200);
|
||||
}),
|
||||
);
|
||||
|
||||
function LazyChildDemo() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Space orientation="vertical" size="middle" style={{ width: '100%', maxWidth: 560 }}>
|
||||
<Button type="primary" onClick={() => setOpen(true)} disabled={open}>
|
||||
Load config preview
|
||||
</Button>
|
||||
<LazyMount when={open} fallback={<Skeleton active paragraph={{ rows: 4 }} />}>
|
||||
<SlowXrayConfigPreview />
|
||||
</LazyMount>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
const placeholderArgs = {
|
||||
when: false,
|
||||
children: null,
|
||||
};
|
||||
|
||||
export const MountOnDemand: Story = {
|
||||
args: placeholderArgs,
|
||||
render: () => <OnDemandDemo />,
|
||||
};
|
||||
|
||||
export const LazyChildWithFallback: Story = {
|
||||
args: placeholderArgs,
|
||||
render: () => <LazyChildDemo />,
|
||||
};
|
||||
|
||||
export const MountedImmediately: Story = {
|
||||
args: {
|
||||
when: true,
|
||||
children: (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
title="Client depleted"
|
||||
description="bob@corp.example used 100 GB of 100 GB and was disabled by the traffic job."
|
||||
style={{ maxWidth: 480 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
};
|
||||
21
frontend/src/components/utility/speedTagStyle.ts
Normal file
21
frontend/src/components/utility/speedTagStyle.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
export const SPEED_TAG_CLASS_NAME = 'table-speed-tag' as const;
|
||||
export const SPEED_TAG_WIDTH = 200 as const;
|
||||
export const SPEED_TABLE_CELL_INLINE_PADDING = 8 as const;
|
||||
|
||||
export const SPEED_TAG_STYLE = {
|
||||
width: SPEED_TAG_WIDTH,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
marginInlineEnd: 0,
|
||||
boxSizing: 'border-box',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
} as const satisfies CSSProperties;
|
||||
|
||||
export const SPEED_COLUMN_WIDTH = SPEED_TAG_WIDTH + SPEED_TABLE_CELL_INLINE_PADDING * 2;
|
||||
@@ -9,7 +9,27 @@ const meta = {
|
||||
title: 'Viz/Sparkline',
|
||||
component: Sparkline,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'padded' },
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Compact canvas line chart (uPlot) for CPU, memory, and traffic trends. Supports up to three series, optional axes/grid, a hover tooltip, min/max markers, reference lines, and light/dark theming.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
data: { description: 'Primary series values, oldest to newest.' },
|
||||
data2: { description: 'Optional second series (e.g. download vs upload).' },
|
||||
data3: { description: 'Optional third series.' },
|
||||
height: { description: 'Chart height in pixels.' },
|
||||
name1: { description: 'Legend/tooltip label for the primary series.' },
|
||||
name2: { description: 'Legend/tooltip label for the second series.' },
|
||||
showAxes: { description: 'Render x/y axes and tick labels.' },
|
||||
showGrid: { description: 'Draw horizontal grid lines.' },
|
||||
showTooltip: { description: 'Show a value tooltip on hover.' },
|
||||
extrema: { description: 'Highlight the min and max points (single-series only).' },
|
||||
},
|
||||
} satisfies Meta<typeof Sparkline>;
|
||||
|
||||
export default meta;
|
||||
|
||||
@@ -26,6 +26,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"ldapUserAttr": "",
|
||||
"ldapUserFilter": "",
|
||||
"ldapVlessField": "",
|
||||
"outboundDownThreshold": 1,
|
||||
"pageSize": 0,
|
||||
"panelOutbound": "",
|
||||
"remarkTemplate": "",
|
||||
@@ -35,6 +36,8 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"smtpEnable": false,
|
||||
"smtpEnabledEvents": "",
|
||||
"smtpEncryptionType": "",
|
||||
"smtpFrom": "",
|
||||
"smtpFromName": "",
|
||||
"smtpHost": "",
|
||||
"smtpMemory": 0,
|
||||
"smtpPassword": "",
|
||||
@@ -43,11 +46,13 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"smtpUsername": "",
|
||||
"subAnnounce": "",
|
||||
"subCertFile": "",
|
||||
"subClashAutoDetect": false,
|
||||
"subClashEnable": false,
|
||||
"subClashEnableRouting": false,
|
||||
"subClashPath": "",
|
||||
"subClashRules": "",
|
||||
"subClashURI": "",
|
||||
"subClashUserAgentRegex": "",
|
||||
"subDomain": "",
|
||||
"subEnable": false,
|
||||
"subEnableRouting": false,
|
||||
@@ -55,12 +60,15 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subHideSettings": false,
|
||||
"subIncyEnableRouting": false,
|
||||
"subIncyRoutingRules": "",
|
||||
"subJsonAlwaysArray": false,
|
||||
"subJsonAutoDetect": false,
|
||||
"subJsonEnable": false,
|
||||
"subJsonFinalMask": "",
|
||||
"subJsonMux": "",
|
||||
"subJsonPath": "",
|
||||
"subJsonRules": "",
|
||||
"subJsonURI": "",
|
||||
"subJsonUserAgentRegex": "",
|
||||
"subKeyFile": "",
|
||||
"subListen": "",
|
||||
"subPath": "",
|
||||
@@ -129,6 +137,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"ldapUserAttr": "",
|
||||
"ldapUserFilter": "",
|
||||
"ldapVlessField": "",
|
||||
"outboundDownThreshold": 1,
|
||||
"pageSize": 0,
|
||||
"panelOutbound": "",
|
||||
"remarkTemplate": "",
|
||||
@@ -138,6 +147,8 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"smtpEnable": false,
|
||||
"smtpEnabledEvents": "",
|
||||
"smtpEncryptionType": "",
|
||||
"smtpFrom": "",
|
||||
"smtpFromName": "",
|
||||
"smtpHost": "",
|
||||
"smtpMemory": 0,
|
||||
"smtpPassword": "",
|
||||
@@ -146,11 +157,13 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"smtpUsername": "",
|
||||
"subAnnounce": "",
|
||||
"subCertFile": "",
|
||||
"subClashAutoDetect": false,
|
||||
"subClashEnable": false,
|
||||
"subClashEnableRouting": false,
|
||||
"subClashPath": "",
|
||||
"subClashRules": "",
|
||||
"subClashURI": "",
|
||||
"subClashUserAgentRegex": "",
|
||||
"subDomain": "",
|
||||
"subEnable": false,
|
||||
"subEnableRouting": false,
|
||||
@@ -158,12 +171,15 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subHideSettings": false,
|
||||
"subIncyEnableRouting": false,
|
||||
"subIncyRoutingRules": "",
|
||||
"subJsonAlwaysArray": false,
|
||||
"subJsonAutoDetect": false,
|
||||
"subJsonEnable": false,
|
||||
"subJsonFinalMask": "",
|
||||
"subJsonMux": "",
|
||||
"subJsonPath": "",
|
||||
"subJsonRules": "",
|
||||
"subJsonURI": "",
|
||||
"subJsonUserAgentRegex": "",
|
||||
"subKeyFile": "",
|
||||
"subListen": "",
|
||||
"subPath": "",
|
||||
@@ -478,7 +494,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"activeCount": 23,
|
||||
"address": "node1.example.com",
|
||||
"allowPrivateAddress": false,
|
||||
"apiToken": "abcdef0123456789",
|
||||
"basePath": "/",
|
||||
"clientCount": 27,
|
||||
"configDirty": false,
|
||||
@@ -519,6 +534,71 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"xrayState": "",
|
||||
"xrayVersion": "25.10.31"
|
||||
},
|
||||
"NodeMutationRequest": {
|
||||
"address": "",
|
||||
"allowPrivateAddress": false,
|
||||
"apiToken": null,
|
||||
"basePath": "",
|
||||
"clearApiToken": false,
|
||||
"enable": false,
|
||||
"id": 0,
|
||||
"inboundSyncMode": "all",
|
||||
"inboundTags": [
|
||||
""
|
||||
],
|
||||
"name": "",
|
||||
"outboundTag": "",
|
||||
"pinnedCertSha256": "",
|
||||
"port": 1,
|
||||
"remark": "",
|
||||
"scheme": "http",
|
||||
"tlsVerifyMode": "verify"
|
||||
},
|
||||
"NodeView": {
|
||||
"activeCount": 20,
|
||||
"address": "node.example.com",
|
||||
"allowPrivateAddress": false,
|
||||
"basePath": "/",
|
||||
"clientCount": 25,
|
||||
"configDirty": false,
|
||||
"configDirtyAt": 0,
|
||||
"cpuPct": 12.5,
|
||||
"createdAt": 1700000000,
|
||||
"depletedCount": 1,
|
||||
"disabledCount": 2,
|
||||
"enable": true,
|
||||
"guid": "node-guid",
|
||||
"hasApiToken": true,
|
||||
"id": 1,
|
||||
"inboundCount": 3,
|
||||
"inboundSyncMode": "all",
|
||||
"inboundTags": [
|
||||
"in-443-tcp"
|
||||
],
|
||||
"lastError": "",
|
||||
"lastHeartbeat": 1700000000,
|
||||
"latencyMs": 42,
|
||||
"memPct": 45.2,
|
||||
"name": "edge-1",
|
||||
"netDown": 1048576,
|
||||
"netUp": 2097152,
|
||||
"onlineCount": 5,
|
||||
"outboundTag": "direct",
|
||||
"panelVersion": "v3.x.x",
|
||||
"parentGuid": "",
|
||||
"pinnedCertSha256": "",
|
||||
"port": 2053,
|
||||
"remark": "Primary edge",
|
||||
"scheme": "https",
|
||||
"status": "online",
|
||||
"tlsVerifyMode": "verify",
|
||||
"transitive": false,
|
||||
"updatedAt": 1700003600,
|
||||
"uptimeSecs": 86400,
|
||||
"xrayError": "",
|
||||
"xrayState": "running",
|
||||
"xrayVersion": "25.10.31"
|
||||
},
|
||||
"OutboundTraffics": {
|
||||
"down": 0,
|
||||
"id": 0,
|
||||
|
||||
@@ -83,6 +83,11 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"ldapVlessField": {
|
||||
"type": "string"
|
||||
},
|
||||
"outboundDownThreshold": {
|
||||
"maximum": 100,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"pageSize": {
|
||||
"maximum": 1000,
|
||||
"minimum": 0,
|
||||
@@ -116,6 +121,12 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"smtpEncryptionType": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpFrom": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpFromName": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpHost": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -144,6 +155,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subCertFile": {
|
||||
"type": "string"
|
||||
},
|
||||
"subClashAutoDetect": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subClashEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -159,6 +173,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subClashURI": {
|
||||
"type": "string"
|
||||
},
|
||||
"subClashUserAgentRegex": {
|
||||
"type": "string"
|
||||
},
|
||||
"subDomain": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -180,6 +197,12 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subIncyRoutingRules": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonAlwaysArray": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subJsonAutoDetect": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subJsonEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -198,6 +221,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subJsonURI": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonUserAgentRegex": {
|
||||
"type": "string"
|
||||
},
|
||||
"subKeyFile": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -340,6 +366,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"ldapUserAttr",
|
||||
"ldapUserFilter",
|
||||
"ldapVlessField",
|
||||
"outboundDownThreshold",
|
||||
"pageSize",
|
||||
"panelOutbound",
|
||||
"remarkTemplate",
|
||||
@@ -349,6 +376,8 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"smtpEnable",
|
||||
"smtpEnabledEvents",
|
||||
"smtpEncryptionType",
|
||||
"smtpFrom",
|
||||
"smtpFromName",
|
||||
"smtpHost",
|
||||
"smtpMemory",
|
||||
"smtpPassword",
|
||||
@@ -357,11 +386,13 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"smtpUsername",
|
||||
"subAnnounce",
|
||||
"subCertFile",
|
||||
"subClashAutoDetect",
|
||||
"subClashEnable",
|
||||
"subClashEnableRouting",
|
||||
"subClashPath",
|
||||
"subClashRules",
|
||||
"subClashURI",
|
||||
"subClashUserAgentRegex",
|
||||
"subDomain",
|
||||
"subEnable",
|
||||
"subEnableRouting",
|
||||
@@ -369,12 +400,15 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subHideSettings",
|
||||
"subIncyEnableRouting",
|
||||
"subIncyRoutingRules",
|
||||
"subJsonAlwaysArray",
|
||||
"subJsonAutoDetect",
|
||||
"subJsonEnable",
|
||||
"subJsonFinalMask",
|
||||
"subJsonMux",
|
||||
"subJsonPath",
|
||||
"subJsonRules",
|
||||
"subJsonURI",
|
||||
"subJsonUserAgentRegex",
|
||||
"subKeyFile",
|
||||
"subListen",
|
||||
"subPath",
|
||||
@@ -516,6 +550,11 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"ldapVlessField": {
|
||||
"type": "string"
|
||||
},
|
||||
"outboundDownThreshold": {
|
||||
"maximum": 100,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"pageSize": {
|
||||
"maximum": 1000,
|
||||
"minimum": 0,
|
||||
@@ -549,6 +588,12 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"smtpEncryptionType": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpFrom": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpFromName": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpHost": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -577,6 +622,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subCertFile": {
|
||||
"type": "string"
|
||||
},
|
||||
"subClashAutoDetect": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subClashEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -592,6 +640,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subClashURI": {
|
||||
"type": "string"
|
||||
},
|
||||
"subClashUserAgentRegex": {
|
||||
"type": "string"
|
||||
},
|
||||
"subDomain": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -613,6 +664,12 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subIncyRoutingRules": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonAlwaysArray": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subJsonAutoDetect": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subJsonEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -631,6 +688,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subJsonURI": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonUserAgentRegex": {
|
||||
"type": "string"
|
||||
},
|
||||
"subKeyFile": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -780,6 +840,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"ldapUserAttr",
|
||||
"ldapUserFilter",
|
||||
"ldapVlessField",
|
||||
"outboundDownThreshold",
|
||||
"pageSize",
|
||||
"panelOutbound",
|
||||
"remarkTemplate",
|
||||
@@ -789,6 +850,8 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"smtpEnable",
|
||||
"smtpEnabledEvents",
|
||||
"smtpEncryptionType",
|
||||
"smtpFrom",
|
||||
"smtpFromName",
|
||||
"smtpHost",
|
||||
"smtpMemory",
|
||||
"smtpPassword",
|
||||
@@ -797,11 +860,13 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"smtpUsername",
|
||||
"subAnnounce",
|
||||
"subCertFile",
|
||||
"subClashAutoDetect",
|
||||
"subClashEnable",
|
||||
"subClashEnableRouting",
|
||||
"subClashPath",
|
||||
"subClashRules",
|
||||
"subClashURI",
|
||||
"subClashUserAgentRegex",
|
||||
"subDomain",
|
||||
"subEnable",
|
||||
"subEnableRouting",
|
||||
@@ -809,12 +874,15 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subHideSettings",
|
||||
"subIncyEnableRouting",
|
||||
"subIncyRoutingRules",
|
||||
"subJsonAlwaysArray",
|
||||
"subJsonAutoDetect",
|
||||
"subJsonEnable",
|
||||
"subJsonFinalMask",
|
||||
"subJsonMux",
|
||||
"subJsonPath",
|
||||
"subJsonRules",
|
||||
"subJsonURI",
|
||||
"subJsonUserAgentRegex",
|
||||
"subKeyFile",
|
||||
"subListen",
|
||||
"subPath",
|
||||
@@ -1964,10 +2032,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"allowPrivateAddress": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"apiToken": {
|
||||
"example": "abcdef0123456789",
|
||||
"type": "string"
|
||||
},
|
||||
"basePath": {
|
||||
"example": "/",
|
||||
"type": "string"
|
||||
@@ -2138,7 +2202,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"activeCount",
|
||||
"address",
|
||||
"allowPrivateAddress",
|
||||
"apiToken",
|
||||
"basePath",
|
||||
"clientCount",
|
||||
"configDirty",
|
||||
@@ -2177,6 +2240,315 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"NodeMutationRequest": {
|
||||
"description": "NodeMutationRequest is the node write/probe contract. ApiToken is accepted\nonly as input. On update, nil means keep the stored token; replacement and\nclearing are explicit and mutually exclusive.",
|
||||
"properties": {
|
||||
"address": {
|
||||
"type": "string"
|
||||
},
|
||||
"allowPrivateAddress": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"apiToken": {
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"basePath": {
|
||||
"type": "string"
|
||||
},
|
||||
"clearApiToken": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"enable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"inboundSyncMode": {
|
||||
"enum": [
|
||||
"all",
|
||||
"selected"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"inboundTags": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"outboundTag": {
|
||||
"type": "string"
|
||||
},
|
||||
"pinnedCertSha256": {
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"maximum": 65535,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"remark": {
|
||||
"type": "string"
|
||||
},
|
||||
"scheme": {
|
||||
"enum": [
|
||||
"http",
|
||||
"https"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"tlsVerifyMode": {
|
||||
"enum": [
|
||||
"verify",
|
||||
"skip",
|
||||
"pin",
|
||||
"mtls"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"address",
|
||||
"allowPrivateAddress",
|
||||
"basePath",
|
||||
"enable",
|
||||
"id",
|
||||
"inboundSyncMode",
|
||||
"inboundTags",
|
||||
"name",
|
||||
"outboundTag",
|
||||
"pinnedCertSha256",
|
||||
"port",
|
||||
"remark",
|
||||
"scheme",
|
||||
"tlsVerifyMode"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"NodeView": {
|
||||
"description": "NodeView is the browser/API read contract for nodes. Credentials are\nwrite-only: responses expose only whether a node has a token configured.",
|
||||
"properties": {
|
||||
"activeCount": {
|
||||
"example": 20,
|
||||
"type": "integer"
|
||||
},
|
||||
"address": {
|
||||
"example": "node.example.com",
|
||||
"type": "string"
|
||||
},
|
||||
"allowPrivateAddress": {
|
||||
"example": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"basePath": {
|
||||
"example": "/",
|
||||
"type": "string"
|
||||
},
|
||||
"clientCount": {
|
||||
"example": 25,
|
||||
"type": "integer"
|
||||
},
|
||||
"configDirty": {
|
||||
"example": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"configDirtyAt": {
|
||||
"example": 0,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"cpuPct": {
|
||||
"example": 12.5,
|
||||
"type": "number"
|
||||
},
|
||||
"createdAt": {
|
||||
"example": 1700000000,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"depletedCount": {
|
||||
"example": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"disabledCount": {
|
||||
"example": 2,
|
||||
"type": "integer"
|
||||
},
|
||||
"enable": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"guid": {
|
||||
"example": "node-guid",
|
||||
"type": "string"
|
||||
},
|
||||
"hasApiToken": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"example": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"inboundCount": {
|
||||
"example": 3,
|
||||
"type": "integer"
|
||||
},
|
||||
"inboundSyncMode": {
|
||||
"example": "all",
|
||||
"type": "string"
|
||||
},
|
||||
"inboundTags": {
|
||||
"example": [
|
||||
"in-443-tcp"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"lastError": {
|
||||
"type": "string"
|
||||
},
|
||||
"lastHeartbeat": {
|
||||
"example": 1700000000,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"latencyMs": {
|
||||
"example": 42,
|
||||
"type": "integer"
|
||||
},
|
||||
"memPct": {
|
||||
"example": 45.2,
|
||||
"type": "number"
|
||||
},
|
||||
"name": {
|
||||
"example": "edge-1",
|
||||
"type": "string"
|
||||
},
|
||||
"netDown": {
|
||||
"example": 1048576,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"netUp": {
|
||||
"example": 2097152,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"onlineCount": {
|
||||
"example": 5,
|
||||
"type": "integer"
|
||||
},
|
||||
"outboundTag": {
|
||||
"example": "direct",
|
||||
"type": "string"
|
||||
},
|
||||
"panelVersion": {
|
||||
"example": "v3.x.x",
|
||||
"type": "string"
|
||||
},
|
||||
"parentGuid": {
|
||||
"type": "string"
|
||||
},
|
||||
"pinnedCertSha256": {
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"example": 2053,
|
||||
"type": "integer"
|
||||
},
|
||||
"remark": {
|
||||
"example": "Primary edge",
|
||||
"type": "string"
|
||||
},
|
||||
"scheme": {
|
||||
"example": "https",
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"example": "online",
|
||||
"type": "string"
|
||||
},
|
||||
"tlsVerifyMode": {
|
||||
"example": "verify",
|
||||
"type": "string"
|
||||
},
|
||||
"transitive": {
|
||||
"example": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"updatedAt": {
|
||||
"example": 1700003600,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"uptimeSecs": {
|
||||
"example": 86400,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"xrayError": {
|
||||
"type": "string"
|
||||
},
|
||||
"xrayState": {
|
||||
"example": "running",
|
||||
"type": "string"
|
||||
},
|
||||
"xrayVersion": {
|
||||
"example": "25.10.31",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeCount",
|
||||
"address",
|
||||
"allowPrivateAddress",
|
||||
"basePath",
|
||||
"clientCount",
|
||||
"configDirty",
|
||||
"configDirtyAt",
|
||||
"cpuPct",
|
||||
"createdAt",
|
||||
"depletedCount",
|
||||
"disabledCount",
|
||||
"enable",
|
||||
"guid",
|
||||
"hasApiToken",
|
||||
"id",
|
||||
"inboundCount",
|
||||
"inboundSyncMode",
|
||||
"inboundTags",
|
||||
"lastError",
|
||||
"lastHeartbeat",
|
||||
"latencyMs",
|
||||
"memPct",
|
||||
"name",
|
||||
"netDown",
|
||||
"netUp",
|
||||
"onlineCount",
|
||||
"outboundTag",
|
||||
"panelVersion",
|
||||
"pinnedCertSha256",
|
||||
"port",
|
||||
"remark",
|
||||
"scheme",
|
||||
"status",
|
||||
"tlsVerifyMode",
|
||||
"updatedAt",
|
||||
"uptimeSecs",
|
||||
"xrayError",
|
||||
"xrayState",
|
||||
"xrayVersion"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"OutboundTraffics": {
|
||||
"description": "OutboundTraffics tracks traffic statistics for Xray outbound connections.",
|
||||
"properties": {
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface AllSetting {
|
||||
ldapUserAttr: string;
|
||||
ldapUserFilter: string;
|
||||
ldapVlessField: string;
|
||||
outboundDownThreshold: number;
|
||||
pageSize: number;
|
||||
panelOutbound: string;
|
||||
remarkTemplate: string;
|
||||
@@ -41,6 +42,8 @@ export interface AllSetting {
|
||||
smtpEnable: boolean;
|
||||
smtpEnabledEvents: string;
|
||||
smtpEncryptionType: string;
|
||||
smtpFrom: string;
|
||||
smtpFromName: string;
|
||||
smtpHost: string;
|
||||
smtpMemory: number;
|
||||
smtpPassword: string;
|
||||
@@ -49,11 +52,13 @@ export interface AllSetting {
|
||||
smtpUsername: string;
|
||||
subAnnounce: string;
|
||||
subCertFile: string;
|
||||
subClashAutoDetect: boolean;
|
||||
subClashEnable: boolean;
|
||||
subClashEnableRouting: boolean;
|
||||
subClashPath: string;
|
||||
subClashRules: string;
|
||||
subClashURI: string;
|
||||
subClashUserAgentRegex: string;
|
||||
subDomain: string;
|
||||
subEnable: boolean;
|
||||
subEnableRouting: boolean;
|
||||
@@ -61,12 +66,15 @@ export interface AllSetting {
|
||||
subHideSettings: boolean;
|
||||
subIncyEnableRouting: boolean;
|
||||
subIncyRoutingRules: string;
|
||||
subJsonAlwaysArray: boolean;
|
||||
subJsonAutoDetect: boolean;
|
||||
subJsonEnable: boolean;
|
||||
subJsonFinalMask: string;
|
||||
subJsonMux: string;
|
||||
subJsonPath: string;
|
||||
subJsonRules: string;
|
||||
subJsonURI: string;
|
||||
subJsonUserAgentRegex: string;
|
||||
subKeyFile: string;
|
||||
subListen: string;
|
||||
subPath: string;
|
||||
@@ -136,6 +144,7 @@ export interface AllSettingView {
|
||||
ldapUserAttr: string;
|
||||
ldapUserFilter: string;
|
||||
ldapVlessField: string;
|
||||
outboundDownThreshold: number;
|
||||
pageSize: number;
|
||||
panelOutbound: string;
|
||||
remarkTemplate: string;
|
||||
@@ -145,6 +154,8 @@ export interface AllSettingView {
|
||||
smtpEnable: boolean;
|
||||
smtpEnabledEvents: string;
|
||||
smtpEncryptionType: string;
|
||||
smtpFrom: string;
|
||||
smtpFromName: string;
|
||||
smtpHost: string;
|
||||
smtpMemory: number;
|
||||
smtpPassword: string;
|
||||
@@ -153,11 +164,13 @@ export interface AllSettingView {
|
||||
smtpUsername: string;
|
||||
subAnnounce: string;
|
||||
subCertFile: string;
|
||||
subClashAutoDetect: boolean;
|
||||
subClashEnable: boolean;
|
||||
subClashEnableRouting: boolean;
|
||||
subClashPath: string;
|
||||
subClashRules: string;
|
||||
subClashURI: string;
|
||||
subClashUserAgentRegex: string;
|
||||
subDomain: string;
|
||||
subEnable: boolean;
|
||||
subEnableRouting: boolean;
|
||||
@@ -165,12 +178,15 @@ export interface AllSettingView {
|
||||
subHideSettings: boolean;
|
||||
subIncyEnableRouting: boolean;
|
||||
subIncyRoutingRules: string;
|
||||
subJsonAlwaysArray: boolean;
|
||||
subJsonAutoDetect: boolean;
|
||||
subJsonEnable: boolean;
|
||||
subJsonFinalMask: string;
|
||||
subJsonMux: string;
|
||||
subJsonPath: string;
|
||||
subJsonRules: string;
|
||||
subJsonURI: string;
|
||||
subJsonUserAgentRegex: string;
|
||||
subKeyFile: string;
|
||||
subListen: string;
|
||||
subPath: string;
|
||||
@@ -461,7 +477,6 @@ export interface Node {
|
||||
activeCount: number;
|
||||
address: string;
|
||||
allowPrivateAddress: boolean;
|
||||
apiToken: string;
|
||||
basePath: string;
|
||||
clientCount: number;
|
||||
configDirty: boolean;
|
||||
@@ -501,6 +516,69 @@ export interface Node {
|
||||
xrayVersion: string;
|
||||
}
|
||||
|
||||
export interface NodeMutationRequest {
|
||||
address: string;
|
||||
allowPrivateAddress: boolean;
|
||||
apiToken?: string | null;
|
||||
basePath: string;
|
||||
clearApiToken?: boolean;
|
||||
enable: boolean;
|
||||
id: number;
|
||||
inboundSyncMode: string;
|
||||
inboundTags: string[];
|
||||
name: string;
|
||||
outboundTag: string;
|
||||
pinnedCertSha256: string;
|
||||
port: number;
|
||||
remark: string;
|
||||
scheme: string;
|
||||
tlsVerifyMode: string;
|
||||
}
|
||||
|
||||
export interface NodeView {
|
||||
activeCount: number;
|
||||
address: string;
|
||||
allowPrivateAddress: boolean;
|
||||
basePath: string;
|
||||
clientCount: number;
|
||||
configDirty: boolean;
|
||||
configDirtyAt: number;
|
||||
cpuPct: number;
|
||||
createdAt: number;
|
||||
depletedCount: number;
|
||||
disabledCount: number;
|
||||
enable: boolean;
|
||||
guid: string;
|
||||
hasApiToken: boolean;
|
||||
id: number;
|
||||
inboundCount: number;
|
||||
inboundSyncMode: string;
|
||||
inboundTags: string[];
|
||||
lastError: string;
|
||||
lastHeartbeat: number;
|
||||
latencyMs: number;
|
||||
memPct: number;
|
||||
name: string;
|
||||
netDown: number;
|
||||
netUp: number;
|
||||
onlineCount: number;
|
||||
outboundTag: string;
|
||||
panelVersion: string;
|
||||
parentGuid?: string;
|
||||
pinnedCertSha256: string;
|
||||
port: number;
|
||||
remark: string;
|
||||
scheme: string;
|
||||
status: string;
|
||||
tlsVerifyMode: string;
|
||||
transitive?: boolean;
|
||||
updatedAt: number;
|
||||
uptimeSecs: number;
|
||||
xrayError: string;
|
||||
xrayState: string;
|
||||
xrayVersion: string;
|
||||
}
|
||||
|
||||
export interface OutboundTraffics {
|
||||
down: number;
|
||||
id: number;
|
||||
|
||||
@@ -44,6 +44,7 @@ export const AllSettingSchema = z.object({
|
||||
ldapUserAttr: z.string(),
|
||||
ldapUserFilter: z.string(),
|
||||
ldapVlessField: z.string(),
|
||||
outboundDownThreshold: z.number().int().min(1).max(100),
|
||||
pageSize: z.number().int().min(0).max(1000),
|
||||
panelOutbound: z.string(),
|
||||
remarkTemplate: z.string(),
|
||||
@@ -53,6 +54,8 @@ export const AllSettingSchema = z.object({
|
||||
smtpEnable: z.boolean(),
|
||||
smtpEnabledEvents: z.string(),
|
||||
smtpEncryptionType: z.string(),
|
||||
smtpFrom: z.string(),
|
||||
smtpFromName: z.string(),
|
||||
smtpHost: z.string(),
|
||||
smtpMemory: z.number().int().min(0).max(100),
|
||||
smtpPassword: z.string(),
|
||||
@@ -61,11 +64,13 @@ export const AllSettingSchema = z.object({
|
||||
smtpUsername: z.string(),
|
||||
subAnnounce: z.string(),
|
||||
subCertFile: z.string(),
|
||||
subClashAutoDetect: z.boolean(),
|
||||
subClashEnable: z.boolean(),
|
||||
subClashEnableRouting: z.boolean(),
|
||||
subClashPath: z.string(),
|
||||
subClashRules: z.string(),
|
||||
subClashURI: z.string(),
|
||||
subClashUserAgentRegex: z.string(),
|
||||
subDomain: z.string(),
|
||||
subEnable: z.boolean(),
|
||||
subEnableRouting: z.boolean(),
|
||||
@@ -73,12 +78,15 @@ export const AllSettingSchema = z.object({
|
||||
subHideSettings: z.boolean(),
|
||||
subIncyEnableRouting: z.boolean(),
|
||||
subIncyRoutingRules: z.string(),
|
||||
subJsonAlwaysArray: z.boolean(),
|
||||
subJsonAutoDetect: z.boolean(),
|
||||
subJsonEnable: z.boolean(),
|
||||
subJsonFinalMask: z.string(),
|
||||
subJsonMux: z.string(),
|
||||
subJsonPath: z.string(),
|
||||
subJsonRules: z.string(),
|
||||
subJsonURI: z.string(),
|
||||
subJsonUserAgentRegex: z.string(),
|
||||
subKeyFile: z.string(),
|
||||
subListen: z.string(),
|
||||
subPath: z.string(),
|
||||
@@ -149,6 +157,7 @@ export const AllSettingViewSchema = z.object({
|
||||
ldapUserAttr: z.string(),
|
||||
ldapUserFilter: z.string(),
|
||||
ldapVlessField: z.string(),
|
||||
outboundDownThreshold: z.number().int().min(1).max(100),
|
||||
pageSize: z.number().int().min(0).max(1000),
|
||||
panelOutbound: z.string(),
|
||||
remarkTemplate: z.string(),
|
||||
@@ -158,6 +167,8 @@ export const AllSettingViewSchema = z.object({
|
||||
smtpEnable: z.boolean(),
|
||||
smtpEnabledEvents: z.string(),
|
||||
smtpEncryptionType: z.string(),
|
||||
smtpFrom: z.string(),
|
||||
smtpFromName: z.string(),
|
||||
smtpHost: z.string(),
|
||||
smtpMemory: z.number().int().min(0).max(100),
|
||||
smtpPassword: z.string(),
|
||||
@@ -166,11 +177,13 @@ export const AllSettingViewSchema = z.object({
|
||||
smtpUsername: z.string(),
|
||||
subAnnounce: z.string(),
|
||||
subCertFile: z.string(),
|
||||
subClashAutoDetect: z.boolean(),
|
||||
subClashEnable: z.boolean(),
|
||||
subClashEnableRouting: z.boolean(),
|
||||
subClashPath: z.string(),
|
||||
subClashRules: z.string(),
|
||||
subClashURI: z.string(),
|
||||
subClashUserAgentRegex: z.string(),
|
||||
subDomain: z.string(),
|
||||
subEnable: z.boolean(),
|
||||
subEnableRouting: z.boolean(),
|
||||
@@ -178,12 +191,15 @@ export const AllSettingViewSchema = z.object({
|
||||
subHideSettings: z.boolean(),
|
||||
subIncyEnableRouting: z.boolean(),
|
||||
subIncyRoutingRules: z.string(),
|
||||
subJsonAlwaysArray: z.boolean(),
|
||||
subJsonAutoDetect: z.boolean(),
|
||||
subJsonEnable: z.boolean(),
|
||||
subJsonFinalMask: z.string(),
|
||||
subJsonMux: z.string(),
|
||||
subJsonPath: z.string(),
|
||||
subJsonRules: z.string(),
|
||||
subJsonURI: z.string(),
|
||||
subJsonUserAgentRegex: z.string(),
|
||||
subKeyFile: z.string(),
|
||||
subListen: z.string(),
|
||||
subPath: z.string(),
|
||||
@@ -491,7 +507,6 @@ export const NodeSchema = z.object({
|
||||
activeCount: z.number().int(),
|
||||
address: z.string(),
|
||||
allowPrivateAddress: z.boolean(),
|
||||
apiToken: z.string(),
|
||||
basePath: z.string(),
|
||||
clientCount: z.number().int(),
|
||||
configDirty: z.boolean(),
|
||||
@@ -532,6 +547,71 @@ export const NodeSchema = z.object({
|
||||
});
|
||||
export type Node = z.infer<typeof NodeSchema>;
|
||||
|
||||
export const NodeMutationRequestSchema = z.object({
|
||||
address: z.string(),
|
||||
allowPrivateAddress: z.boolean(),
|
||||
apiToken: z.string().nullable().optional(),
|
||||
basePath: z.string(),
|
||||
clearApiToken: z.boolean().optional(),
|
||||
enable: z.boolean(),
|
||||
id: z.number().int(),
|
||||
inboundSyncMode: z.enum(['all', 'selected']),
|
||||
inboundTags: z.array(z.string()),
|
||||
name: z.string(),
|
||||
outboundTag: z.string(),
|
||||
pinnedCertSha256: z.string(),
|
||||
port: z.number().int().min(1).max(65535),
|
||||
remark: z.string(),
|
||||
scheme: z.enum(['http', 'https']),
|
||||
tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']),
|
||||
});
|
||||
export type NodeMutationRequest = z.infer<typeof NodeMutationRequestSchema>;
|
||||
|
||||
export const NodeViewSchema = z.object({
|
||||
activeCount: z.number().int(),
|
||||
address: z.string(),
|
||||
allowPrivateAddress: z.boolean(),
|
||||
basePath: z.string(),
|
||||
clientCount: z.number().int(),
|
||||
configDirty: z.boolean(),
|
||||
configDirtyAt: z.number().int(),
|
||||
cpuPct: z.number(),
|
||||
createdAt: z.number().int(),
|
||||
depletedCount: z.number().int(),
|
||||
disabledCount: z.number().int(),
|
||||
enable: z.boolean(),
|
||||
guid: z.string(),
|
||||
hasApiToken: z.boolean(),
|
||||
id: z.number().int(),
|
||||
inboundCount: z.number().int(),
|
||||
inboundSyncMode: z.string(),
|
||||
inboundTags: z.array(z.string()),
|
||||
lastError: z.string(),
|
||||
lastHeartbeat: z.number().int(),
|
||||
latencyMs: z.number().int(),
|
||||
memPct: z.number(),
|
||||
name: z.string(),
|
||||
netDown: z.number().int(),
|
||||
netUp: z.number().int(),
|
||||
onlineCount: z.number().int(),
|
||||
outboundTag: z.string(),
|
||||
panelVersion: z.string(),
|
||||
parentGuid: z.string().optional(),
|
||||
pinnedCertSha256: z.string(),
|
||||
port: z.number().int(),
|
||||
remark: z.string(),
|
||||
scheme: z.string(),
|
||||
status: z.string(),
|
||||
tlsVerifyMode: z.string(),
|
||||
transitive: z.boolean().optional(),
|
||||
updatedAt: z.number().int(),
|
||||
uptimeSecs: z.number().int(),
|
||||
xrayError: z.string(),
|
||||
xrayState: z.string(),
|
||||
xrayVersion: z.string(),
|
||||
});
|
||||
export type NodeView = z.infer<typeof NodeViewSchema>;
|
||||
|
||||
export const OutboundTrafficsSchema = z.object({
|
||||
down: z.number().int(),
|
||||
id: z.number().int(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useLocation } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const TITLE_KEYS: Record<string, string> = {
|
||||
|
||||
@@ -78,13 +78,28 @@ const STATISTIC_TOKENS = {
|
||||
contentFontSize: 17,
|
||||
titleFontSize: 11,
|
||||
};
|
||||
const LIGHT_CONTRAST_TOKENS = {
|
||||
colorTextDescription: 'rgba(0, 0, 0, 0.58)',
|
||||
colorTextTertiary: 'rgba(0, 0, 0, 0.58)',
|
||||
colorTextPlaceholder: '#767676',
|
||||
colorError: '#cf1322',
|
||||
colorErrorText: '#cf1322',
|
||||
colorSuccessText: '#237804',
|
||||
};
|
||||
const LIGHT_BUTTON_TOKENS = {
|
||||
colorPrimary: '#0958d9',
|
||||
colorPrimaryHover: '#2468e5',
|
||||
colorPrimaryActive: '#073ea8',
|
||||
};
|
||||
|
||||
export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeConfig {
|
||||
if (!isDark) {
|
||||
return {
|
||||
algorithm: antdTheme.defaultAlgorithm,
|
||||
token: LIGHT_CONTRAST_TOKENS,
|
||||
components: {
|
||||
Statistic: STATISTIC_TOKENS,
|
||||
Button: LIGHT_BUTTON_TOKENS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,26 +1,11 @@
|
||||
import { useEffect } from 'react';
|
||||
import { WebSocketClient } from '@/api/websocket';
|
||||
import { getSharedWebSocketClient } from '@/api/websocket';
|
||||
|
||||
type Handler = (payload: unknown) => void;
|
||||
|
||||
interface SharedClient {
|
||||
connect(): void;
|
||||
on(event: string, fn: Handler): void;
|
||||
off(event: string, fn: Handler): void;
|
||||
}
|
||||
|
||||
let sharedClient: SharedClient | null = null;
|
||||
|
||||
function getSharedClient(): SharedClient {
|
||||
if (sharedClient) return sharedClient;
|
||||
const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
|
||||
sharedClient = new WebSocketClient(basePath) as SharedClient;
|
||||
return sharedClient;
|
||||
}
|
||||
|
||||
export function useWebSocket(handlers: Record<string, Handler>) {
|
||||
useEffect(() => {
|
||||
const client = getSharedClient();
|
||||
const client = getSharedWebSocketClient();
|
||||
const entries = Object.entries(handlers);
|
||||
for (const [event, fn] of entries) client.on(event, fn);
|
||||
client.connect();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { ComponentType } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Drawer, Layout, Menu } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Outlet } from 'react-router';
|
||||
|
||||
import { useWebSocketBridge } from '@/api/websocketBridge';
|
||||
import { usePageTitle } from '@/hooks/usePageTitle';
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { Form } from 'antd';
|
||||
|
||||
import SniffingFields from '@/lib/xray/forms/SniffingFields';
|
||||
import type { Sniffing } from '@/schemas/primitives/sniffing';
|
||||
import { SniffingSchema, type Sniffing } from '@/schemas/primitives/sniffing';
|
||||
|
||||
interface SniffingFieldProps {
|
||||
value?: Sniffing;
|
||||
@@ -12,12 +12,12 @@ interface SniffingFieldProps {
|
||||
|
||||
export default function SniffingField({ value, onChange, enableLabel }: SniffingFieldProps) {
|
||||
const [form] = Form.useForm();
|
||||
const [initial] = useState(() => value ?? ({} as Sniffing));
|
||||
const [initial] = useState(() => value ?? SniffingSchema.parse({}));
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
const lastEmitted = useRef(JSON.stringify(initial));
|
||||
|
||||
const sniffing = Form.useWatch('sniffing', form) as Sniffing | undefined;
|
||||
const sniffing = Form.useWatch('sniffing', { form, preserve: true }) as Sniffing | undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (sniffing === undefined) return;
|
||||
@@ -27,6 +27,14 @@ export default function SniffingField({ value, onChange, enableLabel }: Sniffing
|
||||
onChangeRef.current?.(sniffing);
|
||||
}, [sniffing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (value === undefined) return;
|
||||
const serialized = JSON.stringify(value);
|
||||
if (serialized === lastEmitted.current) return;
|
||||
lastEmitted.current = serialized;
|
||||
form.setFieldsValue({ sniffing: value });
|
||||
}, [value, form]);
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { Sniffing } from '@/schemas/primitives';
|
||||
import type { z } from 'zod';
|
||||
import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
|
||||
import { canEnableSniffing } from '@/lib/xray/protocol-capabilities';
|
||||
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
|
||||
import { XHttpStreamSettingsSchema, XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
|
||||
|
||||
const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
|
||||
@@ -178,6 +179,13 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
|
||||
xhttp.xmux = { ...XMUX_DEFAULTS, ...(xmux as Record<string, unknown>) };
|
||||
}
|
||||
}
|
||||
const so = streamRecord.sockopt;
|
||||
if (so && typeof so === 'object' && !Array.isArray(so)) {
|
||||
const parsed = SockoptStreamSettingsSchema.safeParse(so);
|
||||
if (parsed.success) {
|
||||
streamRecord.sockopt = { ...(so as Record<string, unknown>), ...parsed.data };
|
||||
}
|
||||
}
|
||||
}
|
||||
const sniffing = coerceJsonObject(row.sniffing) as unknown as Sniffing;
|
||||
|
||||
|
||||
@@ -822,8 +822,8 @@ export function genWireguardLink(input: GenWireguardLinkInput): string {
|
||||
? Wireguard.generateKeypair(settings.secretKey).publicKey
|
||||
: '';
|
||||
if (pubKey.length > 0) url.searchParams.set('publickey', pubKey);
|
||||
if (peer.allowedIPs.length > 0 && peer.allowedIPs[0]) {
|
||||
url.searchParams.set('address', peer.allowedIPs[0]);
|
||||
if (peer.allowedIPs.length > 0) {
|
||||
url.searchParams.set('address', peer.allowedIPs.join(','));
|
||||
}
|
||||
if (typeof settings.mtu === 'number' && settings.mtu > 0) {
|
||||
url.searchParams.set('mtu', String(settings.mtu));
|
||||
@@ -850,7 +850,7 @@ export function genWireguardConfig(input: GenWireguardLinkInput): string {
|
||||
|
||||
let txt = `[Interface]\n`;
|
||||
txt += `PrivateKey = ${peer.privateKey ?? ''}\n`;
|
||||
txt += `Address = ${peer.allowedIPs[0] ?? ''}\n`;
|
||||
txt += `Address = ${peer.allowedIPs.join(', ')}\n`;
|
||||
txt += `DNS = ${settings.dns || '1.1.1.1, 1.0.0.1'}\n`;
|
||||
if (typeof settings.mtu === 'number' && settings.mtu > 0) {
|
||||
txt += `MTU = ${settings.mtu}\n`;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { RouterProvider } from 'react-router/dom';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
import '@/styles/utils.css';
|
||||
|
||||
@@ -29,6 +29,11 @@ export class AllSetting {
|
||||
xrayTemplateConfig = '';
|
||||
subEnable = true;
|
||||
subJsonEnable = false;
|
||||
subJsonAutoDetect = false;
|
||||
subJsonAlwaysArray = false;
|
||||
subJsonUserAgentRegex = '';
|
||||
subClashAutoDetect = false;
|
||||
subClashUserAgentRegex = '';
|
||||
subTitle = '';
|
||||
subSupportUrl = '';
|
||||
subProfileUrl = '';
|
||||
@@ -91,11 +96,14 @@ export class AllSetting {
|
||||
smtpPort = 587;
|
||||
smtpUsername = '';
|
||||
smtpPassword = '';
|
||||
smtpFrom = '';
|
||||
smtpFromName = '';
|
||||
smtpTo = '';
|
||||
smtpEncryptionType = 'starttls';
|
||||
smtpEnabledEvents = '';
|
||||
smtpCpu = 80;
|
||||
smtpMemory = 80;
|
||||
outboundDownThreshold = 3;
|
||||
hasTgBotToken = false;
|
||||
hasTwoFactorToken = false;
|
||||
hasLdapPassword = false;
|
||||
@@ -113,6 +121,8 @@ export class AllSetting {
|
||||
}
|
||||
const cpu = Math.round(Number(this.tgCpu));
|
||||
this.tgCpu = Number.isFinite(cpu) ? Math.min(100, Math.max(0, cpu)) : 80;
|
||||
const threshold = Math.round(Number(this.outboundDownThreshold));
|
||||
this.outboundDownThreshold = Number.isFinite(threshold) ? Math.min(100, Math.max(1, threshold)) : 3;
|
||||
}
|
||||
|
||||
equals(other: AllSetting): boolean {
|
||||
|
||||
@@ -905,7 +905,7 @@ export const sections: readonly Section[] = [
|
||||
method: 'GET',
|
||||
path: '/panel/api/nodes/list',
|
||||
summary: 'List every configured node with its connection details, health, and last heartbeat patch.',
|
||||
responseSchema: 'Node',
|
||||
responseSchema: 'NodeView',
|
||||
responseSchemaArray: true,
|
||||
},
|
||||
{
|
||||
@@ -927,6 +927,7 @@ export const sections: readonly Section[] = [
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
||||
],
|
||||
responseSchema: 'NodeView',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
@@ -940,18 +941,19 @@ export const sections: readonly Section[] = [
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/nodes/add',
|
||||
summary: 'Register a new remote node. Provide its URL, apiToken, and optional remark / allowPrivateAddress flag.',
|
||||
summary: 'Register a new remote node. Provide its URL, write-only apiToken, and optional remark / allowPrivateAddress flag. Responses expose hasApiToken only.',
|
||||
body:
|
||||
'{\n "name": "de-fra-1",\n "remark": "",\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef...",\n "enable": true,\n "allowPrivateAddress": false\n}',
|
||||
'{\n "name": "de-fra-1",\n "remark": "",\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef...",\n "clearApiToken": false,\n "enable": true,\n "allowPrivateAddress": false\n}',
|
||||
responseSchema: 'NodeView',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/nodes/update/:id',
|
||||
summary: 'Replace a node\u2019s connection details. Same body shape as /add.',
|
||||
summary: 'Replace a node\u2019s connection details. apiToken is write-only: omit it or send an empty string to keep the stored token; set clearApiToken=true to clear it.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
||||
],
|
||||
body: '{\n "name": "de-fra-1",\n "remark": "",\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef...",\n "enable": true,\n "allowPrivateAddress": false\n}',
|
||||
body: '{\n "name": "de-fra-1",\n "remark": "",\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "",\n "clearApiToken": false,\n "enable": true,\n "allowPrivateAddress": false\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -1145,7 +1147,7 @@ export const sections: readonly Section[] = [
|
||||
method: 'POST',
|
||||
path: '/panel/api/setting/all',
|
||||
summary: 'Return every panel setting: web server, Telegram bot, subscription, security, LDAP. The full JSON blob that the Settings page edits.',
|
||||
response: '{\n "success": true,\n "obj": {\n "webPort": 2053,\n "webCertFile": "",\n "webKeyFile": "",\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "tgBotEnable": false,\n "tgBotToken": "",\n ...\n }\n}',
|
||||
response: '{\n "success": true,\n "obj": {\n "webPort": 2053,\n "webCertFile": "",\n "webKeyFile": "",\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "subClashAutoDetect": false,\n "subClashUserAgentRegex": "",\n "subJsonEnable": false,\n "subJsonAutoDetect": false,\n "subJsonAlwaysArray": false,\n "subJsonUserAgentRegex": "",\n "subJsonPath": "/json/",\n "subJsonURI": "https://sub.example.com/json/",\n "subClashEnable": true,\n "subClashPath": "/clash/",\n "subClashURI": "https://sub.example.com/clash/",\n "tgBotEnable": false,\n "tgBotToken": "",\n ...\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -1156,7 +1158,14 @@ export const sections: readonly Section[] = [
|
||||
method: 'POST',
|
||||
path: '/panel/api/setting/update',
|
||||
summary: 'Persist every setting at once. The body mirrors the shape returned by /all. Invalid values (bad ports, missing cert pairs, etc.) are rejected before write.',
|
||||
body: '{\n "webPort": 2053,\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "tgBotEnable": false,\n ...\n}',
|
||||
body: '{\n "webPort": 2053,\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "subClashAutoDetect": false,\n "subClashUserAgentRegex": "",\n "subJsonEnable": false,\n "subJsonAutoDetect": false,\n "subJsonAlwaysArray": false,\n "subJsonUserAgentRegex": "",\n "subJsonPath": "/json/",\n "subJsonURI": "https://sub.example.com/json/",\n "subClashEnable": true,\n "subClashPath": "/clash/",\n "subClashURI": "https://sub.example.com/clash/",\n "tgBotEnable": false,\n ...\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/setting/validateRegex',
|
||||
summary: 'Validate any regular expression with the backend Go RE2 compiler without saving it.',
|
||||
body: '{\n "regex": "(?m)^general-purpose$"\n}',
|
||||
response: '{\n "success": true,\n "msg": ""\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -1454,9 +1463,10 @@ export const sections: readonly Section[] = [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/{subPath}:subid',
|
||||
summary: 'Return base64-encoded subscription links for all enabled clients matching the subscription ID. When the request has an Accept: text/html header or ?html=1, renders a styled info page instead. Default path: /sub/:subid.',
|
||||
summary: 'Return base64-encoded subscription links for all enabled clients matching the subscription ID. When the request has an Accept: text/html header or ?html=1, renders a styled info page instead. With ?format=info, returns the page view-model as JSON (traffic, expiry, online status; no links) for live polling. Default path: /sub/:subid.',
|
||||
params: [
|
||||
{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' },
|
||||
{ name: 'format', in: 'query', type: 'string', optional: true, desc: 'Set to "info" to get the subscription status view-model as JSON instead of the links.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -96,7 +96,7 @@ export default function BulkAttachInboundsModal({
|
||||
onChange={setTargetIds}
|
||||
options={targetOptions}
|
||||
placeholder={t('pages.clients.attachToInboundsTargets')}
|
||||
optionFilterProp="label"
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
autoFocus
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -96,7 +96,7 @@ export default function BulkDetachInboundsModal({
|
||||
onChange={setTargetIds}
|
||||
options={targetOptions}
|
||||
placeholder={t('pages.clients.detachFromInboundsTargets')}
|
||||
optionFilterProp="label"
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
autoFocus
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -48,6 +48,7 @@ const CLIENT_IP_LOG_MODAL_Z_INDEX = CLIENT_FORM_MODAL_Z_INDEX + 1;
|
||||
interface ExternalLinkRow {
|
||||
kind: 'link' | 'subscription';
|
||||
value: string;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
interface ApiMsg<T = unknown> {
|
||||
@@ -138,6 +139,7 @@ function toExternalLinkRows(links: ExternalLink[] | undefined): ExternalLinkRow[
|
||||
return (links || []).map((l) => ({
|
||||
kind: l.kind === 'subscription' ? 'subscription' : 'link',
|
||||
value: l.value || '',
|
||||
remark: l.remark || '',
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -146,11 +148,18 @@ function bytesToGB(bytes: number): number {
|
||||
return Math.round((bytes / (1024 * 1024 * 1024)) * 100) / 100;
|
||||
}
|
||||
|
||||
function gbToBytes(gb: number): number {
|
||||
export function gbToBytes(gb: number): number {
|
||||
if (!gb || gb <= 0) return 0;
|
||||
return Math.round(gb * 1024 * 1024 * 1024);
|
||||
}
|
||||
|
||||
export function resolveTotalBytes(originalBytes: number | null | undefined, displayedGB: number): number {
|
||||
if (originalBytes != null && displayedGB === bytesToGB(originalBytes)) {
|
||||
return originalBytes;
|
||||
}
|
||||
return gbToBytes(displayedGB);
|
||||
}
|
||||
|
||||
export default function ClientFormModal({
|
||||
open,
|
||||
mode,
|
||||
@@ -200,7 +209,7 @@ export default function ClientFormModal({
|
||||
const limitIpNotice = getLimitIpNotice(fail2ban, t);
|
||||
|
||||
function addExternalLinkRow(kind: 'link' | 'subscription') {
|
||||
appendExternalLink({ kind, value: '' });
|
||||
appendExternalLink({ kind, value: '', remark: '' });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -364,10 +373,15 @@ export default function ClientFormModal({
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!showFlow && flow) {
|
||||
// Only clear the flow once we actually have inbound options to judge
|
||||
// capability from. While the options list is momentarily empty (e.g. the
|
||||
// options query is (re)loading and `inbounds` falls back to `[]`), showFlow
|
||||
// is a false negative, so clearing here would silently drop a valid
|
||||
// xtls-rprx-vision flow the user picked for a Reality/TLS inbound.
|
||||
if (inbounds.length > 0 && !showFlow && flow) {
|
||||
methods.setValue('flow', '');
|
||||
}
|
||||
}, [showFlow, flow, methods]);
|
||||
}, [inbounds, showFlow, flow, methods]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showReverseTag && reverseTag) {
|
||||
@@ -491,6 +505,7 @@ export default function ClientFormModal({
|
||||
const expiryTime = values.delayedStart
|
||||
? -86400000 * (Number(values.delayedDays) || 0)
|
||||
: (values.expiryDate || 0);
|
||||
const totalBytes = resolveTotalBytes(client ? (client.totalGB ?? 0) : null, values.totalGB);
|
||||
const clientPayload: Record<string, unknown> = {
|
||||
email: values.email.trim(),
|
||||
subId: values.subId,
|
||||
@@ -499,7 +514,7 @@ export default function ClientFormModal({
|
||||
auth: values.auth,
|
||||
flow: showFlow ? (values.flow || '') : '',
|
||||
security: showSecurity ? (values.security || 'auto') : 'auto',
|
||||
totalGB: gbToBytes(values.totalGB),
|
||||
totalGB: totalBytes,
|
||||
expiryTime,
|
||||
reset: Number(values.reset) || 0,
|
||||
limitIp: Number(values.limitIp) || 0,
|
||||
@@ -539,7 +554,7 @@ export default function ClientFormModal({
|
||||
}
|
||||
|
||||
const externalLinks: ExternalLinkInput[] = values.externalLinks
|
||||
.map((r) => ({ kind: r.kind, value: r.value.trim(), remark: '' }))
|
||||
.map((r) => ({ kind: r.kind, value: r.value.trim(), remark: (r.remark || '').trim() }))
|
||||
.filter((r) => r.value !== '');
|
||||
|
||||
setSubmitting(true);
|
||||
@@ -909,6 +924,13 @@ export default function ClientFormModal({
|
||||
placeholder="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField name={`externalLinks.${index}.remark`} noStyle>
|
||||
<Input
|
||||
style={{ width: 140 }}
|
||||
aria-label={t('remark')}
|
||||
placeholder={t('remark')}
|
||||
/>
|
||||
</FormField>
|
||||
<Tooltip title={t('delete')}>
|
||||
<Button aria-label={t('delete')} danger icon={<DeleteOutlined />} onClick={() => removeExternalLink(index)} />
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Divider, Modal, Popover, Tag, Tooltip, message } from 'antd';
|
||||
import { CopyOutlined, EyeOutlined, QrcodeOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { CopyOutlined, DownloadOutlined, EyeOutlined, QrcodeOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
|
||||
import { ClipboardManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
|
||||
import { ClipboardManager, FileManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
@@ -64,6 +64,12 @@ const DEFAULT_SUB: SubSettings = {
|
||||
publicHost: '',
|
||||
};
|
||||
|
||||
const SUBSCRIPTION_DOWNLOAD_NAMES = {
|
||||
standard: 'subscription-standard.txt',
|
||||
json: 'subscription-json.json',
|
||||
clash: 'subscription-clash.yaml',
|
||||
} as const;
|
||||
|
||||
export default function ClientInfoModal({
|
||||
open,
|
||||
client,
|
||||
@@ -89,6 +95,7 @@ export default function ClientInfoModal({
|
||||
const [ipsLoading, setIpsLoading] = useState(false);
|
||||
const [ipsClearing, setIpsClearing] = useState(false);
|
||||
const [ipsModalOpen, setIpsModalOpen] = useState(false);
|
||||
const [downloadingFormat, setDownloadingFormat] = useState<keyof typeof SUBSCRIPTION_DOWNLOAD_NAMES | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -148,6 +155,21 @@ export default function ClientInfoModal({
|
||||
if (ok) messageApi.success(t('copied'));
|
||||
}
|
||||
|
||||
async function downloadSubscription(url: string, format: keyof typeof SUBSCRIPTION_DOWNLOAD_NAMES) {
|
||||
if (!url || downloadingFormat) return;
|
||||
setDownloadingFormat(format);
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Subscription download failed');
|
||||
const content = await response.text();
|
||||
FileManager.downloadTextFile(content, SUBSCRIPTION_DOWNLOAD_NAMES[format]);
|
||||
} catch (_) {
|
||||
messageApi.error(t('somethingWentWrong'));
|
||||
} finally {
|
||||
setDownloadingFormat(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadIps() {
|
||||
if (!client?.email) return;
|
||||
setIpsLoading(true);
|
||||
@@ -383,6 +405,9 @@ export default function ClientInfoModal({
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subLink)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('download')}>
|
||||
<Button size="small" icon={<DownloadOutlined />} aria-label={t('download')} loading={downloadingFormat === 'standard'} disabled={downloadingFormat !== null} onClick={() => void downloadSubscription(subLink, 'standard')} />
|
||||
</Tooltip>
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="left"
|
||||
@@ -411,6 +436,9 @@ export default function ClientInfoModal({
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subJsonLink)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('download')}>
|
||||
<Button size="small" icon={<DownloadOutlined />} aria-label={t('download')} loading={downloadingFormat === 'json'} disabled={downloadingFormat !== null} onClick={() => void downloadSubscription(subJsonLink, 'json')} />
|
||||
</Tooltip>
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="left"
|
||||
@@ -442,6 +470,9 @@ export default function ClientInfoModal({
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subClashLink)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('download')}>
|
||||
<Button size="small" icon={<DownloadOutlined />} aria-label={t('download')} loading={downloadingFormat === 'clash'} disabled={downloadingFormat !== null} onClick={() => void downloadSubscription(subClashLink, 'clash')} />
|
||||
</Tooltip>
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="left"
|
||||
|
||||
@@ -162,6 +162,17 @@
|
||||
background: color-mix(in srgb, var(--ant-color-primary) 6%, transparent);
|
||||
}
|
||||
|
||||
.client-card-comment {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: var(--ant-color-text-secondary);
|
||||
font-size: 12px;
|
||||
max-height: 4.5em;
|
||||
overflow-y: auto;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.client-card-speed {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -62,10 +62,12 @@ import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
|
||||
import ClientTrafficCell from '@/components/clients/ClientTrafficCell';
|
||||
import ClientSpeedTag, { isActiveSpeed } from '@/components/clients/ClientSpeedTag';
|
||||
import ClientCardComment from '@/components/clients/ClientCardComment';
|
||||
import AppSidebar from '@/layouts/AppSidebar';
|
||||
import { IntlUtil, SizeFormatter } from '@/utils';
|
||||
import { setMessageInstance } from '@/utils/messageBus';
|
||||
import { LazyMount } from '@/components/utility';
|
||||
import { SPEED_COLUMN_WIDTH, SPEED_TAG_CLASS_NAME, SPEED_TAG_STYLE } from '@/components/utility/speedTagStyle';
|
||||
const ClientFormModal = lazy(() => import('./ClientFormModal'));
|
||||
const ClientInfoModal = lazy(() => import('./ClientInfoModal'));
|
||||
const ClientQrModal = lazy(() => import('./ClientQrModal'));
|
||||
@@ -818,7 +820,7 @@ export default function ClientsPage() {
|
||||
<div className="email-cell">
|
||||
<span className="email">{record.email}</span>
|
||||
{record.subId && <span className="sub" title={record.subId}>{record.subId}</span>}
|
||||
{record.comment && <span className="sub" title={record.comment}>{record.comment}</span>}
|
||||
<ClientCardComment comment={record.comment} className="sub" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -907,12 +909,14 @@ export default function ClientsPage() {
|
||||
{
|
||||
title: t('pages.clients.speed'),
|
||||
key: 'speed',
|
||||
width: 110,
|
||||
width: SPEED_COLUMN_WIDTH,
|
||||
align: 'center',
|
||||
render: (_v, record) => {
|
||||
const speed = clientSpeed[record.email];
|
||||
if (!isActiveSpeed(speed)) return <Tag color="default">—</Tag>;
|
||||
return <ClientSpeedTag speed={speed} />;
|
||||
if (!isActiveSpeed(speed)) {
|
||||
return <Tag color="default" className={SPEED_TAG_CLASS_NAME} style={SPEED_TAG_STYLE}>—</Tag>;
|
||||
}
|
||||
return <ClientSpeedTag speed={speed} tableCell />;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1212,7 +1216,7 @@ export default function ClientsPage() {
|
||||
value={sortValueFor(sortColumn, sortOrder)}
|
||||
aria-label={t('sort')}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
suffixIcon={<SortAscendingOutlined />}
|
||||
suffix={<SortAscendingOutlined />}
|
||||
style={{ minWidth: isMobile ? 130 : 200 }}
|
||||
onChange={(value) => {
|
||||
const opt = SORT_OPTIONS.find((o) => o.value === value);
|
||||
@@ -1433,6 +1437,7 @@ export default function ClientsPage() {
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<ClientCardComment comment={row.comment} />
|
||||
<ClientTrafficCell
|
||||
compact
|
||||
up={row.traffic?.up}
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function FilterDrawer({
|
||||
title={t('pages.clients.filterTitle')}
|
||||
open={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
width={420}
|
||||
size={420}
|
||||
destroyOnHidden
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
@@ -139,8 +139,7 @@ export default function FilterDrawer({
|
||||
placeholder={t('inbounds')}
|
||||
maxTagCount="responsive"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
listHeight={220}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -155,8 +154,7 @@ export default function FilterDrawer({
|
||||
placeholder={t('pages.clients.filters.nodes')}
|
||||
maxTagCount="responsive"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
listHeight={220}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -171,8 +169,7 @@ export default function FilterDrawer({
|
||||
placeholder={t('pages.clients.groupPlaceholder')}
|
||||
maxTagCount="responsive"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
listHeight={220}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -191,8 +191,7 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi
|
||||
<Select
|
||||
mode="multiple"
|
||||
options={inboundSelectOptions}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
placeholder={t('pages.hosts.selectInbound')}
|
||||
/>
|
||||
</FormField>
|
||||
@@ -211,7 +210,7 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi
|
||||
<Select mode="tags" allowClear tokenSeparators={[',']} />
|
||||
</FormField>
|
||||
<FormField name="nodeGuids" label={t('pages.hosts.fields.nodeGuids')} tooltip={t('pages.hosts.hints.nodeGuids')}>
|
||||
<Select mode="multiple" allowClear options={nodeSelectOptions} optionFilterProp="label" />
|
||||
<Select mode="multiple" allowClear options={nodeSelectOptions} showSearch={{ optionFilterProp: 'label' }} />
|
||||
</FormField>
|
||||
<FormField name="enable" label={t('pages.hosts.fields.enable')} valueProp="checked">
|
||||
<Switch />
|
||||
|
||||
@@ -203,7 +203,7 @@ export default function AttachClientsModal({
|
||||
onChange={setTargetIds}
|
||||
options={targetOptions}
|
||||
placeholder={t('pages.inbounds.attachClientsTargets')}
|
||||
optionFilterProp="label"
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -203,7 +203,7 @@ export default function AttachExistingClientsModal({
|
||||
options={groupOptions}
|
||||
placeholder={t('pages.clients.group')}
|
||||
style={{ minWidth: 160 }}
|
||||
optionFilterProp="label"
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
@@ -86,11 +86,12 @@ export function useSecurityActions({ methods, setSaving, messageApi, nodeId, set
|
||||
messageApi.warning(t('pages.inbounds.form.realityTargetRequired'));
|
||||
return;
|
||||
}
|
||||
const xver = Number(getValues('streamSettings.realitySettings.xver')) || 0;
|
||||
setScanning(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post<RealityScanResult>(
|
||||
'/panel/api/server/scanRealityTarget',
|
||||
{ target },
|
||||
{ target, xver },
|
||||
{ silent: true },
|
||||
);
|
||||
if (!msg?.success || !msg.obj) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Tag, Tooltip } from 'antd';
|
||||
|
||||
import { SizeFormatter } from '@/utils';
|
||||
import { SPEED_TAG_CLASS_NAME, SPEED_TAG_STYLE } from '@/components/utility/speedTagStyle';
|
||||
|
||||
import type { InboundSpeedEntry } from './types';
|
||||
|
||||
@@ -12,12 +13,17 @@ export function isActiveSpeed(speed?: InboundSpeedEntry): speed is InboundSpeedE
|
||||
interface InboundSpeedTagProps {
|
||||
speed: InboundSpeedEntry;
|
||||
withTooltip?: boolean;
|
||||
tableCell?: boolean;
|
||||
}
|
||||
|
||||
// Blue "↑ up / ↓ down" rate tag, optionally with a stacked breakdown tooltip.
|
||||
export function InboundSpeedTag({ speed, withTooltip = false }: InboundSpeedTagProps) {
|
||||
export function InboundSpeedTag({ speed, withTooltip = false, tableCell = false }: InboundSpeedTagProps) {
|
||||
const tag = (
|
||||
<Tag color="blue">
|
||||
<Tag
|
||||
color="blue"
|
||||
className={tableCell ? SPEED_TAG_CLASS_NAME : undefined}
|
||||
style={tableCell ? SPEED_TAG_STYLE : undefined}
|
||||
>
|
||||
↑ {SizeFormatter.speedFormat(speed.up)}
|
||||
{' / '}
|
||||
↓ {SizeFormatter.speedFormat(speed.down)}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||
import { coerceInboundJsonField } from '@/models/dbinbound';
|
||||
|
||||
import { RowActionsCell } from './RowActions';
|
||||
import { SPEED_COLUMN_WIDTH, SPEED_TAG_CLASS_NAME, SPEED_TAG_STYLE } from '@/components/utility/speedTagStyle';
|
||||
import { InboundSpeedTag, isActiveSpeed } from './InboundSpeedTag';
|
||||
import {
|
||||
readStreamHints,
|
||||
@@ -324,14 +325,14 @@ export function useInboundColumns({
|
||||
title: t('pages.inbounds.speed'),
|
||||
key: 'speed',
|
||||
align: 'center',
|
||||
width: 110,
|
||||
width: SPEED_COLUMN_WIDTH,
|
||||
sorter: (a, b) => speedTotal(a) - speedTotal(b),
|
||||
render: (_, record) => {
|
||||
const speed = inboundSpeed[record.id];
|
||||
if (!isActiveSpeed(speed)) {
|
||||
return <Tag color='default'>—</Tag>;
|
||||
return <Tag color="default" className={SPEED_TAG_CLASS_NAME} style={SPEED_TAG_STYLE}>—</Tag>;
|
||||
}
|
||||
return <InboundSpeedTag speed={speed} withTooltip />;
|
||||
return <InboundSpeedTag speed={speed} withTooltip tableCell />;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.index-page .action > span:not(.anticon):not(.tg-icon) {
|
||||
.index-page .action > span:not(.anticon) {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -36,11 +36,6 @@
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.index-page .tg-icon {
|
||||
display: inline-block;
|
||||
vertical-align: -2px;
|
||||
}
|
||||
|
||||
.index-page .ip-toggle-icon {
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
|
||||
@@ -246,7 +246,7 @@ export default function IndexPage() {
|
||||
hoverable
|
||||
actions={[
|
||||
<Space className="action" key="tg" role="button" tabIndex={0} aria-label="@XrayUI" onClick={openTelegram} onKeyDown={activateOnKey(openTelegram)}>
|
||||
<TelegramFilled className="tg-icon" aria-hidden="true" />
|
||||
<TelegramFilled aria-hidden="true" />
|
||||
{!isMobile && <span>@XrayUI</span>}
|
||||
</Space>,
|
||||
<Space
|
||||
|
||||
@@ -45,6 +45,7 @@ function defaultValues(): NodeFormValues {
|
||||
port: 2053,
|
||||
basePath: '/',
|
||||
apiToken: '',
|
||||
hasStoredToken: false,
|
||||
enable: true,
|
||||
allowPrivateAddress: false,
|
||||
tlsVerifyMode: 'verify',
|
||||
@@ -107,6 +108,8 @@ export default function NodeFormModal({
|
||||
scheme: (node.scheme as 'http' | 'https') || base.scheme,
|
||||
inboundSyncMode: (node.inboundSyncMode as 'all' | 'selected') || base.inboundSyncMode,
|
||||
inboundTags: node.inboundTags ?? [],
|
||||
apiToken: '',
|
||||
hasStoredToken: node.hasApiToken ?? false,
|
||||
}
|
||||
: base;
|
||||
if (next.scheme === 'http') next.tlsVerifyMode = 'skip';
|
||||
@@ -120,8 +123,11 @@ export default function NodeFormModal({
|
||||
[mode, t],
|
||||
);
|
||||
|
||||
const editingWithToken = mode === 'edit' && Boolean(node?.hasApiToken);
|
||||
|
||||
function buildPayload(values: NodeFormValues): Partial<NodeRecord> {
|
||||
return {
|
||||
const token = values.apiToken.trim();
|
||||
const payload: Partial<NodeRecord> = {
|
||||
id: values.id || 0,
|
||||
name: values.name.trim(),
|
||||
remark: values.remark?.trim() || '',
|
||||
@@ -129,7 +135,6 @@ export default function NodeFormModal({
|
||||
address: values.address.trim(),
|
||||
port: values.port,
|
||||
basePath: values.basePath.trim() || '/',
|
||||
apiToken: values.apiToken.trim(),
|
||||
enable: values.enable,
|
||||
allowPrivateAddress: values.allowPrivateAddress,
|
||||
tlsVerifyMode: values.tlsVerifyMode,
|
||||
@@ -138,10 +143,12 @@ export default function NodeFormModal({
|
||||
inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [],
|
||||
outboundTag: values.outboundTag || '',
|
||||
};
|
||||
if (token) payload.apiToken = token;
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function onTest() {
|
||||
if (!(await methods.trigger(['address', 'port']))) return;
|
||||
if (!(await methods.trigger(['name', 'address', 'port']))) return;
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
@@ -158,7 +165,7 @@ export default function NodeFormModal({
|
||||
}
|
||||
|
||||
async function onFetchPin() {
|
||||
if (!(await methods.trigger(['address', 'port']))) return;
|
||||
if (!(await methods.trigger(['name', 'address', 'port']))) return;
|
||||
setFetchingPin(true);
|
||||
try {
|
||||
const payload = buildPayload(methods.getValues());
|
||||
@@ -369,8 +376,11 @@ export default function NodeFormModal({
|
||||
name="apiToken"
|
||||
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.apiToken) }}
|
||||
tooltip={t('pages.nodes.apiTokenHint')}
|
||||
extra={editingWithToken ? t('pages.nodes.apiTokenKeepHint') : undefined}
|
||||
>
|
||||
<Input.Password placeholder={t('pages.nodes.apiTokenPlaceholder')} />
|
||||
<Input.Password
|
||||
placeholder={editingWithToken ? t('pages.nodes.apiTokenKeepHint') : t('pages.nodes.apiTokenPlaceholder')}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
|
||||
@@ -650,7 +650,7 @@ export default function NodeList({
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content' }}
|
||||
size="middle"
|
||||
rowKey="id"
|
||||
rowKey="key"
|
||||
rowSelection={dataSource.length > 1 ? {
|
||||
selectedRowKeys: selectedIds,
|
||||
onChange: (keys) => onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user