mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-27 13:37:14 +00:00
Compare commits
103 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c377dca27c | |||
| c56f6447a8 | |||
| 66740b7ef4 | |||
| 8d02ae28f5 | |||
| 2c943da3e0 | |||
| f52c3c4837 | |||
| 1e2d6f6081 | |||
| af5a8e5d40 | |||
| ad288a7ecc | |||
| ad5f2a28cb | |||
| e467b25f03 | |||
| 03cc80bb9e | |||
| 863473783d | |||
| c3fa73d5a0 | |||
| 87ebcc7a6f | |||
| 55f0281692 | |||
| bcd71c9296 | |||
| 33f72f8f4a | |||
| ea35884390 | |||
| 17e6b5a460 | |||
| 34d2591e50 | |||
| ca6955d88b | |||
| 411271b454 | |||
| e862d81c60 | |||
| 6af2995930 | |||
| 041476a317 | |||
| ff954ec48c | |||
| 8f49327efb | |||
| 8bbca76bdd | |||
| a2774bf212 | |||
| 48675ff197 | |||
| 604986598f | |||
| b6473004ac | |||
| 579acbc669 | |||
| 4605f00a15 | |||
| dc6a16019e | |||
| fea6a20f7c | |||
| 7f7b7e16a4 | |||
| fd17255f1d | |||
| 8bc00d1e90 | |||
| 6f4cc1e53c | |||
| 0e69f64e56 | |||
| 7fe9932d7b | |||
| c004c18d90 | |||
| f8e9f2f087 | |||
| 5accd8a611 | |||
| f46b1726cf | |||
| 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 |
+17
-11
@@ -26,7 +26,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
cache: true
|
cache: true
|
||||||
@@ -55,7 +55,7 @@ jobs:
|
|||||||
--health-retries 5
|
--health-retries 5
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
cache: true
|
cache: true
|
||||||
@@ -74,11 +74,11 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
cache: true
|
cache: true
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version-file: .nvmrc
|
node-version-file: .nvmrc
|
||||||
- name: Regenerate schemas, examples and OpenAPI
|
- name: Regenerate schemas, examples and OpenAPI
|
||||||
@@ -91,7 +91,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
cache: true
|
cache: true
|
||||||
@@ -107,7 +107,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
cache: true
|
cache: true
|
||||||
@@ -124,7 +124,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
cache: true
|
cache: true
|
||||||
@@ -139,7 +139,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
cache: true
|
cache: true
|
||||||
@@ -154,7 +154,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version-file: .nvmrc
|
node-version-file: .nvmrc
|
||||||
cache: npm
|
cache: npm
|
||||||
@@ -168,12 +168,18 @@ jobs:
|
|||||||
- name: Typecheck
|
- name: Typecheck
|
||||||
run: npm run typecheck
|
run: npm run typecheck
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
- name: Install Playwright Chromium (Storybook story tests)
|
||||||
|
run: npx playwright install --with-deps chromium
|
||||||
|
working-directory: frontend
|
||||||
- name: Test
|
- name: Test
|
||||||
run: npm test
|
run: npm test
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
- name: Audit
|
- name: Build Storybook
|
||||||
run: npm audit --audit-level=high
|
run: npm run build-storybook
|
||||||
|
working-directory: frontend
|
||||||
|
- name: Audit
|
||||||
|
run: npm audit --omit=dev --audit-level=high
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|||||||
+638
-417
File diff suppressed because it is too large
Load Diff
@@ -49,7 +49,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
if: matrix.language == 'go'
|
if: matrix.language == 'go'
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version-file: .nvmrc
|
node-version-file: .nvmrc
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|||||||
@@ -26,11 +26,11 @@ jobs:
|
|||||||
|
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: pnpm/action-setup@v6
|
||||||
with:
|
with:
|
||||||
version: 11
|
package_json_file: docs/package.json
|
||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version-file: .nvmrc
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: docs/pnpm-lock.yaml
|
cache-dependency-path: docs/pnpm-lock.yaml
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
|
- 'frontend/src/components/**'
|
||||||
|
- 'frontend/.storybook/**'
|
||||||
|
- 'frontend/package-lock.json'
|
||||||
- '.github/workflows/docs-deploy.yml'
|
- '.github/workflows/docs-deploy.yml'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -32,10 +35,10 @@ jobs:
|
|||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: pnpm/action-setup@v6
|
||||||
with:
|
with:
|
||||||
version: 11
|
package_json_file: docs/package.json
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version-file: .nvmrc
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: docs/pnpm-lock.yaml
|
cache-dependency-path: docs/pnpm-lock.yaml
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
@@ -52,6 +55,13 @@ jobs:
|
|||||||
# in place. That way both /docs/... and /en/docs/... resolve. Other
|
# in place. That way both /docs/... and /en/docs/... resolve. Other
|
||||||
# locales stay under /fa, /ru, /zh.
|
# locales stay under /fa, /ru, /zh.
|
||||||
run: cp -a out/en/. out/
|
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
|
- uses: actions/upload-pages-artifact@v5
|
||||||
with:
|
with:
|
||||||
path: docs/out
|
path: docs/out
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
exclude: 'server\.go|xray\.go|inbound\.go|client_bulk\.go|inbound_traffic\.go|.*_postgres_test\.go'
|
exclude: 'server\.go|xray\.go|inbound\.go|client_bulk\.go|inbound_traffic\.go|.*_postgres_test\.go'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
cache: true
|
cache: true
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ on:
|
|||||||
- "x-ui.service.debian"
|
- "x-ui.service.debian"
|
||||||
- "x-ui.service.arch"
|
- "x-ui.service.arch"
|
||||||
- "x-ui.service.rhel"
|
- "x-ui.service.rhel"
|
||||||
|
- ".github/workflows/release.yml"
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- "**.go"
|
- "**.go"
|
||||||
@@ -26,12 +27,15 @@ on:
|
|||||||
- "x-ui.service.debian"
|
- "x-ui.service.debian"
|
||||||
- "x-ui.service.arch"
|
- "x-ui.service.arch"
|
||||||
- "x-ui.service.rhel"
|
- "x-ui.service.rhel"
|
||||||
|
- ".github/workflows/release.yml"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
strategy:
|
strategy:
|
||||||
|
# One platform hitting a transient outage must not cancel the other six.
|
||||||
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
platform:
|
platform:
|
||||||
- amd64
|
- amd64
|
||||||
@@ -47,7 +51,7 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@@ -57,7 +61,7 @@ jobs:
|
|||||||
# at compile time. internal/web/dist/ is .gitignored, so on a fresh CI
|
# at compile time. internal/web/dist/ is .gitignored, so on a fresh CI
|
||||||
# checkout it doesn't exist until vite emits it.
|
# checkout it doesn't exist until vite emits it.
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version-file: .nvmrc
|
node-version-file: .nvmrc
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@@ -71,6 +75,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Build 3X-UI
|
- name: Build 3X-UI
|
||||||
run: |
|
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 CGO_ENABLED=1
|
||||||
export GOOS=linux
|
export GOOS=linux
|
||||||
export GOARCH=${{ matrix.platform }}
|
export GOARCH=${{ matrix.platform }}
|
||||||
@@ -86,11 +92,11 @@ jobs:
|
|||||||
esac
|
esac
|
||||||
echo "Resolving Bootlin musl toolchain for arch=$BOOTLIN_ARCH (platform=${{ matrix.platform }})"
|
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_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; }
|
[ -z "$TARBALL_URL" ] && { echo "Failed to locate Bootlin musl toolchain for arch=$BOOTLIN_ARCH" >&2; exit 1; }
|
||||||
echo "Downloading: $TARBALL_URL"
|
echo "Downloading: $TARBALL_URL"
|
||||||
cd /tmp
|
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")"
|
tar -xf "$(basename "$TARBALL_URL")"
|
||||||
TOOLCHAIN_DIR=$(find . -maxdepth 1 -type d -name "${BOOTLIN_ARCH}--musl--stable-*" | head -n1)
|
TOOLCHAIN_DIR=$(find . -maxdepth 1 -type d -name "${BOOTLIN_ARCH}--musl--stable-*" | head -n1)
|
||||||
export PATH="$(realpath "$TOOLCHAIN_DIR")/bin:$PATH"
|
export PATH="$(realpath "$TOOLCHAIN_DIR")/bin:$PATH"
|
||||||
@@ -118,55 +124,57 @@ jobs:
|
|||||||
cd x-ui/bin
|
cd x-ui/bin
|
||||||
|
|
||||||
# Download dependencies
|
# Download dependencies
|
||||||
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.7.11/"
|
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.7.28/"
|
||||||
if [ "${{ matrix.platform }}" == "amd64" ]; then
|
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
|
unzip Xray-linux-64.zip
|
||||||
rm -f Xray-linux-64.zip
|
rm -f Xray-linux-64.zip
|
||||||
elif [ "${{ matrix.platform }}" == "arm64" ]; then
|
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
|
unzip Xray-linux-arm64-v8a.zip
|
||||||
rm -f Xray-linux-arm64-v8a.zip
|
rm -f Xray-linux-arm64-v8a.zip
|
||||||
elif [ "${{ matrix.platform }}" == "armv7" ]; then
|
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
|
unzip Xray-linux-arm32-v7a.zip
|
||||||
rm -f Xray-linux-arm32-v7a.zip
|
rm -f Xray-linux-arm32-v7a.zip
|
||||||
elif [ "${{ matrix.platform }}" == "armv6" ]; then
|
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
|
unzip Xray-linux-arm32-v6.zip
|
||||||
rm -f Xray-linux-arm32-v6.zip
|
rm -f Xray-linux-arm32-v6.zip
|
||||||
elif [ "${{ matrix.platform }}" == "386" ]; then
|
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
|
unzip Xray-linux-32.zip
|
||||||
rm -f Xray-linux-32.zip
|
rm -f Xray-linux-32.zip
|
||||||
elif [ "${{ matrix.platform }}" == "armv5" ]; then
|
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
|
unzip Xray-linux-arm32-v5.zip
|
||||||
rm -f Xray-linux-arm32-v5.zip
|
rm -f Xray-linux-arm32-v5.zip
|
||||||
elif [ "${{ matrix.platform }}" == "s390x" ]; then
|
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
|
unzip Xray-linux-s390x.zip
|
||||||
rm -f Xray-linux-s390x.zip
|
rm -f Xray-linux-s390x.zip
|
||||||
fi
|
fi
|
||||||
rm -f geoip.dat geosite.dat
|
rm -f geoip.dat geosite.dat
|
||||||
wget -q https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
|
fetch 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
|
fetch 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
|
fetch -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
|
fetch -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
|
fetch -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 -O geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||||
mv xray xray-linux-${{ matrix.platform }}
|
mv xray xray-linux-${{ matrix.platform }}
|
||||||
# mtg-multi (MTProto sidecar) ships prebuilt release binaries whose
|
# mtg-multi (MTProto sidecar) ships prebuilt release binaries whose
|
||||||
# platform labels match our matrix, so download and unpack the matching
|
# platform labels match our matrix, so download and unpack the matching
|
||||||
# archive. Only the platforms the fork publishes are packaged. The tag
|
# archive. Only the platforms the fork publishes are packaged — the tag
|
||||||
# is resolved from the fork's latest release so it never needs bumping
|
# lookup lives inside that branch so unpackaged platforms (s390x) never
|
||||||
# here; the token only lifts the API rate limit for a public read.
|
# depend on it. The tag comes from the release-page redirect on
|
||||||
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)
|
# github.com — the host the downloads need anyway — because api.github.com
|
||||||
if [ -z "$MTG_MULTI_VER" ]; then echo "could not resolve the latest mtg-multi release tag"; exit 1; fi
|
# has 503'd whole release runs while asset downloads kept working.
|
||||||
case "${{ matrix.platform }}" in
|
case "${{ matrix.platform }}" in
|
||||||
amd64|arm64|armv7|armv6|386)
|
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 }}"
|
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"
|
tar -xzf "${MTG_PKG}.tar.gz"
|
||||||
mv "${MTG_PKG}/mtg-multi" "mtg-linux-${{ matrix.platform }}"
|
mv "${MTG_PKG}/mtg-multi" "mtg-linux-${{ matrix.platform }}"
|
||||||
rm -rf "${MTG_PKG}" "${MTG_PKG}.tar.gz"
|
rm -rf "${MTG_PKG}" "${MTG_PKG}.tar.gz"
|
||||||
@@ -211,7 +219,7 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
check-latest: true
|
check-latest: true
|
||||||
@@ -220,7 +228,7 @@ jobs:
|
|||||||
# Linux job above. This step is identical except npm runs on the
|
# Linux job above. This step is identical except npm runs on the
|
||||||
# Windows runner here.
|
# Windows runner here.
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version-file: .nvmrc
|
node-version-file: .nvmrc
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@@ -267,32 +275,34 @@ jobs:
|
|||||||
- name: Copy and download resources
|
- name: Copy and download resources
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
|
$retry = @{ MaximumRetryCount = 5; RetryIntervalSec = 10 }
|
||||||
mkdir x-ui
|
mkdir x-ui
|
||||||
Copy-Item xui-release.exe x-ui\x-ui.exe
|
Copy-Item xui-release.exe x-ui\x-ui.exe
|
||||||
mkdir x-ui\bin
|
mkdir x-ui\bin
|
||||||
cd x-ui\bin
|
cd x-ui\bin
|
||||||
|
|
||||||
# Download Xray for Windows
|
# Download Xray for Windows
|
||||||
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.7.11/"
|
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.7.28/"
|
||||||
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 .
|
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
|
||||||
Remove-Item "Xray-windows-64.zip"
|
Remove-Item "Xray-windows-64.zip"
|
||||||
Remove-Item geoip.dat, geosite.dat -ErrorAction SilentlyContinue
|
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 @retry -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 @retry -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 @retry -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 @retry -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 @retry -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/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat" -OutFile "geosite_RU.dat"
|
||||||
Rename-Item xray.exe xray-windows-amd64.exe
|
Rename-Item xray.exe xray-windows-amd64.exe
|
||||||
|
|
||||||
# mtg-multi (MTProto sidecar) publishes a prebuilt Windows binary, so
|
# mtg-multi (MTProto sidecar) publishes a prebuilt Windows binary, so
|
||||||
# download and unpack it instead of compiling. The tag tracks the
|
# download and unpack it instead of compiling. The tag comes from the
|
||||||
# fork's latest release so it never needs bumping here.
|
# release-page redirect on github.com — not api.github.com, whose
|
||||||
$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
|
# outages have failed release runs while asset downloads kept working.
|
||||||
if (-not $MTG_MULTI_VER) { throw "could not resolve the latest mtg-multi release tag" }
|
$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"
|
$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
|
Expand-Archive -Path "$MTG_PKG.zip" -DestinationPath "mtg-tmp" -Force
|
||||||
Move-Item "mtg-tmp/$MTG_PKG/mtg-multi.exe" "mtg-windows-amd64.exe"
|
Move-Item "mtg-tmp/$MTG_PKG/mtg-multi.exe" "mtg-windows-amd64.exe"
|
||||||
Remove-Item -Recurse -Force "mtg-tmp", "$MTG_PKG.zip"
|
Remove-Item -Recurse -Force "mtg-tmp", "$MTG_PKG.zip"
|
||||||
@@ -359,6 +369,14 @@ jobs:
|
|||||||
COMMIT: ${{ github.sha }}
|
COMMIT: ${{ github.sha }}
|
||||||
run: |
|
run: |
|
||||||
set -e
|
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}"
|
short="${COMMIT::8}"
|
||||||
notes="Rolling development build — installs via the panel's Dev update channel.
|
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.
|
# Force-move the dev-latest tag to this commit so the release tracks it.
|
||||||
git tag -f dev-latest "${COMMIT}"
|
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
|
# The release exists on every run but the first; edit-first avoids an
|
||||||
gh release edit dev-latest --prerelease --latest=false \
|
# existence probe that can 503 and mis-route into create (422).
|
||||||
--title "Dev build ${short}" --notes "${notes}"
|
if ! retry gh release edit dev-latest --prerelease --latest=false \
|
||||||
else
|
--title "Dev build ${short}" --notes "${notes}"; then
|
||||||
gh release create dev-latest --prerelease --latest=false \
|
retry gh release create dev-latest --prerelease --latest=false \
|
||||||
--target "${COMMIT}" --title "Dev build ${short}" --notes "${notes}"
|
--target "${COMMIT}" --title "Dev build ${short}" --notes "${notes}"
|
||||||
fi
|
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
|
||||||
|
|||||||
+2
-1
@@ -2,7 +2,8 @@
|
|||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
.cursor/
|
.cursor/
|
||||||
.claude/*
|
.specify/
|
||||||
|
.claude/
|
||||||
.cache/
|
.cache/
|
||||||
.sync*
|
.sync*
|
||||||
|
|
||||||
|
|||||||
Vendored
+131
@@ -96,6 +96,22 @@
|
|||||||
"options": {
|
"options": {
|
||||||
"cwd": "${workspaceFolder}"
|
"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": [
|
"problemMatcher": [
|
||||||
"$go"
|
"$go"
|
||||||
]
|
]
|
||||||
@@ -111,6 +127,22 @@
|
|||||||
"options": {
|
"options": {
|
||||||
"cwd": "${workspaceFolder}"
|
"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": [
|
"problemMatcher": [
|
||||||
"$go"
|
"$go"
|
||||||
]
|
]
|
||||||
@@ -125,6 +157,22 @@
|
|||||||
"options": {
|
"options": {
|
||||||
"cwd": "${workspaceFolder}"
|
"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": [
|
"problemMatcher": [
|
||||||
"$go"
|
"$go"
|
||||||
]
|
]
|
||||||
@@ -140,10 +188,93 @@
|
|||||||
"options": {
|
"options": {
|
||||||
"cwd": "${workspaceFolder}"
|
"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": [
|
"problemMatcher": [
|
||||||
"$go"
|
"$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",
|
"label": "frontend: ncu -u",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
|
|||||||
+6
-4
@@ -5,7 +5,7 @@ Thanks for taking the time to contribute to 3x-ui. This guide gets a development
|
|||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- **Go 1.26+** (the version pinned in `go.mod`)
|
- **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**
|
- **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.
|
- **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.
|
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 |
|
| 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. |
|
| 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. |
|
| 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.
|
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`).
|
- **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.
|
- **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.
|
- **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
|
### Project layout
|
||||||
|
|
||||||
@@ -277,7 +279,7 @@ CI runs this for you nightly (and on demand) via `.github/workflows/mutation.yml
|
|||||||
|
|
||||||
### CI
|
### 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
|
## 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:
|
3. Run the relevant checks before pushing:
|
||||||
- `go build ./...`
|
- `go build ./...`
|
||||||
- `go test ./...` (when Go code changed)
|
- `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.
|
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.
|
5. Open the PR against `main` with a brief description of what changed and how to test it.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@ if [ -z "$MTG_MULTI_VER" ]; then
|
|||||||
fi
|
fi
|
||||||
mkdir -p build/bin
|
mkdir -p build/bin
|
||||||
cd build/bin
|
cd build/bin
|
||||||
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.7.11/Xray-linux-${ARCH}.zip"
|
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.7.28/Xray-linux-${ARCH}.zip"
|
||||||
unzip "Xray-linux-${ARCH}.zip"
|
unzip "Xray-linux-${ARCH}.zip"
|
||||||
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
|
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
|
||||||
mv xray "xray-linux-${FNAME}"
|
mv xray "xray-linux-${FNAME}"
|
||||||
|
|||||||
@@ -68,8 +68,12 @@ build-fe: ## Build the Vite bundles into internal/web/dist
|
|||||||
build: build-fe ## Build the frontend then the Go binary
|
build: build-fe ## Build the frontend then the Go binary
|
||||||
go build ./...
|
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,
|
# 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
|
.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"
|
@echo "verify: OK"
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ Two key ideas that explain most of the complexity:
|
|||||||
**Frontend (`frontend/`):**
|
**Frontend (`frontend/`):**
|
||||||
- **React 19** + **Ant Design 6** + **Vite 8** + **TypeScript**.
|
- **React 19** + **Ant Design 6** + **Vite 8** + **TypeScript**.
|
||||||
- Data layer: **TanStack Query** (`@tanstack/react-query`) over the native **Fetch API**; **Zod 4** schemas.
|
- 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
|
- **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),
|
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
|
`login.html`, `subpage.html`. The Go server serves the SPA; there is no separate frontend
|
||||||
@@ -369,8 +369,8 @@ All registered in `web.go` → `startTask()`. Each is a struct with a `Run()` me
|
|||||||
| `@every 5m` | `outbound_subscription_job` | Refresh outbound provider configs |
|
| `@every 5m` | `outbound_subscription_job` | Refresh outbound provider configs |
|
||||||
| `@every 10m` | `clear_logs_job` (`PruneXrayLogsJob`) | Truncate Xray access/error logs once either exceeds 64 MiB |
|
| `@every 10m` | `clear_logs_job` (`PruneXrayLogsJob`) | Truncate Xray access/error logs once either exceeds 64 MiB |
|
||||||
| `@hourly` | `warp_ip_job`, `periodic_traffic_reset_job("hourly")` | WARP IP rotation; traffic resets |
|
| `@hourly` | `warp_ip_job`, `periodic_traffic_reset_job("hourly")` | WARP IP rotation; traffic resets |
|
||||||
| `@daily` | `clear_logs_job`, `periodic_traffic_reset_job("daily")` | IP-limit and Xray access/error log cleanup; traffic resets |
|
| `@daily` | `clear_logs_job`, `periodic_traffic_reset_job("daily")`, `periodic_traffic_reset_job("monthly")` | IP-limit and Xray access/error log cleanup; daily resets and due monthly resets |
|
||||||
| `@weekly` / `@monthly` | `periodic_traffic_reset_job(...)` | Weekly/monthly traffic resets |
|
| `@weekly` | `periodic_traffic_reset_job("weekly")` | Weekly traffic resets |
|
||||||
| default `@every 1m` | `ldap_sync_job` | Only if LDAP enabled; schedule configurable |
|
| default `@every 1m` | `ldap_sync_job` | Only if LDAP enabled; schedule configurable |
|
||||||
| default `@daily` | `stats_notify_job` | Only if TG bot enabled; schedule configurable |
|
| default `@daily` | `stats_notify_job` | Only if TG bot enabled; schedule configurable |
|
||||||
| `@every 2m` | `check_hash_storage` | Only if TG bot enabled; expires bot callback hashes |
|
| `@every 2m` | `check_hash_storage` | Only if TG bot enabled; expires bot callback hashes |
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ See [Clients](/docs/config/clients).
|
|||||||
Optionally cap total traffic and set an expiry date for the inbound, and choose a
|
Optionally cap total traffic and set an expiry date for the inbound, and choose a
|
||||||
periodic **traffic reset** schedule: `never` (default), `hourly`, `daily`,
|
periodic **traffic reset** schedule: `never` (default), `hourly`, `daily`,
|
||||||
`weekly`, or `monthly`.
|
`weekly`, or `monthly`.
|
||||||
|
|
||||||
|
For `monthly` resets, select a day from 1 to 31. If the selected day does not
|
||||||
|
exist in a shorter month, the reset runs on that month's last day.
|
||||||
</Step>
|
</Step>
|
||||||
|
|
||||||
</Steps>
|
</Steps>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ browser in full.
|
|||||||
| `webBasePath` | `/` | URL path the panel is served under (always normalized to `/…/`). |
|
| `webBasePath` | `/` | URL path the panel is served under (always normalized to `/…/`). |
|
||||||
| `webCertFile` / `webKeyFile` | _(none)_ | TLS certificate + key. When both are set, the panel serves **HTTPS**. |
|
| `webCertFile` / `webKeyFile` | _(none)_ | TLS certificate + key. When both are set, the panel serves **HTTPS**. |
|
||||||
| `sessionMaxAge` | `360` | Session lifetime in **minutes** (default 6 hours). |
|
| `sessionMaxAge` | `360` | Session lifetime in **minutes** (default 6 hours). |
|
||||||
| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | IPs/CIDRs whose forwarded headers (real client IP) are trusted. |
|
| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | IPs/CIDRs whose forwarded headers (real client IP) are trusted. A custom value also controls forwarded host and scheme in subscription links; include the subscription proxy or set `subURI` to override those links. |
|
||||||
| `panelOutbound` | _(none)_ | Route the panel's own egress (update checks, Telegram, geo/sub fetches) through a named Xray outbound. |
|
| `panelOutbound` | _(none)_ | Route the panel's own egress (update checks, Telegram, geo/sub fetches) through a named Xray outbound. |
|
||||||
|
|
||||||
After changing the port or base path, the panel URL becomes
|
After changing the port or base path, the panel URL becomes
|
||||||
|
|||||||
@@ -118,6 +118,13 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
|
|||||||
- **Leaked private key.** Only ever distribute the **public** key to clients.
|
- **Leaked private key.** Only ever distribute the **public** key to clients.
|
||||||
- **Wrong flow.** REALITY + XTLS-Vision needs `flow = xtls-rprx-vision` on both
|
- **Wrong flow.** REALITY + XTLS-Vision needs `flow = xtls-rprx-vision` on both
|
||||||
the inbound client entry and the share link.
|
the inbound client entry and the share link.
|
||||||
|
- **Old client cores rejected by default.** An empty **Min Client Ver** is not
|
||||||
|
"no limit": Xray-core falls back to the built-in minimum of the core build you
|
||||||
|
run (26.3.27 in current releases) that keeps client TLS fingerprints fresh, so
|
||||||
|
third-party cores such as Mihomo and sing-box fail REALITY verification even
|
||||||
|
with a correct config — clients see timeouts while only Xray-core based apps
|
||||||
|
connect. Set it to `1.0.0` only if you must support them; that also re-admits
|
||||||
|
outdated fingerprints.
|
||||||
|
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ TLS یا REALITY) را انتخاب کنید. به [انتقالها](/docs/c
|
|||||||
بهصورت اختیاری میتوانید کل ترافیک را محدود کنید و یک تاریخ انقضا برای ورودی
|
بهصورت اختیاری میتوانید کل ترافیک را محدود کنید و یک تاریخ انقضا برای ورودی
|
||||||
تعیین کنید، و یک زمانبندی **بازنشانی ترافیک** دورهای انتخاب کنید: `never`
|
تعیین کنید، و یک زمانبندی **بازنشانی ترافیک** دورهای انتخاب کنید: `never`
|
||||||
(پیشفرض)، `hourly`، `daily`، `weekly` یا `monthly`.
|
(پیشفرض)، `hourly`، `daily`، `weekly` یا `monthly`.
|
||||||
|
|
||||||
|
برای بازنشانی `monthly`، روزی از ۱ تا ۳۱ انتخاب کنید. اگر آن روز در ماهی کوتاهتر
|
||||||
|
وجود نداشته باشد، بازنشانی در آخرین روز همان ماه انجام میشود.
|
||||||
</Step>
|
</Step>
|
||||||
|
|
||||||
</Steps>
|
</Steps>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ icon: SlidersHorizontal
|
|||||||
| `webBasePath` | `/` | مسیر URLی که پنل زیر آن ارائه میشود (همیشه به شکل `/…/` نرمالسازی میشود). |
|
| `webBasePath` | `/` | مسیر URLی که پنل زیر آن ارائه میشود (همیشه به شکل `/…/` نرمالسازی میشود). |
|
||||||
| `webCertFile` / `webKeyFile` | _(هیچکدام)_ | گواهی + کلید TLS. وقتی هر دو تنظیم شوند، پنل با **HTTPS** ارائه میشود. |
|
| `webCertFile` / `webKeyFile` | _(هیچکدام)_ | گواهی + کلید TLS. وقتی هر دو تنظیم شوند، پنل با **HTTPS** ارائه میشود. |
|
||||||
| `sessionMaxAge` | `360` | طول عمر نشست بر حسب **دقیقه** (پیشفرض ۶ ساعت). |
|
| `sessionMaxAge` | `360` | طول عمر نشست بر حسب **دقیقه** (پیشفرض ۶ ساعت). |
|
||||||
| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | IPها/CIDRهایی که هدرهای فورواردشدهشان (IP واقعی کلاینت) مورد اعتماد است. |
|
| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | IPها/CIDRهایی که هدرهای فورواردشدهشان (IP واقعی کلاینت) مورد اعتماد است. مقدار سفارشی همچنین میزبان و طرحِ لینکهای اشتراک را کنترل میکند؛ پراکسی اشتراک را اضافه کنید یا برای بازنویسی این لینکها `subURI` را تنظیم کنید. |
|
||||||
| `panelOutbound` | _(هیچکدام)_ | مسیریابی خروجیِ خود پنل (بررسی بهروزرسانیها، Telegram، واکشی geo/sub) از طریق یک خروجی Xray با نام مشخص. |
|
| `panelOutbound` | _(هیچکدام)_ | مسیریابی خروجیِ خود پنل (بررسی بهروزرسانیها، Telegram، واکشی geo/sub) از طریق یک خروجی Xray با نام مشخص. |
|
||||||
|
|
||||||
پس از تغییر پورت یا مسیر پایه، آدرس پنل بهصورت
|
پس از تغییر پورت یا مسیر پایه، آدرس پنل بهصورت
|
||||||
|
|||||||
@@ -118,6 +118,13 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
|
|||||||
- **نشت کلید خصوصی.** فقط و فقط **کلید عمومی** را میان کلاینتها توزیع کنید.
|
- **نشت کلید خصوصی.** فقط و فقط **کلید عمومی** را میان کلاینتها توزیع کنید.
|
||||||
- **جریان نادرست.** REALITY + XTLS-Vision به `flow = xtls-rprx-vision` هم در ورودیِ
|
- **جریان نادرست.** REALITY + XTLS-Vision به `flow = xtls-rprx-vision` هم در ورودیِ
|
||||||
مدخل کلاینت و هم در لینک اشتراکگذاری نیاز دارد.
|
مدخل کلاینت و هم در لینک اشتراکگذاری نیاز دارد.
|
||||||
|
- **هستههای قدیمی کلاینت بهطور پیشفرض رد میشوند.** خالی گذاشتن
|
||||||
|
**حداقل نسخه کلاینت** به معنای «بدون محدودیت» نیست: Xray-core به حداقل داخلیِ
|
||||||
|
نسخهٔ هستهای که اجرا میکنید (در نسخههای فعلی 26.3.27) بازمیگردد تا اثر انگشتهای TLS کلاینتها تازه
|
||||||
|
بمانند؛ در نتیجه هستههای شخص ثالث مانند Mihomo و sing-box حتی با پیکربندی
|
||||||
|
کاملاً درست در تأیید REALITY شکست میخورند — کلاینتها تایماوت میبینند و فقط
|
||||||
|
اپلیکیشنهای مبتنی بر Xray-core وصل میشوند. تنها در صورت نیاز به پشتیبانی از
|
||||||
|
آنها مقدار `1.0.0` را تنظیم کنید؛ این کار اثر انگشتهای قدیمی را هم میپذیرد.
|
||||||
|
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ icon: ArrowDownToLine
|
|||||||
При необходимости ограничьте общий объём трафика и установите дату истечения для
|
При необходимости ограничьте общий объём трафика и установите дату истечения для
|
||||||
входящего подключения, а также выберите расписание периодического **сброса трафика**:
|
входящего подключения, а также выберите расписание периодического **сброса трафика**:
|
||||||
`never` (по умолчанию), `hourly`, `daily`, `weekly` или `monthly`.
|
`never` (по умолчанию), `hourly`, `daily`, `weekly` или `monthly`.
|
||||||
|
|
||||||
|
Для сброса `monthly` выберите день от 1 до 31. Если выбранного дня нет в более
|
||||||
|
коротком месяце, сброс выполняется в последний день этого месяца.
|
||||||
</Step>
|
</Step>
|
||||||
|
|
||||||
</Steps>
|
</Steps>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ icon: SlidersHorizontal
|
|||||||
| `webBasePath` | `/` | URL-путь, по которому обслуживается панель (всегда нормализуется к `/…/`). |
|
| `webBasePath` | `/` | URL-путь, по которому обслуживается панель (всегда нормализуется к `/…/`). |
|
||||||
| `webCertFile` / `webKeyFile` | _(нет)_ | Сертификат TLS + ключ. Когда заданы оба, панель обслуживается по **HTTPS**. |
|
| `webCertFile` / `webKeyFile` | _(нет)_ | Сертификат TLS + ключ. Когда заданы оба, панель обслуживается по **HTTPS**. |
|
||||||
| `sessionMaxAge` | `360` | Время жизни сессии в **минутах** (по умолчанию 6 часов). |
|
| `sessionMaxAge` | `360` | Время жизни сессии в **минутах** (по умолчанию 6 часов). |
|
||||||
| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | IP-адреса/CIDR, чьим переадресованным заголовкам (реальный IP клиента) можно доверять. |
|
| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | IP-адреса/CIDR, чьим переадресованным заголовкам (реальный IP клиента) можно доверять. Пользовательское значение также управляет пересылаемыми хостом и схемой в ссылках подписки; добавьте прокси подписки или задайте `subURI`, чтобы переопределить эти ссылки. |
|
||||||
| `panelOutbound` | _(нет)_ | Маршрутизация собственного исходящего трафика панели (проверка обновлений, Telegram, запросы geo/подписок) через именованный исходящий канал Xray. |
|
| `panelOutbound` | _(нет)_ | Маршрутизация собственного исходящего трафика панели (проверка обновлений, Telegram, запросы geo/подписок) через именованный исходящий канал Xray. |
|
||||||
|
|
||||||
После изменения порта или базового пути URL панели становится
|
После изменения порта или базового пути URL панели становится
|
||||||
|
|||||||
@@ -123,6 +123,14 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
|
|||||||
ключ.
|
ключ.
|
||||||
- **Неправильный поток.** Для REALITY + XTLS-Vision нужен `flow = xtls-rprx-vision`
|
- **Неправильный поток.** Для REALITY + XTLS-Vision нужен `flow = xtls-rprx-vision`
|
||||||
как в записи клиента входящего подключения, так и в ссылке для подключения.
|
как в записи клиента входящего подключения, так и в ссылке для подключения.
|
||||||
|
- **Старые ядра клиентов отклоняются по умолчанию.** Пустое поле
|
||||||
|
**Мин. версия клиента** не означает «без ограничений»: Xray-core использует
|
||||||
|
встроенный минимум используемой сборки ядра (26.3.27 в текущих релизах),
|
||||||
|
который поддерживает свежесть
|
||||||
|
TLS-отпечатков клиентов, поэтому сторонние ядра, такие как Mihomo и sing-box,
|
||||||
|
не проходят проверку REALITY даже при корректной конфигурации — клиенты видят
|
||||||
|
таймауты, а подключаются только приложения на базе Xray-core. Ставьте `1.0.0`,
|
||||||
|
только если они вам необходимы; это также допустит устаревшие отпечатки.
|
||||||
|
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ icon: ArrowDownToLine
|
|||||||
|
|
||||||
可选地为入站设置总流量上限和到期日期,并选择一个周期性的**流量重置**计划:
|
可选地为入站设置总流量上限和到期日期,并选择一个周期性的**流量重置**计划:
|
||||||
`never`(默认)、`hourly`、`daily`、`weekly` 或 `monthly`。
|
`never`(默认)、`hourly`、`daily`、`weekly` 或 `monthly`。
|
||||||
|
|
||||||
|
选择 `monthly` 时,可以指定每月 1 至 31 日重置。如果当月没有指定日期,
|
||||||
|
则在该月最后一天重置。
|
||||||
</Step>
|
</Step>
|
||||||
|
|
||||||
</Steps>
|
</Steps>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ icon: SlidersHorizontal
|
|||||||
| `webBasePath` | `/` | 面板对外提供服务所使用的 URL 路径(始终规范化为 `/…/`)。 |
|
| `webBasePath` | `/` | 面板对外提供服务所使用的 URL 路径(始终规范化为 `/…/`)。 |
|
||||||
| `webCertFile` / `webKeyFile` | _(无)_ | TLS 证书 + 密钥。两者都设置后,面板将以 **HTTPS** 提供服务。 |
|
| `webCertFile` / `webKeyFile` | _(无)_ | TLS 证书 + 密钥。两者都设置后,面板将以 **HTTPS** 提供服务。 |
|
||||||
| `sessionMaxAge` | `360` | 会话有效期,单位为**分钟**(默认 6 小时)。 |
|
| `sessionMaxAge` | `360` | 会话有效期,单位为**分钟**(默认 6 小时)。 |
|
||||||
| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | 其转发头(真实客户端 IP)受信任的 IP/CIDR。 |
|
| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | 其转发头(真实客户端 IP)受信任的 IP/CIDR。自定义值还会控制订阅链接中转发的主机和协议;请将订阅代理加入列表,或设置 `subURI` 覆盖这些链接。 |
|
||||||
| `panelOutbound` | _(无)_ | 通过一个命名的 Xray 出站来路由面板自身的出口流量(更新检查、Telegram、地理/订阅拉取)。 |
|
| `panelOutbound` | _(无)_ | 通过一个命名的 Xray 出站来路由面板自身的出口流量(更新检查、Telegram、地理/订阅拉取)。 |
|
||||||
|
|
||||||
更改端口或基础路径后,面板 URL 将变为
|
更改端口或基础路径后,面板 URL 将变为
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
|
|||||||
- **SNI 不匹配。** SNI / server names 必须与目标站点的真实证书匹配,否则握手会暴露伪装。
|
- **SNI 不匹配。** SNI / server names 必须与目标站点的真实证书匹配,否则握手会暴露伪装。
|
||||||
- **私钥泄露。** 永远只把**公钥**分发给客户端。
|
- **私钥泄露。** 永远只把**公钥**分发给客户端。
|
||||||
- **流控设置错误。** REALITY + XTLS-Vision 要求在入站的客户端条目和分享链接上都设置 `flow = xtls-rprx-vision`。
|
- **流控设置错误。** REALITY + XTLS-Vision 要求在入站的客户端条目和分享链接上都设置 `flow = xtls-rprx-vision`。
|
||||||
|
- **旧客户端内核默认被拒。** **最小客户端版本**留空并不是“不限制”:Xray-core 会退回到所运行内核版本的内置最低值(当前版本为 26.3.27)以保证客户端 TLS 指纹的新鲜度,因此 Mihomo、sing-box 等第三方内核即使配置完全正确也会导致 REALITY 验证失败——表现为客户端超时,只有基于 Xray-core 的应用能连上。只有在必须支持它们时才填 `1.0.0`;这同时也会放行过时的指纹。
|
||||||
|
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ When rendering the template, the following variables are injected into the templ
|
|||||||
|
|
||||||
* `{{ .sId }}`: Subscription ID (UUID).
|
* `{{ .sId }}`: Subscription ID (UUID).
|
||||||
* `{{ .enabled }}`: Whether the subscription/client is enabled (boolean).
|
* `{{ .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").
|
* `{{ .download }}`: Formatted download traffic (e.g. "2.5 GB").
|
||||||
* `{{ .upload }}`: Formatted upload traffic.
|
* `{{ .upload }}`: Formatted upload traffic.
|
||||||
* `{{ .total }}`: Formatted total traffic limit.
|
* `{{ .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.
|
* `{{ .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.
|
* `{{ .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 }}`.
|
* `{{ .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").
|
* `{{ .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 { Heart } from 'lucide-react';
|
||||||
import { Logo } from '@/components/logo';
|
import { Logo } from '@/components/logo';
|
||||||
import { TelegramIcon } from '@/components/icons';
|
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';
|
import { getSiteMessages } from './site-i18n';
|
||||||
|
|
||||||
// Build locale-aware shared layout options. With `hideLocale: 'default-locale'`,
|
// Build locale-aware shared layout options. With `hideLocale: 'default-locale'`,
|
||||||
@@ -28,6 +28,12 @@ export function baseOptions(lang: string): BaseLayoutProps {
|
|||||||
url: `${prefix}/docs`,
|
url: `${prefix}/docs`,
|
||||||
active: 'nested-url',
|
active: 'nested-url',
|
||||||
},
|
},
|
||||||
|
// Live component workbench built from frontend/ and published alongside the docs.
|
||||||
|
{
|
||||||
|
text: 'Storybook',
|
||||||
|
url: `${siteUrl}/storybook/`,
|
||||||
|
external: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: 'icon',
|
type: 'icon',
|
||||||
label: `Telegram channel (@${telegramChannel})`,
|
label: `Telegram channel (@${telegramChannel})`,
|
||||||
|
|||||||
+19
-19
@@ -9,7 +9,7 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"postinstall": "fumadocs-mdx",
|
"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",
|
"typecheck": "fumadocs-mdx && next typegen && tsc --noEmit",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
@@ -19,34 +19,34 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@orama/orama": "^3.1.18",
|
"@orama/orama": "^3.1.18",
|
||||||
"fumadocs-core": "^16.10.5",
|
"fumadocs-core": "^16.11.5",
|
||||||
"fumadocs-docgen": "^3.0.10",
|
"fumadocs-docgen": "^3.1.0",
|
||||||
"fumadocs-mdx": "^15.0.12",
|
"fumadocs-mdx": "^15.2.0",
|
||||||
"fumadocs-openapi": "^11.0.5",
|
"fumadocs-openapi": "^11.2.2",
|
||||||
"fumadocs-ui": "^16.10.5",
|
"fumadocs-ui": "^16.11.5",
|
||||||
"lucide-react": "^1.21.0",
|
"lucide-react": "^1.25.0",
|
||||||
"mermaid": "^11.16.0",
|
"mermaid": "^11.16.0",
|
||||||
"next": "16.2.9",
|
"next": "16.2.11",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.8",
|
||||||
"react-qr-code": "^2.2.0",
|
"react-qr-code": "^2.2.0",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.3.1",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@types/mdx": "^2.0.14",
|
"@types/mdx": "^2.0.14",
|
||||||
"@types/node": "^26.0.1",
|
"@types/node": "^26.1.1",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.5",
|
||||||
"eslint-config-next": "16.2.9",
|
"eslint-config-next": "16.2.11",
|
||||||
"postcss": "^8.5.15",
|
"postcss": "^8.5.21",
|
||||||
"prettier": "^3.8.4",
|
"prettier": "^3.9.6",
|
||||||
"tailwindcss": "^4.3.1",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^6.0.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"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1919
-1585
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).
|
# release — fixes GHSA-qx2v-qp2m-jg93 / CVE-2026-41305 (vulnerable < 8.5.10).
|
||||||
overrides:
|
overrides:
|
||||||
'postcss@<8.5.10': '^8.5.15'
|
'postcss@<8.5.10': '^8.5.15'
|
||||||
|
'sharp@<0.35.0': '^0.35.3'
|
||||||
minimumReleaseAgeExclude:
|
minimumReleaseAgeExclude:
|
||||||
- '@mermaid-js/parser@1.2.0'
|
- '@mermaid-js/parser@1.2.0'
|
||||||
- mermaid@11.16.0
|
- mermaid@11.16.0
|
||||||
|
|||||||
@@ -411,6 +411,9 @@
|
|||||||
"maximum": 65535,
|
"maximum": 65535,
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"subShowIdentityOnAllLinks": {
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -479,6 +482,7 @@
|
|||||||
"subPort",
|
"subPort",
|
||||||
"subProfileUrl",
|
"subProfileUrl",
|
||||||
"subRoutingRules",
|
"subRoutingRules",
|
||||||
|
"subShowIdentityOnAllLinks",
|
||||||
"subSupportUrl",
|
"subSupportUrl",
|
||||||
"subThemeDir",
|
"subThemeDir",
|
||||||
"subTitle",
|
"subTitle",
|
||||||
@@ -916,6 +920,9 @@
|
|||||||
"maximum": 65535,
|
"maximum": 65535,
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"subShowIdentityOnAllLinks": {
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -991,6 +998,7 @@
|
|||||||
"subPort",
|
"subPort",
|
||||||
"subProfileUrl",
|
"subProfileUrl",
|
||||||
"subRoutingRules",
|
"subRoutingRules",
|
||||||
|
"subShowIdentityOnAllLinks",
|
||||||
"subSupportUrl",
|
"subSupportUrl",
|
||||||
"subThemeDir",
|
"subThemeDir",
|
||||||
"subTitle",
|
"subTitle",
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ const config: StorybookConfig = {
|
|||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
stories: ['../src/**/*.stories.@(ts|tsx)'],
|
stories: ['../src/**/*.stories.@(ts|tsx)'],
|
||||||
addons: ['@storybook/addon-docs', '@storybook/addon-a11y'],
|
addons: [
|
||||||
|
'@storybook/addon-docs',
|
||||||
|
'@storybook/addon-a11y',
|
||||||
|
'@storybook/addon-vitest'
|
||||||
|
],
|
||||||
viteFinal: (viteConfig) => {
|
viteFinal: (viteConfig) => {
|
||||||
if (viteConfig.build) {
|
if (viteConfig.build) {
|
||||||
viteConfig.build.outDir = undefined;
|
viteConfig.build.outDir = undefined;
|
||||||
|
|||||||
@@ -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 { useLayoutEffect } from 'react';
|
||||||
import type { Decorator, Preview } from '@storybook/react-vite';
|
import type { Decorator, Preview } from '@storybook/react-vite';
|
||||||
import { ConfigProvider, theme as antdTheme } from 'antd';
|
import { ConfigProvider } from 'antd';
|
||||||
import i18next from 'i18next';
|
import i18next from 'i18next';
|
||||||
import { initReactI18next } from 'react-i18next';
|
import { initReactI18next } from 'react-i18next';
|
||||||
|
|
||||||
|
import { buildAntdThemeConfig } from '@/hooks/useTheme';
|
||||||
import enUS from '../../internal/web/translation/en-US.json';
|
import enUS from '../../internal/web/translation/en-US.json';
|
||||||
|
|
||||||
if (!i18next.isInitialized) {
|
if (!i18next.isInitialized) {
|
||||||
@@ -16,13 +17,15 @@ if (!i18next.isInitialized) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const withTheme: Decorator = (Story, context) => {
|
export const withTheme: Decorator = (Story, context) => {
|
||||||
const dark = context.globals.theme === 'dark';
|
const dark = context.globals.theme === 'dark';
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
|
document.body.classList.remove('dark', 'light');
|
||||||
|
document.body.classList.add(dark ? 'dark' : 'light');
|
||||||
|
document.documentElement.removeAttribute('data-theme');
|
||||||
}, [dark]);
|
}, [dark]);
|
||||||
return (
|
return (
|
||||||
<ConfigProvider theme={{ algorithm: dark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm }}>
|
<ConfigProvider theme={buildAntdThemeConfig(dark, false)}>
|
||||||
<div style={{ padding: 24, minWidth: 320 }}>
|
<div style={{ padding: 24, minWidth: 320 }}>
|
||||||
<Story />
|
<Story />
|
||||||
</div>
|
</div>
|
||||||
@@ -49,11 +52,16 @@ const preview: Preview = {
|
|||||||
},
|
},
|
||||||
parameters: {
|
parameters: {
|
||||||
controls: {
|
controls: {
|
||||||
|
expanded: true,
|
||||||
|
sort: 'requiredFirst',
|
||||||
matchers: {
|
matchers: {
|
||||||
color: /(background|color)$/i,
|
color: /(background|color)$/i,
|
||||||
date: /Date$/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 typecheck` / `npm run lint` / `npm run test` / `npm run build`.
|
||||||
- `npm run gen` = `gen:zod` (Go → `src/generated/`) + `gen:api`
|
- `npm run gen` = `gen:zod` (Go → `src/generated/`) + `gen:api`
|
||||||
(`build-openapi.mjs` → `public/openapi.json`).
|
(`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
|
- After `npm run build`, RESTART `go run .` (see the XUI_DEBUG gotcha in root
|
||||||
CLAUDE.md) before checking the panel.
|
CLAUDE.md) before checking the panel.
|
||||||
|
|||||||
+36
-3
@@ -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 lint` | ESLint flat config (`@typescript-eslint` + `react-hooks`) |
|
||||||
| `npm run test` | Vitest single run (schema fixtures, link parsers, …) |
|
| `npm run test` | Vitest single run (schema fixtures, link parsers, …) |
|
||||||
| `npm run test:watch` | Vitest watch mode |
|
| `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: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` |
|
| `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
|
CI runs `typecheck`, `lint`, `test`, `build`, and `build-storybook` on
|
||||||
(see `../.github/workflows/ci.yml`).
|
every PR (see `../.github/workflows/ci.yml`).
|
||||||
|
|
||||||
### One-off: scan for deprecated APIs
|
### One-off: scan for deprecated APIs
|
||||||
|
|
||||||
@@ -79,6 +81,7 @@ frontend/
|
|||||||
│ # usages of APIs marked with JSDoc @deprecated
|
│ # usages of APIs marked with JSDoc @deprecated
|
||||||
├── vitest.config.ts
|
├── vitest.config.ts
|
||||||
├── vite.config.js
|
├── vite.config.js
|
||||||
|
├── .storybook/ # Storybook config (main.ts, preview.tsx)
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ └── build-openapi.mjs # endpoints.ts → openapi.json
|
│ └── build-openapi.mjs # endpoints.ts → openapi.json
|
||||||
└── src/
|
└── src/
|
||||||
@@ -89,7 +92,7 @@ frontend/
|
|||||||
│ ├── index/, login/, inbounds/, clients/, xray/, nodes/,
|
│ ├── index/, login/, inbounds/, clients/, xray/, nodes/,
|
||||||
│ ├── settings/, api-docs/, sub/
|
│ ├── settings/, api-docs/, sub/
|
||||||
├── layouts/ # AdminLayout (sidebar + header + outlet)
|
├── layouts/ # AdminLayout (sidebar + header + outlet)
|
||||||
├── components/ # Cross-page React components
|
├── components/ # Cross-page React components (+ co-located *.stories.tsx)
|
||||||
├── hooks/ # useClients, useTheme, useWebSocket, …
|
├── hooks/ # useClients, useTheme, useWebSocket, …
|
||||||
├── api/ # fetch client + CSRF handling, TanStack Query bridge,
|
├── api/ # fetch client + CSRF handling, TanStack Query bridge,
|
||||||
│ # WebSocket client + queryClient.ts
|
│ # WebSocket client + queryClient.ts
|
||||||
@@ -187,6 +190,36 @@ npx vitest run -u
|
|||||||
Fixtures live in `src/test/golden/fixtures/` and are auto-discovered
|
Fixtures live in `src/test/golden/fixtures/` and are auto-discovered
|
||||||
via `import.meta.glob`.
|
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
|
## Adding a new page
|
||||||
|
|
||||||
Most new routes go inside the admin SPA (`index.html`) via
|
Most new routes go inside the admin SPA (`index.html`) via
|
||||||
|
|||||||
@@ -53,4 +53,37 @@ export default [
|
|||||||
'jsx-a11y/no-autofocus': 'off',
|
'jsx-a11y/no-autofocus': 'off',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// The settings and xray pages write numeric InputNumber changes straight
|
||||||
|
// into state, so a null-collapsing handler (`Number(v) || N`, or the
|
||||||
|
// ternary `typeof v === 'number' ? v : N`) turns a cleared field into a
|
||||||
|
// stored N — the cleared-port bug, #6121. Handlers here go through
|
||||||
|
// onNumber() (src/utils/onNumber.ts) instead. Known limit: a handler
|
||||||
|
// extracted into a variable and passed as onChange={handler} is not
|
||||||
|
// matched; the inline shapes below are the ones that drift in practice.
|
||||||
|
files: ['src/pages/settings/**/*.tsx', 'src/pages/xray/**/*.tsx'],
|
||||||
|
rules: {
|
||||||
|
'no-restricted-syntax': ['error', {
|
||||||
|
selector: 'JSXElement[openingElement.name.name="InputNumber"] JSXAttribute[name.name="onChange"] LogicalExpression[operator="||"] > CallExpression[callee.name="Number"]',
|
||||||
|
message: 'A cleared InputNumber must not write a synthetic value; wrap the handler with onNumber() from @/utils/onNumber (see #6127).',
|
||||||
|
}, {
|
||||||
|
selector: 'JSXElement[openingElement.name.name="InputNumber"] JSXAttribute[name.name="onChange"] ConditionalExpression[test.left.operator="typeof"][alternate.type="Literal"]',
|
||||||
|
message: 'A cleared InputNumber must not write a synthetic value; wrap the handler with onNumber() from @/utils/onNumber (see #6127).',
|
||||||
|
}, {
|
||||||
|
selector: 'JSXElement[openingElement.name.name="InputNumber"] JSXAttribute[name.name="onChange"] LogicalExpression[operator="??"][right.type="Literal"]',
|
||||||
|
message: 'A cleared InputNumber must not write a synthetic value; wrap the handler with onNumber() from @/utils/onNumber (see #6127).',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The xray form modals (OutboundFormModal, BalancerFormModal,
|
||||||
|
// DnsServerModal, WarpModal, …) stage values behind Zod validation like
|
||||||
|
// the clients/inbounds modals do, and some of their fields carry a
|
||||||
|
// deliberate clear-means-zero semantic — the direct-write rule above
|
||||||
|
// does not apply to them.
|
||||||
|
files: ['src/pages/xray/**/*Modal.tsx'],
|
||||||
|
rules: {
|
||||||
|
'no-restricted-syntax': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
Generated
+892
-1000
File diff suppressed because it is too large
Load Diff
+31
-25
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "3x-ui-frontend",
|
"name": "3x-ui-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.4.3",
|
"version": "0.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
|
"description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.0.0",
|
"node": ">=24.0.0",
|
||||||
"npm": ">=10.0.0"
|
"npm": ">=10.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"storybook": "storybook dev -p 6006",
|
"storybook": "storybook dev -p 6006",
|
||||||
"build-storybook": "storybook build",
|
"build-storybook": "storybook build",
|
||||||
"gen": "npm run gen:zod && npm run gen:api",
|
"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",
|
"gen:zod": "cd .. && go run ./tools/openapigen",
|
||||||
"prepare": "cd .. && husky frontend/.husky || true"
|
"prepare": "cd .. && husky frontend/.husky || true"
|
||||||
},
|
},
|
||||||
@@ -30,49 +30,52 @@
|
|||||||
"@ant-design/icons": "^6.3.2",
|
"@ant-design/icons": "^6.3.2",
|
||||||
"@codemirror/lang-json": "^6.0.2",
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.5.7",
|
||||||
"@noble/hashes": "^2.2.0",
|
"@noble/hashes": "^2.2.0",
|
||||||
"@tanstack/react-query": "^5.101.2",
|
"@tanstack/react-query": "^5.101.4",
|
||||||
"@tanstack/react-query-devtools": "^5.101.2",
|
"@tanstack/react-query-devtools": "^5.101.4",
|
||||||
"antd": "^6.5.0",
|
"antd": "^6.5.2",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"dayjs": "^1.11.21",
|
"dayjs": "^1.11.21",
|
||||||
"i18next": "^26.3.6",
|
"i18next": "^26.3.6",
|
||||||
"otpauth": "^9.5.1",
|
"otpauth": "^9.5.1",
|
||||||
"persian-calendar-suite": "^1.5.5",
|
"persian-calendar-suite": "^1.5.5",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.8",
|
||||||
"react-hook-form": "^7.81.0",
|
"react-hook-form": "^7.83.0",
|
||||||
"react-i18next": "^17.0.9",
|
"react-i18next": "^17.0.11",
|
||||||
"react-router-dom": "^7.18.1",
|
"react-router": "^8.3.0",
|
||||||
"swagger-ui-react": "^5.32.8",
|
"swagger-ui-react": "^5.32.11",
|
||||||
"uplot": "^1.6.32",
|
"uplot": "^1.6.32",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@storybook/addon-a11y": "^10.5.0",
|
"@storybook/addon-a11y": "^10.5.5",
|
||||||
"@storybook/addon-docs": "^10.5.0",
|
"@storybook/addon-docs": "^10.5.5",
|
||||||
"@storybook/react-vite": "^10.5.0",
|
"@storybook/addon-vitest": "^10.5.5",
|
||||||
|
"@storybook/react-vite": "^10.5.5",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/swagger-ui-react": "^5.18.0",
|
"@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",
|
"@vitest/coverage-v8": "^4.1.10",
|
||||||
"eslint": "^10.7.0",
|
"eslint": "^10.8.0",
|
||||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"globals": "^17.7.0",
|
"globals": "^17.8.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^30.0.1",
|
||||||
"lint-staged": "^17.0.8",
|
"lint-staged": "^17.2.0",
|
||||||
"msw": "^2.15.0",
|
"msw": "^2.15.0",
|
||||||
"storybook": "^10.5.0",
|
"playwright": "^1.62.0",
|
||||||
"typescript": "^6.0.3",
|
"storybook": "^10.5.5",
|
||||||
"typescript-eslint": "^8.63.0",
|
"typescript": "6.0.3",
|
||||||
"vite": "8.1.4",
|
"typescript-eslint": "^8.65.0",
|
||||||
|
"vite": "8.1.5",
|
||||||
"vitest": "^4.1.10"
|
"vitest": "^4.1.10"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
@@ -87,6 +90,9 @@
|
|||||||
},
|
},
|
||||||
"swagger-ui-react": {
|
"swagger-ui-react": {
|
||||||
"js-yaml": "^4.2.0"
|
"js-yaml": "^4.2.0"
|
||||||
|
},
|
||||||
|
"@typeschema/valibot": {
|
||||||
|
"valibot": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"allowScripts": {
|
"allowScripts": {
|
||||||
|
|||||||
+686
-32
File diff suppressed because it is too large
Load Diff
@@ -88,6 +88,28 @@ function encodeForm(data: unknown): string {
|
|||||||
return parts.join('&');
|
return parts.join('&');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendQuery(url: string, query: string): string {
|
||||||
|
if (query === '') return url;
|
||||||
|
const hashIndex = url.indexOf('#');
|
||||||
|
const path = hashIndex === -1 ? url : url.slice(0, hashIndex);
|
||||||
|
const hash = hashIndex === -1 ? '' : url.slice(hashIndex);
|
||||||
|
const hasQuery = path.includes('?');
|
||||||
|
const separator = !hasQuery ? '?' : path.endsWith('?') || path.endsWith('&') ? '' : '&';
|
||||||
|
return `${path}${separator}${query}${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestSignal(options: HttpRequestOptions): AbortSignal | undefined {
|
||||||
|
if (!options.timeout) return options.signal;
|
||||||
|
const timeout = AbortSignal.timeout(options.timeout);
|
||||||
|
if (!options.signal) return timeout;
|
||||||
|
if (typeof AbortSignal.any === 'function') return AbortSignal.any([options.signal, timeout]);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const abort = () => controller.abort();
|
||||||
|
options.signal.addEventListener('abort', abort, { once: true });
|
||||||
|
timeout.addEventListener('abort', abort, { once: true });
|
||||||
|
return controller.signal;
|
||||||
|
}
|
||||||
|
|
||||||
async function performFetch(
|
async function performFetch(
|
||||||
method: string,
|
method: string,
|
||||||
url: string,
|
url: string,
|
||||||
@@ -121,8 +143,8 @@ async function performFetch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const query = encodeForm(options.params);
|
const query = encodeForm(options.params);
|
||||||
const fullUrl = basePathPrefix + url + (query ? `?${query}` : '');
|
const fullUrl = basePathPrefix + appendQuery(url, query);
|
||||||
const signal = options.timeout ? AbortSignal.timeout(options.timeout) : options.signal;
|
const signal = requestSignal(options);
|
||||||
|
|
||||||
return fetch(fullUrl, { method: upper, headers, body, credentials: 'same-origin', signal });
|
return fetch(fullUrl, { method: upper, headers, body, credentials: 'same-origin', signal });
|
||||||
}
|
}
|
||||||
@@ -152,8 +174,11 @@ export async function httpRequest(
|
|||||||
|
|
||||||
if (res.status === 403 && !SAFE_METHODS.has(method.toUpperCase())) {
|
if (res.status === 403 && !SAFE_METHODS.has(method.toUpperCase())) {
|
||||||
csrfToken = null;
|
csrfToken = null;
|
||||||
const fresh = await ensureCsrfToken();
|
const fresh = await fetchCsrfToken();
|
||||||
if (fresh) res = await performFetch(method, url, data, options, fresh);
|
if (fresh) {
|
||||||
|
csrfToken = fresh;
|
||||||
|
res = await performFetch(method, url, data, options, fresh);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { HttpUtil, Msg } from '@/utils';
|
import { HttpUtil, Msg } from '@/utils';
|
||||||
@@ -6,8 +6,13 @@ import { parseMsg } from '@/utils/zodValidate';
|
|||||||
import { AllSetting } from '@/models/setting';
|
import { AllSetting } from '@/models/setting';
|
||||||
import { AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
|
import { AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
|
||||||
import { keys } from '@/api/queryKeys';
|
import { keys } from '@/api/queryKeys';
|
||||||
|
import { useServerDraft } from '@/hooks/useServerDraft';
|
||||||
|
|
||||||
type SettingSavePayload = Partial<AllSetting> & Record<string, unknown>;
|
type SettingSavePayload = Partial<AllSetting> & Record<string, unknown>;
|
||||||
|
type SettingSaveResult = {
|
||||||
|
msg: Msg<unknown>;
|
||||||
|
saved?: AllSetting;
|
||||||
|
};
|
||||||
|
|
||||||
async function fetchAllSetting(): Promise<AllSettingInput | null> {
|
async function fetchAllSetting(): Promise<AllSettingInput | null> {
|
||||||
const msg = await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
|
const msg = await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
|
||||||
@@ -18,7 +23,6 @@ async function fetchAllSetting(): Promise<AllSettingInput | null> {
|
|||||||
|
|
||||||
export function useAllSettings() {
|
export function useAllSettings() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [draft, setDraft] = useState<AllSetting>(() => new AllSetting());
|
|
||||||
const [extraSpinning, setExtraSpinning] = useState(false);
|
const [extraSpinning, setExtraSpinning] = useState(false);
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
@@ -28,41 +32,54 @@ export function useAllSettings() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const server = useMemo(() => new AllSetting(query.data), [query.data]);
|
const server = useMemo(() => new AllSetting(query.data), [query.data]);
|
||||||
|
const { draft, setDraft, isDirty, markSaved } = useServerDraft(
|
||||||
useEffect(() => {
|
query.data === undefined ? undefined : server,
|
||||||
if (query.data !== undefined) {
|
(setting) => new AllSetting(setting),
|
||||||
setDraft(new AllSetting(query.data));
|
(left, right) => left.equals(right),
|
||||||
}
|
);
|
||||||
}, [query.data]);
|
const allSetting = draft ?? server;
|
||||||
|
|
||||||
const updateSetting = useCallback((patch: Partial<AllSetting>) => {
|
const updateSetting = useCallback((patch: Partial<AllSetting>) => {
|
||||||
setDraft((prev) => {
|
setDraft((prev) => {
|
||||||
const next = new AllSetting(prev);
|
const next = new AllSetting(prev ?? server);
|
||||||
Object.assign(next, patch);
|
Object.assign(next, patch);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, [server, setDraft]);
|
||||||
|
|
||||||
const saveMut = useMutation({
|
const saveMut = useMutation({
|
||||||
mutationFn: async (next: SettingSavePayload): Promise<Msg<unknown>> => {
|
mutationFn: async ({ payload, saved }: { payload: SettingSavePayload; saved?: AllSetting }): Promise<SettingSaveResult> => {
|
||||||
const payload = { ...next };
|
const next = { ...payload };
|
||||||
const body = AllSettingSchema.partial().safeParse(payload);
|
const body = AllSettingSchema.partial().safeParse(next);
|
||||||
if (!body.success) {
|
if (!body.success) {
|
||||||
console.warn('[zod] setting/update body failed validation', body.error.issues);
|
console.warn('[zod] setting/update body failed validation', body.error.issues);
|
||||||
}
|
}
|
||||||
return HttpUtil.post('/panel/api/setting/update', body.success ? { ...payload, ...body.data } : payload);
|
const msg = await HttpUtil.post('/panel/api/setting/update', body.success ? { ...next, ...body.data } : next);
|
||||||
|
return { msg, saved };
|
||||||
},
|
},
|
||||||
onSuccess: (msg) => {
|
onSuccess: ({ msg, saved }) => {
|
||||||
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.settings.all() });
|
if (!msg?.success) return;
|
||||||
|
if (saved) markSaved(saved);
|
||||||
|
queryClient.invalidateQueries({ queryKey: keys.settings.all() });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const saveAll = useCallback(() => saveMut.mutateAsync({ ...draft }), [saveMut, draft]);
|
const saveAll = useCallback(async () => {
|
||||||
const savePayload = useCallback((payload: SettingSavePayload) => saveMut.mutateAsync(payload), [saveMut]);
|
const saved = new AllSetting(allSetting);
|
||||||
const saveDisabled = useMemo(() => server.equals(draft), [server, draft]);
|
return (await saveMut.mutateAsync({ payload: { ...saved }, saved })).msg;
|
||||||
|
}, [allSetting, saveMut]);
|
||||||
|
const savePayload = useCallback(
|
||||||
|
async (payload: SettingSavePayload) => {
|
||||||
|
const saved = new AllSetting(allSetting);
|
||||||
|
Object.assign(saved, payload);
|
||||||
|
return (await saveMut.mutateAsync({ payload, saved })).msg;
|
||||||
|
},
|
||||||
|
[allSetting, saveMut],
|
||||||
|
);
|
||||||
|
const saveDisabled = !isDirty;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
allSetting: draft,
|
allSetting,
|
||||||
updateSetting,
|
updateSetting,
|
||||||
fetched: query.data !== undefined,
|
fetched: query.data !== undefined,
|
||||||
spinning: extraSpinning || saveMut.isPending,
|
spinning: extraSpinning || saveMut.isPending,
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
import { parseMsg } from '@/utils/zodValidate';
|
||||||
|
import { FactoryDefaultsSchema, type FactoryDefaults } from '@/schemas/setting';
|
||||||
|
import { keys } from '@/api/queryKeys';
|
||||||
|
|
||||||
|
async function fetchFactoryDefaults(): Promise<FactoryDefaults> {
|
||||||
|
const msg = await HttpUtil.post('/panel/api/setting/factoryDefaults', undefined, { silent: true });
|
||||||
|
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch factory defaults');
|
||||||
|
const validated = parseMsg(msg, FactoryDefaultsSchema, 'setting/factoryDefaults');
|
||||||
|
const parsed = FactoryDefaultsSchema.safeParse(validated.obj);
|
||||||
|
return parsed.success ? parsed.data : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFactoryDefaults() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: keys.settings.factoryDefaults(),
|
||||||
|
queryFn: fetchFactoryDefaults,
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ export const keys = {
|
|||||||
root: () => ['settings'] as const,
|
root: () => ['settings'] as const,
|
||||||
all: () => ['settings', 'all'] as const,
|
all: () => ['settings', 'all'] as const,
|
||||||
defaults: () => ['settings', 'defaults'] as const,
|
defaults: () => ['settings', 'defaults'] as const,
|
||||||
|
factoryDefaults: () => ['settings', 'factoryDefaults'] as const,
|
||||||
},
|
},
|
||||||
inbounds: {
|
inbounds: {
|
||||||
root: () => ['inbounds'] as const,
|
root: () => ['inbounds'] as const,
|
||||||
|
|||||||
@@ -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 { useEffect } from 'react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { WebSocketClient } from '@/api/websocket';
|
import { getSharedWebSocketClient } from '@/api/websocket';
|
||||||
import { keys } from '@/api/queryKeys';
|
import { keys } from '@/api/queryKeys';
|
||||||
import { isRecentLocalInvalidate } from '@/api/invalidationTracker';
|
import { isRecentLocalInvalidate } from '@/api/invalidationTracker';
|
||||||
|
|
||||||
type Handler = (payload: unknown) => void;
|
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;
|
let invalidateTimer: number | null = null;
|
||||||
|
|
||||||
export function useWebSocketBridge() {
|
export function useWebSocketBridge() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const client = getSharedClient();
|
const client = getSharedWebSocketClient();
|
||||||
|
|
||||||
const onInvalidate: Handler = (payload) => {
|
const onInvalidate: Handler = (payload) => {
|
||||||
const p = payload as { type?: string } | undefined;
|
const p = payload as { type?: string } | undefined;
|
||||||
@@ -46,6 +31,7 @@ export function useWebSocketBridge() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onOutbounds: Handler = (payload) => {
|
const onOutbounds: Handler = (payload) => {
|
||||||
|
if (!Array.isArray(payload)) return;
|
||||||
queryClient.setQueryData(keys.xray.outboundsTraffic(), payload);
|
queryClient.setQueryData(keys.xray.outboundsTraffic(), payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
title: 'Clients/ClientSpeedTag',
|
||||||
component: ClientSpeedTag,
|
component: ClientSpeedTag,
|
||||||
tags: ['autodocs'],
|
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>;
|
} satisfies Meta<typeof ClientSpeedTag>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Tag } from 'antd';
|
|||||||
|
|
||||||
import { SizeFormatter } from '@/utils';
|
import { SizeFormatter } from '@/utils';
|
||||||
import type { ClientSpeedEntry } from '@/hooks/useClients';
|
import type { ClientSpeedEntry } from '@/hooks/useClients';
|
||||||
|
import { SPEED_TAG_CLASS_NAME, SPEED_TAG_STYLE } from '@/components/utility/speedTagStyle';
|
||||||
|
|
||||||
export type { ClientSpeedEntry };
|
export type { ClientSpeedEntry };
|
||||||
|
|
||||||
@@ -11,11 +12,16 @@ export function isActiveSpeed(speed?: ClientSpeedEntry): speed is ClientSpeedEnt
|
|||||||
|
|
||||||
interface ClientSpeedTagProps {
|
interface ClientSpeedTagProps {
|
||||||
speed: ClientSpeedEntry;
|
speed: ClientSpeedEntry;
|
||||||
|
tableCell?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClientSpeedTag({ speed }: ClientSpeedTagProps) {
|
export function ClientSpeedTag({ speed, tableCell = false }: ClientSpeedTagProps) {
|
||||||
return (
|
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.up)}
|
||||||
{' / '}
|
{' / '}
|
||||||
↓ {SizeFormatter.speedFormat(speed.down)}
|
↓ {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,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Popover, Progress } from 'antd';
|
import { Popover, Progress } from 'antd';
|
||||||
|
|
||||||
@@ -17,7 +17,11 @@ export interface ClientTrafficCellProps {
|
|||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ClientTrafficCell({
|
// Every prop is a primitive and the component is pure, so the memo bails out
|
||||||
|
// whenever a client's counters did not move — which is most of them on most
|
||||||
|
// pushes. Each skipped instance is one antd Popover (rc-trigger), one Progress,
|
||||||
|
// a useTranslation subscription and a theme context read, times up to 200 rows.
|
||||||
|
const ClientTrafficCell = memo(function ClientTrafficCell({
|
||||||
up = 0,
|
up = 0,
|
||||||
down = 0,
|
down = 0,
|
||||||
total = 0,
|
total = 0,
|
||||||
@@ -64,6 +68,7 @@ export default function ClientTrafficCell({
|
|||||||
<span className="client-traffic-cell-used">{SizeFormatter.sizeFormat(display.used)}</span>
|
<span className="client-traffic-cell-used">{SizeFormatter.sizeFormat(display.used)}</span>
|
||||||
<Progress
|
<Progress
|
||||||
className="client-traffic-cell-bar"
|
className="client-traffic-cell-bar"
|
||||||
|
aria-label={`${SizeFormatter.sizeFormat(display.used)} / ${display.isUnlimited ? t('subscription.unlimited') : SizeFormatter.sizeFormat(total)}`}
|
||||||
percent={display.percent}
|
percent={display.percent}
|
||||||
showInfo={false}
|
showInfo={false}
|
||||||
strokeColor={display.strokeColor}
|
strokeColor={display.strokeColor}
|
||||||
@@ -72,7 +77,7 @@ export default function ClientTrafficCell({
|
|||||||
/>
|
/>
|
||||||
<span className="client-traffic-cell-limit">
|
<span className="client-traffic-cell-limit">
|
||||||
{display.isUnlimited ? (
|
{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 />
|
<InfinityIcon />
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -82,4 +87,6 @@ export default function ClientTrafficCell({
|
|||||||
</div>
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
export default ClientTrafficCell;
|
||||||
|
|||||||
@@ -12,6 +12,14 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.light .config-block .ant-tag.ant-tag-filled.ant-tag-gold {
|
||||||
|
color: #874d00;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light .config-block-text {
|
||||||
|
color: #595959;
|
||||||
|
}
|
||||||
|
|
||||||
.config-block .ant-collapse-extra {
|
.config-block .ant-collapse-extra {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -34,6 +42,7 @@
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
|
color: var(--ant-color-text);
|
||||||
background: var(--ant-color-fill-tertiary);
|
background: var(--ant-color-fill-tertiary);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
user-select: all;
|
user-select: all;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
import { expect, waitFor } from 'storybook/test';
|
||||||
|
|
||||||
import ConfigBlock from './ConfigBlock';
|
import ConfigBlock from './ConfigBlock';
|
||||||
|
|
||||||
@@ -6,7 +7,24 @@ const meta = {
|
|||||||
title: 'Clients/ConfigBlock',
|
title: 'Clients/ConfigBlock',
|
||||||
component: ConfigBlock,
|
component: ConfigBlock,
|
||||||
tags: ['autodocs'],
|
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>;
|
} satisfies Meta<typeof ConfigBlock>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
@@ -18,6 +36,15 @@ const sampleLink = 'vless://11112222-3333-4444-5555-666677778888@panel.example.c
|
|||||||
|
|
||||||
export const Collapsed: Story = {
|
export const Collapsed: Story = {
|
||||||
args: { label: 'vless', text: sampleLink, fileName: 'client-config.txt' },
|
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 = {
|
export const Expanded: Story = {
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export default function ConfigBlock({
|
|||||||
{messageContextHolder}
|
{messageContextHolder}
|
||||||
<Collapse
|
<Collapse
|
||||||
className="config-block"
|
className="config-block"
|
||||||
|
collapsible="header"
|
||||||
defaultActiveKey={defaultOpen ? ['cfg'] : []}
|
defaultActiveKey={defaultOpen ? ['cfg'] : []}
|
||||||
items={[{
|
items={[{
|
||||||
key: 'cfg',
|
key: 'cfg',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
import { expect, waitFor, within } from 'storybook/test';
|
||||||
import { Button } from 'antd';
|
import { Button } from 'antd';
|
||||||
|
|
||||||
import PromptModal from './PromptModal';
|
import PromptModal from './PromptModal';
|
||||||
@@ -8,6 +9,25 @@ const meta = {
|
|||||||
title: 'Feedback/PromptModal',
|
title: 'Feedback/PromptModal',
|
||||||
component: PromptModal,
|
component: PromptModal,
|
||||||
tags: ['autodocs'],
|
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>;
|
} satisfies Meta<typeof PromptModal>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
@@ -62,9 +82,25 @@ const placeholderArgs = {
|
|||||||
export const Input: Story = {
|
export const Input: Story = {
|
||||||
args: placeholderArgs,
|
args: placeholderArgs,
|
||||||
render: () => <InputDemo />,
|
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 = {
|
export const Textarea: Story = {
|
||||||
args: placeholderArgs,
|
args: placeholderArgs,
|
||||||
render: () => <TextareaDemo />,
|
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' ? (
|
) : type === 'textarea' ? (
|
||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
ref={(el) => { textareaRef.current = (el as unknown as { resizableTextArea?: { textArea: HTMLTextAreaElement } })?.resizableTextArea?.textArea ?? null; }}
|
ref={(el) => { textareaRef.current = (el as unknown as { resizableTextArea?: { textArea: HTMLTextAreaElement } })?.resizableTextArea?.textArea ?? null; }}
|
||||||
|
aria-label={title}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={(e) => setValue(e.target.value)}
|
||||||
autoSize={{ minRows: 10, maxRows: 20 }}
|
autoSize={{ minRows: 10, maxRows: 20 }}
|
||||||
@@ -80,6 +81,7 @@ export default function PromptModal({
|
|||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
aria-label={title}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={(e) => setValue(e.target.value)}
|
||||||
onKeyDown={onKeydown}
|
onKeyDown={onKeydown}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
import { expect, waitFor, within } from 'storybook/test';
|
||||||
import { Button } from 'antd';
|
import { Button } from 'antd';
|
||||||
|
|
||||||
import TextModal from './TextModal';
|
import TextModal from './TextModal';
|
||||||
@@ -8,6 +9,23 @@ const meta = {
|
|||||||
title: 'Feedback/TextModal',
|
title: 'Feedback/TextModal',
|
||||||
component: TextModal,
|
component: TextModal,
|
||||||
tags: ['autodocs'],
|
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>;
|
} satisfies Meta<typeof TextModal>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
@@ -43,6 +61,13 @@ const placeholderArgs = {
|
|||||||
export const PlainText: Story = {
|
export const PlainText: Story = {
|
||||||
args: placeholderArgs,
|
args: placeholderArgs,
|
||||||
render: () => <Demo fileName="client.txt" />,
|
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 = {
|
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" />
|
<JsonEditor value={activeContent} readOnly minHeight="240px" maxHeight="60vh" />
|
||||||
) : (
|
) : (
|
||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
|
aria-label={title}
|
||||||
value={activeContent}
|
value={activeContent}
|
||||||
readOnly
|
readOnly
|
||||||
autoSize={{ minRows: 10, maxRows: 20 }}
|
autoSize={{ minRows: 10, maxRows: 20 }}
|
||||||
|
|||||||
@@ -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 />,
|
||||||
|
};
|
||||||
@@ -121,7 +121,9 @@ export default function DateTimePicker({
|
|||||||
<DatePicker
|
<DatePicker
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(next) => onChange(next || null)}
|
onChange={(next) => onChange(next || null)}
|
||||||
|
onCalendarChange={(next) => onChange((Array.isArray(next) ? next[0] : next) || null)}
|
||||||
showTime={showTime ? { format: 'HH:mm:ss' } : false}
|
showTime={showTime ? { format: 'HH:mm:ss' } : false}
|
||||||
|
needConfirm={false}
|
||||||
format={format}
|
format={format}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 />,
|
||||||
|
};
|
||||||
@@ -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,
|
doc: value,
|
||||||
extensions: [
|
extensions: [
|
||||||
basicSetup,
|
basicSetup,
|
||||||
|
EditorView.contentAttributes.of({ 'aria-label': t('jsonEditor') }),
|
||||||
keymap.of([indentWithTab]),
|
keymap.of([indentWithTab]),
|
||||||
json(),
|
json(),
|
||||||
linter(jsonParseLinter()),
|
linter(jsonParseLinter()),
|
||||||
|
|||||||
@@ -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 />,
|
||||||
|
};
|
||||||
@@ -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 />,
|
||||||
|
};
|
||||||
@@ -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 DateTimePicker } from './DateTimePicker';
|
||||||
export { default as JsonEditor } from './JsonEditor';
|
export { default as JsonEditor } from './JsonEditor';
|
||||||
export { default as HeaderMapEditor } from './HeaderMapEditor';
|
export { default as HeaderMapEditor } from './HeaderMapEditor';
|
||||||
|
export { default as GoRegexInput, validateGoRegex } from './GoRegexInput';
|
||||||
export { default as SelectAllClearButtons } from './SelectAllClearButtons';
|
export { default as SelectAllClearButtons } from './SelectAllClearButtons';
|
||||||
export { default as RemarkTemplateField } from './RemarkTemplateField';
|
export { default as RemarkTemplateField } from './RemarkTemplateField';
|
||||||
export { default as RemarkVarPicker } from './RemarkVarPicker';
|
export { default as RemarkVarPicker } from './RemarkVarPicker';
|
||||||
|
|||||||
@@ -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 type { CSSProperties, ReactElement, ReactNode } from 'react';
|
||||||
import { Controller } from 'react-hook-form';
|
import { Controller } from 'react-hook-form';
|
||||||
import type { Control, ControllerProps, FieldValues, Path } 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>) {
|
}: FormFieldProps<T>) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const dottedName = toDotted(name) as Path<T>;
|
const dottedName = toDotted(name) as Path<T>;
|
||||||
|
const generatedId = useId();
|
||||||
|
const explicitId = (children as ReactElement<{ id?: string }>).props.id;
|
||||||
|
const fieldId = explicitId ?? generatedId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Controller
|
<Controller
|
||||||
@@ -60,6 +63,7 @@ export function FormField<T extends FieldValues = FieldValues>({
|
|||||||
? t(fieldState.error.message, { defaultValue: fieldState.error.message })
|
? t(fieldState.error.message, { defaultValue: fieldState.error.message })
|
||||||
: undefined;
|
: undefined;
|
||||||
const childProps: Record<string, unknown> = {
|
const childProps: Record<string, unknown> = {
|
||||||
|
id: fieldId,
|
||||||
[valueProp]: displayValue,
|
[valueProp]: displayValue,
|
||||||
onChange: (...args: unknown[]) => {
|
onChange: (...args: unknown[]) => {
|
||||||
const raw = normalizeAntdOnChange(args, valueProp);
|
const raw = normalizeAntdOnChange(args, valueProp);
|
||||||
@@ -73,6 +77,7 @@ export function FormField<T extends FieldValues = FieldValues>({
|
|||||||
return (
|
return (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={label}
|
label={label}
|
||||||
|
htmlFor={label ? fieldId : undefined}
|
||||||
tooltip={tooltip}
|
tooltip={tooltip}
|
||||||
extra={extra}
|
extra={extra}
|
||||||
required={required}
|
required={required}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Tag } from 'antd';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useFactoryDefaults } from '@/api/queries/useFactoryDefaults';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value semantics on purpose: the tag answers "does this equal the shipped
|
||||||
|
* default?", not "has the user ever saved this key?" — a stored 2096 and a
|
||||||
|
* fallback 2096 behave identically, so they read identically.
|
||||||
|
*/
|
||||||
|
export function matchesFactoryDefault(current: unknown, factoryDefault: string | undefined): boolean {
|
||||||
|
if (factoryDefault === undefined) return false;
|
||||||
|
if (typeof current === 'number') {
|
||||||
|
const parsed = Number(factoryDefault);
|
||||||
|
return factoryDefault.trim() !== '' && !Number.isNaN(parsed) && parsed === current;
|
||||||
|
}
|
||||||
|
if (typeof current === 'boolean') {
|
||||||
|
if (factoryDefault !== 'true' && factoryDefault !== 'false') return false;
|
||||||
|
return (factoryDefault === 'true') === current;
|
||||||
|
}
|
||||||
|
if (typeof current === 'string') return factoryDefault === current;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DefaultSettingTagProps {
|
||||||
|
settingKey: string;
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DefaultSettingTag({ settingKey, value }: DefaultSettingTagProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const defaults = useFactoryDefaults();
|
||||||
|
|
||||||
|
if (!matchesFactoryDefault(value, defaults.data?.[settingKey])) return null;
|
||||||
|
|
||||||
|
return <Tag style={{ marginLeft: 8 }}>{t('pages.settings.defaultTag')}</Tag>;
|
||||||
|
}
|
||||||
@@ -6,6 +6,18 @@ const meta = {
|
|||||||
title: 'UI/InfinityIcon',
|
title: 'UI/InfinityIcon',
|
||||||
component: InfinityIcon,
|
component: InfinityIcon,
|
||||||
tags: ['autodocs'],
|
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>;
|
} satisfies Meta<typeof InfinityIcon>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|||||||
@@ -7,6 +7,21 @@ const meta = {
|
|||||||
title: 'UI/InputAddon',
|
title: 'UI/InputAddon',
|
||||||
component: InputAddon,
|
component: InputAddon,
|
||||||
tags: ['autodocs'],
|
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>;
|
} satisfies Meta<typeof InputAddon>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
@@ -26,7 +41,7 @@ export const BesideInput: Story = {
|
|||||||
render: () => (
|
render: () => (
|
||||||
<Space.Compact>
|
<Space.Compact>
|
||||||
<InputAddon>https://</InputAddon>
|
<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>
|
</Space.Compact>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,22 @@ const meta = {
|
|||||||
title: 'UI/SettingListItem',
|
title: 'UI/SettingListItem',
|
||||||
component: SettingListItem,
|
component: SettingListItem,
|
||||||
tags: ['autodocs'],
|
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>;
|
} satisfies Meta<typeof SettingListItem>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import './SettingListItem.css';
|
|||||||
interface SettingListItemProps {
|
interface SettingListItemProps {
|
||||||
paddings?: 'small' | 'default';
|
paddings?: 'small' | 'default';
|
||||||
title?: ReactNode;
|
title?: ReactNode;
|
||||||
|
badge?: ReactNode;
|
||||||
description?: ReactNode;
|
description?: ReactNode;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
control?: ReactNode;
|
control?: ReactNode;
|
||||||
@@ -13,6 +14,7 @@ interface SettingListItemProps {
|
|||||||
export default function SettingListItem({
|
export default function SettingListItem({
|
||||||
paddings = 'default',
|
paddings = 'default',
|
||||||
title,
|
title,
|
||||||
|
badge,
|
||||||
description,
|
description,
|
||||||
children,
|
children,
|
||||||
control,
|
control,
|
||||||
@@ -28,7 +30,12 @@ export default function SettingListItem({
|
|||||||
<Row gutter={[8, 16]} style={{ width: '100%' }}>
|
<Row gutter={[8, 16]} style={{ width: '100%' }}>
|
||||||
<Col xs={24} lg={12}>
|
<Col xs={24} lg={12}>
|
||||||
<div className="setting-list-meta">
|
<div className="setting-list-meta">
|
||||||
{title && <div className="setting-list-title" id={titleId}>{title}</div>}
|
{title && (
|
||||||
|
<div className="setting-list-title">
|
||||||
|
<span id={titleId}>{title}</span>
|
||||||
|
{badge}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{description && <div className="setting-list-description">{description}</div>}
|
{description && <div className="setting-list-description">{description}</div>}
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export { default as InputAddon } from './InputAddon';
|
export { default as InputAddon } from './InputAddon';
|
||||||
export { default as InfinityIcon } from './InfinityIcon';
|
export { default as InfinityIcon } from './InfinityIcon';
|
||||||
export { default as SettingListItem } from './SettingListItem';
|
export { default as SettingListItem } from './SettingListItem';
|
||||||
|
export { default as DefaultSettingTag } from './DefaultSettingTag';
|
||||||
|
|||||||
@@ -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 />,
|
icon: <CloudServerOutlined />,
|
||||||
title: 'eventGroupOutbound',
|
title: 'eventGroupOutbound',
|
||||||
events: [
|
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: '' },
|
{ key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -37,16 +44,16 @@ const GROUPS: NotificationGroupConfig[] = [
|
|||||||
key: 'cpu.high',
|
key: 'cpu.high',
|
||||||
label: 'eventCPUHigh',
|
label: 'eventCPUHigh',
|
||||||
settingKey: 'smtpCpu',
|
settingKey: 'smtpCpu',
|
||||||
extra: ({ value, onChange }) => (
|
extra: ({ value, onChange, ariaLabel }) => (
|
||||||
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} style={{ width: 80 }} />
|
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'memory.high',
|
key: 'memory.high',
|
||||||
label: 'eventMemoryHigh',
|
label: 'eventMemoryHigh',
|
||||||
settingKey: 'smtpMemory',
|
settingKey: 'smtpMemory',
|
||||||
extra: ({ value, onChange }) => (
|
extra: ({ value, onChange, ariaLabel }) => (
|
||||||
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} style={{ width: 80 }} />
|
<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',
|
title: 'UI/Notifications/NotificationCard',
|
||||||
component: NotificationCard,
|
component: NotificationCard,
|
||||||
tags: ['autodocs'],
|
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>;
|
} satisfies Meta<typeof NotificationCard>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
@@ -19,7 +33,7 @@ export const Default: Story = {
|
|||||||
args: {
|
args: {
|
||||||
icon: <BellOutlined />,
|
icon: <BellOutlined />,
|
||||||
title: 'Telegram',
|
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>,
|
children: <span>Push a message to the configured chat when an event fires.</span>,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -28,7 +42,7 @@ export const Disabled: Story = {
|
|||||||
args: {
|
args: {
|
||||||
icon: <BellOutlined />,
|
icon: <BellOutlined />,
|
||||||
title: 'Email',
|
title: 'Email',
|
||||||
extra: <Switch />,
|
extra: <Switch aria-label="Enable email notifications" />,
|
||||||
children: <span>Email delivery is turned off for this channel.</span>,
|
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?.({
|
{event.extra?.({
|
||||||
value: Number((allSetting as unknown as Record<string, unknown>)[event.settingKey]) || 0,
|
value: Number((allSetting as unknown as Record<string, unknown>)[event.settingKey]) || 0,
|
||||||
onChange: (v) => updateSetting({ [event.settingKey]: v }),
|
onChange: (v) => updateSetting({ [event.settingKey]: v }),
|
||||||
|
ariaLabel: t(`pages.settings.${event.label}`),
|
||||||
})}
|
})}
|
||||||
</NotificationEvent>
|
</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 />,
|
icon: <CloudServerOutlined />,
|
||||||
title: 'eventGroupOutbound',
|
title: 'eventGroupOutbound',
|
||||||
events: [
|
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: '' },
|
{ key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -37,16 +44,16 @@ const GROUPS: NotificationGroupConfig[] = [
|
|||||||
key: 'cpu.high',
|
key: 'cpu.high',
|
||||||
label: 'eventCPUHigh',
|
label: 'eventCPUHigh',
|
||||||
settingKey: 'tgCpu',
|
settingKey: 'tgCpu',
|
||||||
extra: ({ value, onChange }) => (
|
extra: ({ value, onChange, ariaLabel }) => (
|
||||||
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} style={{ width: 80 }} />
|
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'memory.high',
|
key: 'memory.high',
|
||||||
label: 'eventMemoryHigh',
|
label: 'eventMemoryHigh',
|
||||||
settingKey: 'tgMemory',
|
settingKey: 'tgMemory',
|
||||||
extra: ({ value, onChange }) => (
|
extra: ({ value, onChange, ariaLabel }) => (
|
||||||
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} style={{ width: 80 }} />
|
<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;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
settingKey: 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 {
|
export interface NotificationGroupConfig {
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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',
|
title: 'Viz/Sparkline',
|
||||||
component: Sparkline,
|
component: Sparkline,
|
||||||
tags: ['autodocs'],
|
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>;
|
} satisfies Meta<typeof Sparkline>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ interface SparklineProps {
|
|||||||
yTickStep?: number;
|
yTickStep?: number;
|
||||||
tickCountX?: number;
|
tickCountX?: number;
|
||||||
showTooltip?: boolean;
|
showTooltip?: boolean;
|
||||||
|
showLegend?: boolean;
|
||||||
valueMin?: number;
|
valueMin?: number;
|
||||||
valueMax?: number | null;
|
valueMax?: number | null;
|
||||||
yFormatter?: (v: number) => string;
|
yFormatter?: (v: number) => string;
|
||||||
@@ -80,13 +81,23 @@ interface SparklineView {
|
|||||||
extremaPoints: ExtremaResult | null;
|
extremaPoints: ExtremaResult | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hexToRgba(hex: string, alpha: number): string {
|
function hexToRgba(color: string, alpha: number): string {
|
||||||
let h = hex.trim();
|
const trimmed = color.trim();
|
||||||
|
const fn = trimmed.match(/^rgba?\(([^)]+)\)$/i);
|
||||||
|
if (fn) {
|
||||||
|
const parts = fn[1].split(/[,/]\s*|\s+/).filter(Boolean).map(Number);
|
||||||
|
if (parts.length >= 3 && parts.slice(0, 3).every((n) => Number.isFinite(n))) {
|
||||||
|
const baseAlpha = parts.length > 3 && Number.isFinite(parts[3]) ? parts[3] : 1;
|
||||||
|
return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${baseAlpha * alpha})`;
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
let h = trimmed;
|
||||||
if (h.startsWith('#')) h = h.slice(1);
|
if (h.startsWith('#')) h = h.slice(1);
|
||||||
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
|
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
|
||||||
if (h.length !== 6) return hex;
|
if (h.length !== 6) return trimmed;
|
||||||
const int = Number.parseInt(h, 16);
|
const int = Number.parseInt(h, 16);
|
||||||
if (Number.isNaN(int)) return hex;
|
if (Number.isNaN(int)) return trimmed;
|
||||||
const r = (int >> 16) & 255;
|
const r = (int >> 16) & 255;
|
||||||
const g = (int >> 8) & 255;
|
const g = (int >> 8) & 255;
|
||||||
const b = int & 255;
|
const b = int & 255;
|
||||||
@@ -129,6 +140,7 @@ export default function Sparkline(props: SparklineProps) {
|
|||||||
yTickStep = 25,
|
yTickStep = 25,
|
||||||
tickCountX = 4,
|
tickCountX = 4,
|
||||||
showTooltip = false,
|
showTooltip = false,
|
||||||
|
showLegend = true,
|
||||||
valueMin = 0,
|
valueMin = 0,
|
||||||
valueMax = 100,
|
valueMax = 100,
|
||||||
yFormatter = (v: number) => `${Math.round(v)}%`,
|
yFormatter = (v: number) => `${Math.round(v)}%`,
|
||||||
@@ -542,10 +554,6 @@ export default function Sparkline(props: SparklineProps) {
|
|||||||
);
|
);
|
||||||
}, [points, hasSeries2, hasSeries3, valueMin, valueMax]);
|
}, [points, hasSeries2, hasSeries3, valueMin, valueMax]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
plotRef.current?.redraw(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const redraw = () => plotRef.current?.redraw(false);
|
const redraw = () => plotRef.current?.redraw(false);
|
||||||
const moBody = new MutationObserver(redraw);
|
const moBody = new MutationObserver(redraw);
|
||||||
@@ -570,7 +578,7 @@ export default function Sparkline(props: SparklineProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{legendItems.length > 0 && (
|
{showLegend && legendItems.length > 0 && (
|
||||||
<div className="sparkline-legend" aria-hidden="true">
|
<div className="sparkline-legend" aria-hidden="true">
|
||||||
{legendItems.map((s) => (
|
{legendItems.map((s) => (
|
||||||
<span key={s.name} className="extrema-item" style={{ color: s.color }}>● {s.name}</span>
|
<span key={s.name} className="extrema-item" style={{ color: s.color }}>● {s.name}</span>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"ldapUserAttr": "",
|
"ldapUserAttr": "",
|
||||||
"ldapUserFilter": "",
|
"ldapUserFilter": "",
|
||||||
"ldapVlessField": "",
|
"ldapVlessField": "",
|
||||||
|
"outboundDownThreshold": 1,
|
||||||
"pageSize": 0,
|
"pageSize": 0,
|
||||||
"panelOutbound": "",
|
"panelOutbound": "",
|
||||||
"remarkTemplate": "",
|
"remarkTemplate": "",
|
||||||
@@ -35,6 +36,8 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"smtpEnable": false,
|
"smtpEnable": false,
|
||||||
"smtpEnabledEvents": "",
|
"smtpEnabledEvents": "",
|
||||||
"smtpEncryptionType": "",
|
"smtpEncryptionType": "",
|
||||||
|
"smtpFrom": "",
|
||||||
|
"smtpFromName": "",
|
||||||
"smtpHost": "",
|
"smtpHost": "",
|
||||||
"smtpMemory": 0,
|
"smtpMemory": 0,
|
||||||
"smtpPassword": "",
|
"smtpPassword": "",
|
||||||
@@ -43,11 +46,13 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"smtpUsername": "",
|
"smtpUsername": "",
|
||||||
"subAnnounce": "",
|
"subAnnounce": "",
|
||||||
"subCertFile": "",
|
"subCertFile": "",
|
||||||
|
"subClashAutoDetect": false,
|
||||||
"subClashEnable": false,
|
"subClashEnable": false,
|
||||||
"subClashEnableRouting": false,
|
"subClashEnableRouting": false,
|
||||||
"subClashPath": "",
|
"subClashPath": "",
|
||||||
"subClashRules": "",
|
"subClashRules": "",
|
||||||
"subClashURI": "",
|
"subClashURI": "",
|
||||||
|
"subClashUserAgentRegex": "",
|
||||||
"subDomain": "",
|
"subDomain": "",
|
||||||
"subEnable": false,
|
"subEnable": false,
|
||||||
"subEnableRouting": false,
|
"subEnableRouting": false,
|
||||||
@@ -55,18 +60,22 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"subHideSettings": false,
|
"subHideSettings": false,
|
||||||
"subIncyEnableRouting": false,
|
"subIncyEnableRouting": false,
|
||||||
"subIncyRoutingRules": "",
|
"subIncyRoutingRules": "",
|
||||||
|
"subJsonAlwaysArray": false,
|
||||||
|
"subJsonAutoDetect": false,
|
||||||
"subJsonEnable": false,
|
"subJsonEnable": false,
|
||||||
"subJsonFinalMask": "",
|
"subJsonFinalMask": "",
|
||||||
"subJsonMux": "",
|
"subJsonMux": "",
|
||||||
"subJsonPath": "",
|
"subJsonPath": "",
|
||||||
"subJsonRules": "",
|
"subJsonRules": "",
|
||||||
"subJsonURI": "",
|
"subJsonURI": "",
|
||||||
|
"subJsonUserAgentRegex": "",
|
||||||
"subKeyFile": "",
|
"subKeyFile": "",
|
||||||
"subListen": "",
|
"subListen": "",
|
||||||
"subPath": "",
|
"subPath": "",
|
||||||
"subPort": 1,
|
"subPort": 1,
|
||||||
"subProfileUrl": "",
|
"subProfileUrl": "",
|
||||||
"subRoutingRules": "",
|
"subRoutingRules": "",
|
||||||
|
"subShowIdentityOnAllLinks": false,
|
||||||
"subSupportUrl": "",
|
"subSupportUrl": "",
|
||||||
"subThemeDir": "",
|
"subThemeDir": "",
|
||||||
"subTitle": "",
|
"subTitle": "",
|
||||||
@@ -129,6 +138,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"ldapUserAttr": "",
|
"ldapUserAttr": "",
|
||||||
"ldapUserFilter": "",
|
"ldapUserFilter": "",
|
||||||
"ldapVlessField": "",
|
"ldapVlessField": "",
|
||||||
|
"outboundDownThreshold": 1,
|
||||||
"pageSize": 0,
|
"pageSize": 0,
|
||||||
"panelOutbound": "",
|
"panelOutbound": "",
|
||||||
"remarkTemplate": "",
|
"remarkTemplate": "",
|
||||||
@@ -138,6 +148,8 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"smtpEnable": false,
|
"smtpEnable": false,
|
||||||
"smtpEnabledEvents": "",
|
"smtpEnabledEvents": "",
|
||||||
"smtpEncryptionType": "",
|
"smtpEncryptionType": "",
|
||||||
|
"smtpFrom": "",
|
||||||
|
"smtpFromName": "",
|
||||||
"smtpHost": "",
|
"smtpHost": "",
|
||||||
"smtpMemory": 0,
|
"smtpMemory": 0,
|
||||||
"smtpPassword": "",
|
"smtpPassword": "",
|
||||||
@@ -146,11 +158,13 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"smtpUsername": "",
|
"smtpUsername": "",
|
||||||
"subAnnounce": "",
|
"subAnnounce": "",
|
||||||
"subCertFile": "",
|
"subCertFile": "",
|
||||||
|
"subClashAutoDetect": false,
|
||||||
"subClashEnable": false,
|
"subClashEnable": false,
|
||||||
"subClashEnableRouting": false,
|
"subClashEnableRouting": false,
|
||||||
"subClashPath": "",
|
"subClashPath": "",
|
||||||
"subClashRules": "",
|
"subClashRules": "",
|
||||||
"subClashURI": "",
|
"subClashURI": "",
|
||||||
|
"subClashUserAgentRegex": "",
|
||||||
"subDomain": "",
|
"subDomain": "",
|
||||||
"subEnable": false,
|
"subEnable": false,
|
||||||
"subEnableRouting": false,
|
"subEnableRouting": false,
|
||||||
@@ -158,18 +172,22 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"subHideSettings": false,
|
"subHideSettings": false,
|
||||||
"subIncyEnableRouting": false,
|
"subIncyEnableRouting": false,
|
||||||
"subIncyRoutingRules": "",
|
"subIncyRoutingRules": "",
|
||||||
|
"subJsonAlwaysArray": false,
|
||||||
|
"subJsonAutoDetect": false,
|
||||||
"subJsonEnable": false,
|
"subJsonEnable": false,
|
||||||
"subJsonFinalMask": "",
|
"subJsonFinalMask": "",
|
||||||
"subJsonMux": "",
|
"subJsonMux": "",
|
||||||
"subJsonPath": "",
|
"subJsonPath": "",
|
||||||
"subJsonRules": "",
|
"subJsonRules": "",
|
||||||
"subJsonURI": "",
|
"subJsonURI": "",
|
||||||
|
"subJsonUserAgentRegex": "",
|
||||||
"subKeyFile": "",
|
"subKeyFile": "",
|
||||||
"subListen": "",
|
"subListen": "",
|
||||||
"subPath": "",
|
"subPath": "",
|
||||||
"subPort": 1,
|
"subPort": 1,
|
||||||
"subProfileUrl": "",
|
"subProfileUrl": "",
|
||||||
"subRoutingRules": "",
|
"subRoutingRules": "",
|
||||||
|
"subShowIdentityOnAllLinks": false,
|
||||||
"subSupportUrl": "",
|
"subSupportUrl": "",
|
||||||
"subThemeDir": "",
|
"subThemeDir": "",
|
||||||
"subTitle": "",
|
"subTitle": "",
|
||||||
@@ -432,6 +450,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"tag": "in-443-tcp",
|
"tag": "in-443-tcp",
|
||||||
"total": 0,
|
"total": 0,
|
||||||
"trafficReset": "never",
|
"trafficReset": "never",
|
||||||
|
"trafficResetDay": 1,
|
||||||
"up": 0
|
"up": 0
|
||||||
},
|
},
|
||||||
"InboundClientIps": {
|
"InboundClientIps": {
|
||||||
@@ -478,7 +497,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"activeCount": 23,
|
"activeCount": 23,
|
||||||
"address": "node1.example.com",
|
"address": "node1.example.com",
|
||||||
"allowPrivateAddress": false,
|
"allowPrivateAddress": false,
|
||||||
"apiToken": "abcdef0123456789",
|
|
||||||
"basePath": "/",
|
"basePath": "/",
|
||||||
"clientCount": 27,
|
"clientCount": 27,
|
||||||
"configDirty": false,
|
"configDirty": false,
|
||||||
@@ -519,6 +537,71 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"xrayState": "",
|
"xrayState": "",
|
||||||
"xrayVersion": "25.10.31"
|
"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": {
|
"OutboundTraffics": {
|
||||||
"down": 0,
|
"down": 0,
|
||||||
"id": 0,
|
"id": 0,
|
||||||
|
|||||||
@@ -83,6 +83,11 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"ldapVlessField": {
|
"ldapVlessField": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"outboundDownThreshold": {
|
||||||
|
"maximum": 100,
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"pageSize": {
|
"pageSize": {
|
||||||
"maximum": 1000,
|
"maximum": 1000,
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
@@ -116,6 +121,12 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"smtpEncryptionType": {
|
"smtpEncryptionType": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"smtpFrom": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"smtpFromName": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"smtpHost": {
|
"smtpHost": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -144,6 +155,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subCertFile": {
|
"subCertFile": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subClashAutoDetect": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"subClashEnable": {
|
"subClashEnable": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -159,6 +173,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subClashURI": {
|
"subClashURI": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subClashUserAgentRegex": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subDomain": {
|
"subDomain": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -180,6 +197,12 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subIncyRoutingRules": {
|
"subIncyRoutingRules": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subJsonAlwaysArray": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"subJsonAutoDetect": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"subJsonEnable": {
|
"subJsonEnable": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -198,6 +221,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subJsonURI": {
|
"subJsonURI": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subJsonUserAgentRegex": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subKeyFile": {
|
"subKeyFile": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -218,6 +244,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subRoutingRules": {
|
"subRoutingRules": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subShowIdentityOnAllLinks": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"subSupportUrl": {
|
"subSupportUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -340,6 +369,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"ldapUserAttr",
|
"ldapUserAttr",
|
||||||
"ldapUserFilter",
|
"ldapUserFilter",
|
||||||
"ldapVlessField",
|
"ldapVlessField",
|
||||||
|
"outboundDownThreshold",
|
||||||
"pageSize",
|
"pageSize",
|
||||||
"panelOutbound",
|
"panelOutbound",
|
||||||
"remarkTemplate",
|
"remarkTemplate",
|
||||||
@@ -349,6 +379,8 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"smtpEnable",
|
"smtpEnable",
|
||||||
"smtpEnabledEvents",
|
"smtpEnabledEvents",
|
||||||
"smtpEncryptionType",
|
"smtpEncryptionType",
|
||||||
|
"smtpFrom",
|
||||||
|
"smtpFromName",
|
||||||
"smtpHost",
|
"smtpHost",
|
||||||
"smtpMemory",
|
"smtpMemory",
|
||||||
"smtpPassword",
|
"smtpPassword",
|
||||||
@@ -357,11 +389,13 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"smtpUsername",
|
"smtpUsername",
|
||||||
"subAnnounce",
|
"subAnnounce",
|
||||||
"subCertFile",
|
"subCertFile",
|
||||||
|
"subClashAutoDetect",
|
||||||
"subClashEnable",
|
"subClashEnable",
|
||||||
"subClashEnableRouting",
|
"subClashEnableRouting",
|
||||||
"subClashPath",
|
"subClashPath",
|
||||||
"subClashRules",
|
"subClashRules",
|
||||||
"subClashURI",
|
"subClashURI",
|
||||||
|
"subClashUserAgentRegex",
|
||||||
"subDomain",
|
"subDomain",
|
||||||
"subEnable",
|
"subEnable",
|
||||||
"subEnableRouting",
|
"subEnableRouting",
|
||||||
@@ -369,18 +403,22 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subHideSettings",
|
"subHideSettings",
|
||||||
"subIncyEnableRouting",
|
"subIncyEnableRouting",
|
||||||
"subIncyRoutingRules",
|
"subIncyRoutingRules",
|
||||||
|
"subJsonAlwaysArray",
|
||||||
|
"subJsonAutoDetect",
|
||||||
"subJsonEnable",
|
"subJsonEnable",
|
||||||
"subJsonFinalMask",
|
"subJsonFinalMask",
|
||||||
"subJsonMux",
|
"subJsonMux",
|
||||||
"subJsonPath",
|
"subJsonPath",
|
||||||
"subJsonRules",
|
"subJsonRules",
|
||||||
"subJsonURI",
|
"subJsonURI",
|
||||||
|
"subJsonUserAgentRegex",
|
||||||
"subKeyFile",
|
"subKeyFile",
|
||||||
"subListen",
|
"subListen",
|
||||||
"subPath",
|
"subPath",
|
||||||
"subPort",
|
"subPort",
|
||||||
"subProfileUrl",
|
"subProfileUrl",
|
||||||
"subRoutingRules",
|
"subRoutingRules",
|
||||||
|
"subShowIdentityOnAllLinks",
|
||||||
"subSupportUrl",
|
"subSupportUrl",
|
||||||
"subThemeDir",
|
"subThemeDir",
|
||||||
"subTitle",
|
"subTitle",
|
||||||
@@ -516,6 +554,11 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"ldapVlessField": {
|
"ldapVlessField": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"outboundDownThreshold": {
|
||||||
|
"maximum": 100,
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"pageSize": {
|
"pageSize": {
|
||||||
"maximum": 1000,
|
"maximum": 1000,
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
@@ -549,6 +592,12 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"smtpEncryptionType": {
|
"smtpEncryptionType": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"smtpFrom": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"smtpFromName": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"smtpHost": {
|
"smtpHost": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -577,6 +626,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subCertFile": {
|
"subCertFile": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subClashAutoDetect": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"subClashEnable": {
|
"subClashEnable": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -592,6 +644,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subClashURI": {
|
"subClashURI": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subClashUserAgentRegex": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subDomain": {
|
"subDomain": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -613,6 +668,12 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subIncyRoutingRules": {
|
"subIncyRoutingRules": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subJsonAlwaysArray": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"subJsonAutoDetect": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"subJsonEnable": {
|
"subJsonEnable": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -631,6 +692,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subJsonURI": {
|
"subJsonURI": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subJsonUserAgentRegex": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subKeyFile": {
|
"subKeyFile": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -651,6 +715,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subRoutingRules": {
|
"subRoutingRules": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subShowIdentityOnAllLinks": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"subSupportUrl": {
|
"subSupportUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -780,6 +847,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"ldapUserAttr",
|
"ldapUserAttr",
|
||||||
"ldapUserFilter",
|
"ldapUserFilter",
|
||||||
"ldapVlessField",
|
"ldapVlessField",
|
||||||
|
"outboundDownThreshold",
|
||||||
"pageSize",
|
"pageSize",
|
||||||
"panelOutbound",
|
"panelOutbound",
|
||||||
"remarkTemplate",
|
"remarkTemplate",
|
||||||
@@ -789,6 +857,8 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"smtpEnable",
|
"smtpEnable",
|
||||||
"smtpEnabledEvents",
|
"smtpEnabledEvents",
|
||||||
"smtpEncryptionType",
|
"smtpEncryptionType",
|
||||||
|
"smtpFrom",
|
||||||
|
"smtpFromName",
|
||||||
"smtpHost",
|
"smtpHost",
|
||||||
"smtpMemory",
|
"smtpMemory",
|
||||||
"smtpPassword",
|
"smtpPassword",
|
||||||
@@ -797,11 +867,13 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"smtpUsername",
|
"smtpUsername",
|
||||||
"subAnnounce",
|
"subAnnounce",
|
||||||
"subCertFile",
|
"subCertFile",
|
||||||
|
"subClashAutoDetect",
|
||||||
"subClashEnable",
|
"subClashEnable",
|
||||||
"subClashEnableRouting",
|
"subClashEnableRouting",
|
||||||
"subClashPath",
|
"subClashPath",
|
||||||
"subClashRules",
|
"subClashRules",
|
||||||
"subClashURI",
|
"subClashURI",
|
||||||
|
"subClashUserAgentRegex",
|
||||||
"subDomain",
|
"subDomain",
|
||||||
"subEnable",
|
"subEnable",
|
||||||
"subEnableRouting",
|
"subEnableRouting",
|
||||||
@@ -809,18 +881,22 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subHideSettings",
|
"subHideSettings",
|
||||||
"subIncyEnableRouting",
|
"subIncyEnableRouting",
|
||||||
"subIncyRoutingRules",
|
"subIncyRoutingRules",
|
||||||
|
"subJsonAlwaysArray",
|
||||||
|
"subJsonAutoDetect",
|
||||||
"subJsonEnable",
|
"subJsonEnable",
|
||||||
"subJsonFinalMask",
|
"subJsonFinalMask",
|
||||||
"subJsonMux",
|
"subJsonMux",
|
||||||
"subJsonPath",
|
"subJsonPath",
|
||||||
"subJsonRules",
|
"subJsonRules",
|
||||||
"subJsonURI",
|
"subJsonURI",
|
||||||
|
"subJsonUserAgentRegex",
|
||||||
"subKeyFile",
|
"subKeyFile",
|
||||||
"subListen",
|
"subListen",
|
||||||
"subPath",
|
"subPath",
|
||||||
"subPort",
|
"subPort",
|
||||||
"subProfileUrl",
|
"subProfileUrl",
|
||||||
"subRoutingRules",
|
"subRoutingRules",
|
||||||
|
"subShowIdentityOnAllLinks",
|
||||||
"subSupportUrl",
|
"subSupportUrl",
|
||||||
"subThemeDir",
|
"subThemeDir",
|
||||||
"subTitle",
|
"subTitle",
|
||||||
@@ -1766,6 +1842,13 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"trafficResetDay": {
|
||||||
|
"description": "Day of month for monthly traffic resets",
|
||||||
|
"example": 1,
|
||||||
|
"maximum": 31,
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"up": {
|
"up": {
|
||||||
"description": "Upload traffic in bytes",
|
"description": "Upload traffic in bytes",
|
||||||
"format": "int64",
|
"format": "int64",
|
||||||
@@ -1792,6 +1875,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"tag",
|
"tag",
|
||||||
"total",
|
"total",
|
||||||
"trafficReset",
|
"trafficReset",
|
||||||
|
"trafficResetDay",
|
||||||
"up"
|
"up"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
@@ -1964,10 +2048,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"allowPrivateAddress": {
|
"allowPrivateAddress": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"apiToken": {
|
|
||||||
"example": "abcdef0123456789",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"basePath": {
|
"basePath": {
|
||||||
"example": "/",
|
"example": "/",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -2138,7 +2218,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"activeCount",
|
"activeCount",
|
||||||
"address",
|
"address",
|
||||||
"allowPrivateAddress",
|
"allowPrivateAddress",
|
||||||
"apiToken",
|
|
||||||
"basePath",
|
"basePath",
|
||||||
"clientCount",
|
"clientCount",
|
||||||
"configDirty",
|
"configDirty",
|
||||||
@@ -2177,6 +2256,315 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"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": {
|
"OutboundTraffics": {
|
||||||
"description": "OutboundTraffics tracks traffic statistics for Xray outbound connections.",
|
"description": "OutboundTraffics tracks traffic statistics for Xray outbound connections.",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export interface AllSetting {
|
|||||||
ldapUserAttr: string;
|
ldapUserAttr: string;
|
||||||
ldapUserFilter: string;
|
ldapUserFilter: string;
|
||||||
ldapVlessField: string;
|
ldapVlessField: string;
|
||||||
|
outboundDownThreshold: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
panelOutbound: string;
|
panelOutbound: string;
|
||||||
remarkTemplate: string;
|
remarkTemplate: string;
|
||||||
@@ -41,6 +42,8 @@ export interface AllSetting {
|
|||||||
smtpEnable: boolean;
|
smtpEnable: boolean;
|
||||||
smtpEnabledEvents: string;
|
smtpEnabledEvents: string;
|
||||||
smtpEncryptionType: string;
|
smtpEncryptionType: string;
|
||||||
|
smtpFrom: string;
|
||||||
|
smtpFromName: string;
|
||||||
smtpHost: string;
|
smtpHost: string;
|
||||||
smtpMemory: number;
|
smtpMemory: number;
|
||||||
smtpPassword: string;
|
smtpPassword: string;
|
||||||
@@ -49,11 +52,13 @@ export interface AllSetting {
|
|||||||
smtpUsername: string;
|
smtpUsername: string;
|
||||||
subAnnounce: string;
|
subAnnounce: string;
|
||||||
subCertFile: string;
|
subCertFile: string;
|
||||||
|
subClashAutoDetect: boolean;
|
||||||
subClashEnable: boolean;
|
subClashEnable: boolean;
|
||||||
subClashEnableRouting: boolean;
|
subClashEnableRouting: boolean;
|
||||||
subClashPath: string;
|
subClashPath: string;
|
||||||
subClashRules: string;
|
subClashRules: string;
|
||||||
subClashURI: string;
|
subClashURI: string;
|
||||||
|
subClashUserAgentRegex: string;
|
||||||
subDomain: string;
|
subDomain: string;
|
||||||
subEnable: boolean;
|
subEnable: boolean;
|
||||||
subEnableRouting: boolean;
|
subEnableRouting: boolean;
|
||||||
@@ -61,18 +66,22 @@ export interface AllSetting {
|
|||||||
subHideSettings: boolean;
|
subHideSettings: boolean;
|
||||||
subIncyEnableRouting: boolean;
|
subIncyEnableRouting: boolean;
|
||||||
subIncyRoutingRules: string;
|
subIncyRoutingRules: string;
|
||||||
|
subJsonAlwaysArray: boolean;
|
||||||
|
subJsonAutoDetect: boolean;
|
||||||
subJsonEnable: boolean;
|
subJsonEnable: boolean;
|
||||||
subJsonFinalMask: string;
|
subJsonFinalMask: string;
|
||||||
subJsonMux: string;
|
subJsonMux: string;
|
||||||
subJsonPath: string;
|
subJsonPath: string;
|
||||||
subJsonRules: string;
|
subJsonRules: string;
|
||||||
subJsonURI: string;
|
subJsonURI: string;
|
||||||
|
subJsonUserAgentRegex: string;
|
||||||
subKeyFile: string;
|
subKeyFile: string;
|
||||||
subListen: string;
|
subListen: string;
|
||||||
subPath: string;
|
subPath: string;
|
||||||
subPort: number;
|
subPort: number;
|
||||||
subProfileUrl: string;
|
subProfileUrl: string;
|
||||||
subRoutingRules: string;
|
subRoutingRules: string;
|
||||||
|
subShowIdentityOnAllLinks: boolean;
|
||||||
subSupportUrl: string;
|
subSupportUrl: string;
|
||||||
subThemeDir: string;
|
subThemeDir: string;
|
||||||
subTitle: string;
|
subTitle: string;
|
||||||
@@ -136,6 +145,7 @@ export interface AllSettingView {
|
|||||||
ldapUserAttr: string;
|
ldapUserAttr: string;
|
||||||
ldapUserFilter: string;
|
ldapUserFilter: string;
|
||||||
ldapVlessField: string;
|
ldapVlessField: string;
|
||||||
|
outboundDownThreshold: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
panelOutbound: string;
|
panelOutbound: string;
|
||||||
remarkTemplate: string;
|
remarkTemplate: string;
|
||||||
@@ -145,6 +155,8 @@ export interface AllSettingView {
|
|||||||
smtpEnable: boolean;
|
smtpEnable: boolean;
|
||||||
smtpEnabledEvents: string;
|
smtpEnabledEvents: string;
|
||||||
smtpEncryptionType: string;
|
smtpEncryptionType: string;
|
||||||
|
smtpFrom: string;
|
||||||
|
smtpFromName: string;
|
||||||
smtpHost: string;
|
smtpHost: string;
|
||||||
smtpMemory: number;
|
smtpMemory: number;
|
||||||
smtpPassword: string;
|
smtpPassword: string;
|
||||||
@@ -153,11 +165,13 @@ export interface AllSettingView {
|
|||||||
smtpUsername: string;
|
smtpUsername: string;
|
||||||
subAnnounce: string;
|
subAnnounce: string;
|
||||||
subCertFile: string;
|
subCertFile: string;
|
||||||
|
subClashAutoDetect: boolean;
|
||||||
subClashEnable: boolean;
|
subClashEnable: boolean;
|
||||||
subClashEnableRouting: boolean;
|
subClashEnableRouting: boolean;
|
||||||
subClashPath: string;
|
subClashPath: string;
|
||||||
subClashRules: string;
|
subClashRules: string;
|
||||||
subClashURI: string;
|
subClashURI: string;
|
||||||
|
subClashUserAgentRegex: string;
|
||||||
subDomain: string;
|
subDomain: string;
|
||||||
subEnable: boolean;
|
subEnable: boolean;
|
||||||
subEnableRouting: boolean;
|
subEnableRouting: boolean;
|
||||||
@@ -165,18 +179,22 @@ export interface AllSettingView {
|
|||||||
subHideSettings: boolean;
|
subHideSettings: boolean;
|
||||||
subIncyEnableRouting: boolean;
|
subIncyEnableRouting: boolean;
|
||||||
subIncyRoutingRules: string;
|
subIncyRoutingRules: string;
|
||||||
|
subJsonAlwaysArray: boolean;
|
||||||
|
subJsonAutoDetect: boolean;
|
||||||
subJsonEnable: boolean;
|
subJsonEnable: boolean;
|
||||||
subJsonFinalMask: string;
|
subJsonFinalMask: string;
|
||||||
subJsonMux: string;
|
subJsonMux: string;
|
||||||
subJsonPath: string;
|
subJsonPath: string;
|
||||||
subJsonRules: string;
|
subJsonRules: string;
|
||||||
subJsonURI: string;
|
subJsonURI: string;
|
||||||
|
subJsonUserAgentRegex: string;
|
||||||
subKeyFile: string;
|
subKeyFile: string;
|
||||||
subListen: string;
|
subListen: string;
|
||||||
subPath: string;
|
subPath: string;
|
||||||
subPort: number;
|
subPort: number;
|
||||||
subProfileUrl: string;
|
subProfileUrl: string;
|
||||||
subRoutingRules: string;
|
subRoutingRules: string;
|
||||||
|
subShowIdentityOnAllLinks: boolean;
|
||||||
subSupportUrl: string;
|
subSupportUrl: string;
|
||||||
subThemeDir: string;
|
subThemeDir: string;
|
||||||
subTitle: string;
|
subTitle: string;
|
||||||
@@ -410,6 +428,7 @@ export interface Inbound {
|
|||||||
tag: string;
|
tag: string;
|
||||||
total: number;
|
total: number;
|
||||||
trafficReset: string;
|
trafficReset: string;
|
||||||
|
trafficResetDay: number;
|
||||||
up: number;
|
up: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,7 +480,6 @@ export interface Node {
|
|||||||
activeCount: number;
|
activeCount: number;
|
||||||
address: string;
|
address: string;
|
||||||
allowPrivateAddress: boolean;
|
allowPrivateAddress: boolean;
|
||||||
apiToken: string;
|
|
||||||
basePath: string;
|
basePath: string;
|
||||||
clientCount: number;
|
clientCount: number;
|
||||||
configDirty: boolean;
|
configDirty: boolean;
|
||||||
@@ -501,6 +519,69 @@ export interface Node {
|
|||||||
xrayVersion: string;
|
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 {
|
export interface OutboundTraffics {
|
||||||
down: number;
|
down: number;
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export const AllSettingSchema = z.object({
|
|||||||
ldapUserAttr: z.string(),
|
ldapUserAttr: z.string(),
|
||||||
ldapUserFilter: z.string(),
|
ldapUserFilter: z.string(),
|
||||||
ldapVlessField: z.string(),
|
ldapVlessField: z.string(),
|
||||||
|
outboundDownThreshold: z.number().int().min(1).max(100),
|
||||||
pageSize: z.number().int().min(0).max(1000),
|
pageSize: z.number().int().min(0).max(1000),
|
||||||
panelOutbound: z.string(),
|
panelOutbound: z.string(),
|
||||||
remarkTemplate: z.string(),
|
remarkTemplate: z.string(),
|
||||||
@@ -53,6 +54,8 @@ export const AllSettingSchema = z.object({
|
|||||||
smtpEnable: z.boolean(),
|
smtpEnable: z.boolean(),
|
||||||
smtpEnabledEvents: z.string(),
|
smtpEnabledEvents: z.string(),
|
||||||
smtpEncryptionType: z.string(),
|
smtpEncryptionType: z.string(),
|
||||||
|
smtpFrom: z.string(),
|
||||||
|
smtpFromName: z.string(),
|
||||||
smtpHost: z.string(),
|
smtpHost: z.string(),
|
||||||
smtpMemory: z.number().int().min(0).max(100),
|
smtpMemory: z.number().int().min(0).max(100),
|
||||||
smtpPassword: z.string(),
|
smtpPassword: z.string(),
|
||||||
@@ -61,11 +64,13 @@ export const AllSettingSchema = z.object({
|
|||||||
smtpUsername: z.string(),
|
smtpUsername: z.string(),
|
||||||
subAnnounce: z.string(),
|
subAnnounce: z.string(),
|
||||||
subCertFile: z.string(),
|
subCertFile: z.string(),
|
||||||
|
subClashAutoDetect: z.boolean(),
|
||||||
subClashEnable: z.boolean(),
|
subClashEnable: z.boolean(),
|
||||||
subClashEnableRouting: z.boolean(),
|
subClashEnableRouting: z.boolean(),
|
||||||
subClashPath: z.string(),
|
subClashPath: z.string(),
|
||||||
subClashRules: z.string(),
|
subClashRules: z.string(),
|
||||||
subClashURI: z.string(),
|
subClashURI: z.string(),
|
||||||
|
subClashUserAgentRegex: z.string(),
|
||||||
subDomain: z.string(),
|
subDomain: z.string(),
|
||||||
subEnable: z.boolean(),
|
subEnable: z.boolean(),
|
||||||
subEnableRouting: z.boolean(),
|
subEnableRouting: z.boolean(),
|
||||||
@@ -73,18 +78,22 @@ export const AllSettingSchema = z.object({
|
|||||||
subHideSettings: z.boolean(),
|
subHideSettings: z.boolean(),
|
||||||
subIncyEnableRouting: z.boolean(),
|
subIncyEnableRouting: z.boolean(),
|
||||||
subIncyRoutingRules: z.string(),
|
subIncyRoutingRules: z.string(),
|
||||||
|
subJsonAlwaysArray: z.boolean(),
|
||||||
|
subJsonAutoDetect: z.boolean(),
|
||||||
subJsonEnable: z.boolean(),
|
subJsonEnable: z.boolean(),
|
||||||
subJsonFinalMask: z.string(),
|
subJsonFinalMask: z.string(),
|
||||||
subJsonMux: z.string(),
|
subJsonMux: z.string(),
|
||||||
subJsonPath: z.string(),
|
subJsonPath: z.string(),
|
||||||
subJsonRules: z.string(),
|
subJsonRules: z.string(),
|
||||||
subJsonURI: z.string(),
|
subJsonURI: z.string(),
|
||||||
|
subJsonUserAgentRegex: z.string(),
|
||||||
subKeyFile: z.string(),
|
subKeyFile: z.string(),
|
||||||
subListen: z.string(),
|
subListen: z.string(),
|
||||||
subPath: z.string(),
|
subPath: z.string(),
|
||||||
subPort: z.number().int().min(1).max(65535),
|
subPort: z.number().int().min(1).max(65535),
|
||||||
subProfileUrl: z.string(),
|
subProfileUrl: z.string(),
|
||||||
subRoutingRules: z.string(),
|
subRoutingRules: z.string(),
|
||||||
|
subShowIdentityOnAllLinks: z.boolean(),
|
||||||
subSupportUrl: z.string(),
|
subSupportUrl: z.string(),
|
||||||
subThemeDir: z.string(),
|
subThemeDir: z.string(),
|
||||||
subTitle: z.string(),
|
subTitle: z.string(),
|
||||||
@@ -149,6 +158,7 @@ export const AllSettingViewSchema = z.object({
|
|||||||
ldapUserAttr: z.string(),
|
ldapUserAttr: z.string(),
|
||||||
ldapUserFilter: z.string(),
|
ldapUserFilter: z.string(),
|
||||||
ldapVlessField: z.string(),
|
ldapVlessField: z.string(),
|
||||||
|
outboundDownThreshold: z.number().int().min(1).max(100),
|
||||||
pageSize: z.number().int().min(0).max(1000),
|
pageSize: z.number().int().min(0).max(1000),
|
||||||
panelOutbound: z.string(),
|
panelOutbound: z.string(),
|
||||||
remarkTemplate: z.string(),
|
remarkTemplate: z.string(),
|
||||||
@@ -158,6 +168,8 @@ export const AllSettingViewSchema = z.object({
|
|||||||
smtpEnable: z.boolean(),
|
smtpEnable: z.boolean(),
|
||||||
smtpEnabledEvents: z.string(),
|
smtpEnabledEvents: z.string(),
|
||||||
smtpEncryptionType: z.string(),
|
smtpEncryptionType: z.string(),
|
||||||
|
smtpFrom: z.string(),
|
||||||
|
smtpFromName: z.string(),
|
||||||
smtpHost: z.string(),
|
smtpHost: z.string(),
|
||||||
smtpMemory: z.number().int().min(0).max(100),
|
smtpMemory: z.number().int().min(0).max(100),
|
||||||
smtpPassword: z.string(),
|
smtpPassword: z.string(),
|
||||||
@@ -166,11 +178,13 @@ export const AllSettingViewSchema = z.object({
|
|||||||
smtpUsername: z.string(),
|
smtpUsername: z.string(),
|
||||||
subAnnounce: z.string(),
|
subAnnounce: z.string(),
|
||||||
subCertFile: z.string(),
|
subCertFile: z.string(),
|
||||||
|
subClashAutoDetect: z.boolean(),
|
||||||
subClashEnable: z.boolean(),
|
subClashEnable: z.boolean(),
|
||||||
subClashEnableRouting: z.boolean(),
|
subClashEnableRouting: z.boolean(),
|
||||||
subClashPath: z.string(),
|
subClashPath: z.string(),
|
||||||
subClashRules: z.string(),
|
subClashRules: z.string(),
|
||||||
subClashURI: z.string(),
|
subClashURI: z.string(),
|
||||||
|
subClashUserAgentRegex: z.string(),
|
||||||
subDomain: z.string(),
|
subDomain: z.string(),
|
||||||
subEnable: z.boolean(),
|
subEnable: z.boolean(),
|
||||||
subEnableRouting: z.boolean(),
|
subEnableRouting: z.boolean(),
|
||||||
@@ -178,18 +192,22 @@ export const AllSettingViewSchema = z.object({
|
|||||||
subHideSettings: z.boolean(),
|
subHideSettings: z.boolean(),
|
||||||
subIncyEnableRouting: z.boolean(),
|
subIncyEnableRouting: z.boolean(),
|
||||||
subIncyRoutingRules: z.string(),
|
subIncyRoutingRules: z.string(),
|
||||||
|
subJsonAlwaysArray: z.boolean(),
|
||||||
|
subJsonAutoDetect: z.boolean(),
|
||||||
subJsonEnable: z.boolean(),
|
subJsonEnable: z.boolean(),
|
||||||
subJsonFinalMask: z.string(),
|
subJsonFinalMask: z.string(),
|
||||||
subJsonMux: z.string(),
|
subJsonMux: z.string(),
|
||||||
subJsonPath: z.string(),
|
subJsonPath: z.string(),
|
||||||
subJsonRules: z.string(),
|
subJsonRules: z.string(),
|
||||||
subJsonURI: z.string(),
|
subJsonURI: z.string(),
|
||||||
|
subJsonUserAgentRegex: z.string(),
|
||||||
subKeyFile: z.string(),
|
subKeyFile: z.string(),
|
||||||
subListen: z.string(),
|
subListen: z.string(),
|
||||||
subPath: z.string(),
|
subPath: z.string(),
|
||||||
subPort: z.number().int().min(1).max(65535),
|
subPort: z.number().int().min(1).max(65535),
|
||||||
subProfileUrl: z.string(),
|
subProfileUrl: z.string(),
|
||||||
subRoutingRules: z.string(),
|
subRoutingRules: z.string(),
|
||||||
|
subShowIdentityOnAllLinks: z.boolean(),
|
||||||
subSupportUrl: z.string(),
|
subSupportUrl: z.string(),
|
||||||
subThemeDir: z.string(),
|
subThemeDir: z.string(),
|
||||||
subTitle: z.string(),
|
subTitle: z.string(),
|
||||||
@@ -435,6 +453,7 @@ export const InboundSchema = z.object({
|
|||||||
tag: z.string(),
|
tag: z.string(),
|
||||||
total: z.number().int(),
|
total: z.number().int(),
|
||||||
trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']),
|
trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']),
|
||||||
|
trafficResetDay: z.number().int().min(1).max(31),
|
||||||
up: z.number().int(),
|
up: z.number().int(),
|
||||||
});
|
});
|
||||||
export type Inbound = z.infer<typeof InboundSchema>;
|
export type Inbound = z.infer<typeof InboundSchema>;
|
||||||
@@ -491,7 +510,6 @@ export const NodeSchema = z.object({
|
|||||||
activeCount: z.number().int(),
|
activeCount: z.number().int(),
|
||||||
address: z.string(),
|
address: z.string(),
|
||||||
allowPrivateAddress: z.boolean(),
|
allowPrivateAddress: z.boolean(),
|
||||||
apiToken: z.string(),
|
|
||||||
basePath: z.string(),
|
basePath: z.string(),
|
||||||
clientCount: z.number().int(),
|
clientCount: z.number().int(),
|
||||||
configDirty: z.boolean(),
|
configDirty: z.boolean(),
|
||||||
@@ -532,6 +550,71 @@ export const NodeSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type Node = z.infer<typeof NodeSchema>;
|
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({
|
export const OutboundTrafficsSchema = z.object({
|
||||||
down: z.number().int(),
|
down: z.number().int(),
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
|
|||||||
@@ -73,7 +73,9 @@ export interface ClientQueryParams {
|
|||||||
|
|
||||||
const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 };
|
const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 };
|
||||||
const DEFAULT_SUMMARY: ClientsSummary = {
|
const DEFAULT_SUMMARY: ClientsSummary = {
|
||||||
total: 0, active: 0, online: [], depleted: [], expiring: [], deactive: [],
|
total: 0, active: 0,
|
||||||
|
onlineCount: 0, depletedCount: 0, expiringCount: 0, deactiveCount: 0,
|
||||||
|
online: [], depleted: [], expiring: [], deactive: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ClientSpeedEntry {
|
export interface ClientSpeedEntry {
|
||||||
@@ -114,7 +116,63 @@ export function computeClientsSummary(
|
|||||||
if (nearExpiry || nearLimit) expiring.push(email);
|
if (nearExpiry || nearLimit) expiring.push(email);
|
||||||
else active += 1;
|
else active += 1;
|
||||||
}
|
}
|
||||||
return { total: stats.length, active, online, depleted, expiring, deactive };
|
return {
|
||||||
|
total: stats.length,
|
||||||
|
active,
|
||||||
|
onlineCount: online.length,
|
||||||
|
depletedCount: depleted.length,
|
||||||
|
expiringCount: expiring.length,
|
||||||
|
deactiveCount: deactive.length,
|
||||||
|
online,
|
||||||
|
depleted,
|
||||||
|
expiring,
|
||||||
|
deactive,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sameSpeedMap(
|
||||||
|
a: Record<string, ClientSpeedEntry>,
|
||||||
|
b: Record<string, ClientSpeedEntry>,
|
||||||
|
): boolean {
|
||||||
|
const aKeys = Object.keys(a);
|
||||||
|
if (aKeys.length !== Object.keys(b).length) return false;
|
||||||
|
for (const key of aKeys) {
|
||||||
|
const left = a[key];
|
||||||
|
const right = b[key];
|
||||||
|
if (!right || left.up !== right.up || left.down !== right.down) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The field list computeClientsSummary reads, and deliberately nothing else.
|
||||||
|
// lastOnline in particular churns for every online client on every push and no
|
||||||
|
// counter depends on it, so including it here would defeat the comparison.
|
||||||
|
export function sameSummaryInputs(a: ClientStatRow[], b: ClientStatRow[]): boolean {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
const left = a[i];
|
||||||
|
const right = b[i];
|
||||||
|
if (left.email !== right.email
|
||||||
|
|| left.up !== right.up
|
||||||
|
|| left.down !== right.down
|
||||||
|
|| left.total !== right.total
|
||||||
|
|| left.enable !== right.enable
|
||||||
|
|| left.expiryTime !== right.expiryTime) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickClientsSummary(
|
||||||
|
serverSummary: ClientsSummary,
|
||||||
|
allClientStats: ClientStatRow[],
|
||||||
|
onlineSet: Set<string>,
|
||||||
|
expireDiffMs: number,
|
||||||
|
trafficDiffBytes: number,
|
||||||
|
): ClientsSummary {
|
||||||
|
if (allClientStats.length === 0) return serverSummary;
|
||||||
|
if (serverSummary.total > allClientStats.length) return serverSummary;
|
||||||
|
const live = computeClientsSummary(allClientStats, onlineSet, expireDiffMs, trafficDiffBytes);
|
||||||
|
return { ...live, total: serverSummary.total || live.total };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildQS(p: ClientQueryParams): string {
|
function buildQS(p: ClientQueryParams): string {
|
||||||
@@ -142,7 +200,7 @@ async function fetchClientPage(params: ClientQueryParams): Promise<ClientPageRes
|
|||||||
const qs = buildQS(params);
|
const qs = buildQS(params);
|
||||||
const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`, undefined, { silent: true });
|
const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`, undefined, { silent: true });
|
||||||
if (!msg?.success || !msg.obj) throw new Error(msg?.msg || 'Failed to fetch clients');
|
if (!msg?.success || !msg.obj) throw new Error(msg?.msg || 'Failed to fetch clients');
|
||||||
const validated = parseMsg(msg, ClientPageResponseSchema, 'clients/list/paged');
|
const validated = parseMsg(msg, ClientPageResponseSchema, 'clients/list/paged', { strict: true });
|
||||||
if (!validated.obj) throw new Error('Empty clients response');
|
if (!validated.obj) throw new Error('Empty clients response');
|
||||||
return validated.obj;
|
return validated.obj;
|
||||||
}
|
}
|
||||||
@@ -161,17 +219,31 @@ async function fetchDefaults(): Promise<Record<string, unknown>> {
|
|||||||
return validated.obj || {};
|
return validated.obj || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useClients() {
|
export interface UseClientsOptions {
|
||||||
|
// Callers that only need the mutations — the bulk modals, the groups page —
|
||||||
|
// pass false. Mounting them used to start a second 5-second poll of the paged
|
||||||
|
// list whose result they never read, which on a large panel means a full
|
||||||
|
// summary aggregate every 5 seconds for nothing.
|
||||||
|
list?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useClients(options: UseClientsOptions = {}) {
|
||||||
|
const withList = options.list ?? true;
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [query, setQueryState] = useState<ClientQueryParams>(DEFAULT_QUERY);
|
// Null until the page has settled on a query. The clients page cannot build
|
||||||
|
// one until the persisted sort and the panel's configured page size are both
|
||||||
|
// known, and fetching before then cost three sequential requests per load —
|
||||||
|
// the first two thrown away (#trace).
|
||||||
|
const [query, setQueryState] = useState<ClientQueryParams | null>(null);
|
||||||
// setQuery shallow-compares so callers can pass a fresh object every render
|
// setQuery shallow-compares so callers can pass a fresh object every render
|
||||||
// (the common React pattern) without triggering a re-fetch when nothing
|
// (the common React pattern) without triggering a re-fetch when nothing
|
||||||
// actually changed.
|
// actually changed.
|
||||||
const setQuery = useCallback((next: ClientQueryParams) => {
|
const setQuery = useCallback((next: ClientQueryParams) => {
|
||||||
setQueryState((prev) => {
|
setQueryState((prev) => {
|
||||||
if (
|
if (
|
||||||
prev.page === next.page
|
prev
|
||||||
|
&& prev.page === next.page
|
||||||
&& prev.pageSize === next.pageSize
|
&& prev.pageSize === next.pageSize
|
||||||
&& (prev.search ?? '') === (next.search ?? '')
|
&& (prev.search ?? '') === (next.search ?? '')
|
||||||
&& (prev.filter ?? '') === (next.filter ?? '')
|
&& (prev.filter ?? '') === (next.filter ?? '')
|
||||||
@@ -193,8 +265,9 @@ export function useClients() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const listQuery = useQuery({
|
const listQuery = useQuery({
|
||||||
queryKey: keys.clients.list(query),
|
queryKey: keys.clients.list(query ?? DEFAULT_QUERY),
|
||||||
queryFn: () => fetchClientPage(query),
|
queryFn: () => fetchClientPage(query ?? DEFAULT_QUERY),
|
||||||
|
enabled: withList && query !== null,
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
// List is sorted/paged server-side, so the WS patch can't add new or
|
// List is sorted/paged server-side, so the WS patch can't add new or
|
||||||
// re-sort rows; poll the current page to keep it live (pauses when hidden).
|
// re-sort rows; poll the current page to keep it live (pauses when hidden).
|
||||||
@@ -205,6 +278,7 @@ export function useClients() {
|
|||||||
const inboundOptionsQuery = useQuery({
|
const inboundOptionsQuery = useQuery({
|
||||||
queryKey: keys.inbounds.options(),
|
queryKey: keys.inbounds.options(),
|
||||||
queryFn: fetchInboundOptions,
|
queryFn: fetchInboundOptions,
|
||||||
|
enabled: withList,
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -222,6 +296,7 @@ export function useClients() {
|
|||||||
const validated = parseMsg(msg, OnlinesSchema, 'clients/onlines');
|
const validated = parseMsg(msg, OnlinesSchema, 'clients/onlines');
|
||||||
return Array.isArray(validated.obj) ? validated.obj : [];
|
return Array.isArray(validated.obj) ? validated.obj : [];
|
||||||
},
|
},
|
||||||
|
enabled: withList,
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -231,7 +306,11 @@ export function useClients() {
|
|||||||
const allGroups = listQuery.data?.groups ?? [];
|
const allGroups = listQuery.data?.groups ?? [];
|
||||||
const fetched = listQuery.data !== undefined || listQuery.isError;
|
const fetched = listQuery.data !== undefined || listQuery.isError;
|
||||||
const fetchError = listQuery.error ? (listQuery.error as Error).message : '';
|
const fetchError = listQuery.error ? (listQuery.error as Error).message : '';
|
||||||
const loading = listQuery.isFetching;
|
// isFetching is deliberately NOT read here. Touching it makes it a tracked
|
||||||
|
// property, so the 5s refetchInterval notifies twice per cycle — two whole
|
||||||
|
// page renders even when structural sharing leaves the data identical, and
|
||||||
|
// each one bumps rc-table's immutable mark and re-runs every cell renderer.
|
||||||
|
// Callers that want a spinner for an explicit refresh drive it locally.
|
||||||
// Showing kept-previous data for a new key (filter/sort/page) — drives the
|
// Showing kept-previous data for a new key (filter/sort/page) — drives the
|
||||||
// table overlay so the 5s background poll doesn't flash it.
|
// table overlay so the 5s background poll doesn't flash it.
|
||||||
const transitioning = listQuery.isPlaceholderData;
|
const transitioning = listQuery.isPlaceholderData;
|
||||||
@@ -264,19 +343,18 @@ export function useClients() {
|
|||||||
const expireDiff = ((defaults.expireDiff as number) ?? 0) * 86400000;
|
const expireDiff = ((defaults.expireDiff as number) ?? 0) * 86400000;
|
||||||
const trafficDiff = ((defaults.trafficDiff as number) ?? 0) * 1073741824;
|
const trafficDiff = ((defaults.trafficDiff as number) ?? 0) * 1073741824;
|
||||||
const pageSize = (defaults.pageSize as number) ?? 0;
|
const pageSize = (defaults.pageSize as number) ?? 0;
|
||||||
|
// pageSize 0 means "one long page", which is indistinguishable from "the
|
||||||
|
// settings have not arrived yet" — so callers need this flag to know when the
|
||||||
|
// configured page size is real. isFetched (not isSuccess) so a failed
|
||||||
|
// settings request still lets the page fall back and render.
|
||||||
|
const settingsReady = defaultsQuery.isFetched;
|
||||||
|
|
||||||
// Live summary: the client_stats WS event refreshes allClientStats every few
|
|
||||||
// seconds, so the top counters track reality without a page refresh. Falls
|
|
||||||
// back to the server-computed summary until the first event lands, and keeps
|
|
||||||
// the server's authoritative total for the headline count.
|
|
||||||
const [allClientStats, setAllClientStats] = useState<ClientStatRow[]>([]);
|
const [allClientStats, setAllClientStats] = useState<ClientStatRow[]>([]);
|
||||||
const [clientSpeed, setClientSpeed] = useState<Record<string, ClientSpeedEntry>>({});
|
const [clientSpeed, setClientSpeed] = useState<Record<string, ClientSpeedEntry>>({});
|
||||||
const summary = useMemo<ClientsSummary>(() => {
|
const summary = useMemo<ClientsSummary>(
|
||||||
const serverSummary = listQuery.data?.summary ?? DEFAULT_SUMMARY;
|
() => pickClientsSummary(listQuery.data?.summary ?? DEFAULT_SUMMARY, allClientStats, new Set(onlines), expireDiff, trafficDiff),
|
||||||
if (allClientStats.length === 0) return serverSummary;
|
[allClientStats, onlines, expireDiff, trafficDiff, listQuery.data?.summary],
|
||||||
const live = computeClientsSummary(allClientStats, new Set(onlines), expireDiff, trafficDiff);
|
);
|
||||||
return { ...live, total: serverSummary.total || live.total };
|
|
||||||
}, [allClientStats, onlines, expireDiff, trafficDiff, listQuery.data?.summary]);
|
|
||||||
|
|
||||||
const invalidateAll = useCallback(
|
const invalidateAll = useCallback(
|
||||||
() => {
|
() => {
|
||||||
@@ -558,15 +636,23 @@ export function useClients() {
|
|||||||
queryClient.setQueryData(keys.clients.onlines(), p.onlineClients);
|
queryClient.setQueryData(keys.clients.onlines(), p.onlineClients);
|
||||||
}
|
}
|
||||||
if (Array.isArray(p.clientTraffics)) {
|
if (Array.isArray(p.clientTraffics)) {
|
||||||
|
// Xray reports a row per client whether or not it moved a byte, so most of
|
||||||
|
// this map used to be zeros. A missing entry and a zero entry render
|
||||||
|
// identically (isActiveSpeed treats both as inactive), so the zeros are
|
||||||
|
// dropped and an unchanged result returns the previous object — which lets
|
||||||
|
// React bail out of the update instead of re-rendering the table.
|
||||||
const next: Record<string, ClientSpeedEntry> = {};
|
const next: Record<string, ClientSpeedEntry> = {};
|
||||||
for (const ct of p.clientTraffics) {
|
for (const ct of p.clientTraffics) {
|
||||||
if (!ct || !ct.email) continue;
|
if (!ct || !ct.email) continue;
|
||||||
|
const up = ct.up || 0;
|
||||||
|
const down = ct.down || 0;
|
||||||
|
if (up === 0 && down === 0) continue;
|
||||||
next[ct.email] = {
|
next[ct.email] = {
|
||||||
up: (ct.up || 0) / TRAFFIC_POLL_INTERVAL_S,
|
up: up / TRAFFIC_POLL_INTERVAL_S,
|
||||||
down: (ct.down || 0) / TRAFFIC_POLL_INTERVAL_S,
|
down: down / TRAFFIC_POLL_INTERVAL_S,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
setClientSpeed(next);
|
setClientSpeed((prev) => (sameSpeedMap(prev, next) ? prev : next));
|
||||||
}
|
}
|
||||||
}, [queryClient]);
|
}, [queryClient]);
|
||||||
|
|
||||||
@@ -574,12 +660,17 @@ export function useClients() {
|
|||||||
if (!payload || typeof payload !== 'object') return;
|
if (!payload || typeof payload !== 'object') return;
|
||||||
const p = payload as { clients?: ClientStatRow[]; snapshot?: boolean };
|
const p = payload as { clients?: ClientStatRow[]; snapshot?: boolean };
|
||||||
if (!Array.isArray(p.clients) || p.clients.length === 0) return;
|
if (!Array.isArray(p.clients) || p.clients.length === 0) return;
|
||||||
if (p.snapshot !== false) setAllClientStats(p.clients);
|
if (p.snapshot !== false) {
|
||||||
|
const rows = p.clients;
|
||||||
|
setAllClientStats((prev) => (sameSummaryInputs(prev, rows) ? prev : rows));
|
||||||
|
}
|
||||||
|
const active = queryRef.current;
|
||||||
|
if (!active) return;
|
||||||
const byEmail = new Map<string, ClientTraffic>();
|
const byEmail = new Map<string, ClientTraffic>();
|
||||||
for (const row of p.clients) {
|
for (const row of p.clients) {
|
||||||
if (row && row.email) byEmail.set(row.email, row);
|
if (row && row.email) byEmail.set(row.email, row);
|
||||||
}
|
}
|
||||||
queryClient.setQueryData<ClientPageResponse>(keys.clients.list(queryRef.current), (prev) => {
|
queryClient.setQueryData<ClientPageResponse>(keys.clients.list(active), (prev) => {
|
||||||
if (!prev) return prev;
|
if (!prev) return prev;
|
||||||
let touched = false;
|
let touched = false;
|
||||||
const next = prev.items.slice();
|
const next = prev.items.slice();
|
||||||
@@ -617,7 +708,6 @@ export function useClients() {
|
|||||||
setQuery,
|
setQuery,
|
||||||
inbounds,
|
inbounds,
|
||||||
onlines,
|
onlines,
|
||||||
loading,
|
|
||||||
transitioning,
|
transitioning,
|
||||||
fetched,
|
fetched,
|
||||||
fetchError,
|
fetchError,
|
||||||
@@ -627,6 +717,7 @@ export function useClients() {
|
|||||||
expireDiff,
|
expireDiff,
|
||||||
trafficDiff,
|
trafficDiff,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
settingsReady,
|
||||||
refresh,
|
refresh,
|
||||||
create,
|
create,
|
||||||
bulkCreate,
|
bulkCreate,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const TITLE_KEYS: Record<string, string> = {
|
const TITLE_KEYS: Record<string, string> = {
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
export function useServerDraft<T>(server: T | undefined, clone: (value: T) => T, equals: (left: T, right: T) => boolean) {
|
||||||
|
const cloneRef = useRef(clone);
|
||||||
|
const equalsRef = useRef(equals);
|
||||||
|
cloneRef.current = clone;
|
||||||
|
equalsRef.current = equals;
|
||||||
|
|
||||||
|
const [draft, setDraft] = useState<T | undefined>();
|
||||||
|
const [baseline, setBaseline] = useState<T | undefined>();
|
||||||
|
const draftRef = useRef(draft);
|
||||||
|
const baselineRef = useRef(baseline);
|
||||||
|
draftRef.current = draft;
|
||||||
|
baselineRef.current = baseline;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (server === undefined) return;
|
||||||
|
const currentDraft = draftRef.current;
|
||||||
|
const currentBaseline = baselineRef.current;
|
||||||
|
const isDirty = currentDraft !== undefined
|
||||||
|
&& (currentBaseline === undefined || !equalsRef.current(currentDraft, currentBaseline));
|
||||||
|
setBaseline(server);
|
||||||
|
if (isDirty && !equalsRef.current(currentDraft, server)) return;
|
||||||
|
setDraft(cloneRef.current(server));
|
||||||
|
}, [server]);
|
||||||
|
|
||||||
|
const markSaved = useCallback((value: T) => {
|
||||||
|
setBaseline(cloneRef.current(value));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isDirty = useMemo(
|
||||||
|
() => draft !== undefined && (baseline === undefined || !equalsRef.current(draft, baseline)),
|
||||||
|
[baseline, draft],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { draft, setDraft, isDirty, markSaved };
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useState } from 'react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { theme as antdTheme } from 'antd';
|
import { theme as antdTheme } from 'antd';
|
||||||
import type { ThemeConfig } from 'antd';
|
import type { ThemeConfig } from 'antd';
|
||||||
@@ -13,14 +13,18 @@ function readBool(key: string, fallback: boolean): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyDom(isDark: boolean, isUltra: boolean) {
|
function applyDom(isDark: boolean, isUltra: boolean) {
|
||||||
document.body.setAttribute('class', isDark ? 'dark' : 'light');
|
document.body.classList.remove('dark', 'light');
|
||||||
|
document.body.classList.add(isDark ? 'dark' : 'light');
|
||||||
if (isUltra) {
|
if (isUltra) {
|
||||||
document.documentElement.setAttribute('data-theme', 'ultra-dark');
|
document.documentElement.setAttribute('data-theme', 'ultra-dark');
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.removeAttribute('data-theme');
|
document.documentElement.removeAttribute('data-theme');
|
||||||
}
|
}
|
||||||
const msg = document.getElementById('message');
|
const msg = document.getElementById('message');
|
||||||
if (msg) msg.className = isDark ? 'dark' : 'light';
|
if (msg) {
|
||||||
|
msg.classList.remove('dark', 'light');
|
||||||
|
msg.classList.add(isDark ? 'dark' : 'light');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// module load so the document is in the right theme before React mounts.
|
// module load so the document is in the right theme before React mounts.
|
||||||
@@ -78,17 +82,48 @@ const STATISTIC_TOKENS = {
|
|||||||
contentFontSize: 17,
|
contentFontSize: 17,
|
||||||
titleFontSize: 11,
|
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',
|
||||||
|
};
|
||||||
|
|
||||||
|
// hashed:false drops the `:where(.css-<hash>)` wrapper antd puts around every
|
||||||
|
// rule. It costs nothing in specificity — `:where()` contributes zero, so the
|
||||||
|
// panel's own `.ant-*` overrides still win — and it removes roughly 5,700
|
||||||
|
// wrappers, 16% of the generated stylesheet, from what the browser has to parse.
|
||||||
|
//
|
||||||
|
// cssVar.key pins the CSS-variable scope. Every panel page mounts its own
|
||||||
|
// ConfigProvider (there is no root one), and without a fixed key each mints a
|
||||||
|
// fresh useId-derived scope, so navigating re-serialises and re-injects the whole
|
||||||
|
// token block under a new class instead of reusing the one already in the head.
|
||||||
|
const SHARED_STYLE_CONFIG = {
|
||||||
|
hashed: false,
|
||||||
|
cssVar: { key: 'xui' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeConfig {
|
export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeConfig {
|
||||||
if (!isDark) {
|
if (!isDark) {
|
||||||
return {
|
return {
|
||||||
|
...SHARED_STYLE_CONFIG,
|
||||||
algorithm: antdTheme.defaultAlgorithm,
|
algorithm: antdTheme.defaultAlgorithm,
|
||||||
|
token: LIGHT_CONTRAST_TOKENS,
|
||||||
components: {
|
components: {
|
||||||
Statistic: STATISTIC_TOKENS,
|
Statistic: STATISTIC_TOKENS,
|
||||||
|
Button: LIGHT_BUTTON_TOKENS,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
...SHARED_STYLE_CONFIG,
|
||||||
algorithm: antdTheme.darkAlgorithm,
|
algorithm: antdTheme.darkAlgorithm,
|
||||||
token: isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS,
|
token: isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS,
|
||||||
components: {
|
components: {
|
||||||
@@ -127,7 +162,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
const [isDark, setIsDark] = useState<boolean>(initialDark);
|
const [isDark, setIsDark] = useState<boolean>(initialDark);
|
||||||
const [isUltra, setIsUltra] = useState<boolean>(initialUltra);
|
const [isUltra, setIsUltra] = useState<boolean>(initialUltra);
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
applyDom(isDark, isUltra);
|
applyDom(isDark, isUltra);
|
||||||
localStorage.setItem(STORAGE_DARK, String(isDark));
|
localStorage.setItem(STORAGE_DARK, String(isDark));
|
||||||
localStorage.setItem(STORAGE_ULTRA, String(isUltra));
|
localStorage.setItem(STORAGE_ULTRA, String(isUltra));
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user