mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-11 23:55:01 +03:00
Compare commits
80 Commits
2f156c8eb0
..
v3.6.0
| 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 |
@@ -181,5 +181,5 @@ jobs:
|
|||||||
run: npm run build-storybook
|
run: npm run build-storybook
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
- name: Audit
|
- name: Audit
|
||||||
run: npm audit --audit-level=high
|
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
@@ -124,7 +124,7 @@ 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
|
||||||
fetch ${Xray_URL}Xray-linux-64.zip
|
fetch ${Xray_URL}Xray-linux-64.zip
|
||||||
unzip Xray-linux-64.zip
|
unzip Xray-linux-64.zip
|
||||||
@@ -282,7 +282,7 @@ jobs:
|
|||||||
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 @retry -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"
|
||||||
|
|||||||
+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",
|
||||||
|
|||||||
+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}"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
```
|
||||||
|
|||||||
+18
-18
@@ -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",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
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 } from 'antd';
|
import { ConfigProvider } from 'antd';
|
||||||
import i18next from 'i18next';
|
import i18next from 'i18next';
|
||||||
@@ -17,11 +17,12 @@ 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.body.setAttribute('class', dark ? 'dark' : 'light');
|
document.body.classList.remove('dark', 'light');
|
||||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
|
document.body.classList.add(dark ? 'dark' : 'light');
|
||||||
|
document.documentElement.removeAttribute('data-theme');
|
||||||
}, [dark]);
|
}, [dark]);
|
||||||
return (
|
return (
|
||||||
<ConfigProvider theme={buildAntdThemeConfig(dark, false)}>
|
<ConfigProvider theme={buildAntdThemeConfig(dark, false)}>
|
||||||
|
|||||||
@@ -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
+588
-891
File diff suppressed because it is too large
Load Diff
+29
-26
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"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": {
|
||||||
@@ -30,53 +30,53 @@
|
|||||||
"@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.1",
|
"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.10",
|
"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/addon-vitest": "^10.5.0",
|
"@storybook/addon-vitest": "^10.5.5",
|
||||||
"@storybook/react-vite": "^10.5.0",
|
"@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.64.0",
|
"typescript": "6.0.3",
|
||||||
|
"typescript-eslint": "^8.65.0",
|
||||||
"vite": "8.1.5",
|
"vite": "8.1.5",
|
||||||
"vitest": "^4.1.10",
|
"vitest": "^4.1.10"
|
||||||
"@vitest/browser-playwright": "4.1.10",
|
|
||||||
"playwright": "^1.61.1"
|
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"eslint-plugin-jsx-a11y": {
|
"eslint-plugin-jsx-a11y": {
|
||||||
@@ -90,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": {
|
||||||
|
|||||||
+583
-32
@@ -109,6 +109,11 @@
|
|||||||
"ldapVlessField": {
|
"ldapVlessField": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"outboundDownThreshold": {
|
||||||
|
"maximum": 100,
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"pageSize": {
|
"pageSize": {
|
||||||
"maximum": 1000,
|
"maximum": 1000,
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
@@ -265,6 +270,9 @@
|
|||||||
"subRoutingRules": {
|
"subRoutingRules": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subShowIdentityOnAllLinks": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"subSupportUrl": {
|
"subSupportUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -387,6 +395,7 @@
|
|||||||
"ldapUserAttr",
|
"ldapUserAttr",
|
||||||
"ldapUserFilter",
|
"ldapUserFilter",
|
||||||
"ldapVlessField",
|
"ldapVlessField",
|
||||||
|
"outboundDownThreshold",
|
||||||
"pageSize",
|
"pageSize",
|
||||||
"panelOutbound",
|
"panelOutbound",
|
||||||
"remarkTemplate",
|
"remarkTemplate",
|
||||||
@@ -435,6 +444,7 @@
|
|||||||
"subPort",
|
"subPort",
|
||||||
"subProfileUrl",
|
"subProfileUrl",
|
||||||
"subRoutingRules",
|
"subRoutingRules",
|
||||||
|
"subShowIdentityOnAllLinks",
|
||||||
"subSupportUrl",
|
"subSupportUrl",
|
||||||
"subThemeDir",
|
"subThemeDir",
|
||||||
"subTitle",
|
"subTitle",
|
||||||
@@ -570,6 +580,11 @@
|
|||||||
"ldapVlessField": {
|
"ldapVlessField": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"outboundDownThreshold": {
|
||||||
|
"maximum": 100,
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"pageSize": {
|
"pageSize": {
|
||||||
"maximum": 1000,
|
"maximum": 1000,
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
@@ -726,6 +741,9 @@
|
|||||||
"subRoutingRules": {
|
"subRoutingRules": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subShowIdentityOnAllLinks": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"subSupportUrl": {
|
"subSupportUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -855,6 +873,7 @@
|
|||||||
"ldapUserAttr",
|
"ldapUserAttr",
|
||||||
"ldapUserFilter",
|
"ldapUserFilter",
|
||||||
"ldapVlessField",
|
"ldapVlessField",
|
||||||
|
"outboundDownThreshold",
|
||||||
"pageSize",
|
"pageSize",
|
||||||
"panelOutbound",
|
"panelOutbound",
|
||||||
"remarkTemplate",
|
"remarkTemplate",
|
||||||
@@ -903,6 +922,7 @@
|
|||||||
"subPort",
|
"subPort",
|
||||||
"subProfileUrl",
|
"subProfileUrl",
|
||||||
"subRoutingRules",
|
"subRoutingRules",
|
||||||
|
"subShowIdentityOnAllLinks",
|
||||||
"subSupportUrl",
|
"subSupportUrl",
|
||||||
"subThemeDir",
|
"subThemeDir",
|
||||||
"subTitle",
|
"subTitle",
|
||||||
@@ -1848,6 +1868,13 @@
|
|||||||
],
|
],
|
||||||
"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",
|
||||||
@@ -1874,6 +1901,7 @@
|
|||||||
"tag",
|
"tag",
|
||||||
"total",
|
"total",
|
||||||
"trafficReset",
|
"trafficReset",
|
||||||
|
"trafficResetDay",
|
||||||
"up"
|
"up"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
@@ -2046,10 +2074,6 @@
|
|||||||
"allowPrivateAddress": {
|
"allowPrivateAddress": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"apiToken": {
|
|
||||||
"example": "abcdef0123456789",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"basePath": {
|
"basePath": {
|
||||||
"example": "/",
|
"example": "/",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -2220,7 +2244,6 @@
|
|||||||
"activeCount",
|
"activeCount",
|
||||||
"address",
|
"address",
|
||||||
"allowPrivateAddress",
|
"allowPrivateAddress",
|
||||||
"apiToken",
|
|
||||||
"basePath",
|
"basePath",
|
||||||
"clientCount",
|
"clientCount",
|
||||||
"configDirty",
|
"configDirty",
|
||||||
@@ -2259,6 +2282,315 @@
|
|||||||
],
|
],
|
||||||
"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": {
|
||||||
@@ -2835,6 +3167,7 @@
|
|||||||
"tag": "in-443-tcp",
|
"tag": "in-443-tcp",
|
||||||
"total": 0,
|
"total": 0,
|
||||||
"trafficReset": "never",
|
"trafficReset": "never",
|
||||||
|
"trafficResetDay": 1,
|
||||||
"up": 0
|
"up": 0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -3647,6 +3980,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/panel/api/openapi.json": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Server"
|
||||||
|
],
|
||||||
|
"summary": "Serve this API description as an OpenAPI 3 document — the same file that powers the API Docs page. Requires a session or Bearer token like the rest of /panel/api. Useful for generating clients or importing into API tooling.",
|
||||||
|
"operationId": "get_panel_api_openapi_json",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/panel/api/server/status": {
|
"/panel/api/server/status": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -5328,7 +5691,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Clients"
|
"Clients"
|
||||||
],
|
],
|
||||||
"summary": "Filter, sort, and paginate clients on the server. Each item is a slim row (no uuid/password/auth/flow/security/reverse/tgId) so the clients page can ship 25-ish rows in a few KB instead of the full table. The response also includes a summary computed across the full DB row set so dashboard counters stay stable as the user paginates or filters. Page size capped at 200; fetch /get/:email to obtain the full per-client payload for an edit/info modal.",
|
"summary": "Filter, sort, and paginate clients on the server. Each item is a slim row (no uuid/password/auth/flow/security/reverse/tgId) so the clients page can ship 25-ish rows in a few KB instead of the full table. The response also includes a summary computed across the full DB row set so dashboard counters stay stable as the user paginates or filters: the *Count fields are exact, while the email arrays beside them stop at 200 entries so the payload does not grow with the panel. Page size capped at 200; fetch /get/:email to obtain the full per-client payload for an edit/info modal.",
|
||||||
"operationId": "get_panel_api_clients_list_paged",
|
"operationId": "get_panel_api_clients_list_paged",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -5444,12 +5807,18 @@
|
|||||||
"summary": {
|
"summary": {
|
||||||
"total": 2000,
|
"total": 2000,
|
||||||
"active": 1850,
|
"active": 1850,
|
||||||
|
"onlineCount": 1,
|
||||||
|
"depletedCount": 0,
|
||||||
|
"expiringCount": 0,
|
||||||
|
"deactiveCount": 150,
|
||||||
"online": [
|
"online": [
|
||||||
"alice@example.com"
|
"alice@example.com"
|
||||||
],
|
],
|
||||||
"depleted": [],
|
"depleted": [],
|
||||||
"expiring": [],
|
"expiring": [],
|
||||||
"deactive": []
|
"deactive": [
|
||||||
|
"bob@example.com"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5500,6 +5869,47 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/panel/api/clients/get/tgId/{tgId}": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Clients"
|
||||||
|
],
|
||||||
|
"summary": "Fetch clients by Telegram user ID. Returns an array since multiple clients can share the same Telegram ID.",
|
||||||
|
"operationId": "get_panel_api_clients_get_tgId_tgId",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "tgId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"description": "Telegram user ID (numeric).",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/panel/api/clients/add": {
|
"/panel/api/clients/add": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -7508,7 +7918,7 @@
|
|||||||
"obj": {
|
"obj": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/Node"
|
"$ref": "#/components/schemas/NodeView"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7517,48 +7927,48 @@
|
|||||||
"success": true,
|
"success": true,
|
||||||
"obj": [
|
"obj": [
|
||||||
{
|
{
|
||||||
"activeCount": 23,
|
"activeCount": 20,
|
||||||
"address": "node1.example.com",
|
"address": "node.example.com",
|
||||||
"allowPrivateAddress": false,
|
"allowPrivateAddress": false,
|
||||||
"apiToken": "abcdef0123456789",
|
|
||||||
"basePath": "/",
|
"basePath": "/",
|
||||||
"clientCount": 27,
|
"clientCount": 25,
|
||||||
"configDirty": false,
|
"configDirty": false,
|
||||||
"configDirtyAt": 0,
|
"configDirtyAt": 0,
|
||||||
"cpuPct": 23.5,
|
"cpuPct": 12.5,
|
||||||
"createdAt": 1700000000,
|
"createdAt": 1700000000,
|
||||||
"depletedCount": 1,
|
"depletedCount": 1,
|
||||||
"disabledCount": 3,
|
"disabledCount": 2,
|
||||||
"enable": true,
|
"enable": true,
|
||||||
"guid": "",
|
"guid": "node-guid",
|
||||||
|
"hasApiToken": true,
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"inboundCount": 5,
|
"inboundCount": 3,
|
||||||
"inboundSyncMode": "all",
|
"inboundSyncMode": "all",
|
||||||
"inboundTags": [
|
"inboundTags": [
|
||||||
""
|
"in-443-tcp"
|
||||||
],
|
],
|
||||||
"lastError": "",
|
"lastError": "",
|
||||||
"lastHeartbeat": 1700000000,
|
"lastHeartbeat": 1700000000,
|
||||||
"latencyMs": 42,
|
"latencyMs": 42,
|
||||||
"memPct": 45.1,
|
"memPct": 45.2,
|
||||||
"name": "de-fra-1",
|
"name": "edge-1",
|
||||||
"netDown": 2097152,
|
"netDown": 1048576,
|
||||||
"netUp": 1048576,
|
"netUp": 2097152,
|
||||||
"onlineCount": 3,
|
"onlineCount": 5,
|
||||||
"outboundTag": "",
|
"outboundTag": "direct",
|
||||||
"panelVersion": "v3.x.x",
|
"panelVersion": "v3.x.x",
|
||||||
"parentGuid": "",
|
"parentGuid": "",
|
||||||
"pinnedCertSha256": "",
|
"pinnedCertSha256": "",
|
||||||
"port": 2053,
|
"port": 2053,
|
||||||
"remark": "",
|
"remark": "Primary edge",
|
||||||
"scheme": "https",
|
"scheme": "https",
|
||||||
"status": "online",
|
"status": "online",
|
||||||
"tlsVerifyMode": "verify",
|
"tlsVerifyMode": "verify",
|
||||||
"transitive": false,
|
"transitive": false,
|
||||||
"updatedAt": 1700000000,
|
"updatedAt": 1700003600,
|
||||||
"uptimeSecs": 86400,
|
"uptimeSecs": 86400,
|
||||||
"xrayError": "",
|
"xrayError": "",
|
||||||
"xrayState": "",
|
"xrayState": "running",
|
||||||
"xrayVersion": "25.10.31"
|
"xrayVersion": "25.10.31"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -7680,7 +8090,57 @@
|
|||||||
"msg": {
|
"msg": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"obj": {}
|
"obj": {
|
||||||
|
"$ref": "#/components/schemas/NodeView"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"success": true,
|
||||||
|
"obj": {
|
||||||
|
"activeCount": 20,
|
||||||
|
"address": "node.example.com",
|
||||||
|
"allowPrivateAddress": false,
|
||||||
|
"basePath": "/",
|
||||||
|
"clientCount": 25,
|
||||||
|
"configDirty": false,
|
||||||
|
"configDirtyAt": 0,
|
||||||
|
"cpuPct": 12.5,
|
||||||
|
"createdAt": 1700000000,
|
||||||
|
"depletedCount": 1,
|
||||||
|
"disabledCount": 2,
|
||||||
|
"enable": true,
|
||||||
|
"guid": "node-guid",
|
||||||
|
"hasApiToken": true,
|
||||||
|
"id": 1,
|
||||||
|
"inboundCount": 3,
|
||||||
|
"inboundSyncMode": "all",
|
||||||
|
"inboundTags": [
|
||||||
|
"in-443-tcp"
|
||||||
|
],
|
||||||
|
"lastError": "",
|
||||||
|
"lastHeartbeat": 1700000000,
|
||||||
|
"latencyMs": 42,
|
||||||
|
"memPct": 45.2,
|
||||||
|
"name": "edge-1",
|
||||||
|
"netDown": 1048576,
|
||||||
|
"netUp": 2097152,
|
||||||
|
"onlineCount": 5,
|
||||||
|
"outboundTag": "direct",
|
||||||
|
"panelVersion": "v3.x.x",
|
||||||
|
"parentGuid": "",
|
||||||
|
"pinnedCertSha256": "",
|
||||||
|
"port": 2053,
|
||||||
|
"remark": "Primary edge",
|
||||||
|
"scheme": "https",
|
||||||
|
"status": "online",
|
||||||
|
"tlsVerifyMode": "verify",
|
||||||
|
"transitive": false,
|
||||||
|
"updatedAt": 1700003600,
|
||||||
|
"uptimeSecs": 86400,
|
||||||
|
"xrayError": "",
|
||||||
|
"xrayState": "running",
|
||||||
|
"xrayVersion": "25.10.31"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7742,7 +8202,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Nodes"
|
"Nodes"
|
||||||
],
|
],
|
||||||
"summary": "Register a new remote node. Provide its URL, apiToken, and optional remark / allowPrivateAddress flag.",
|
"summary": "Register a new remote node. Provide its URL, write-only apiToken, and optional remark / allowPrivateAddress flag. Responses expose hasApiToken only.",
|
||||||
"operationId": "post_panel_api_nodes_add",
|
"operationId": "post_panel_api_nodes_add",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -7759,6 +8219,7 @@
|
|||||||
"port": 2053,
|
"port": 2053,
|
||||||
"basePath": "/",
|
"basePath": "/",
|
||||||
"apiToken": "abcdef...",
|
"apiToken": "abcdef...",
|
||||||
|
"clearApiToken": false,
|
||||||
"enable": true,
|
"enable": true,
|
||||||
"allowPrivateAddress": false
|
"allowPrivateAddress": false
|
||||||
}
|
}
|
||||||
@@ -7779,7 +8240,57 @@
|
|||||||
"msg": {
|
"msg": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"obj": {}
|
"obj": {
|
||||||
|
"$ref": "#/components/schemas/NodeView"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"success": true,
|
||||||
|
"obj": {
|
||||||
|
"activeCount": 20,
|
||||||
|
"address": "node.example.com",
|
||||||
|
"allowPrivateAddress": false,
|
||||||
|
"basePath": "/",
|
||||||
|
"clientCount": 25,
|
||||||
|
"configDirty": false,
|
||||||
|
"configDirtyAt": 0,
|
||||||
|
"cpuPct": 12.5,
|
||||||
|
"createdAt": 1700000000,
|
||||||
|
"depletedCount": 1,
|
||||||
|
"disabledCount": 2,
|
||||||
|
"enable": true,
|
||||||
|
"guid": "node-guid",
|
||||||
|
"hasApiToken": true,
|
||||||
|
"id": 1,
|
||||||
|
"inboundCount": 3,
|
||||||
|
"inboundSyncMode": "all",
|
||||||
|
"inboundTags": [
|
||||||
|
"in-443-tcp"
|
||||||
|
],
|
||||||
|
"lastError": "",
|
||||||
|
"lastHeartbeat": 1700000000,
|
||||||
|
"latencyMs": 42,
|
||||||
|
"memPct": 45.2,
|
||||||
|
"name": "edge-1",
|
||||||
|
"netDown": 1048576,
|
||||||
|
"netUp": 2097152,
|
||||||
|
"onlineCount": 5,
|
||||||
|
"outboundTag": "direct",
|
||||||
|
"panelVersion": "v3.x.x",
|
||||||
|
"parentGuid": "",
|
||||||
|
"pinnedCertSha256": "",
|
||||||
|
"port": 2053,
|
||||||
|
"remark": "Primary edge",
|
||||||
|
"scheme": "https",
|
||||||
|
"status": "online",
|
||||||
|
"tlsVerifyMode": "verify",
|
||||||
|
"transitive": false,
|
||||||
|
"updatedAt": 1700003600,
|
||||||
|
"uptimeSecs": 86400,
|
||||||
|
"xrayError": "",
|
||||||
|
"xrayState": "running",
|
||||||
|
"xrayVersion": "25.10.31"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7793,7 +8304,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Nodes"
|
"Nodes"
|
||||||
],
|
],
|
||||||
"summary": "Replace a node’s connection details. Same body shape as /add.",
|
"summary": "Replace a node’s connection details. apiToken is write-only: omit it or send an empty string to keep the stored token; set clearApiToken=true to clear it.",
|
||||||
"operationId": "post_panel_api_nodes_update_id",
|
"operationId": "post_panel_api_nodes_update_id",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -7820,7 +8331,8 @@
|
|||||||
"address": "node1.example.com",
|
"address": "node1.example.com",
|
||||||
"port": 2053,
|
"port": 2053,
|
||||||
"basePath": "/",
|
"basePath": "/",
|
||||||
"apiToken": "abcdef...",
|
"apiToken": "",
|
||||||
|
"clearApiToken": false,
|
||||||
"enable": true,
|
"enable": true,
|
||||||
"allowPrivateAddress": false
|
"allowPrivateAddress": false
|
||||||
}
|
}
|
||||||
@@ -9263,6 +9775,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/panel/api/setting/factoryDefaults": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Settings"
|
||||||
|
],
|
||||||
|
"summary": "Return the shipped (factory) default value per browser-safe setting key, so clients can tell a stored value apart from the default it would fall back to. Per-install material (secret, panelGuid, mTLS keys) and credential fields are never included.",
|
||||||
|
"operationId": "post_panel_api_setting_factoryDefaults",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/panel/api/setting/update": {
|
"/panel/api/setting/update": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -10563,7 +11105,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Subscription Server"
|
"Subscription Server"
|
||||||
],
|
],
|
||||||
"summary": "Return base64-encoded subscription links for all enabled clients matching the subscription ID. When the request has an Accept: text/html header or ?html=1, renders a styled info page instead. Default path: /sub/:subid.",
|
"summary": "Return base64-encoded subscription links for all enabled clients matching the subscription ID. When the request has an Accept: text/html header or ?html=1, renders a styled info page instead. With ?format=info, returns the page view-model as JSON (traffic, expiry, online status; no links) for live polling. Default path: /sub/:subid.",
|
||||||
"operationId": "get_subPath_subid",
|
"operationId": "get_subPath_subid",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -10575,6 +11117,15 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "format",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"description": "Set to \"info\" to get the subscription status view-model as JSON instead of the links.",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "subPath",
|
"name": "subPath",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -83,4 +87,6 @@ export default function ClientTrafficCell({
|
|||||||
</div>
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
export default ClientTrafficCell;
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ body.light .config-block .ant-tag.ant-tag-filled.ant-tag-gold {
|
|||||||
color: #874d00;
|
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;
|
||||||
|
|||||||
@@ -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,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>;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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: '' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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: '' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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": "",
|
||||||
@@ -74,6 +75,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"subPort": 1,
|
"subPort": 1,
|
||||||
"subProfileUrl": "",
|
"subProfileUrl": "",
|
||||||
"subRoutingRules": "",
|
"subRoutingRules": "",
|
||||||
|
"subShowIdentityOnAllLinks": false,
|
||||||
"subSupportUrl": "",
|
"subSupportUrl": "",
|
||||||
"subThemeDir": "",
|
"subThemeDir": "",
|
||||||
"subTitle": "",
|
"subTitle": "",
|
||||||
@@ -136,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": "",
|
||||||
@@ -184,6 +187,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"subPort": 1,
|
"subPort": 1,
|
||||||
"subProfileUrl": "",
|
"subProfileUrl": "",
|
||||||
"subRoutingRules": "",
|
"subRoutingRules": "",
|
||||||
|
"subShowIdentityOnAllLinks": false,
|
||||||
"subSupportUrl": "",
|
"subSupportUrl": "",
|
||||||
"subThemeDir": "",
|
"subThemeDir": "",
|
||||||
"subTitle": "",
|
"subTitle": "",
|
||||||
@@ -446,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": {
|
||||||
@@ -492,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,
|
||||||
@@ -533,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,
|
||||||
@@ -239,6 +244,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subRoutingRules": {
|
"subRoutingRules": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subShowIdentityOnAllLinks": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"subSupportUrl": {
|
"subSupportUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -361,6 +369,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"ldapUserAttr",
|
"ldapUserAttr",
|
||||||
"ldapUserFilter",
|
"ldapUserFilter",
|
||||||
"ldapVlessField",
|
"ldapVlessField",
|
||||||
|
"outboundDownThreshold",
|
||||||
"pageSize",
|
"pageSize",
|
||||||
"panelOutbound",
|
"panelOutbound",
|
||||||
"remarkTemplate",
|
"remarkTemplate",
|
||||||
@@ -409,6 +418,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subPort",
|
"subPort",
|
||||||
"subProfileUrl",
|
"subProfileUrl",
|
||||||
"subRoutingRules",
|
"subRoutingRules",
|
||||||
|
"subShowIdentityOnAllLinks",
|
||||||
"subSupportUrl",
|
"subSupportUrl",
|
||||||
"subThemeDir",
|
"subThemeDir",
|
||||||
"subTitle",
|
"subTitle",
|
||||||
@@ -544,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,
|
||||||
@@ -700,6 +715,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subRoutingRules": {
|
"subRoutingRules": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subShowIdentityOnAllLinks": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"subSupportUrl": {
|
"subSupportUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -829,6 +847,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"ldapUserAttr",
|
"ldapUserAttr",
|
||||||
"ldapUserFilter",
|
"ldapUserFilter",
|
||||||
"ldapVlessField",
|
"ldapVlessField",
|
||||||
|
"outboundDownThreshold",
|
||||||
"pageSize",
|
"pageSize",
|
||||||
"panelOutbound",
|
"panelOutbound",
|
||||||
"remarkTemplate",
|
"remarkTemplate",
|
||||||
@@ -877,6 +896,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subPort",
|
"subPort",
|
||||||
"subProfileUrl",
|
"subProfileUrl",
|
||||||
"subRoutingRules",
|
"subRoutingRules",
|
||||||
|
"subShowIdentityOnAllLinks",
|
||||||
"subSupportUrl",
|
"subSupportUrl",
|
||||||
"subThemeDir",
|
"subThemeDir",
|
||||||
"subTitle",
|
"subTitle",
|
||||||
@@ -1822,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",
|
||||||
@@ -1848,6 +1875,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"tag",
|
"tag",
|
||||||
"total",
|
"total",
|
||||||
"trafficReset",
|
"trafficReset",
|
||||||
|
"trafficResetDay",
|
||||||
"up"
|
"up"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
@@ -2020,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"
|
||||||
@@ -2194,7 +2218,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"activeCount",
|
"activeCount",
|
||||||
"address",
|
"address",
|
||||||
"allowPrivateAddress",
|
"allowPrivateAddress",
|
||||||
"apiToken",
|
|
||||||
"basePath",
|
"basePath",
|
||||||
"clientCount",
|
"clientCount",
|
||||||
"configDirty",
|
"configDirty",
|
||||||
@@ -2233,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;
|
||||||
@@ -80,6 +81,7 @@ export interface AllSetting {
|
|||||||
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;
|
||||||
@@ -143,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;
|
||||||
@@ -191,6 +194,7 @@ export interface AllSettingView {
|
|||||||
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;
|
||||||
@@ -424,6 +428,7 @@ export interface Inbound {
|
|||||||
tag: string;
|
tag: string;
|
||||||
total: number;
|
total: number;
|
||||||
trafficReset: string;
|
trafficReset: string;
|
||||||
|
trafficResetDay: number;
|
||||||
up: number;
|
up: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,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;
|
||||||
@@ -515,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(),
|
||||||
@@ -92,6 +93,7 @@ export const AllSettingSchema = z.object({
|
|||||||
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(),
|
||||||
@@ -156,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(),
|
||||||
@@ -204,6 +207,7 @@ export const AllSettingViewSchema = z.object({
|
|||||||
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(),
|
||||||
@@ -449,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>;
|
||||||
@@ -505,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(),
|
||||||
@@ -546,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.
|
||||||
@@ -92,9 +96,24 @@ const LIGHT_BUTTON_TOKENS = {
|
|||||||
colorPrimaryActive: '#073ea8',
|
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,
|
token: LIGHT_CONTRAST_TOKENS,
|
||||||
components: {
|
components: {
|
||||||
@@ -104,6 +123,7 @@ export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeCo
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
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: {
|
||||||
@@ -142,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));
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
type OutboundTrafficRow,
|
type OutboundTrafficRow,
|
||||||
} from '@/schemas/xray';
|
} from '@/schemas/xray';
|
||||||
|
|
||||||
const DIRTY_POLL_MS = 1000;
|
|
||||||
const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
|
const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
|
||||||
// One HTTP-mode batch request tests this many outbounds through a single
|
// One HTTP-mode batch request tests this many outbounds through a single
|
||||||
// shared temp xray instance; chunking keeps responses bounded (~30s worst
|
// shared temp xray instance; chunking keeps responses bounded (~30s worst
|
||||||
@@ -22,6 +21,10 @@ const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
|
|||||||
// results progressively.
|
// results progressively.
|
||||||
const HTTP_BATCH_CHUNK = 16;
|
const HTTP_BATCH_CHUNK = 16;
|
||||||
|
|
||||||
|
function normalizeOutboundTestUrl(url: string) {
|
||||||
|
return url || DEFAULT_TEST_URL;
|
||||||
|
}
|
||||||
|
|
||||||
export function isUdpOutbound(outbound: unknown): boolean {
|
export function isUdpOutbound(outbound: unknown): boolean {
|
||||||
const o = outbound as { protocol?: string; streamSettings?: { network?: string } } | null | undefined;
|
const o = outbound as { protocol?: string; streamSettings?: { network?: string } } | null | undefined;
|
||||||
const p = o?.protocol;
|
const p = o?.protocol;
|
||||||
@@ -125,10 +128,11 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [saveDisabled, setSaveDisabled] = useState(true);
|
|
||||||
const [xraySetting, setXraySettingState] = useState('');
|
const [xraySetting, setXraySettingState] = useState('');
|
||||||
const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null);
|
const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null);
|
||||||
const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
|
const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
|
||||||
|
const [savedXraySetting, setSavedXraySetting] = useState('');
|
||||||
|
const [savedOutboundTestUrl, setSavedOutboundTestUrl] = useState(DEFAULT_TEST_URL);
|
||||||
const [inboundTags, setInboundTags] = useState<string[]>([]);
|
const [inboundTags, setInboundTags] = useState<string[]>([]);
|
||||||
const [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
|
const [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
|
||||||
const [subscriptionOutbounds, setSubscriptionOutbounds] = useState<unknown[]>([]);
|
const [subscriptionOutbounds, setSubscriptionOutbounds] = useState<unknown[]>([]);
|
||||||
@@ -139,38 +143,40 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
const [subscriptionTestStates, setSubscriptionTestStates] = useState<Record<string, OutboundTestState>>({});
|
const [subscriptionTestStates, setSubscriptionTestStates] = useState<Record<string, OutboundTestState>>({});
|
||||||
const [testingAll, setTestingAll] = useState(false);
|
const [testingAll, setTestingAll] = useState(false);
|
||||||
|
|
||||||
const oldXraySettingRef = useRef('');
|
|
||||||
const oldOutboundTestUrlRef = useRef('');
|
|
||||||
const syncingRef = useRef(false);
|
const syncingRef = useRef(false);
|
||||||
const xraySettingRef = useRef('');
|
const xraySettingRef = useRef('');
|
||||||
const outboundTestUrlRef = useRef(outboundTestUrl);
|
const outboundTestUrlRef = useRef(outboundTestUrl);
|
||||||
|
const savedXraySettingRef = useRef(savedXraySetting);
|
||||||
|
const savedOutboundTestUrlRef = useRef(savedOutboundTestUrl);
|
||||||
const templateSettingsRef = useRef<XraySettingsValue | null>(null);
|
const templateSettingsRef = useRef<XraySettingsValue | null>(null);
|
||||||
const subscriptionOutboundsRef = useRef<unknown[]>([]);
|
const subscriptionOutboundsRef = useRef<unknown[]>([]);
|
||||||
|
|
||||||
xraySettingRef.current = xraySetting;
|
xraySettingRef.current = xraySetting;
|
||||||
outboundTestUrlRef.current = outboundTestUrl;
|
outboundTestUrlRef.current = outboundTestUrl;
|
||||||
|
savedXraySettingRef.current = savedXraySetting;
|
||||||
|
savedOutboundTestUrlRef.current = savedOutboundTestUrl;
|
||||||
templateSettingsRef.current = templateSettings;
|
templateSettingsRef.current = templateSettings;
|
||||||
subscriptionOutboundsRef.current = subscriptionOutbounds;
|
subscriptionOutboundsRef.current = subscriptionOutbounds;
|
||||||
|
|
||||||
// Seed local editor state from the config query. Runs on first fetch and
|
|
||||||
// every time the query refetches (e.g. after a successful save).
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!configQuery.data) return;
|
if (!configQuery.data) return;
|
||||||
const obj = configQuery.data;
|
const obj = configQuery.data;
|
||||||
const pretty = JSON.stringify(obj.xraySetting, null, 2);
|
const pretty = JSON.stringify(obj.xraySetting, null, 2);
|
||||||
syncingRef.current = true;
|
const nextUrl = normalizeOutboundTestUrl(obj.outboundTestUrl || '');
|
||||||
setXraySettingState(pretty);
|
|
||||||
setTemplateSettingsState(obj.xraySetting);
|
|
||||||
oldXraySettingRef.current = pretty;
|
|
||||||
syncingRef.current = false;
|
|
||||||
setInboundTags(obj.inboundTags || []);
|
setInboundTags(obj.inboundTags || []);
|
||||||
setClientReverseTags(obj.clientReverseTags || []);
|
setClientReverseTags(obj.clientReverseTags || []);
|
||||||
setSubscriptionOutbounds(obj.subscriptionOutbounds || []);
|
setSubscriptionOutbounds(obj.subscriptionOutbounds || []);
|
||||||
setSubscriptionOutboundTags(obj.subscriptionOutboundTags || []);
|
setSubscriptionOutboundTags(obj.subscriptionOutboundTags || []);
|
||||||
const nextUrl = obj.outboundTestUrl || DEFAULT_TEST_URL;
|
const isDirty = savedXraySettingRef.current !== xraySettingRef.current
|
||||||
|
|| savedOutboundTestUrlRef.current !== normalizeOutboundTestUrl(outboundTestUrlRef.current);
|
||||||
|
if (isDirty) return;
|
||||||
|
syncingRef.current = true;
|
||||||
|
setXraySettingState(pretty);
|
||||||
|
setTemplateSettingsState(obj.xraySetting);
|
||||||
|
setSavedXraySetting(pretty);
|
||||||
|
syncingRef.current = false;
|
||||||
setOutboundTestUrlState(nextUrl);
|
setOutboundTestUrlState(nextUrl);
|
||||||
oldOutboundTestUrlRef.current = nextUrl;
|
setSavedOutboundTestUrl(nextUrl);
|
||||||
setSaveDisabled(true);
|
|
||||||
}, [configQuery.data]);
|
}, [configQuery.data]);
|
||||||
|
|
||||||
const fetched = configQuery.data !== undefined || configQuery.isError;
|
const fetched = configQuery.data !== undefined || configQuery.isError;
|
||||||
@@ -220,7 +226,7 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
const saveMut = useMutation({
|
const saveMut = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const sentXraySetting = xraySettingRef.current;
|
const sentXraySetting = xraySettingRef.current;
|
||||||
const sentTestUrl = outboundTestUrlRef.current || DEFAULT_TEST_URL;
|
const sentTestUrl = normalizeOutboundTestUrl(outboundTestUrlRef.current);
|
||||||
const msg = await HttpUtil.post('/panel/api/xray/update', {
|
const msg = await HttpUtil.post('/panel/api/xray/update', {
|
||||||
xraySetting: sentXraySetting,
|
xraySetting: sentXraySetting,
|
||||||
outboundTestUrl: sentTestUrl,
|
outboundTestUrl: sentTestUrl,
|
||||||
@@ -229,9 +235,8 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
},
|
},
|
||||||
onSuccess: ({ msg, sentXraySetting, sentTestUrl }) => {
|
onSuccess: ({ msg, sentXraySetting, sentTestUrl }) => {
|
||||||
if (!msg?.success) return;
|
if (!msg?.success) return;
|
||||||
oldXraySettingRef.current = sentXraySetting;
|
setSavedXraySetting(sentXraySetting);
|
||||||
oldOutboundTestUrlRef.current = sentTestUrl;
|
setSavedOutboundTestUrl(sentTestUrl);
|
||||||
setSaveDisabled(true);
|
|
||||||
queryClient.invalidateQueries({ queryKey: keys.xray.config() });
|
queryClient.invalidateQueries({ queryKey: keys.xray.config() });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -425,14 +430,8 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
}
|
}
|
||||||
}, [testingAll, testOutbound, testSubscriptionOutbound, postOutboundTestBatch]);
|
}, [testingAll, testOutbound, testSubscriptionOutbound, postOutboundTestBatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
const saveDisabled = savedXraySetting === xraySetting
|
||||||
const timer = window.setInterval(() => {
|
&& savedOutboundTestUrl === normalizeOutboundTestUrl(outboundTestUrl);
|
||||||
const dirtyXray = oldXraySettingRef.current !== xraySettingRef.current;
|
|
||||||
const dirtyUrl = oldOutboundTestUrlRef.current !== outboundTestUrlRef.current;
|
|
||||||
setSaveDisabled(!(dirtyXray || dirtyUrl));
|
|
||||||
}, DIRTY_POLL_MS);
|
|
||||||
return () => window.clearInterval(timer);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const outboundsTraffic = useMemo(() => trafficQuery.data ?? [], [trafficQuery.data]);
|
const outboundsTraffic = useMemo(() => trafficQuery.data ?? [], [trafficQuery.data]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
.ant-sidebar {
|
||||||
|
flex: 0 0 var(--sider-rail, 72px);
|
||||||
|
width: var(--sider-rail, 72px);
|
||||||
|
position: relative;
|
||||||
|
z-index: 210;
|
||||||
|
}
|
||||||
|
|
||||||
.ant-sidebar > .ant-layout-sider {
|
.ant-sidebar > .ant-layout-sider {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -5,6 +12,16 @@
|
|||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ant-sidebar > .ant-layout-sider:not(.ant-layout-sider-collapsed) {
|
||||||
|
box-shadow: 0 0 32px rgba(0, 0, 0, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sider-nav .ant-menu-item .anticon,
|
||||||
|
.sider-nav .ant-menu-submenu-title .anticon,
|
||||||
|
.sider-utility .ant-menu-item .anticon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.sider-brand,
|
.sider-brand,
|
||||||
.drawer-brand {
|
.drawer-brand {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -18,16 +35,12 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 14px 16px 14px 24px;
|
height: 58px;
|
||||||
|
padding: 0 16px 0 24px;
|
||||||
border-bottom: 1px solid var(--ant-color-border-secondary);
|
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
.sider-brand-collapsed {
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 16px;
|
|
||||||
padding: 14px 4px;
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-block {
|
.brand-block {
|
||||||
@@ -37,10 +50,6 @@
|
|||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sider-brand-collapsed .brand-block {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-actions {
|
.brand-actions {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -246,11 +255,6 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sider-version.is-collapsed {
|
|
||||||
justify-content: center;
|
|
||||||
padding: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawer-footer {
|
.drawer-footer {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 8px 8px 12px;
|
padding: 8px 8px 12px;
|
||||||
@@ -261,8 +265,7 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-sidebar > .ant-layout-sider .ant-layout-sider-children,
|
.ant-sidebar > .ant-layout-sider .ant-layout-sider-children {
|
||||||
.ant-sidebar > .ant-layout-sider .ant-layout-sider-trigger {
|
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,6 +275,11 @@
|
|||||||
min-width: 0 !important;
|
min-width: 0 !important;
|
||||||
width: 0 !important;
|
width: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ant-sidebar {
|
||||||
|
flex: 0 0 0;
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark .ant-drawer-content,
|
body.dark .ant-drawer-content,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { ComponentType } from 'react';
|
import type { ComponentType, CSSProperties } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Drawer, Layout, Menu } from 'antd';
|
import { Drawer, Layout, Menu } from 'antd';
|
||||||
import type { MenuProps } from 'antd';
|
import type { MenuProps } from 'antd';
|
||||||
@@ -39,11 +39,14 @@ import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
|||||||
import { useAllSettings } from '@/api/queries/useAllSettings';
|
import { useAllSettings } from '@/api/queries/useAllSettings';
|
||||||
import './AppSidebar.css';
|
import './AppSidebar.css';
|
||||||
|
|
||||||
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
|
|
||||||
const DONATE_URL = 'https://donate.sanaei.dev/';
|
const DONATE_URL = 'https://donate.sanaei.dev/';
|
||||||
const DOCS_URL = 'https://docs.sanaei.dev/';
|
const DOCS_URL = 'https://docs.sanaei.dev/';
|
||||||
const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
|
const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
|
||||||
const LOGOUT_KEY = '__logout__';
|
const LOGOUT_KEY = '__logout__';
|
||||||
|
const RAIL_WIDTH = 72;
|
||||||
|
const railStyle = { '--sider-rail': `${RAIL_WIDTH}px` } as CSSProperties;
|
||||||
|
|
||||||
|
let hoveredAcrossRemounts = false;
|
||||||
|
|
||||||
type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'hosts' | 'logout' | 'apidocs' | 'outbound' | 'routing';
|
type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'hosts' | 'logout' | 'apidocs' | 'outbound' | 'routing';
|
||||||
|
|
||||||
@@ -62,14 +65,6 @@ const iconByName: Record<IconName, ComponentType> = {
|
|||||||
routing: SwapOutlined,
|
routing: SwapOutlined,
|
||||||
};
|
};
|
||||||
|
|
||||||
function readCollapsed(): boolean {
|
|
||||||
try {
|
|
||||||
return JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false');
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function DonateButton({ ariaLabel }: { ariaLabel: string }) {
|
function DonateButton({ ariaLabel }: { ariaLabel: string }) {
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
@@ -108,7 +103,7 @@ function VersionBadge({ version, collapsed }: { version: string; collapsed?: boo
|
|||||||
href={REPO_URL}
|
href={REPO_URL}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={`sider-version${collapsed ? ' is-collapsed' : ''}`}
|
className="sider-version"
|
||||||
aria-label={`GitHub ${label}`}
|
aria-label={`GitHub ${label}`}
|
||||||
title={label}
|
title={label}
|
||||||
>
|
>
|
||||||
@@ -148,8 +143,23 @@ export default function AppSidebar() {
|
|||||||
const { allSetting } = useAllSettings();
|
const { allSetting } = useAllSettings();
|
||||||
const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable);
|
const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable);
|
||||||
|
|
||||||
const [collapsed, setCollapsed] = useState<boolean>(() => readCollapsed());
|
const [hovered, setHovered] = useState(() => hoveredAcrossRemounts);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const railCollapsed = !hovered;
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const updateHovered = useCallback((value: boolean) => {
|
||||||
|
hoveredAcrossRemounts = value;
|
||||||
|
setHovered(value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
const el = rootRef.current;
|
||||||
|
if (el) updateHovered(el.matches(':hover'));
|
||||||
|
}, 150);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [updateHovered]);
|
||||||
|
|
||||||
const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light';
|
const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light';
|
||||||
const panelVersion = window.X_UI_CUR_VER || '';
|
const panelVersion = window.X_UI_CUR_VER || '';
|
||||||
@@ -218,7 +228,7 @@ export default function AppSidebar() {
|
|||||||
if (tab.key === '/xray') {
|
if (tab.key === '/xray') {
|
||||||
return { key: tab.key, icon: <Icon />, label: tab.title, children: xrayChildren };
|
return { key: tab.key, icon: <Icon />, label: tab.title, children: xrayChildren };
|
||||||
}
|
}
|
||||||
return { key: tab.key, icon: <Icon />, label: tab.title };
|
return { key: tab.key, icon: <Icon />, label: tab.title, title: '' };
|
||||||
}),
|
}),
|
||||||
[settingsChildren, xrayChildren]);
|
[settingsChildren, xrayChildren]);
|
||||||
|
|
||||||
@@ -235,13 +245,6 @@ export default function AppSidebar() {
|
|||||||
openLink(String(key));
|
openLink(String(key));
|
||||||
}, [openLink]);
|
}, [openLink]);
|
||||||
|
|
||||||
const onSiderCollapse = useCallback((isCollapsed: boolean, type: 'clickTrigger' | 'responsive') => {
|
|
||||||
if (type === 'clickTrigger') {
|
|
||||||
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(isCollapsed));
|
|
||||||
setCollapsed(isCollapsed);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const cycleTheme = useCallback((id: string) => {
|
const cycleTheme = useCallback((id: string) => {
|
||||||
pauseAnimationsUntilLeave(id);
|
pauseAnimationsUntilLeave(id);
|
||||||
if (!isDark) {
|
if (!isDark) {
|
||||||
@@ -256,20 +259,24 @@ export default function AppSidebar() {
|
|||||||
}, [isDark, isUltra, toggleTheme, toggleUltra]);
|
}, [isDark, isUltra, toggleTheme, toggleUltra]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ant-sidebar">
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
className="ant-sidebar"
|
||||||
|
style={railStyle}
|
||||||
|
onMouseEnter={() => updateHovered(true)}
|
||||||
|
onMouseLeave={() => updateHovered(false)}
|
||||||
|
>
|
||||||
<Layout.Sider
|
<Layout.Sider
|
||||||
theme={currentTheme}
|
theme={currentTheme}
|
||||||
width={220}
|
width={220}
|
||||||
collapsible
|
collapsedWidth={RAIL_WIDTH}
|
||||||
collapsed={collapsed}
|
collapsed={railCollapsed}
|
||||||
breakpoint="md"
|
|
||||||
onCollapse={onSiderCollapse}
|
|
||||||
>
|
>
|
||||||
<div className={`sider-brand${collapsed ? ' sider-brand-collapsed' : ''}`}>
|
<div className="sider-brand">
|
||||||
<div className="brand-block">
|
<div className="brand-block">
|
||||||
<span className="brand-text">{collapsed ? '3X' : '3X-UI'}</span>
|
<span className="brand-text">{railCollapsed ? '3X' : '3X-UI'}</span>
|
||||||
</div>
|
</div>
|
||||||
{!collapsed && (
|
{!railCollapsed && (
|
||||||
<div className="brand-actions">
|
<div className="brand-actions">
|
||||||
<DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
|
<DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
|
||||||
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
|
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
|
||||||
@@ -287,7 +294,7 @@ export default function AppSidebar() {
|
|||||||
theme={currentTheme}
|
theme={currentTheme}
|
||||||
mode="inline"
|
mode="inline"
|
||||||
selectedKeys={[selectedKey]}
|
selectedKeys={[selectedKey]}
|
||||||
openKeys={collapsed ? undefined : openKeys}
|
openKeys={railCollapsed ? undefined : openKeys}
|
||||||
onOpenChange={(keys) => setOpenKeys(keys as string[])}
|
onOpenChange={(keys) => setOpenKeys(keys as string[])}
|
||||||
className="sider-nav"
|
className="sider-nav"
|
||||||
items={toMenuItems(navItems)}
|
items={toMenuItems(navItems)}
|
||||||
@@ -302,7 +309,7 @@ export default function AppSidebar() {
|
|||||||
onClick={onMenuClick}
|
onClick={onMenuClick}
|
||||||
/>
|
/>
|
||||||
<div className="sider-footer">
|
<div className="sider-footer">
|
||||||
<VersionBadge version={panelVersion} collapsed={collapsed} />
|
<VersionBadge version={panelVersion} collapsed={railCollapsed} />
|
||||||
</div>
|
</div>
|
||||||
</Layout.Sider>
|
</Layout.Sider>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router';
|
||||||
|
|
||||||
import { useWebSocketBridge } from '@/api/websocketBridge';
|
import { useWebSocketBridge } from '@/api/websocketBridge';
|
||||||
import { usePageTitle } from '@/hooks/usePageTitle';
|
import { usePageTitle } from '@/hooks/usePageTitle';
|
||||||
|
|||||||
@@ -82,12 +82,43 @@ function defaultTcpMaskSettings(type: string): Record<string, unknown> {
|
|||||||
case 'header-custom':
|
case 'header-custom':
|
||||||
return { clients: [], servers: [] };
|
return { clients: [], servers: [] };
|
||||||
case 'xmc':
|
case 'xmc':
|
||||||
return { hostname: '', usernames: [], password: RandomUtil.randomLowerAndNum(16) };
|
return { hostname: '', profiles: [defaultXmcProfile()], password: RandomUtil.randomLowerAndNum(16) };
|
||||||
default:
|
default:
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function defaultXmcProfile(): Record<string, unknown> {
|
||||||
|
return { username: '', uuid: '', texturesValue: '', texturesSignature: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// xray-core #6487 replaced the xmc mask's `usernames` string list with
|
||||||
|
// `profiles` objects carrying a Mojang-signed session profile, and dropped the
|
||||||
|
// "default to Dream" fallback so at least one complete profile is now
|
||||||
|
// mandatory. The signature can only come from Mojang's session server, so a
|
||||||
|
// legacy username cannot be upgraded automatically — carry it into a profile
|
||||||
|
// stub instead, which keeps the operator's player names visible and leaves the
|
||||||
|
// per-field validators pointing at exactly what still has to be filled in.
|
||||||
|
export function migrateXmcSettings(settings: Record<string, unknown>): { next: Record<string, unknown>; changed: boolean } {
|
||||||
|
const out: Record<string, unknown> = { ...settings };
|
||||||
|
let changed = false;
|
||||||
|
if (!Array.isArray(out.profiles) && Array.isArray(out.usernames)) {
|
||||||
|
out.profiles = out.usernames
|
||||||
|
.filter((name): name is string => typeof name === 'string' && name.trim() !== '')
|
||||||
|
.map((name) => ({ ...defaultXmcProfile(), username: name }));
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if ('usernames' in out) {
|
||||||
|
delete out.usernames;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(out.profiles)) {
|
||||||
|
out.profiles = [];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
return { next: out, changed };
|
||||||
|
}
|
||||||
|
|
||||||
// xray-core #6334 replaced a fragment mask's single `length`/`delay` ranges
|
// xray-core #6334 replaced a fragment mask's single `length`/`delay` ranges
|
||||||
// with `lengths`/`delays` arrays (the singular keys remain in core only as a
|
// with `lengths`/`delays` arrays (the singular keys remain in core only as a
|
||||||
// fallback). Lift any legacy singular value into a one-element array so the
|
// fallback). Lift any legacy singular value into a one-element array so the
|
||||||
@@ -171,8 +202,8 @@ function defaultUdpHop(): Record<string, unknown> {
|
|||||||
export default function FinalMaskForm({ name, network, protocol, form, showAll = false }: FinalMaskFormProps) {
|
export default function FinalMaskForm({ name, network, protocol, form, showAll = false }: FinalMaskFormProps) {
|
||||||
const base = asPath(name);
|
const base = asPath(name);
|
||||||
|
|
||||||
// Migrate legacy single-range fragment masks to the per-segment arrays once
|
// Migrate legacy TCP mask shapes once on mount so configs saved before
|
||||||
// on mount so configs saved before #6334 render in the list UI.
|
// #6334 (fragment ranges) and #6487 (xmc profiles) render in the list UI.
|
||||||
const migratedRef = useRef(false);
|
const migratedRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (migratedRef.current) return;
|
if (migratedRef.current) return;
|
||||||
@@ -183,8 +214,12 @@ export default function FinalMaskForm({ name, network, protocol, form, showAll =
|
|||||||
const next = tcp.map((mask) => {
|
const next = tcp.map((mask) => {
|
||||||
if (!mask || typeof mask !== 'object') return mask;
|
if (!mask || typeof mask !== 'object') return mask;
|
||||||
const m = mask as Record<string, unknown>;
|
const m = mask as Record<string, unknown>;
|
||||||
if (m.type !== 'fragment' || !m.settings || typeof m.settings !== 'object') return mask;
|
if (m.type !== 'fragment' && m.type !== 'xmc') return mask;
|
||||||
const { next: migrated, changed } = migrateFragmentSettings(m.settings as Record<string, unknown>);
|
if (!m.settings || typeof m.settings !== 'object') return mask;
|
||||||
|
const settings = m.settings as Record<string, unknown>;
|
||||||
|
const { next: migrated, changed } = m.type === 'fragment'
|
||||||
|
? migrateFragmentSettings(settings)
|
||||||
|
: migrateXmcSettings(settings);
|
||||||
if (!changed) return mask;
|
if (!changed) return mask;
|
||||||
anyChanged = true;
|
anyChanged = true;
|
||||||
return { ...m, settings: migrated };
|
return { ...m, settings: migrated };
|
||||||
@@ -380,13 +415,7 @@ function TcpMaskItem({
|
|||||||
<Form.Item label="Hostname" name={[fieldName, 'settings', 'hostname']}>
|
<Form.Item label="Hostname" name={[fieldName, 'settings', 'hostname']}>
|
||||||
<Input placeholder="Server address mimicked in the handshake" />
|
<Input placeholder="Server address mimicked in the handshake" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<XmcProfilesList tcpFieldName={fieldName} />
|
||||||
label="Usernames"
|
|
||||||
name={[fieldName, 'settings', 'usernames']}
|
|
||||||
extra="Player names offered to probes; core defaults to Dream when empty."
|
|
||||||
>
|
|
||||||
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="Password" required>
|
<Form.Item label="Password" required>
|
||||||
<Space.Compact block>
|
<Space.Compact block>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
@@ -528,6 +557,92 @@ function getDeep(obj: unknown, path: (string | number)[]): unknown {
|
|||||||
return cur;
|
return cur;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mojang hands the profile UUID back undashed from the session server and
|
||||||
|
// dashed from most other endpoints; xray-core parses either, so accept both
|
||||||
|
// rather than forcing the operator to reformat what they pasted.
|
||||||
|
const XMC_UUID_PATTERN = /^(?:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{32})$/;
|
||||||
|
const XMC_USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
|
||||||
|
|
||||||
|
function validateXmcUsername(_rule: unknown, value: unknown): Promise<void> {
|
||||||
|
if (typeof value === 'string' && XMC_USERNAME_PATTERN.test(value)) return Promise.resolve();
|
||||||
|
return Promise.reject(new Error('3-16 characters, letters/digits/underscore only'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateXmcUuid(_rule: unknown, value: unknown): Promise<void> {
|
||||||
|
if (typeof value === 'string' && XMC_UUID_PATTERN.test(value.trim())) return Promise.resolve();
|
||||||
|
return Promise.reject(new Error('Enter the profile UUID (dashed or 32 hex characters)'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each mask needs at least one fully signed profile since xray-core #6487 —
|
||||||
|
// an empty or partial list makes the core reject the whole config, so the
|
||||||
|
// panel blocks the save here rather than letting the backend drop the mask.
|
||||||
|
function XmcProfilesList({ tcpFieldName }: { tcpFieldName: number }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Form.List name={[tcpFieldName, 'settings', 'profiles']}>
|
||||||
|
{(profiles, { add, remove }) => (
|
||||||
|
<>
|
||||||
|
<Form.Item
|
||||||
|
label="Profiles"
|
||||||
|
extra="Signed Minecraft session profiles; resolve the UUID by username, then fetch the profile with unsigned=false."
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
aria-label={t('add')}
|
||||||
|
onClick={() => add(defaultXmcProfile())}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
{profiles.map((profile, idx) => (
|
||||||
|
<div key={profile.key}>
|
||||||
|
<Divider style={{ margin: 0 }}>
|
||||||
|
Profile {idx + 1}
|
||||||
|
<DeleteOutlined
|
||||||
|
className="danger-icon"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={t('remove')}
|
||||||
|
onClick={() => remove(profile.name)}
|
||||||
|
onKeyDown={activateOnKey(() => remove(profile.name))}
|
||||||
|
/>
|
||||||
|
</Divider>
|
||||||
|
<Form.Item
|
||||||
|
label="Username"
|
||||||
|
name={[profile.name, 'username']}
|
||||||
|
rules={[{ validator: validateXmcUsername }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="Notch" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="UUID"
|
||||||
|
name={[profile.name, 'uuid']}
|
||||||
|
rules={[{ validator: validateXmcUuid }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="069a79f4-44e9-4726-a5be-fca90e38aaf5" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="Textures Value"
|
||||||
|
name={[profile.name, 'texturesValue']}
|
||||||
|
rules={[{ required: true, message: 'Textures value is required' }]}
|
||||||
|
>
|
||||||
|
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} placeholder="Base64 value from the session profile" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="Textures Signature"
|
||||||
|
name={[profile.name, 'texturesSignature']}
|
||||||
|
rules={[{ required: true, message: 'Textures signature is required' }]}
|
||||||
|
>
|
||||||
|
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} placeholder="Base64 signature from the session profile" />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function HeaderCustomGroups({
|
function HeaderCustomGroups({
|
||||||
tcpFieldName, form, absoluteSettingsPath,
|
tcpFieldName, form, absoluteSettingsPath,
|
||||||
}: {
|
}: {
|
||||||
@@ -1031,12 +1146,18 @@ function ItemEditor({
|
|||||||
onRemove?: () => void;
|
onRemove?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
/**
|
||||||
|
* Switching to `array` clears the packet instead of emptying it to `[]`:
|
||||||
|
* that branch is rand-driven, and xray-core counts even an empty array as a
|
||||||
|
* packet, rejecting an item that carries both a packet and a rand. That
|
||||||
|
* error fails the whole config, so one such item keeps every inbound offline.
|
||||||
|
*/
|
||||||
const onTypeChange = (v: string) => {
|
const onTypeChange = (v: string) => {
|
||||||
if (v === 'base64') {
|
if (v === 'base64') {
|
||||||
form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64());
|
form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64());
|
||||||
} else if (v === 'array') {
|
} else if (v === 'array') {
|
||||||
form.setFieldValue([...absoluteItemPath, 'rand'], delayMode === 'string' ? '1-8192' : 0);
|
form.setFieldValue([...absoluteItemPath, 'rand'], delayMode === 'string' ? '1-8192' : 0);
|
||||||
form.setFieldValue([...absoluteItemPath, 'packet'], []);
|
form.setFieldValue([...absoluteItemPath, 'packet'], undefined);
|
||||||
} else {
|
} else {
|
||||||
form.setFieldValue([...absoluteItemPath, 'packet'], '');
|
form.setFieldValue([...absoluteItemPath, 'packet'], '');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ export function createDefaultShadowsocksInboundSettings(
|
|||||||
// constructor — the field discriminates v1 vs v2 inside the same settings
|
// constructor — the field discriminates v1 vs v2 inside the same settings
|
||||||
// shape. Callers that explicitly want v1 pass `{ version: 1 }`.
|
// shape. Callers that explicitly want v1 pass `{ version: 1 }`.
|
||||||
export interface HysteriaInboundSeed {
|
export interface HysteriaInboundSeed {
|
||||||
version?: number;
|
version?: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDefaultHysteriaInboundSettings(
|
export function createDefaultHysteriaInboundSettings(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type { Sniffing } from '@/schemas/primitives';
|
|||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
|
import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
|
||||||
import { canEnableSniffing } from '@/lib/xray/protocol-capabilities';
|
import { canEnableSniffing } from '@/lib/xray/protocol-capabilities';
|
||||||
|
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
|
||||||
import { XHttpStreamSettingsSchema, XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
|
import { XHttpStreamSettingsSchema, XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
|
||||||
|
|
||||||
const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
|
const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
|
||||||
@@ -40,6 +41,7 @@ export interface RawInboundRow {
|
|||||||
enable?: boolean;
|
enable?: boolean;
|
||||||
expiryTime?: number;
|
expiryTime?: number;
|
||||||
trafficReset?: string;
|
trafficReset?: string;
|
||||||
|
trafficResetDay?: number;
|
||||||
lastTrafficResetTime?: number;
|
lastTrafficResetTime?: number;
|
||||||
nodeId?: number | null;
|
nodeId?: number | null;
|
||||||
shareAddrStrategy?: string;
|
shareAddrStrategy?: string;
|
||||||
@@ -59,6 +61,7 @@ export interface WireInboundPayload {
|
|||||||
enable: boolean;
|
enable: boolean;
|
||||||
expiryTime: number;
|
expiryTime: number;
|
||||||
trafficReset: TrafficReset;
|
trafficReset: TrafficReset;
|
||||||
|
trafficResetDay: number;
|
||||||
lastTrafficResetTime: number;
|
lastTrafficResetTime: number;
|
||||||
listen: string;
|
listen: string;
|
||||||
port: number;
|
port: number;
|
||||||
@@ -178,6 +181,13 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
|
|||||||
xhttp.xmux = { ...XMUX_DEFAULTS, ...(xmux as Record<string, unknown>) };
|
xhttp.xmux = { ...XMUX_DEFAULTS, ...(xmux as Record<string, unknown>) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const so = streamRecord.sockopt;
|
||||||
|
if (so && typeof so === 'object' && !Array.isArray(so)) {
|
||||||
|
const parsed = SockoptStreamSettingsSchema.safeParse(so);
|
||||||
|
if (parsed.success) {
|
||||||
|
streamRecord.sockopt = { ...(so as Record<string, unknown>), ...parsed.data };
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const sniffing = coerceJsonObject(row.sniffing) as unknown as Sniffing;
|
const sniffing = coerceJsonObject(row.sniffing) as unknown as Sniffing;
|
||||||
|
|
||||||
@@ -194,6 +204,7 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
|
|||||||
down: row.down ?? 0,
|
down: row.down ?? 0,
|
||||||
total: row.total ?? 0,
|
total: row.total ?? 0,
|
||||||
trafficReset: coerceTrafficReset(row.trafficReset),
|
trafficReset: coerceTrafficReset(row.trafficReset),
|
||||||
|
trafficResetDay: Math.min(31, Math.max(1, row.trafficResetDay ?? 1)),
|
||||||
lastTrafficResetTime: row.lastTrafficResetTime ?? 0,
|
lastTrafficResetTime: row.lastTrafficResetTime ?? 0,
|
||||||
nodeId: row.nodeId ?? null,
|
nodeId: row.nodeId ?? null,
|
||||||
shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy),
|
shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy),
|
||||||
@@ -336,6 +347,7 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
|
|||||||
enable: values.enable,
|
enable: values.enable,
|
||||||
expiryTime: values.expiryTime,
|
expiryTime: values.expiryTime,
|
||||||
trafficReset: values.trafficReset,
|
trafficReset: values.trafficReset,
|
||||||
|
trafficResetDay: values.trafficResetDay,
|
||||||
lastTrafficResetTime: values.lastTrafficResetTime,
|
lastTrafficResetTime: values.lastTrafficResetTime,
|
||||||
listen: values.listen,
|
listen: values.listen,
|
||||||
port: values.port,
|
port: values.port,
|
||||||
|
|||||||
@@ -42,8 +42,7 @@ function xhttpHostFallback(xhttp: XHttpStreamSettings | undefined): string {
|
|||||||
// Pull the bidirectional SplitHTTPConfig fields out of xhttp into a
|
// Pull the bidirectional SplitHTTPConfig fields out of xhttp into a
|
||||||
// compact extra payload. Server-only fields (noSSEHeader, scMaxBufferedPosts,
|
// compact extra payload. Server-only fields (noSSEHeader, scMaxBufferedPosts,
|
||||||
// scStreamUpServerSecs, serverMaxHeaderBytes) are excluded — the client
|
// scStreamUpServerSecs, serverMaxHeaderBytes) are excluded — the client
|
||||||
// reading the share link wouldn't honor them. Mirrors the legacy
|
// reading the share link wouldn't honor them.
|
||||||
// Inbound.buildXhttpExtra exactly so the shadow link snapshots line up.
|
|
||||||
function buildXhttpExtra(xhttp: XHttpStreamSettings | undefined): Record<string, unknown> | null {
|
function buildXhttpExtra(xhttp: XHttpStreamSettings | undefined): Record<string, unknown> | null {
|
||||||
if (!xhttp) return null;
|
if (!xhttp) return null;
|
||||||
const extra: Record<string, unknown> = {};
|
const extra: Record<string, unknown> = {};
|
||||||
@@ -85,6 +84,15 @@ function buildXhttpExtra(xhttp: XHttpStreamSettings | undefined): Record<string,
|
|||||||
const v = xhttp[k];
|
const v = xhttp[k];
|
||||||
if (typeof v === 'string' && v.length > 0 && v !== coreDefaults[k]) extra[k] = v;
|
if (typeof v === 'string' && v.length > 0 && v !== coreDefaults[k]) extra[k] = v;
|
||||||
}
|
}
|
||||||
|
// xray-core #6258 renamed these fields, but older clients still read the
|
||||||
|
// legacy names from share-link extra. Emit both names so one link works
|
||||||
|
// across old and new clients while the stored panel config stays canonical.
|
||||||
|
if (typeof extra.sessionIDPlacement === 'string') {
|
||||||
|
extra.sessionPlacement = extra.sessionIDPlacement;
|
||||||
|
}
|
||||||
|
if (typeof extra.sessionIDKey === 'string') {
|
||||||
|
extra.sessionKey = extra.sessionIDKey;
|
||||||
|
}
|
||||||
|
|
||||||
// Headers on the wire are a record; emit them as a map upstream's
|
// Headers on the wire are a record; emit them as a map upstream's
|
||||||
// SplitHTTPConfig.headers expects, dropping Host (already on the URL).
|
// SplitHTTPConfig.headers expects, dropping Host (already on the URL).
|
||||||
@@ -704,11 +712,12 @@ function hysteriaPinHex(pin: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hysteria share link: hysteria://<auth>@<host>:<port>?<query>#<remark>.
|
// Hysteria share link: hysteria2://<auth>@<host>:<port>?<query>#<remark>.
|
||||||
// The URL scheme is "hysteria2" when settings.version === 2 (hysteria v2
|
// The scheme is always hysteria2 — xray-core builds version 2 only, so the
|
||||||
// AKA hysteria2), "hysteria" otherwise. Salamander obfuscation pulls its
|
// settings schema pins it there and the subscription server emits the same
|
||||||
// password from finalmask.udp[type=salamander] when present; the broader
|
// scheme. Salamander obfuscation pulls its password from
|
||||||
// finalmask payload still rides under `fm` like the other links.
|
// finalmask.udp[type=salamander] when present; the broader finalmask payload
|
||||||
|
// still rides under `fm` like the other links.
|
||||||
//
|
//
|
||||||
// Note: legacy genHysteriaLink reads stream.tls.settings.allowInsecure,
|
// Note: legacy genHysteriaLink reads stream.tls.settings.allowInsecure,
|
||||||
// which isn't a field on TlsStreamSettings.Settings — the guard is always
|
// which isn't a field on TlsStreamSettings.Settings — the guard is always
|
||||||
@@ -727,8 +736,7 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string {
|
|||||||
const stream = inbound.streamSettings;
|
const stream = inbound.streamSettings;
|
||||||
if (!stream || stream.security !== 'tls') return '';
|
if (!stream || stream.security !== 'tls') return '';
|
||||||
|
|
||||||
const settings = inbound.settings;
|
const scheme = 'hysteria2';
|
||||||
const scheme = settings.version === 2 ? 'hysteria2' : 'hysteria';
|
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('security', 'tls');
|
params.set('security', 'tls');
|
||||||
@@ -822,8 +830,8 @@ export function genWireguardLink(input: GenWireguardLinkInput): string {
|
|||||||
? Wireguard.generateKeypair(settings.secretKey).publicKey
|
? Wireguard.generateKeypair(settings.secretKey).publicKey
|
||||||
: '';
|
: '';
|
||||||
if (pubKey.length > 0) url.searchParams.set('publickey', pubKey);
|
if (pubKey.length > 0) url.searchParams.set('publickey', pubKey);
|
||||||
if (peer.allowedIPs.length > 0 && peer.allowedIPs[0]) {
|
if (peer.allowedIPs.length > 0) {
|
||||||
url.searchParams.set('address', peer.allowedIPs[0]);
|
url.searchParams.set('address', peer.allowedIPs.join(','));
|
||||||
}
|
}
|
||||||
if (typeof settings.mtu === 'number' && settings.mtu > 0) {
|
if (typeof settings.mtu === 'number' && settings.mtu > 0) {
|
||||||
url.searchParams.set('mtu', String(settings.mtu));
|
url.searchParams.set('mtu', String(settings.mtu));
|
||||||
@@ -850,7 +858,7 @@ export function genWireguardConfig(input: GenWireguardLinkInput): string {
|
|||||||
|
|
||||||
let txt = `[Interface]\n`;
|
let txt = `[Interface]\n`;
|
||||||
txt += `PrivateKey = ${peer.privateKey ?? ''}\n`;
|
txt += `PrivateKey = ${peer.privateKey ?? ''}\n`;
|
||||||
txt += `Address = ${peer.allowedIPs[0] ?? ''}\n`;
|
txt += `Address = ${peer.allowedIPs.join(', ')}\n`;
|
||||||
txt += `DNS = ${settings.dns || '1.1.1.1, 1.0.0.1'}\n`;
|
txt += `DNS = ${settings.dns || '1.1.1.1, 1.0.0.1'}\n`;
|
||||||
if (typeof settings.mtu === 'number' && settings.mtu > 0) {
|
if (typeof settings.mtu === 'number' && settings.mtu > 0) {
|
||||||
txt += `MTU = ${settings.mtu}\n`;
|
txt += `MTU = ${settings.mtu}\n`;
|
||||||
|
|||||||
@@ -104,6 +104,63 @@ export function validateRealityTarget(target: string): string | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a REALITY client-version string the way xray-core's config loader
|
||||||
|
* does: one to three dot-separated numeric parts, each 0-255. Returns the
|
||||||
|
* parts padded to three entries, or undefined when the string is not a valid
|
||||||
|
* version.
|
||||||
|
*/
|
||||||
|
export function parseRealityClientVer(value: string): [number, number, number] | undefined {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
const parts = trimmed.split('.');
|
||||||
|
if (parts.length > 3) return undefined;
|
||||||
|
const nums: number[] = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!/^\d+$/.test(part)) return undefined;
|
||||||
|
const n = Number(part);
|
||||||
|
if (n > 255) return undefined;
|
||||||
|
nums.push(n);
|
||||||
|
}
|
||||||
|
while (nums.length < 3) nums.push(0);
|
||||||
|
return nums as [number, number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a REALITY client-version field; empty means "not set" and is
|
||||||
|
* valid. The value is saved exactly as typed and xray-core's part parser
|
||||||
|
* accepts no surrounding whitespace, so a value that differs from its
|
||||||
|
* trimmed form is rejected rather than silently passed to the wire.
|
||||||
|
*/
|
||||||
|
export function validateRealityClientVer(value: string): string | undefined {
|
||||||
|
if (!value) return undefined;
|
||||||
|
if (value !== value.trim() || !parseRealityClientVer(value)) {
|
||||||
|
return 'pages.inbounds.form.clientVerInvalid';
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the max client-version field: format first, then that a non-empty
|
||||||
|
* max is not below a non-empty min (an inverted range rejects every client).
|
||||||
|
* An empty or malformed min is left to the min field's own validation.
|
||||||
|
*/
|
||||||
|
export function validateRealityMaxClientVer(max: string, min: string): string | undefined {
|
||||||
|
const formatError = validateRealityClientVer(max);
|
||||||
|
if (formatError) return formatError;
|
||||||
|
const maxParts = parseRealityClientVer(max);
|
||||||
|
const minParts = parseRealityClientVer(min);
|
||||||
|
if (!maxParts || !minParts) return undefined;
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
if (maxParts[i] !== minParts[i]) {
|
||||||
|
return maxParts[i] < minParts[i]
|
||||||
|
? 'pages.inbounds.form.maxClientVerBelowMin'
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function liftLegacyXhttpSessionKeys(obj: Record<string, unknown>): void {
|
function liftLegacyXhttpSessionKeys(obj: Record<string, unknown>): void {
|
||||||
const lift = (legacy: string, renamed: string) => {
|
const lift = (legacy: string, renamed: string) => {
|
||||||
const v = obj[legacy];
|
const v = obj[legacy];
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { RouterProvider } from 'react-router-dom';
|
import { RouterProvider } from 'react-router/dom';
|
||||||
import { message } from 'antd';
|
import { message } from 'antd';
|
||||||
import 'antd/dist/reset.css';
|
import 'antd/dist/reset.css';
|
||||||
import '@/styles/utils.css';
|
import '@/styles/utils.css';
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export type DBInboundInit = Partial<{
|
|||||||
enable: boolean;
|
enable: boolean;
|
||||||
expiryTime: number;
|
expiryTime: number;
|
||||||
trafficReset: string;
|
trafficReset: string;
|
||||||
|
trafficResetDay: number;
|
||||||
lastTrafficResetTime: number;
|
lastTrafficResetTime: number;
|
||||||
listen: string;
|
listen: string;
|
||||||
port: number;
|
port: number;
|
||||||
@@ -76,6 +77,7 @@ export class DBInbound {
|
|||||||
enable: boolean;
|
enable: boolean;
|
||||||
expiryTime: number;
|
expiryTime: number;
|
||||||
trafficReset: string;
|
trafficReset: string;
|
||||||
|
trafficResetDay: number;
|
||||||
lastTrafficResetTime: number;
|
lastTrafficResetTime: number;
|
||||||
|
|
||||||
listen: string;
|
listen: string;
|
||||||
@@ -105,6 +107,7 @@ export class DBInbound {
|
|||||||
this.enable = true;
|
this.enable = true;
|
||||||
this.expiryTime = 0;
|
this.expiryTime = 0;
|
||||||
this.trafficReset = "never";
|
this.trafficReset = "never";
|
||||||
|
this.trafficResetDay = 1;
|
||||||
this.lastTrafficResetTime = 0;
|
this.lastTrafficResetTime = 0;
|
||||||
|
|
||||||
this.listen = "";
|
this.listen = "";
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export class AllSetting {
|
|||||||
expireDiff = 0;
|
expireDiff = 0;
|
||||||
trafficDiff = 0;
|
trafficDiff = 0;
|
||||||
remarkTemplate = '{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D';
|
remarkTemplate = '{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D';
|
||||||
|
subShowIdentityOnAllLinks = false;
|
||||||
datepicker: 'gregorian' | 'jalalian' = 'gregorian';
|
datepicker: 'gregorian' | 'jalalian' = 'gregorian';
|
||||||
tgBotEnable = false;
|
tgBotEnable = false;
|
||||||
tgBotToken = '';
|
tgBotToken = '';
|
||||||
@@ -90,7 +91,7 @@ export class AllSetting {
|
|||||||
ldapDefaultTotalGB = 0;
|
ldapDefaultTotalGB = 0;
|
||||||
ldapDefaultExpiryDays = 0;
|
ldapDefaultExpiryDays = 0;
|
||||||
ldapDefaultLimitIP = 0;
|
ldapDefaultLimitIP = 0;
|
||||||
tgEnabledEvents = '';
|
tgEnabledEvents = 'login.attempt,cpu.high';
|
||||||
smtpEnable = false;
|
smtpEnable = false;
|
||||||
smtpHost = '';
|
smtpHost = '';
|
||||||
smtpPort = 587;
|
smtpPort = 587;
|
||||||
@@ -100,9 +101,10 @@ export class AllSetting {
|
|||||||
smtpFromName = '';
|
smtpFromName = '';
|
||||||
smtpTo = '';
|
smtpTo = '';
|
||||||
smtpEncryptionType = 'starttls';
|
smtpEncryptionType = 'starttls';
|
||||||
smtpEnabledEvents = '';
|
smtpEnabledEvents = 'login.attempt,cpu.high';
|
||||||
smtpCpu = 80;
|
smtpCpu = 80;
|
||||||
smtpMemory = 80;
|
smtpMemory = 80;
|
||||||
|
outboundDownThreshold = 3;
|
||||||
hasTgBotToken = false;
|
hasTgBotToken = false;
|
||||||
hasTwoFactorToken = false;
|
hasTwoFactorToken = false;
|
||||||
hasLdapPassword = false;
|
hasLdapPassword = false;
|
||||||
@@ -120,6 +122,8 @@ export class AllSetting {
|
|||||||
}
|
}
|
||||||
const cpu = Math.round(Number(this.tgCpu));
|
const cpu = Math.round(Number(this.tgCpu));
|
||||||
this.tgCpu = Number.isFinite(cpu) ? Math.min(100, Math.max(0, cpu)) : 80;
|
this.tgCpu = Number.isFinite(cpu) ? Math.min(100, Math.max(0, cpu)) : 80;
|
||||||
|
const threshold = Math.round(Number(this.outboundDownThreshold));
|
||||||
|
this.outboundDownThreshold = Number.isFinite(threshold) ? Math.min(100, Math.max(1, threshold)) : 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
equals(other: AllSetting): boolean {
|
equals(other: AllSetting): boolean {
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { NumberFormatter } from '@/utils';
|
import { NumberFormatter } from '@/utils';
|
||||||
|
|
||||||
|
export const USAGE_WARN_PERCENT = 80;
|
||||||
|
export const USAGE_CRIT_PERCENT = 90;
|
||||||
|
export const USAGE_WARN_COLOR = '#faad14';
|
||||||
|
export const USAGE_CRIT_COLOR = '#ff4d4f';
|
||||||
|
|
||||||
export class CurTotal {
|
export class CurTotal {
|
||||||
current: number;
|
current: number;
|
||||||
total: number;
|
total: number;
|
||||||
@@ -16,9 +21,9 @@ export class CurTotal {
|
|||||||
|
|
||||||
get color(): string {
|
get color(): string {
|
||||||
const p = this.percent;
|
const p = this.percent;
|
||||||
if (p < 80) return '#1677ff';
|
if (p < USAGE_WARN_PERCENT) return '#1677ff';
|
||||||
if (p < 90) return '#faad14';
|
if (p < USAGE_CRIT_PERCENT) return USAGE_WARN_COLOR;
|
||||||
return '#ff4d4f';
|
return USAGE_CRIT_COLOR;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -254,6 +254,11 @@ export const sections: readonly Section[] = [
|
|||||||
description:
|
description:
|
||||||
'System status, log retrieval, certificate generators, Xray binary management, and backup/restore. All under /panel/api/server.',
|
'System status, log retrieval, certificate generators, Xray binary management, and backup/restore. All under /panel/api/server.',
|
||||||
endpoints: [
|
endpoints: [
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/openapi.json',
|
||||||
|
summary: 'Serve this API description as an OpenAPI 3 document — the same file that powers the API Docs page. Requires a session or Bearer token like the rest of /panel/api. Useful for generating clients or importing into API tooling.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/panel/api/server/status',
|
path: '/panel/api/server/status',
|
||||||
@@ -559,7 +564,7 @@ export const sections: readonly Section[] = [
|
|||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/panel/api/clients/list/paged',
|
path: '/panel/api/clients/list/paged',
|
||||||
summary: 'Filter, sort, and paginate clients on the server. Each item is a slim row (no uuid/password/auth/flow/security/reverse/tgId) so the clients page can ship 25-ish rows in a few KB instead of the full table. The response also includes a summary computed across the full DB row set so dashboard counters stay stable as the user paginates or filters. Page size capped at 200; fetch /get/:email to obtain the full per-client payload for an edit/info modal.',
|
summary: 'Filter, sort, and paginate clients on the server. Each item is a slim row (no uuid/password/auth/flow/security/reverse/tgId) so the clients page can ship 25-ish rows in a few KB instead of the full table. The response also includes a summary computed across the full DB row set so dashboard counters stay stable as the user paginates or filters: the *Count fields are exact, while the email arrays beside them stop at 200 entries so the payload does not grow with the panel. Page size capped at 200; fetch /get/:email to obtain the full per-client payload for an edit/info modal.',
|
||||||
params: [
|
params: [
|
||||||
{ name: 'page', in: 'query', type: 'number', desc: '1-indexed page number. Defaults to 1.' },
|
{ name: 'page', in: 'query', type: 'number', desc: '1-indexed page number. Defaults to 1.' },
|
||||||
{ name: 'pageSize', in: 'query', type: 'number', desc: 'Rows per page. Defaults to 25, capped at 200.' },
|
{ name: 'pageSize', in: 'query', type: 'number', desc: 'Rows per page. Defaults to 25, capped at 200.' },
|
||||||
@@ -570,7 +575,7 @@ export const sections: readonly Section[] = [
|
|||||||
{ name: 'order', in: 'query', type: 'string', desc: 'ascend or descend.' },
|
{ name: 'order', in: 'query', type: 'string', desc: 'ascend or descend.' },
|
||||||
],
|
],
|
||||||
response:
|
response:
|
||||||
'{\n "success": true,\n "obj": {\n "items": [\n {\n "email": "alice@example.com",\n "subId": "abcd1234",\n "enable": true,\n "totalGB": 53687091200,\n "expiryTime": 1735689600000,\n "limitIp": 0,\n "reset": 0,\n "inboundIds": [3, 5],\n "traffic": { "up": 1024, "down": 4096, "enable": true },\n "createdAt": 1735000000000,\n "updatedAt": 1735100000000\n }\n ],\n "total": 2000,\n "filtered": 47,\n "page": 1,\n "pageSize": 25,\n "summary": {\n "total": 2000,\n "active": 1850,\n "online": ["alice@example.com"],\n "depleted": [],\n "expiring": [],\n "deactive": []\n }\n }\n}',
|
'{\n "success": true,\n "obj": {\n "items": [\n {\n "email": "alice@example.com",\n "subId": "abcd1234",\n "enable": true,\n "totalGB": 53687091200,\n "expiryTime": 1735689600000,\n "limitIp": 0,\n "reset": 0,\n "inboundIds": [3, 5],\n "traffic": { "up": 1024, "down": 4096, "enable": true },\n "createdAt": 1735000000000,\n "updatedAt": 1735100000000\n }\n ],\n "total": 2000,\n "filtered": 47,\n "page": 1,\n "pageSize": 25,\n "summary": {\n "total": 2000,\n "active": 1850,\n "onlineCount": 1,\n "depletedCount": 0,\n "expiringCount": 0,\n "deactiveCount": 150,\n "online": ["alice@example.com"],\n "depleted": [],\n "expiring": [],\n "deactive": ["bob@example.com"]\n }\n }\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -582,6 +587,16 @@ export const sections: readonly Section[] = [
|
|||||||
response:
|
response:
|
||||||
'{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5],\n "externalLinks": [{ "kind": "link", "value": "vless://...", "remark": "DE" }]\n }\n}',
|
'{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5],\n "externalLinks": [{ "kind": "link", "value": "vless://...", "remark": "DE" }]\n }\n}',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/clients/get/tgId/:tgId',
|
||||||
|
summary: 'Fetch clients by Telegram user ID. Returns an array since multiple clients can share the same Telegram ID.',
|
||||||
|
params: [
|
||||||
|
{ name: 'tgId', in: 'path', type: 'integer', desc: 'Telegram user ID (numeric).' },
|
||||||
|
],
|
||||||
|
response:
|
||||||
|
'{\n "success": true,\n "obj": [\n {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5],\n "externalLinks": [],\n "usedTraffic": 1048576\n }\n ]\n}',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/api/clients/add',
|
path: '/panel/api/clients/add',
|
||||||
@@ -905,7 +920,7 @@ export const sections: readonly Section[] = [
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/panel/api/nodes/list',
|
path: '/panel/api/nodes/list',
|
||||||
summary: 'List every configured node with its connection details, health, and last heartbeat patch.',
|
summary: 'List every configured node with its connection details, health, and last heartbeat patch.',
|
||||||
responseSchema: 'Node',
|
responseSchema: 'NodeView',
|
||||||
responseSchemaArray: true,
|
responseSchemaArray: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -927,6 +942,7 @@ export const sections: readonly Section[] = [
|
|||||||
params: [
|
params: [
|
||||||
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
||||||
],
|
],
|
||||||
|
responseSchema: 'NodeView',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -940,18 +956,19 @@ export const sections: readonly Section[] = [
|
|||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/api/nodes/add',
|
path: '/panel/api/nodes/add',
|
||||||
summary: 'Register a new remote node. Provide its URL, apiToken, and optional remark / allowPrivateAddress flag.',
|
summary: 'Register a new remote node. Provide its URL, write-only apiToken, and optional remark / allowPrivateAddress flag. Responses expose hasApiToken only.',
|
||||||
body:
|
body:
|
||||||
'{\n "name": "de-fra-1",\n "remark": "",\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef...",\n "enable": true,\n "allowPrivateAddress": false\n}',
|
'{\n "name": "de-fra-1",\n "remark": "",\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef...",\n "clearApiToken": false,\n "enable": true,\n "allowPrivateAddress": false\n}',
|
||||||
|
responseSchema: 'NodeView',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/api/nodes/update/:id',
|
path: '/panel/api/nodes/update/:id',
|
||||||
summary: 'Replace a node\u2019s connection details. Same body shape as /add.',
|
summary: 'Replace a node\u2019s connection details. apiToken is write-only: omit it or send an empty string to keep the stored token; set clearApiToken=true to clear it.',
|
||||||
params: [
|
params: [
|
||||||
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
||||||
],
|
],
|
||||||
body: '{\n "name": "de-fra-1",\n "remark": "",\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef...",\n "enable": true,\n "allowPrivateAddress": false\n}',
|
body: '{\n "name": "de-fra-1",\n "remark": "",\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "",\n "clearApiToken": false,\n "enable": true,\n "allowPrivateAddress": false\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1152,6 +1169,11 @@ export const sections: readonly Section[] = [
|
|||||||
path: '/panel/api/setting/defaultSettings',
|
path: '/panel/api/setting/defaultSettings',
|
||||||
summary: 'Return the computed default settings based on the request host. Useful to preview what a fresh install would use.',
|
summary: 'Return the computed default settings based on the request host. Useful to preview what a fresh install would use.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/setting/factoryDefaults',
|
||||||
|
summary: 'Return the shipped (factory) default value per browser-safe setting key, so clients can tell a stored value apart from the default it would fall back to. Per-install material (secret, panelGuid, mTLS keys) and credential fields are never included.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/api/setting/update',
|
path: '/panel/api/setting/update',
|
||||||
@@ -1461,9 +1483,10 @@ export const sections: readonly Section[] = [
|
|||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/{subPath}:subid',
|
path: '/{subPath}:subid',
|
||||||
summary: 'Return base64-encoded subscription links for all enabled clients matching the subscription ID. When the request has an Accept: text/html header or ?html=1, renders a styled info page instead. Default path: /sub/:subid.',
|
summary: 'Return base64-encoded subscription links for all enabled clients matching the subscription ID. When the request has an Accept: text/html header or ?html=1, renders a styled info page instead. With ?format=info, returns the page view-model as JSON (traffic, expiry, online status; no links) for live polling. Default path: /sub/:subid.',
|
||||||
params: [
|
params: [
|
||||||
{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' },
|
{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' },
|
||||||
|
{ name: 'format', in: 'query', type: 'string', optional: true, desc: 'Set to "info" to get the subscription status view-model as JSON instead of the links.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export default function ClientBulkAddModal({
|
|||||||
}: ClientBulkAddModalProps) {
|
}: ClientBulkAddModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [messageApi, messageContextHolder] = message.useMessage();
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
const { bulkCreate } = useClients();
|
const { bulkCreate } = useClients({ list: false });
|
||||||
|
|
||||||
const methods = useForm<ClientBulkAddFormValues>({ defaultValues: EMPTY });
|
const methods = useForm<ClientBulkAddFormValues>({ defaultValues: EMPTY });
|
||||||
const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' });
|
const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' });
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ const CLIENT_IP_LOG_MODAL_Z_INDEX = CLIENT_FORM_MODAL_Z_INDEX + 1;
|
|||||||
interface ExternalLinkRow {
|
interface ExternalLinkRow {
|
||||||
kind: 'link' | 'subscription';
|
kind: 'link' | 'subscription';
|
||||||
value: string;
|
value: string;
|
||||||
|
remark: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiMsg<T = unknown> {
|
interface ApiMsg<T = unknown> {
|
||||||
@@ -138,6 +139,7 @@ function toExternalLinkRows(links: ExternalLink[] | undefined): ExternalLinkRow[
|
|||||||
return (links || []).map((l) => ({
|
return (links || []).map((l) => ({
|
||||||
kind: l.kind === 'subscription' ? 'subscription' : 'link',
|
kind: l.kind === 'subscription' ? 'subscription' : 'link',
|
||||||
value: l.value || '',
|
value: l.value || '',
|
||||||
|
remark: l.remark || '',
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +209,7 @@ export default function ClientFormModal({
|
|||||||
const limitIpNotice = getLimitIpNotice(fail2ban, t);
|
const limitIpNotice = getLimitIpNotice(fail2ban, t);
|
||||||
|
|
||||||
function addExternalLinkRow(kind: 'link' | 'subscription') {
|
function addExternalLinkRow(kind: 'link' | 'subscription') {
|
||||||
appendExternalLink({ kind, value: '' });
|
appendExternalLink({ kind, value: '', remark: '' });
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -371,10 +373,15 @@ export default function ClientFormModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showFlow && flow) {
|
// Only clear the flow once we actually have inbound options to judge
|
||||||
|
// capability from. While the options list is momentarily empty (e.g. the
|
||||||
|
// options query is (re)loading and `inbounds` falls back to `[]`), showFlow
|
||||||
|
// is a false negative, so clearing here would silently drop a valid
|
||||||
|
// xtls-rprx-vision flow the user picked for a Reality/TLS inbound.
|
||||||
|
if (inbounds.length > 0 && !showFlow && flow) {
|
||||||
methods.setValue('flow', '');
|
methods.setValue('flow', '');
|
||||||
}
|
}
|
||||||
}, [showFlow, flow, methods]);
|
}, [inbounds, showFlow, flow, methods]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showReverseTag && reverseTag) {
|
if (!showReverseTag && reverseTag) {
|
||||||
@@ -547,7 +554,7 @@ export default function ClientFormModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const externalLinks: ExternalLinkInput[] = values.externalLinks
|
const externalLinks: ExternalLinkInput[] = values.externalLinks
|
||||||
.map((r) => ({ kind: r.kind, value: r.value.trim(), remark: '' }))
|
.map((r) => ({ kind: r.kind, value: r.value.trim(), remark: (r.remark || '').trim() }))
|
||||||
.filter((r) => r.value !== '');
|
.filter((r) => r.value !== '');
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@@ -917,6 +924,13 @@ export default function ClientFormModal({
|
|||||||
placeholder="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
|
placeholder="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
<FormField name={`externalLinks.${index}.remark`} noStyle>
|
||||||
|
<Input
|
||||||
|
style={{ width: 140 }}
|
||||||
|
aria-label={t('remark')}
|
||||||
|
placeholder={t('remark')}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
<Tooltip title={t('delete')}>
|
<Tooltip title={t('delete')}>
|
||||||
<Button aria-label={t('delete')} danger icon={<DeleteOutlined />} onClick={() => removeExternalLink(index)} />
|
<Button aria-label={t('delete')} danger icon={<DeleteOutlined />} onClick={() => removeExternalLink(index)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Divider, Modal, Popover, Tag, Tooltip, message } from 'antd';
|
import { Button, Divider, Modal, Popover, Tag, Tooltip, message } from 'antd';
|
||||||
import { CopyOutlined, EyeOutlined, QrcodeOutlined, ReloadOutlined } from '@ant-design/icons';
|
import { CopyOutlined, DownloadOutlined, EyeOutlined, QrcodeOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { ClipboardManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
|
import { ClipboardManager, FileManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
|
||||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||||
import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
|
import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
|
||||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||||
@@ -64,6 +64,12 @@ const DEFAULT_SUB: SubSettings = {
|
|||||||
publicHost: '',
|
publicHost: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SUBSCRIPTION_DOWNLOAD_NAMES = {
|
||||||
|
standard: 'subscription-standard.txt',
|
||||||
|
json: 'subscription-json.json',
|
||||||
|
clash: 'subscription-clash.yaml',
|
||||||
|
} as const;
|
||||||
|
|
||||||
export default function ClientInfoModal({
|
export default function ClientInfoModal({
|
||||||
open,
|
open,
|
||||||
client,
|
client,
|
||||||
@@ -89,6 +95,7 @@ export default function ClientInfoModal({
|
|||||||
const [ipsLoading, setIpsLoading] = useState(false);
|
const [ipsLoading, setIpsLoading] = useState(false);
|
||||||
const [ipsClearing, setIpsClearing] = useState(false);
|
const [ipsClearing, setIpsClearing] = useState(false);
|
||||||
const [ipsModalOpen, setIpsModalOpen] = useState(false);
|
const [ipsModalOpen, setIpsModalOpen] = useState(false);
|
||||||
|
const [downloadingFormat, setDownloadingFormat] = useState<keyof typeof SUBSCRIPTION_DOWNLOAD_NAMES | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@@ -148,6 +155,21 @@ export default function ClientInfoModal({
|
|||||||
if (ok) messageApi.success(t('copied'));
|
if (ok) messageApi.success(t('copied'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function downloadSubscription(url: string, format: keyof typeof SUBSCRIPTION_DOWNLOAD_NAMES) {
|
||||||
|
if (!url || downloadingFormat) return;
|
||||||
|
setDownloadingFormat(format);
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) throw new Error('Subscription download failed');
|
||||||
|
const content = await response.text();
|
||||||
|
FileManager.downloadTextFile(content, SUBSCRIPTION_DOWNLOAD_NAMES[format]);
|
||||||
|
} catch (_) {
|
||||||
|
messageApi.error(t('somethingWentWrong'));
|
||||||
|
} finally {
|
||||||
|
setDownloadingFormat(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadIps() {
|
async function loadIps() {
|
||||||
if (!client?.email) return;
|
if (!client?.email) return;
|
||||||
setIpsLoading(true);
|
setIpsLoading(true);
|
||||||
@@ -383,6 +405,9 @@ export default function ClientInfoModal({
|
|||||||
<Tooltip title={t('copy')}>
|
<Tooltip title={t('copy')}>
|
||||||
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subLink)} />
|
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subLink)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip title={t('download')}>
|
||||||
|
<Button size="small" icon={<DownloadOutlined />} aria-label={t('download')} loading={downloadingFormat === 'standard'} disabled={downloadingFormat !== null} onClick={() => void downloadSubscription(subLink, 'standard')} />
|
||||||
|
</Tooltip>
|
||||||
<Popover
|
<Popover
|
||||||
trigger="click"
|
trigger="click"
|
||||||
placement="left"
|
placement="left"
|
||||||
@@ -411,6 +436,9 @@ export default function ClientInfoModal({
|
|||||||
<Tooltip title={t('copy')}>
|
<Tooltip title={t('copy')}>
|
||||||
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subJsonLink)} />
|
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subJsonLink)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip title={t('download')}>
|
||||||
|
<Button size="small" icon={<DownloadOutlined />} aria-label={t('download')} loading={downloadingFormat === 'json'} disabled={downloadingFormat !== null} onClick={() => void downloadSubscription(subJsonLink, 'json')} />
|
||||||
|
</Tooltip>
|
||||||
<Popover
|
<Popover
|
||||||
trigger="click"
|
trigger="click"
|
||||||
placement="left"
|
placement="left"
|
||||||
@@ -442,6 +470,9 @@ export default function ClientInfoModal({
|
|||||||
<Tooltip title={t('copy')}>
|
<Tooltip title={t('copy')}>
|
||||||
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subClashLink)} />
|
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subClashLink)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip title={t('download')}>
|
||||||
|
<Button size="small" icon={<DownloadOutlined />} aria-label={t('download')} loading={downloadingFormat === 'clash'} disabled={downloadingFormat !== null} onClick={() => void downloadSubscription(subClashLink, 'clash')} />
|
||||||
|
</Tooltip>
|
||||||
<Popover
|
<Popover
|
||||||
trigger="click"
|
trigger="click"
|
||||||
placement="left"
|
placement="left"
|
||||||
|
|||||||
@@ -16,6 +16,13 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-email-more {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 4px;
|
||||||
|
border-top: 1px solid var(--ant-color-border-secondary, rgba(128, 128, 128, 0.2));
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
.filter-bar {
|
.filter-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
|
import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
Result,
|
Result,
|
||||||
Row,
|
Row,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
|
||||||
Spin,
|
Spin,
|
||||||
Statistic,
|
Statistic,
|
||||||
Switch,
|
Switch,
|
||||||
@@ -80,12 +79,14 @@ const BulkAttachInboundsModal = lazy(() => import('./BulkAttachInboundsModal'));
|
|||||||
const BulkDetachInboundsModal = lazy(() => import('./BulkDetachInboundsModal'));
|
const BulkDetachInboundsModal = lazy(() => import('./BulkDetachInboundsModal'));
|
||||||
const TextModal = lazy(() => import('@/components/feedback/TextModal'));
|
const TextModal = lazy(() => import('@/components/feedback/TextModal'));
|
||||||
const PromptModal = lazy(() => import('@/components/feedback/PromptModal'));
|
const PromptModal = lazy(() => import('@/components/feedback/PromptModal'));
|
||||||
|
import { ClientInboundChips, ClientRowActions } from './RowCells';
|
||||||
import { emptyFilters, activeFilterCount } from './filters';
|
import { emptyFilters, activeFilterCount } from './filters';
|
||||||
import type { ClientFilters } from './filters';
|
import type { ClientFilters } from './filters';
|
||||||
import './ClientsPage.css';
|
import './ClientsPage.css';
|
||||||
|
|
||||||
const FILTER_STATE_KEY = 'clientsFilterState';
|
const FILTER_STATE_KEY = 'clientsFilterState';
|
||||||
const DISABLED_PAGE_SIZE = 200;
|
const DISABLED_PAGE_SIZE = 200;
|
||||||
|
const DEFAULT_TABLE_PAGE_SIZE = 25;
|
||||||
|
|
||||||
function UngroupIcon() {
|
function UngroupIcon() {
|
||||||
return (
|
return (
|
||||||
@@ -126,12 +127,29 @@ function UngroupIcon() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The server sends exact counters but caps the email arrays behind them, so a
|
||||||
|
// panel with thousands of depleted clients neither ships nor renders them all.
|
||||||
|
// The trailing chip reports what the popover left out.
|
||||||
|
function ClientEmailList({ emails, total }: { emails: string[]; total: number }) {
|
||||||
|
const hidden = total - emails.length;
|
||||||
|
return (
|
||||||
|
<div className="client-email-list">
|
||||||
|
{emails.map((e) => <div key={e}>{e}</div>)}
|
||||||
|
{hidden > 0 && <div className="client-email-more">+{hidden}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
|
type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
|
||||||
|
|
||||||
interface PersistedFilterState {
|
interface PersistedFilterState {
|
||||||
searchKey: string;
|
searchKey: string;
|
||||||
filters: ClientFilters;
|
filters: ClientFilters;
|
||||||
sort: string;
|
sort: string;
|
||||||
|
// The page size resolved on the previous visit. Without it the first list
|
||||||
|
// request has to wait for /setting/defaultSettings just to learn how many rows
|
||||||
|
// to ask for, which serialises two round trips on every load.
|
||||||
|
pageSize: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
|
const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
|
||||||
@@ -147,6 +165,9 @@ const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
|
|||||||
tunnel: 'orange',
|
tunnel: 'orange',
|
||||||
};
|
};
|
||||||
const INBOUND_CHIP_LIMIT = 1;
|
const INBOUND_CHIP_LIMIT = 1;
|
||||||
|
// A shared empty array keeps the memoised chip cell from seeing a fresh prop for
|
||||||
|
// every unattached client on every render.
|
||||||
|
const EMPTY_INBOUND_IDS: number[] = [];
|
||||||
|
|
||||||
function readFilterState(): PersistedFilterState {
|
function readFilterState(): PersistedFilterState {
|
||||||
try {
|
try {
|
||||||
@@ -164,9 +185,10 @@ function readFilterState(): PersistedFilterState {
|
|||||||
groups: Array.isArray(fromRaw.groups) ? fromRaw.groups : [],
|
groups: Array.isArray(fromRaw.groups) ? fromRaw.groups : [],
|
||||||
},
|
},
|
||||||
sort: typeof raw.sort === 'string' ? raw.sort : '',
|
sort: typeof raw.sort === 'string' ? raw.sort : '',
|
||||||
|
pageSize: typeof raw.pageSize === 'number' && raw.pageSize > 0 ? raw.pageSize : null,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { searchKey: '', filters: emptyFilters(), sort: '' };
|
return { searchKey: '', filters: emptyFilters(), sort: '', pageSize: null };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,11 +227,11 @@ export default function ClientsPage() {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
clients, total, filtered,
|
clients, total, filtered,
|
||||||
summary: serverSummary,
|
summary,
|
||||||
allGroups,
|
allGroups,
|
||||||
setQuery,
|
setQuery,
|
||||||
inbounds, onlines, loading, transitioning, fetched, fetchError, subSettings,
|
inbounds, onlines, transitioning, fetched, fetchError, subSettings,
|
||||||
tgBotEnable, expireDiff, trafficDiff, pageSize,
|
tgBotEnable, expireDiff, trafficDiff, pageSize, settingsReady,
|
||||||
create, update, remove, bulkDelete, bulkAdjust, bulkEnable, bulkDisable, bulkAddToGroup, bulkRemoveFromGroup, attach, setExternalLinks, bulkAttach, detach, bulkDetach,
|
create, update, remove, bulkDelete, bulkAdjust, bulkEnable, bulkDisable, bulkAddToGroup, bulkRemoveFromGroup, attach, setExternalLinks, bulkAttach, detach, bulkDetach,
|
||||||
resetTraffic, resetAllTraffics, delDepleted, delOrphans, exportClients, importClients, setEnable,
|
resetTraffic, resetAllTraffics, delDepleted, delOrphans, exportClients, importClients, setEnable,
|
||||||
clientSpeed,
|
clientSpeed,
|
||||||
@@ -265,14 +287,31 @@ export default function ClientsPage() {
|
|||||||
const [sortColumn, setSortColumn] = useState<string | null>(initialSort.column);
|
const [sortColumn, setSortColumn] = useState<string | null>(initialSort.column);
|
||||||
const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(initialSort.order);
|
const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(initialSort.order);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [tablePageSize, setTablePageSize] = useState(25);
|
// Derived, not mirrored into state by an effect: an effect lags one render
|
||||||
|
// behind the settings arriving, and that lag is what made the page fetch the
|
||||||
|
// list once with the placeholder size and again with the real one.
|
||||||
|
const [pageSizeChoice, setPageSizeChoice] = useState<number | null>(null);
|
||||||
|
const settingsPageSize = settingsReady ? (pageSize > 0 ? pageSize : DISABLED_PAGE_SIZE) : null;
|
||||||
|
// Last visit's resolved size stands in until the settings land, so the list
|
||||||
|
// request goes out with the page mount instead of queueing behind them. If the
|
||||||
|
// admin has since changed the setting the authoritative value replaces it and
|
||||||
|
// costs one refetch — only on the load that follows the change. Null means
|
||||||
|
// nothing is known yet, which is the one case worth waiting for.
|
||||||
|
const resolvedPageSize = pageSizeChoice ?? settingsPageSize ?? initial.pageSize;
|
||||||
|
const tablePageSize = resolvedPageSize ?? DEFAULT_TABLE_PAGE_SIZE;
|
||||||
// debouncedSearch lags behind the input so we don't spam the server on every
|
// debouncedSearch lags behind the input so we don't spam the server on every
|
||||||
// keystroke; the search box still feels instant locally.
|
// keystroke; the search box still feels instant locally.
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState(searchKey);
|
const [debouncedSearch, setDebouncedSearch] = useState(searchKey);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({ searchKey, filters, sort: sortValueFor(sortColumn, sortOrder) }));
|
localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
|
||||||
}, [searchKey, filters, sortColumn, sortOrder]);
|
searchKey,
|
||||||
|
filters,
|
||||||
|
sort: sortValueFor(sortColumn, sortOrder),
|
||||||
|
// Only ever persist a size we actually resolved, never the render fallback.
|
||||||
|
pageSize: resolvedPageSize,
|
||||||
|
}));
|
||||||
|
}, [searchKey, filters, sortColumn, sortOrder, resolvedPageSize]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handle = window.setTimeout(() => setDebouncedSearch(searchKey), 300);
|
const handle = window.setTimeout(() => setDebouncedSearch(searchKey), 300);
|
||||||
@@ -303,6 +342,10 @@ export default function ClientsPage() {
|
|||||||
}, [filters.nodeIds, filters.inboundIds, inbounds]);
|
}, [filters.nodeIds, filters.inboundIds, inbounds]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// With no remembered size and no settings yet, any query we build would be a
|
||||||
|
// guess, and issuing it costs a full server round trip that is thrown away as
|
||||||
|
// soon as the real size arrives.
|
||||||
|
if (resolvedPageSize === null) return;
|
||||||
setQuery({
|
setQuery({
|
||||||
page: currentPage,
|
page: currentPage,
|
||||||
pageSize: tablePageSize,
|
pageSize: tablePageSize,
|
||||||
@@ -321,13 +364,21 @@ export default function ClientsPage() {
|
|||||||
sort: sortColumn || undefined,
|
sort: sortColumn || undefined,
|
||||||
order: sortOrder || undefined,
|
order: sortOrder || undefined,
|
||||||
});
|
});
|
||||||
}, [setQuery, currentPage, tablePageSize, debouncedSearch, filters, effectiveInboundCsv, sortColumn, sortOrder]);
|
}, [setQuery, resolvedPageSize, currentPage, tablePageSize, debouncedSearch, filters, effectiveInboundCsv, sortColumn, sortOrder]);
|
||||||
|
|
||||||
const activeCount = activeFilterCount(filters);
|
const activeCount = activeFilterCount(filters);
|
||||||
|
|
||||||
useEffect(() => {
|
// Row handlers take an email and look the row up here at call time. Keying
|
||||||
setTablePageSize(pageSize > 0 ? pageSize : DISABLED_PAGE_SIZE);
|
// them on the record object instead would defeat the memoised cells: every
|
||||||
}, [pageSize]);
|
// traffic push replaces the row object of every client whose counters moved,
|
||||||
|
// so the memo would miss on exactly the rows that are busy. Reading through
|
||||||
|
// the ref also means a modal opened mid-poll shows current usage.
|
||||||
|
const rowsByEmail = useRef(new Map<string, ClientRecord>());
|
||||||
|
rowsByEmail.current = useMemo(() => {
|
||||||
|
const map = new Map<string, ClientRecord>();
|
||||||
|
for (const c of clients) map.set(c.email, c);
|
||||||
|
return map;
|
||||||
|
}, [clients]);
|
||||||
|
|
||||||
const onlineSet = useMemo(() => new Set(onlines || []), [onlines]);
|
const onlineSet = useMemo(() => new Set(onlines || []), [onlines]);
|
||||||
const inboundsById = useMemo(() => {
|
const inboundsById = useMemo(() => {
|
||||||
@@ -385,9 +436,6 @@ export default function ClientsPage() {
|
|||||||
// a rename.
|
// a rename.
|
||||||
const filteredClients = clients;
|
const filteredClients = clients;
|
||||||
|
|
||||||
// Server-computed counts that stay stable as the user paginates/filters.
|
|
||||||
const summary = serverSummary;
|
|
||||||
|
|
||||||
// Sort is server-side now; the page already arrives in the requested
|
// Sort is server-side now; the page already arrives in the requested
|
||||||
// order, so we just hand it through.
|
// order, so we just hand it through.
|
||||||
const sortedClients = filteredClients;
|
const sortedClients = filteredClients;
|
||||||
@@ -457,7 +505,9 @@ export default function ClientsPage() {
|
|||||||
setFormOpen(true);
|
setFormOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onEdit(row: ClientRecord) {
|
const onEdit = useCallback(async (email: string) => {
|
||||||
|
const row = rowsByEmail.current.get(email);
|
||||||
|
if (!row) return;
|
||||||
setFormMode('edit');
|
setFormMode('edit');
|
||||||
// Paged list omits per-client secrets to keep the row payload tiny;
|
// Paged list omits per-client secrets to keep the row payload tiny;
|
||||||
// edit needs them, so fetch the full record first.
|
// edit needs them, so fetch the full record first.
|
||||||
@@ -468,9 +518,11 @@ export default function ClientsPage() {
|
|||||||
setEditingAttachedIds([...ids]);
|
setEditingAttachedIds([...ids]);
|
||||||
setEditingExternalLinks(Array.isArray(full?.externalLinks) ? [...full.externalLinks] : []);
|
setEditingExternalLinks(Array.isArray(full?.externalLinks) ? [...full.externalLinks] : []);
|
||||||
setFormOpen(true);
|
setFormOpen(true);
|
||||||
}
|
}, [hydrate]);
|
||||||
|
|
||||||
function onDelete(row: ClientRecord) {
|
const onDelete = useCallback((email: string) => {
|
||||||
|
const row = rowsByEmail.current.get(email);
|
||||||
|
if (!row) return;
|
||||||
modal.confirm({
|
modal.confirm({
|
||||||
title: t('pages.clients.deleteConfirmTitle', { email: row.email }),
|
title: t('pages.clients.deleteConfirmTitle', { email: row.email }),
|
||||||
content: t('pages.clients.deleteConfirmContent'),
|
content: t('pages.clients.deleteConfirmContent'),
|
||||||
@@ -482,9 +534,10 @@ export default function ClientsPage() {
|
|||||||
if (msg?.success) messageApi.success(t('pages.clients.toasts.deleted'));
|
if (msg?.success) messageApi.success(t('pages.clients.toasts.deleted'));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}, [modal, t, remove, messageApi]);
|
||||||
|
|
||||||
function onResetTraffic(row: ClientRecord) {
|
const onResetTraffic = useCallback((email: string) => {
|
||||||
|
const row = rowsByEmail.current.get(email);
|
||||||
if (!row?.email) {
|
if (!row?.email) {
|
||||||
messageApi.warning(t('pages.clients.resetNotPossible'));
|
messageApi.warning(t('pages.clients.resetNotPossible'));
|
||||||
return;
|
return;
|
||||||
@@ -499,19 +552,33 @@ export default function ClientsPage() {
|
|||||||
if (msg?.success) messageApi.success(t('pages.clients.toasts.trafficReset'));
|
if (msg?.success) messageApi.success(t('pages.clients.toasts.trafficReset'));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}, [modal, t, resetTraffic, messageApi]);
|
||||||
|
|
||||||
async function onShowInfo(row: ClientRecord) {
|
const onShowInfo = useCallback(async (email: string) => {
|
||||||
|
const row = rowsByEmail.current.get(email);
|
||||||
|
if (!row) return;
|
||||||
const full = await hydrate(row.email);
|
const full = await hydrate(row.email);
|
||||||
setInfoClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
|
setInfoClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
|
||||||
setInfoOpen(true);
|
setInfoOpen(true);
|
||||||
}
|
}, [hydrate]);
|
||||||
|
|
||||||
async function onShowQr(row: ClientRecord) {
|
const onShowQr = useCallback(async (email: string) => {
|
||||||
|
const row = rowsByEmail.current.get(email);
|
||||||
|
if (!row) return;
|
||||||
const full = await hydrate(row.email);
|
const full = await hydrate(row.email);
|
||||||
setQrClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
|
setQrClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
|
||||||
setQrOpen(true);
|
setQrOpen(true);
|
||||||
}
|
}, [hydrate]);
|
||||||
|
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const onRefreshClick = useCallback(async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
try {
|
||||||
|
await refresh();
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
const openText = useCallback((opts: { title: string; content: string; fileName?: string }) => {
|
const openText = useCallback((opts: { title: string; content: string; fileName?: string }) => {
|
||||||
setTextTitle(opts.title);
|
setTextTitle(opts.title);
|
||||||
@@ -746,7 +813,7 @@ export default function ClientsPage() {
|
|||||||
|
|
||||||
const onTableChange: NonNullable<TableProps<ClientRecord>['onChange']> = (pag) => {
|
const onTableChange: NonNullable<TableProps<ClientRecord>['onChange']> = (pag) => {
|
||||||
if (pag?.current) setCurrentPage(pag.current);
|
if (pag?.current) setCurrentPage(pag.current);
|
||||||
if (pag?.pageSize) setTablePageSize(pag.pageSize);
|
if (pag?.pageSize) setPageSizeChoice(pag.pageSize);
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = useMemo<ColumnsType<ClientRecord>>(() => [
|
const columns = useMemo<ColumnsType<ClientRecord>>(() => [
|
||||||
@@ -755,23 +822,14 @@ export default function ClientsPage() {
|
|||||||
key: 'actions',
|
key: 'actions',
|
||||||
width: 200,
|
width: 200,
|
||||||
render: (_v, record) => (
|
render: (_v, record) => (
|
||||||
<Space size={4}>
|
<ClientRowActions
|
||||||
<Tooltip title={t('pages.clients.qrCode')}>
|
email={record.email}
|
||||||
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} onClick={() => onShowQr(record)} />
|
onShowQr={onShowQr}
|
||||||
</Tooltip>
|
onShowInfo={onShowInfo}
|
||||||
<Tooltip title={t('pages.clients.clientInfo')}>
|
onResetTraffic={onResetTraffic}
|
||||||
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<InfoCircleOutlined />} aria-label={t('pages.clients.clientInfo')} onClick={() => onShowInfo(record)} />
|
onEdit={onEdit}
|
||||||
</Tooltip>
|
onDelete={onDelete}
|
||||||
<Tooltip title={t('pages.inbounds.resetTraffic')}>
|
/>
|
||||||
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<RetweetOutlined />} aria-label={t('pages.inbounds.resetTraffic')} onClick={() => onResetTraffic(record)} />
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title={t('edit')}>
|
|
||||||
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<EditOutlined />} aria-label={t('edit')} onClick={() => onEdit(record)} />
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title={t('delete')}>
|
|
||||||
<Button size="small" type="text" danger style={{ fontSize: 16 }} icon={<DeleteOutlined />} aria-label={t('delete')} onClick={() => onDelete(record)} />
|
|
||||||
</Tooltip>
|
|
||||||
</Space>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -853,42 +911,13 @@ export default function ClientsPage() {
|
|||||||
key: 'inboundIds',
|
key: 'inboundIds',
|
||||||
width: 170,
|
width: 170,
|
||||||
render: (_v, record) => {
|
render: (_v, record) => {
|
||||||
const ids = record.inboundIds || [];
|
|
||||||
if (ids.length === 0) return <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>;
|
|
||||||
const visible = ids.slice(0, INBOUND_CHIP_LIMIT);
|
|
||||||
const overflow = ids.slice(INBOUND_CHIP_LIMIT);
|
|
||||||
const chip = (id: number, compact: boolean) => {
|
|
||||||
const ib = inboundsById[id];
|
|
||||||
const proto = (ib?.protocol || '').toLowerCase();
|
|
||||||
const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
|
|
||||||
const compactLabel = formatInboundLabel(ib?.tag, ib?.remark);
|
|
||||||
return (
|
|
||||||
<Tooltip key={id} title={inboundLabel(id)}>
|
|
||||||
<Tag color={color} style={{ margin: 2 }}>
|
|
||||||
{compact ? compactLabel : inboundLabel(id)}
|
|
||||||
</Tag>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ClientInboundChips
|
||||||
{visible.map((id) => chip(id, true))}
|
ids={record.inboundIds || EMPTY_INBOUND_IDS}
|
||||||
{overflow.length > 0 && (
|
inboundsById={inboundsById}
|
||||||
<Popover
|
protocolColors={INBOUND_PROTOCOL_COLORS}
|
||||||
trigger="click"
|
chipLimit={INBOUND_CHIP_LIMIT}
|
||||||
placement="bottomRight"
|
/>
|
||||||
content={
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxWidth: 280, maxHeight: 280, overflowY: 'auto' }}>
|
|
||||||
{overflow.map((id) => chip(id, false))}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Tag color="default" style={{ margin: 2, cursor: 'pointer' }}>
|
|
||||||
+{overflow.length}
|
|
||||||
</Tag>
|
|
||||||
</Popover>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -997,7 +1026,7 @@ export default function ClientsPage() {
|
|||||||
status="error"
|
status="error"
|
||||||
title={t('somethingWentWrong')}
|
title={t('somethingWentWrong')}
|
||||||
subTitle={fetchError}
|
subTitle={fetchError}
|
||||||
extra={<Button type="primary" loading={loading} onClick={refresh}>{t('refresh')}</Button>}
|
extra={<Button type="primary" loading={refreshing} onClick={onRefreshClick}>{t('refresh')}</Button>}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
|
<Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
|
||||||
@@ -1010,37 +1039,37 @@ export default function ClientsPage() {
|
|||||||
<Col xs={12} sm={8} md={4}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
<Popover
|
<Popover
|
||||||
title={t('online')}
|
title={t('online')}
|
||||||
open={summary.online.length ? undefined : false}
|
open={summary.onlineCount ? undefined : false}
|
||||||
content={<div className="client-email-list">{summary.online.map((e) => <div key={e}>{e}</div>)}</div>}
|
content={<ClientEmailList emails={summary.online} total={summary.onlineCount} />}
|
||||||
>
|
>
|
||||||
<Statistic title={t('online')} value={String(summary.online.length)} prefix={<span className="dot dot-blue" />} />
|
<Statistic title={t('online')} value={String(summary.onlineCount)} prefix={<span className="dot dot-blue" />} />
|
||||||
</Popover>
|
</Popover>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={8} md={4}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
<Popover
|
<Popover
|
||||||
title={t('depleted')}
|
title={t('depleted')}
|
||||||
open={summary.depleted.length ? undefined : false}
|
open={summary.depletedCount ? undefined : false}
|
||||||
content={<div className="client-email-list">{summary.depleted.map((e) => <div key={e}>{e}</div>)}</div>}
|
content={<ClientEmailList emails={summary.depleted} total={summary.depletedCount} />}
|
||||||
>
|
>
|
||||||
<Statistic title={t('depleted')} value={String(summary.depleted.length)} prefix={<span className="dot dot-red" />} />
|
<Statistic title={t('depleted')} value={String(summary.depletedCount)} prefix={<span className="dot dot-red" />} />
|
||||||
</Popover>
|
</Popover>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={8} md={4}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
<Popover
|
<Popover
|
||||||
title={t('depletingSoon')}
|
title={t('depletingSoon')}
|
||||||
open={summary.expiring.length ? undefined : false}
|
open={summary.expiringCount ? undefined : false}
|
||||||
content={<div className="client-email-list">{summary.expiring.map((e) => <div key={e}>{e}</div>)}</div>}
|
content={<ClientEmailList emails={summary.expiring} total={summary.expiringCount} />}
|
||||||
>
|
>
|
||||||
<Statistic title={t('depletingSoon')} value={String(summary.expiring.length)} prefix={<span className="dot dot-orange" />} />
|
<Statistic title={t('depletingSoon')} value={String(summary.expiringCount)} prefix={<span className="dot dot-orange" />} />
|
||||||
</Popover>
|
</Popover>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={8} md={4}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
<Popover
|
<Popover
|
||||||
title={t('disabled')}
|
title={t('disabled')}
|
||||||
open={summary.deactive.length ? undefined : false}
|
open={summary.deactiveCount ? undefined : false}
|
||||||
content={<div className="client-email-list">{summary.deactive.map((e) => <div key={e}>{e}</div>)}</div>}
|
content={<ClientEmailList emails={summary.deactive} total={summary.deactiveCount} />}
|
||||||
>
|
>
|
||||||
<Statistic title={t('disabled')} value={String(summary.deactive.length)} prefix={<span className="dot dot-gray" />} />
|
<Statistic title={t('disabled')} value={String(summary.deactiveCount)} prefix={<span className="dot dot-gray" />} />
|
||||||
</Popover>
|
</Popover>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={8} md={4}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
@@ -1367,7 +1396,7 @@ export default function ClientsPage() {
|
|||||||
showTotal={(n) => `${n}`}
|
showTotal={(n) => `${n}`}
|
||||||
onChange={(p, s) => {
|
onChange={(p, s) => {
|
||||||
setCurrentPage(p);
|
setCurrentPage(p);
|
||||||
if (s && s !== tablePageSize) setTablePageSize(s);
|
if (s && s !== tablePageSize) setPageSizeChoice(s);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1394,8 +1423,8 @@ export default function ClientsPage() {
|
|||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={t('pages.clients.clientInfo')}
|
aria-label={t('pages.clients.clientInfo')}
|
||||||
onClick={() => onShowInfo(row)}
|
onClick={() => onShowInfo(row.email)}
|
||||||
onKeyDown={activateOnKey(() => onShowInfo(row))}
|
onKeyDown={activateOnKey(() => onShowInfo(row.email))}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -1412,23 +1441,23 @@ export default function ClientsPage() {
|
|||||||
{
|
{
|
||||||
key: 'qr',
|
key: 'qr',
|
||||||
label: <><QrcodeOutlined /> {t('pages.clients.qrCode')}</>,
|
label: <><QrcodeOutlined /> {t('pages.clients.qrCode')}</>,
|
||||||
onClick: () => onShowQr(row),
|
onClick: () => onShowQr(row.email),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'reset',
|
key: 'reset',
|
||||||
label: <><RetweetOutlined /> {t('pages.inbounds.resetTraffic')}</>,
|
label: <><RetweetOutlined /> {t('pages.inbounds.resetTraffic')}</>,
|
||||||
onClick: () => onResetTraffic(row),
|
onClick: () => onResetTraffic(row.email),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'edit',
|
key: 'edit',
|
||||||
label: <><EditOutlined /> {t('edit')}</>,
|
label: <><EditOutlined /> {t('edit')}</>,
|
||||||
onClick: () => onEdit(row),
|
onClick: () => onEdit(row.email),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'delete',
|
key: 'delete',
|
||||||
danger: true,
|
danger: true,
|
||||||
label: <><DeleteOutlined /> {t('delete')}</>,
|
label: <><DeleteOutlined /> {t('delete')}</>,
|
||||||
onClick: () => onDelete(row),
|
onClick: () => onDelete(row.email),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, Popover, Space, Tag, Tooltip } from 'antd';
|
||||||
|
import {
|
||||||
|
DeleteOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
InfoCircleOutlined,
|
||||||
|
QrcodeOutlined,
|
||||||
|
RetweetOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||||
|
import type { InboundOption } from '@/hooks/useClients';
|
||||||
|
|
||||||
|
const ICON_BUTTON_STYLE = { fontSize: 16 } as const;
|
||||||
|
|
||||||
|
interface ClientRowActionsProps {
|
||||||
|
email: string;
|
||||||
|
onShowQr: (email: string) => void;
|
||||||
|
onShowInfo: (email: string) => void;
|
||||||
|
onResetTraffic: (email: string) => void;
|
||||||
|
onEdit: (email: string) => void;
|
||||||
|
onDelete: (email: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Five Tooltip-wrapped buttons per row, none of which depend on traffic. Left
|
||||||
|
// inline they re-ran rc-tooltip's alignment machinery for every visible row on
|
||||||
|
// every traffic push — 125 Tooltips on a 25-row page, five seconds apart.
|
||||||
|
// Keyed on the email rather than the row object, because a push replaces the row
|
||||||
|
// object of every client whose counters moved; the page resolves the live row.
|
||||||
|
export const ClientRowActions = memo(function ClientRowActions({
|
||||||
|
email,
|
||||||
|
onShowQr,
|
||||||
|
onShowInfo,
|
||||||
|
onResetTraffic,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: ClientRowActionsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Space size={4}>
|
||||||
|
<Tooltip title={t('pages.clients.qrCode')}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
style={ICON_BUTTON_STYLE}
|
||||||
|
icon={<QrcodeOutlined />}
|
||||||
|
aria-label={t('pages.clients.qrCode')}
|
||||||
|
onClick={() => onShowQr(email)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t('pages.clients.clientInfo')}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
style={ICON_BUTTON_STYLE}
|
||||||
|
icon={<InfoCircleOutlined />}
|
||||||
|
aria-label={t('pages.clients.clientInfo')}
|
||||||
|
onClick={() => onShowInfo(email)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t('pages.inbounds.resetTraffic')}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
style={ICON_BUTTON_STYLE}
|
||||||
|
icon={<RetweetOutlined />}
|
||||||
|
aria-label={t('pages.inbounds.resetTraffic')}
|
||||||
|
onClick={() => onResetTraffic(email)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t('edit')}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
style={ICON_BUTTON_STYLE}
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
aria-label={t('edit')}
|
||||||
|
onClick={() => onEdit(email)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t('delete')}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
style={ICON_BUTTON_STYLE}
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
aria-label={t('delete')}
|
||||||
|
onClick={() => onDelete(email)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const CHIP_STYLE = { margin: 2 } as const;
|
||||||
|
const OVERFLOW_CHIP_STYLE = { margin: 2, cursor: 'pointer' } as const;
|
||||||
|
const OVERFLOW_LIST_STYLE = {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column' as const,
|
||||||
|
gap: 4,
|
||||||
|
maxWidth: 280,
|
||||||
|
maxHeight: 280,
|
||||||
|
overflowY: 'auto' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ClientInboundChipsProps {
|
||||||
|
ids: number[];
|
||||||
|
inboundsById: Record<number, InboundOption>;
|
||||||
|
protocolColors: Record<string, string>;
|
||||||
|
chipLimit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachments never change on a traffic push either, so the same memoisation
|
||||||
|
// applies: one Tooltip per visible chip plus a Popover for the overflow.
|
||||||
|
export const ClientInboundChips = memo(function ClientInboundChips({
|
||||||
|
ids,
|
||||||
|
inboundsById,
|
||||||
|
protocolColors,
|
||||||
|
chipLimit,
|
||||||
|
}: ClientInboundChipsProps) {
|
||||||
|
if (ids.length === 0) return <span className="cell-empty">—</span>;
|
||||||
|
|
||||||
|
const label = (id: number) => {
|
||||||
|
const ib = inboundsById[id];
|
||||||
|
return formatInboundLabel(ib?.tag, ib?.remark);
|
||||||
|
};
|
||||||
|
const chip = (id: number) => {
|
||||||
|
const proto = (inboundsById[id]?.protocol || '').toLowerCase();
|
||||||
|
return (
|
||||||
|
<Tooltip key={id} title={label(id)}>
|
||||||
|
<Tag color={protocolColors[proto] ?? 'default'} style={CHIP_STYLE}>{label(id)}</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const visible = ids.slice(0, chipLimit);
|
||||||
|
const overflow = ids.slice(chipLimit);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{visible.map(chip)}
|
||||||
|
{overflow.length > 0 && (
|
||||||
|
<Popover
|
||||||
|
trigger="click"
|
||||||
|
placement="bottomRight"
|
||||||
|
content={<div style={OVERFLOW_LIST_STYLE}>{overflow.map(chip)}</div>}
|
||||||
|
>
|
||||||
|
<Tag color="default" style={OVERFLOW_CHIP_STYLE}>+{overflow.length}</Tag>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -93,7 +93,7 @@ export default function GroupsPage() {
|
|||||||
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { subSettings, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, bulkDelete } = useClients();
|
const { subSettings, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, bulkDelete } = useClients({ list: false });
|
||||||
|
|
||||||
const groupsQuery = useQuery({
|
const groupsQuery = useQuery({
|
||||||
queryKey: keys.clients.groups(),
|
queryKey: keys.clients.groups(),
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
isSS2022,
|
isSS2022,
|
||||||
} from '@/lib/xray/protocol-capabilities';
|
} from '@/lib/xray/protocol-capabilities';
|
||||||
import {
|
import {
|
||||||
|
InboundDbFieldsSchema,
|
||||||
InboundFormBaseSchema,
|
InboundFormBaseSchema,
|
||||||
InboundFormSchema,
|
InboundFormSchema,
|
||||||
type InboundFormValues,
|
type InboundFormValues,
|
||||||
@@ -255,6 +256,7 @@ export default function InboundFormModal({
|
|||||||
const wTunnelNetwork = useWatch({ control, name: 'settings.allowedNetwork' });
|
const wTunnelNetwork = useWatch({ control, name: 'settings.allowedNetwork' });
|
||||||
const wTotal = (useWatch({ control, name: 'total' }) as number | undefined) ?? 0;
|
const wTotal = (useWatch({ control, name: 'total' }) as number | undefined) ?? 0;
|
||||||
const wExpiry = (useWatch({ control, name: 'expiryTime' }) as number | undefined) ?? 0;
|
const wExpiry = (useWatch({ control, name: 'expiryTime' }) as number | undefined) ?? 0;
|
||||||
|
const trafficReset = useWatch({ control, name: 'trafficReset' }) ?? 'never';
|
||||||
const autoTagRef = useRef(true);
|
const autoTagRef = useRef(true);
|
||||||
const lastWrittenTagRef = useRef('');
|
const lastWrittenTagRef = useRef('');
|
||||||
const currentTagInput = (): InboundTagInput => ({
|
const currentTagInput = (): InboundTagInput => ({
|
||||||
@@ -619,6 +621,16 @@ export default function InboundFormModal({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
{trafficReset === 'monthly' && (
|
||||||
|
<FormField
|
||||||
|
name="trafficResetDay"
|
||||||
|
label={t('pages.inbounds.periodicTrafficResetDay')}
|
||||||
|
rules={{ validate: rhfZodValidate(InboundDbFieldsSchema.shape.trafficResetDay) }}
|
||||||
|
>
|
||||||
|
<InputNumber min={1} max={31} />
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={
|
label={
|
||||||
<Tooltip title={t('pages.inbounds.leaveBlankToNeverExpire')}>
|
<Tooltip title={t('pages.inbounds.leaveBlankToNeverExpire')}>
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useFormContext } from 'react-hook-form';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Alert, Button, Collapse, Descriptions, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
import { Alert, Button, Collapse, Descriptions, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
||||||
import { RadarChartOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
import { RadarChartOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { FormField } from '@/components/form/rhf';
|
import { FormField } from '@/components/form/rhf';
|
||||||
import { UTLS_FINGERPRINT } from '@/schemas/primitives';
|
import { UTLS_FINGERPRINT } from '@/schemas/primitives';
|
||||||
import { validateRealityTarget } from '@/lib/xray/stream-wire-normalize';
|
import {
|
||||||
|
validateRealityClientVer,
|
||||||
|
validateRealityMaxClientVer,
|
||||||
|
validateRealityTarget,
|
||||||
|
} from '@/lib/xray/stream-wire-normalize';
|
||||||
import type { RealityScanResult } from '@/generated/types';
|
import type { RealityScanResult } from '@/generated/types';
|
||||||
import RealityTargetScannerModal from './RealityTargetScannerModal';
|
import RealityTargetScannerModal from './RealityTargetScannerModal';
|
||||||
|
|
||||||
@@ -39,7 +44,14 @@ export default function RealityForm({
|
|||||||
clearMldsa65,
|
clearMldsa65,
|
||||||
}: RealityFormProps) {
|
}: RealityFormProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { getFieldState, trigger } = useFormContext();
|
||||||
const [scannerOpen, setScannerOpen] = useState(false);
|
const [scannerOpen, setScannerOpen] = useState(false);
|
||||||
|
const maxClientVerPath = 'streamSettings.realitySettings.maxClientVer';
|
||||||
|
const revalidateMaxClientVer = () => {
|
||||||
|
if (getFieldState(maxClientVerPath).error) {
|
||||||
|
void trigger(maxClientVerPath);
|
||||||
|
}
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
@@ -127,14 +139,31 @@ export default function RealityForm({
|
|||||||
<FormField
|
<FormField
|
||||||
name={['streamSettings', 'realitySettings', 'minClientVer']}
|
name={['streamSettings', 'realitySettings', 'minClientVer']}
|
||||||
label={t('pages.inbounds.form.minClientVer')}
|
label={t('pages.inbounds.form.minClientVer')}
|
||||||
|
tooltip={t('pages.inbounds.form.minClientVerHint')}
|
||||||
|
onAfterChange={revalidateMaxClientVer}
|
||||||
|
rules={{
|
||||||
|
validate: (value) => {
|
||||||
|
const errKey = validateRealityClientVer(typeof value === 'string' ? value : '');
|
||||||
|
return errKey ? errKey : true;
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Input placeholder="26.3.27" />
|
<Input placeholder="26.3.27" />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField
|
<FormField
|
||||||
name={['streamSettings', 'realitySettings', 'maxClientVer']}
|
name={['streamSettings', 'realitySettings', 'maxClientVer']}
|
||||||
label={t('pages.inbounds.form.maxClientVer')}
|
label={t('pages.inbounds.form.maxClientVer')}
|
||||||
|
tooltip={t('pages.inbounds.form.maxClientVerHint')}
|
||||||
|
rules={{
|
||||||
|
validate: (value, formValues) => {
|
||||||
|
const max = typeof value === 'string' ? value : '';
|
||||||
|
const min = formValues?.streamSettings?.realitySettings?.minClientVer;
|
||||||
|
const errKey = validateRealityMaxClientVer(max, typeof min === 'string' ? min : '');
|
||||||
|
return errKey ? errKey : true;
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Input placeholder="25.9.11" />
|
<Input placeholder="x.y.z" />
|
||||||
</FormField>
|
</FormField>
|
||||||
<Form.Item label={t('pages.inbounds.form.shortIds')}>
|
<Form.Item label={t('pages.inbounds.form.shortIds')}>
|
||||||
<Space.Compact block style={{ display: 'flex' }}>
|
<Space.Compact block style={{ display: 'flex' }}>
|
||||||
|
|||||||
@@ -265,11 +265,11 @@ export default function XhttpForm() {
|
|||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: 'Default (body)' },
|
{ value: '', label: 'Default (auto)' },
|
||||||
|
{ value: 'auto', label: 'auto' },
|
||||||
{ value: 'body', label: 'body' },
|
{ value: 'body', label: 'body' },
|
||||||
{ value: 'header', label: 'header' },
|
{ value: 'header', label: 'header' },
|
||||||
{ value: 'cookie', label: 'cookie' },
|
{ value: 'cookie', label: 'cookie' },
|
||||||
{ value: 'query', label: 'query' },
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|||||||
@@ -86,11 +86,12 @@ export function useSecurityActions({ methods, setSaving, messageApi, nodeId, set
|
|||||||
messageApi.warning(t('pages.inbounds.form.realityTargetRequired'));
|
messageApi.warning(t('pages.inbounds.form.realityTargetRequired'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const xver = Number(getValues('streamSettings.realitySettings.xver')) || 0;
|
||||||
setScanning(true);
|
setScanning(true);
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post<RealityScanResult>(
|
const msg = await HttpUtil.post<RealityScanResult>(
|
||||||
'/panel/api/server/scanRealityTarget',
|
'/panel/api/server/scanRealityTarget',
|
||||||
{ target },
|
{ target, xver },
|
||||||
{ silent: true },
|
{ silent: true },
|
||||||
);
|
);
|
||||||
if (!msg?.success || !msg.obj) {
|
if (!msg?.success || !msg.obj) {
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card, theme } from 'antd';
|
||||||
|
|
||||||
|
import { Sparkline } from '@/components/viz';
|
||||||
|
import type { Status } from '@/models/status';
|
||||||
|
|
||||||
|
interface ConnectionsCardProps {
|
||||||
|
status: Status;
|
||||||
|
tcp: number[];
|
||||||
|
udp: number[];
|
||||||
|
labels: string[];
|
||||||
|
isMobile: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConnectionsCard({ status, tcp, udp, labels, isMobile }: ConnectionsCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const accent = token.colorPrimary;
|
||||||
|
const udpColor = token.colorTextTertiary;
|
||||||
|
|
||||||
|
const referenceLines = useMemo(
|
||||||
|
() => [
|
||||||
|
{ y: status.udpCount, color: udpColor, dash: '2 4' },
|
||||||
|
{ y: status.tcpCount, color: accent, dash: '2 4' },
|
||||||
|
],
|
||||||
|
[status.tcpCount, status.udpCount, accent, udpColor],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card hoverable styles={{ body: { padding: 0 } }}>
|
||||||
|
<div className="ov-wide-head ov-wide-head-stack">
|
||||||
|
<div className="ov-kicker">{t('pages.index.connectionCount')}</div>
|
||||||
|
<div className="ov-conn-total">
|
||||||
|
<span className="ov-tile-number">{status.tcpCount + status.udpCount}</span>
|
||||||
|
<span className="ov-tile-unit">{t('pages.index.openSockets')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ov-conn-legend">
|
||||||
|
<div className="ov-legend-label">
|
||||||
|
<span className="ov-swatch" style={{ background: accent }} />
|
||||||
|
TCP
|
||||||
|
<span className="ov-legend-num">{status.tcpCount.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="ov-legend-label">
|
||||||
|
<span className="ov-swatch" style={{ background: udpColor }} />
|
||||||
|
UDP
|
||||||
|
<span className="ov-legend-num">{status.udpCount.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ov-wide-chart">
|
||||||
|
<Sparkline
|
||||||
|
data={tcp}
|
||||||
|
data2={udp}
|
||||||
|
labels={labels}
|
||||||
|
height={isMobile ? 120 : 170}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
fillOpacity={0.24}
|
||||||
|
showTooltip
|
||||||
|
showLegend={false}
|
||||||
|
valueMax={null}
|
||||||
|
stroke={accent}
|
||||||
|
stroke2={udpColor}
|
||||||
|
name1="TCP"
|
||||||
|
name2="UDP"
|
||||||
|
yFormatter={(v) => Math.round(v).toLocaleString()}
|
||||||
|
referenceLines={referenceLines}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,56 +1,470 @@
|
|||||||
|
/* Overview page — trend-first layout. Every colour comes from the AntD theme
|
||||||
|
tokens so light / dark / ultra-dark keep working and the sidebar is untouched.
|
||||||
|
Set --ov-accent once here if you want a fixed accent instead of the primary. */
|
||||||
|
|
||||||
|
.index-page {
|
||||||
|
--ov-accent: var(--ant-color-primary);
|
||||||
|
--ov-line: var(--ant-color-border);
|
||||||
|
--ov-label: var(--ant-color-text-secondary);
|
||||||
|
--ov-faint: var(--ant-color-text-tertiary);
|
||||||
|
--ov-gap: 12px;
|
||||||
|
--ov-pad: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.index-page .content-area {
|
.index-page .content-area {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
padding-top: 64px;
|
padding-top: 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.index-page {
|
||||||
|
--ov-gap: 8px;
|
||||||
|
--ov-pad: 14px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.index-page .action {
|
.ov-page {
|
||||||
cursor: pointer;
|
display: flex;
|
||||||
justify-content: center;
|
flex-direction: column;
|
||||||
max-width: 100%;
|
gap: var(--ov-gap);
|
||||||
flex-wrap: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.index-page .action > span:not(.anticon):not(.tg-icon) {
|
/* — action bar — */
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
.ov-bar {
|
||||||
text-overflow: ellipsis;
|
display: flex;
|
||||||
min-width: 0;
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.index-page .action-update {
|
.ov-state {
|
||||||
color: var(--ant-color-warning);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.index-page .action-update .anticon {
|
|
||||||
color: var(--ant-color-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.index-page .history-tag {
|
|
||||||
cursor: pointer;
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 8px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 1px solid var(--ov-line);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ant-color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-state-dot {
|
||||||
|
position: relative;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-state[data-state='running'] .ov-state-dot::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -1px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
animation: ovPulse 1.6s infinite ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ovPulse {
|
||||||
|
0% { transform: scale(0.9); opacity: 0.5; }
|
||||||
|
100% { transform: scale(2.4); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.ov-state[data-state='running'] .ov-state-dot::after {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-state-version,
|
||||||
|
.ov-panel-version {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--ov-label);
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-state-version:hover,
|
||||||
|
.ov-state-version:focus-visible,
|
||||||
|
.ov-panel-version:hover,
|
||||||
|
.ov-panel-version:focus-visible {
|
||||||
|
color: var(--ant-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-panel-version {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ov-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-update-tag {
|
||||||
|
cursor: pointer;
|
||||||
margin-inline-end: 0;
|
margin-inline-end: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.index-page .tg-icon {
|
.ov-error-detail {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-bar-actions {
|
||||||
|
margin-inline-start: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.ov-bar-actions {
|
||||||
|
margin-inline-start: 0;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-bar-sep {
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--ov-line);
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-health {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-health-mark {
|
||||||
|
width: 14px;
|
||||||
|
height: 1px;
|
||||||
|
background: currentColor;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-rule {
|
||||||
|
height: 1px;
|
||||||
|
border: 0;
|
||||||
|
margin: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
transparent,
|
||||||
|
var(--ov-line) 48px,
|
||||||
|
var(--ov-line) calc(100% - 48px),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — shared type — */
|
||||||
|
|
||||||
|
.ov-kicker {
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ov-label);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-kicker-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-sub {
|
||||||
|
font-size: 12.5px;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--ov-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — vitals tiles — */
|
||||||
|
|
||||||
|
.ov-vitals {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: var(--ov-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.ov-vitals { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.ov-vitals { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-tile {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-tile-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px var(--ov-pad) 0;
|
||||||
|
color: var(--ov-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-tile-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-tile-value {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 12px var(--ov-pad) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-tile-number {
|
||||||
|
font-size: 34px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--ant-color-text);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-tile-unit {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--ov-label);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-tile-detail {
|
||||||
|
padding: 5px var(--ov-pad) 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ov-label);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-tile-foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px var(--ov-pad) 0;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ov-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-tile-chart {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — throughput + connections — */
|
||||||
|
|
||||||
|
.ov-mid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
||||||
|
gap: var(--ov-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.ov-mid { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-wide-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
padding: var(--ov-pad) var(--ov-pad) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-wide-head-stack {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-wide-legend {
|
||||||
|
margin-inline-start: auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 22px;
|
||||||
|
text-align: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-legend-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ov-label);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-legend-num {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ant-color-text);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-conn-total {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-conn-legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px var(--ov-pad) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-conn-legend > div {
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-conn-legend .ov-legend-label {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-swatch {
|
||||||
|
width: 14px;
|
||||||
|
height: 2px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-wide-chart {
|
||||||
|
padding: 12px 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-wide-foot {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin: 12px var(--ov-pad) 0;
|
||||||
|
padding: 14px 0 var(--ov-pad);
|
||||||
|
border-top: 1px solid var(--ov-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-wide-foot > div {
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-foot-sep {
|
||||||
|
width: 1px;
|
||||||
|
background: var(--ov-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-foot-value {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--ant-color-text);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-foot-part {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
vertical-align: -2px;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.index-page .ip-toggle-icon {
|
@media (max-width: 560px) {
|
||||||
cursor: pointer;
|
.ov-wide-foot {
|
||||||
font-size: 16px;
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-wide-foot .ov-foot-sep {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.index-page .ip-hidden .ant-statistic-content-value {
|
/* — system strip — */
|
||||||
filter: blur(6px);
|
|
||||||
|
/* tracks follow SystemStrip's cell order:
|
||||||
|
uptime (xray | os) · panel (memory | threads) · ip addresses */
|
||||||
|
.ov-strip-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns:
|
||||||
|
minmax(max-content, 1.2fr) minmax(max-content, 1.2fr) minmax(0, 1.6fr);
|
||||||
|
gap: 16px;
|
||||||
|
padding: var(--ov-pad);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1439px) {
|
||||||
|
.ov-strip-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.ov-strip-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.ov-strip-grid { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1440px) {
|
||||||
|
.ov-strip-cell + .ov-strip-cell {
|
||||||
|
border-inline-start: 1px solid var(--ov-line);
|
||||||
|
padding-inline-start: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-strip-value {
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--ant-color-text);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-strip-split {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-strip-split-sep {
|
||||||
|
width: 1px;
|
||||||
|
background: var(--ov-line);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-strip-sub {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--ov-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-strip-sub + .ov-strip-value {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-ip {
|
||||||
|
margin-top: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
transition: filter 0.2s ease;
|
transition: filter 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.index-page .ip-visible .ant-statistic-content-value {
|
.ov-ip-v6 {
|
||||||
filter: none;
|
margin-top: 3px;
|
||||||
|
color: var(--ov-label);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — preserved from the previous overview — */
|
||||||
|
|
||||||
|
.index-page .ip-toggle-icon {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 15px;
|
||||||
|
margin-inline-start: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-page .ip-hidden {
|
||||||
|
filter: blur(6px);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,29 @@
|
|||||||
import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
|
import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, ConfigProvider, Layout, Modal, Result, Spin, message } from 'antd';
|
||||||
import {
|
import {
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Col,
|
|
||||||
ConfigProvider,
|
|
||||||
Layout,
|
|
||||||
message,
|
|
||||||
Modal,
|
|
||||||
Result,
|
|
||||||
Row,
|
|
||||||
Space,
|
|
||||||
Spin,
|
|
||||||
Statistic,
|
|
||||||
Tag,
|
|
||||||
Tooltip,
|
|
||||||
} from 'antd';
|
|
||||||
import {
|
|
||||||
BarsOutlined,
|
|
||||||
ControlOutlined,
|
|
||||||
CloudServerOutlined,
|
|
||||||
CloudDownloadOutlined,
|
|
||||||
CloudUploadOutlined,
|
|
||||||
ArrowUpOutlined,
|
|
||||||
ArrowDownOutlined,
|
|
||||||
AreaChartOutlined,
|
|
||||||
GlobalOutlined,
|
|
||||||
SwapOutlined,
|
|
||||||
EyeOutlined,
|
|
||||||
EyeInvisibleOutlined,
|
|
||||||
ThunderboltOutlined,
|
|
||||||
DesktopOutlined,
|
|
||||||
DatabaseOutlined,
|
|
||||||
ForkOutlined,
|
|
||||||
CopyOutlined,
|
CopyOutlined,
|
||||||
TelegramFilled,
|
CloudDownloadOutlined,
|
||||||
|
DashboardOutlined,
|
||||||
|
DatabaseOutlined,
|
||||||
|
HddOutlined,
|
||||||
|
SwapOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
import { HttpUtil, SizeFormatter, TimeFormatter, ClipboardManager, FileManager } from '@/utils';
|
import { HttpUtil, CPUFormatter, SizeFormatter, ClipboardManager, FileManager } from '@/utils';
|
||||||
import { formatPanelVersion } from '@/lib/panel-version';
|
import { USAGE_CRIT_COLOR, USAGE_CRIT_PERCENT, USAGE_WARN_COLOR, USAGE_WARN_PERCENT } from '@/models/status';
|
||||||
import { activateOnKey } from '@/utils/a11y';
|
|
||||||
import { useTheme } from '@/hooks/useTheme';
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
import { useStatusQuery } from '@/api/queries/useStatusQuery';
|
import { useStatusQuery } from '@/api/queries/useStatusQuery';
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import AppSidebar from '@/layouts/AppSidebar';
|
import AppSidebar from '@/layouts/AppSidebar';
|
||||||
import { LazyMount } from '@/components/utility';
|
import { LazyMount } from '@/components/utility';
|
||||||
import { setMessageInstance } from '@/utils/messageBus';
|
import { setMessageInstance } from '@/utils/messageBus';
|
||||||
import StatusCard from './StatusCard';
|
import OverviewActionBar from './OverviewActionBar';
|
||||||
import XrayStatusCard from './XrayStatusCard';
|
import VitalTile from './VitalTile';
|
||||||
|
import ThroughputCard from './ThroughputCard';
|
||||||
|
import ConnectionsCard from './ConnectionsCard';
|
||||||
|
import SystemStrip from './SystemStrip';
|
||||||
|
import { mean, peak, useOverviewHistory } from './useOverviewHistory';
|
||||||
import type { PanelUpdateInfo } from './PanelUpdateModal';
|
import type { PanelUpdateInfo } from './PanelUpdateModal';
|
||||||
const JsonEditor = lazy(() => import('@/components/form/JsonEditor'));
|
const JsonEditor = lazy(() => import('@/components/form/JsonEditor'));
|
||||||
const PanelUpdateModal = lazy(() => import('./PanelUpdateModal'));
|
const PanelUpdateModal = lazy(() => import('./PanelUpdateModal'));
|
||||||
@@ -90,6 +66,8 @@ export default function IndexPage() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [loadingTip, setLoadingTip] = useState(t('loading'));
|
const [loadingTip, setLoadingTip] = useState(t('loading'));
|
||||||
|
|
||||||
|
const history = useOverviewHistory(status, fetched && !fetchError);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
HttpUtil.post<{ accessLogEnable?: boolean; devChannelEnable?: boolean }>(
|
HttpUtil.post<{ accessLogEnable?: boolean; devChannelEnable?: boolean }>(
|
||||||
'/panel/api/setting/defaultSettings',
|
'/panel/api/setting/defaultSettings',
|
||||||
@@ -127,10 +105,6 @@ export default function IndexPage() {
|
|||||||
await refresh();
|
await refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
function openPanelVersion() {
|
|
||||||
setPanelUpdateOpen(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleChannelChange(dev: boolean) {
|
async function handleChannelChange(dev: boolean) {
|
||||||
const res = await HttpUtil.post('/panel/api/server/setUpdateChannel', { dev });
|
const res = await HttpUtil.post('/panel/api/server/setUpdateChannel', { dev });
|
||||||
if (!res?.success) return;
|
if (!res?.success) return;
|
||||||
@@ -139,10 +113,6 @@ export default function IndexPage() {
|
|||||||
if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
|
if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openTelegram() {
|
|
||||||
window.open('https://t.me/XrayUI', '_blank', 'noopener,noreferrer');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openConfig() {
|
async function openConfig() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -165,6 +135,23 @@ export default function IndexPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pageClass = `index-page ${isDark ? 'is-dark' : ''} ${isUltra ? 'is-ultra' : ''}`.trim();
|
const pageClass = `index-page ${isDark ? 'is-dark' : ''} ${isUltra ? 'is-ultra' : ''}`.trim();
|
||||||
|
const totalDisk = status.disk.total;
|
||||||
|
const freeDisk = Math.max(0, totalDisk - status.disk.current);
|
||||||
|
|
||||||
|
const health = useMemo(() => {
|
||||||
|
const items = [
|
||||||
|
{ name: t('pages.index.cpu'), value: status.cpu.percent },
|
||||||
|
{ name: t('pages.index.memory'), value: status.mem.percent },
|
||||||
|
{ name: t('pages.index.swap'), value: status.swap.percent },
|
||||||
|
{ name: t('pages.index.storage'), value: status.disk.percent },
|
||||||
|
];
|
||||||
|
const list = (xs: typeof items) => xs.map((i) => `${i.name} ${i.value.toFixed(0)}%`).join(', ');
|
||||||
|
const crit = items.filter((i) => i.value >= USAGE_CRIT_PERCENT);
|
||||||
|
if (crit.length) return { text: t('pages.index.healthCritical', { list: list(crit) }), color: USAGE_CRIT_COLOR };
|
||||||
|
const warm = items.filter((i) => i.value >= USAGE_WARN_PERCENT);
|
||||||
|
if (warm.length) return { text: t('pages.index.healthWarm', { list: list(warm) }), color: USAGE_WARN_COLOR };
|
||||||
|
return null;
|
||||||
|
}, [status, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigProvider theme={antdThemeConfig}>
|
<ConfigProvider theme={antdThemeConfig}>
|
||||||
@@ -190,277 +177,105 @@ export default function IndexPage() {
|
|||||||
extra={<Button type="primary" onClick={refresh}>{t('refresh')}</Button>}
|
extra={<Button type="primary" onClick={refresh}>{t('refresh')}</Button>}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Row gutter={[isMobile ? 8 : 16, 12]}>
|
<div className="ov-page">
|
||||||
<Col span={24}>
|
<OverviewActionBar
|
||||||
<StatusCard status={status} isMobile={isMobile} />
|
status={status}
|
||||||
</Col>
|
isMobile={isMobile}
|
||||||
|
accessLogEnable={accessLogEnable}
|
||||||
|
panelVersion={displayVersion}
|
||||||
|
latestVersion={panelUpdateInfo.latestVersion}
|
||||||
|
updateAvailable={panelUpdateInfo.updateAvailable}
|
||||||
|
onStopXray={stopXray}
|
||||||
|
onRestartXray={restartXray}
|
||||||
|
onOpenLogs={() => setLogsOpen(true)}
|
||||||
|
onOpenXrayLogs={() => setXrayLogsOpen(true)}
|
||||||
|
onOpenConfig={openConfig}
|
||||||
|
onOpenBackup={() => setBackupOpen(true)}
|
||||||
|
onOpenSystemHistory={() => setSysHistoryOpen(true)}
|
||||||
|
onOpenXrayMetrics={() => setXrayMetricsOpen(true)}
|
||||||
|
onOpenPanelUpdate={() => setPanelUpdateOpen(true)}
|
||||||
|
onOpenVersionSwitch={() => setVersionOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
<Col xs={24} lg={12}>
|
{health && (
|
||||||
<XrayStatusCard
|
<div className="ov-health" style={{ color: health.color }}>
|
||||||
status={status}
|
<span className="ov-health-mark" />
|
||||||
|
{health.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<hr className="ov-rule" />
|
||||||
|
|
||||||
|
<div className="ov-vitals">
|
||||||
|
<VitalTile
|
||||||
|
icon={<DashboardOutlined />}
|
||||||
|
label={t('pages.index.cpu')}
|
||||||
|
percent={status.cpu.percent}
|
||||||
|
statusColor={status.cpu.color}
|
||||||
|
detail={`${CPUFormatter.cpuCoreFormat(status.cpuCores)} / ${status.logicalPro}T · ${CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz)}`}
|
||||||
|
footLeft={`${t('pages.index.avg')} ${mean(history.series.cpu).toFixed(0)}%`}
|
||||||
|
footRight={`${t('pages.index.peak')} ${peak(history.series.cpu).toFixed(0)}%`}
|
||||||
|
data={history.series.cpu}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
accessLogEnable={accessLogEnable}
|
|
||||||
onStopXray={stopXray}
|
|
||||||
onRestartXray={restartXray}
|
|
||||||
onOpenXrayLogs={() => setXrayLogsOpen(true)}
|
|
||||||
onOpenLogs={() => setLogsOpen(true)}
|
|
||||||
onOpenVersionSwitch={() => setVersionOpen(true)}
|
|
||||||
/>
|
/>
|
||||||
</Col>
|
<VitalTile
|
||||||
|
icon={<DatabaseOutlined />}
|
||||||
<Col xs={24} lg={12}>
|
label={t('pages.index.memory')}
|
||||||
<Card
|
percent={status.mem.percent}
|
||||||
title={t('menu.link')}
|
statusColor={status.mem.color}
|
||||||
hoverable
|
detail={`${SizeFormatter.sizeFormat(status.mem.current)} / ${SizeFormatter.sizeFormat(status.mem.total)}`}
|
||||||
actions={[
|
footLeft={`${t('pages.index.avg')} ${mean(history.series.mem).toFixed(0)}%`}
|
||||||
<Space className="action" key="logs" role="button" tabIndex={0} aria-label={t('pages.index.logs')} onClick={() => setLogsOpen(true)} onKeyDown={activateOnKey(() => setLogsOpen(true))}>
|
footRight={`${t('pages.index.peak')} ${peak(history.series.mem).toFixed(0)}%`}
|
||||||
<BarsOutlined />
|
data={history.series.mem}
|
||||||
{!isMobile && <span>{t('pages.index.logs')}</span>}
|
isMobile={isMobile}
|
||||||
</Space>,
|
|
||||||
<Space className="action" key="config" role="button" tabIndex={0} aria-label={t('pages.index.config')} onClick={openConfig} onKeyDown={activateOnKey(openConfig)}>
|
|
||||||
<ControlOutlined />
|
|
||||||
{!isMobile && <span>{t('pages.index.config')}</span>}
|
|
||||||
</Space>,
|
|
||||||
<Space className="action" key="backup" role="button" tabIndex={0} aria-label={t('pages.index.backupTitle')} onClick={() => setBackupOpen(true)} onKeyDown={activateOnKey(() => setBackupOpen(true))}>
|
|
||||||
<CloudServerOutlined />
|
|
||||||
{!isMobile && <span>{t('pages.index.backupTitle')}</span>}
|
|
||||||
</Space>,
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</Col>
|
<VitalTile
|
||||||
|
icon={<SwapOutlined />}
|
||||||
<Col xs={24} lg={12}>
|
label={t('pages.index.swap')}
|
||||||
<Card
|
percent={status.swap.percent}
|
||||||
title={
|
statusColor={status.swap.color}
|
||||||
<Space>
|
detail={`${SizeFormatter.sizeFormat(status.swap.current)} / ${SizeFormatter.sizeFormat(status.swap.total)}`}
|
||||||
<span>3X-UI</span>
|
footLeft={`${t('pages.index.avg')} ${mean(history.series.swap).toFixed(1)}%`}
|
||||||
{isMobile && displayVersion && (
|
footRight={`${t('pages.index.peak')} ${peak(history.series.swap).toFixed(0)}%`}
|
||||||
<Tag color={panelUpdateInfo.updateAvailable ? 'orange' : 'green'}>
|
data={history.series.swap}
|
||||||
{panelUpdateInfo.updateAvailable
|
isMobile={isMobile}
|
||||||
? formatPanelVersion(panelUpdateInfo.latestVersion)
|
|
||||||
: formatPanelVersion(displayVersion)}
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
hoverable
|
|
||||||
actions={[
|
|
||||||
<Space className="action" key="tg" role="button" tabIndex={0} aria-label="@XrayUI" onClick={openTelegram} onKeyDown={activateOnKey(openTelegram)}>
|
|
||||||
<TelegramFilled className="tg-icon" aria-hidden="true" />
|
|
||||||
{!isMobile && <span>@XrayUI</span>}
|
|
||||||
</Space>,
|
|
||||||
<Space
|
|
||||||
key="panel-version"
|
|
||||||
className={`action ${panelUpdateInfo.updateAvailable ? 'action-update' : ''}`}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label={t('pages.index.updatePanel')}
|
|
||||||
onClick={openPanelVersion}
|
|
||||||
onKeyDown={activateOnKey(openPanelVersion)}
|
|
||||||
>
|
|
||||||
<CloudDownloadOutlined />
|
|
||||||
{!isMobile && (
|
|
||||||
<span>
|
|
||||||
{panelUpdateInfo.updateAvailable
|
|
||||||
? `${t('update')} ${formatPanelVersion(panelUpdateInfo.latestVersion)}`
|
|
||||||
: formatPanelVersion(displayVersion)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Space>,
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</Col>
|
<VitalTile
|
||||||
|
icon={<HddOutlined />}
|
||||||
<Col xs={24} lg={12}>
|
label={t('pages.index.storage')}
|
||||||
<Card
|
percent={status.disk.percent}
|
||||||
title={t('pages.index.charts')}
|
statusColor={status.disk.color}
|
||||||
hoverable
|
detail={`${SizeFormatter.sizeFormat(status.disk.current)} / ${SizeFormatter.sizeFormat(totalDisk)}`}
|
||||||
actions={[
|
footLeft={`${t('pages.index.free')} ${SizeFormatter.sizeFormat(freeDisk)}`}
|
||||||
<Space
|
footRight={`${t('pages.index.avg')} ${mean(history.series.diskUsage).toFixed(1)}%`}
|
||||||
className="action"
|
data={history.series.diskUsage}
|
||||||
key="sys-history"
|
isMobile={isMobile}
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label={t('pages.index.systemHistoryTitle')}
|
|
||||||
onClick={() => setSysHistoryOpen(true)}
|
|
||||||
onKeyDown={activateOnKey(() => setSysHistoryOpen(true))}
|
|
||||||
>
|
|
||||||
<AreaChartOutlined />
|
|
||||||
{!isMobile && <span>{t('pages.index.systemHistoryTitle')}</span>}
|
|
||||||
</Space>,
|
|
||||||
<Space
|
|
||||||
className="action"
|
|
||||||
key="xray-metrics"
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label={t('pages.index.xrayMetricsTitle')}
|
|
||||||
onClick={() => setXrayMetricsOpen(true)}
|
|
||||||
onKeyDown={activateOnKey(() => setXrayMetricsOpen(true))}
|
|
||||||
>
|
|
||||||
<AreaChartOutlined />
|
|
||||||
{!isMobile && <span>{t('pages.index.xrayMetricsTitle')}</span>}
|
|
||||||
</Space>,
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</Col>
|
</div>
|
||||||
|
|
||||||
<Col xs={24} lg={12}>
|
<div className="ov-mid">
|
||||||
<Card title={t('pages.index.operationHours')} hoverable>
|
<ThroughputCard
|
||||||
<Row gutter={isMobile ? [8, 8] : 0}>
|
status={status}
|
||||||
<Col span={12}>
|
up={history.series.netUp}
|
||||||
<Statistic
|
down={history.series.netDown}
|
||||||
title="Xray"
|
labels={history.labels}
|
||||||
value={TimeFormatter.formatSecond(status.appStats.uptime)}
|
isMobile={isMobile}
|
||||||
prefix={<ThunderboltOutlined />}
|
/>
|
||||||
/>
|
<ConnectionsCard
|
||||||
</Col>
|
status={status}
|
||||||
<Col span={12}>
|
tcp={history.series.tcpCount}
|
||||||
<Statistic
|
udp={history.series.udpCount}
|
||||||
title="OS"
|
labels={history.labels}
|
||||||
value={TimeFormatter.formatSecond(status.uptime)}
|
isMobile={isMobile}
|
||||||
prefix={<DesktopOutlined />}
|
/>
|
||||||
/>
|
</div>
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
<Col xs={24} lg={12}>
|
<SystemStrip
|
||||||
<Card title={t('usage')} hoverable>
|
status={status}
|
||||||
<Row gutter={isMobile ? [8, 8] : 0}>
|
showIp={showIp}
|
||||||
<Col span={12}>
|
onToggleIp={() => setShowIp((v) => !v)}
|
||||||
<Statistic
|
/>
|
||||||
title={t('pages.index.memory')}
|
</div>
|
||||||
value={SizeFormatter.sizeFormat(status.appStats.mem)}
|
|
||||||
prefix={<DatabaseOutlined />}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
<Col span={12}>
|
|
||||||
<Statistic
|
|
||||||
title={t('pages.index.threads')}
|
|
||||||
value={status.appStats.threads}
|
|
||||||
prefix={<ForkOutlined />}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
<Col xs={24} lg={12}>
|
|
||||||
<Card title={t('pages.index.overallSpeed')} hoverable>
|
|
||||||
<Row gutter={isMobile ? [8, 8] : 0}>
|
|
||||||
<Col span={12}>
|
|
||||||
<Statistic
|
|
||||||
title={t('pages.index.upload')}
|
|
||||||
value={SizeFormatter.sizeFormat(status.netIO.up)}
|
|
||||||
prefix={<ArrowUpOutlined />}
|
|
||||||
suffix="/s"
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
<Col span={12}>
|
|
||||||
<Statistic
|
|
||||||
title={t('pages.index.download')}
|
|
||||||
value={SizeFormatter.sizeFormat(status.netIO.down)}
|
|
||||||
prefix={<ArrowDownOutlined />}
|
|
||||||
suffix="/s"
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
<Col xs={24} lg={12}>
|
|
||||||
<Card title={t('pages.index.totalData')} hoverable>
|
|
||||||
<Row gutter={isMobile ? [8, 8] : 0}>
|
|
||||||
<Col span={12}>
|
|
||||||
<Statistic
|
|
||||||
title={t('pages.index.sent')}
|
|
||||||
value={SizeFormatter.sizeFormat(status.netTraffic.sent)}
|
|
||||||
prefix={<CloudUploadOutlined />}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
<Col span={12}>
|
|
||||||
<Statistic
|
|
||||||
title={t('pages.index.received')}
|
|
||||||
value={SizeFormatter.sizeFormat(status.netTraffic.recv)}
|
|
||||||
prefix={<CloudDownloadOutlined />}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
<Col xs={24} lg={12}>
|
|
||||||
<Card
|
|
||||||
title={t('pages.index.ipAddresses')}
|
|
||||||
hoverable
|
|
||||||
extra={
|
|
||||||
<Tooltip
|
|
||||||
title={t('pages.index.toggleIpVisibility')}
|
|
||||||
placement={isMobile ? 'topRight' : 'top'}
|
|
||||||
>
|
|
||||||
{showIp ? (
|
|
||||||
<EyeOutlined
|
|
||||||
className="ip-toggle-icon"
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label={t('pages.index.toggleIpVisibility')}
|
|
||||||
onClick={() => setShowIp(false)}
|
|
||||||
onKeyDown={activateOnKey(() => setShowIp(false))}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<EyeInvisibleOutlined
|
|
||||||
className="ip-toggle-icon"
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label={t('pages.index.toggleIpVisibility')}
|
|
||||||
onClick={() => setShowIp(true)}
|
|
||||||
onKeyDown={activateOnKey(() => setShowIp(true))}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Tooltip>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Row className={showIp ? 'ip-visible' : 'ip-hidden'} gutter={isMobile ? [8, 8] : 0}>
|
|
||||||
<Col span={isMobile ? 24 : 12}>
|
|
||||||
<Statistic
|
|
||||||
title="IPv4"
|
|
||||||
value={status.publicIP.ipv4}
|
|
||||||
prefix={<GlobalOutlined />}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
<Col span={isMobile ? 24 : 12}>
|
|
||||||
<Statistic
|
|
||||||
title="IPv6"
|
|
||||||
value={status.publicIP.ipv6}
|
|
||||||
prefix={<GlobalOutlined />}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
<Col xs={24} lg={12}>
|
|
||||||
<Card title={t('pages.index.connectionCount')} hoverable>
|
|
||||||
<Row gutter={isMobile ? [8, 8] : 0}>
|
|
||||||
<Col span={12}>
|
|
||||||
<Statistic
|
|
||||||
title="TCP"
|
|
||||||
value={status.tcpCount}
|
|
||||||
prefix={<SwapOutlined />}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
<Col span={12}>
|
|
||||||
<Statistic
|
|
||||||
title="UDP"
|
|
||||||
value={status.udpCount}
|
|
||||||
prefix={<SwapOutlined />}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
)}
|
)}
|
||||||
</Spin>
|
</Spin>
|
||||||
</Layout.Content>
|
</Layout.Content>
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { Fragment } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, Tag, Tooltip } from 'antd';
|
||||||
|
import {
|
||||||
|
ArrowUpOutlined,
|
||||||
|
AreaChartOutlined,
|
||||||
|
BarsOutlined,
|
||||||
|
CloudDownloadOutlined,
|
||||||
|
CloudServerOutlined,
|
||||||
|
ControlOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
PoweroffOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { formatPanelVersion } from '@/lib/panel-version';
|
||||||
|
import type { Status } from '@/models/status';
|
||||||
|
|
||||||
|
interface OverviewActionBarProps {
|
||||||
|
status: Status;
|
||||||
|
isMobile: boolean;
|
||||||
|
accessLogEnable: boolean;
|
||||||
|
panelVersion: string;
|
||||||
|
latestVersion: string;
|
||||||
|
updateAvailable: boolean;
|
||||||
|
onStopXray: () => void;
|
||||||
|
onRestartXray: () => void;
|
||||||
|
onOpenLogs: () => void;
|
||||||
|
onOpenXrayLogs: () => void;
|
||||||
|
onOpenConfig: () => void;
|
||||||
|
onOpenBackup: () => void;
|
||||||
|
onOpenSystemHistory: () => void;
|
||||||
|
onOpenXrayMetrics: () => void;
|
||||||
|
onOpenPanelUpdate: () => void;
|
||||||
|
onOpenVersionSwitch: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BarAction {
|
||||||
|
key: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
text: string;
|
||||||
|
onClick: () => void;
|
||||||
|
primary?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const XRAY_STATE_KEYS: Record<string, string> = {
|
||||||
|
running: 'pages.index.xrayStatusRunning',
|
||||||
|
stop: 'pages.index.xrayStatusStop',
|
||||||
|
error: 'pages.index.xrayStatusError',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function OverviewActionBar({
|
||||||
|
status,
|
||||||
|
isMobile,
|
||||||
|
accessLogEnable,
|
||||||
|
panelVersion,
|
||||||
|
latestVersion,
|
||||||
|
updateAvailable,
|
||||||
|
onStopXray,
|
||||||
|
onRestartXray,
|
||||||
|
onOpenLogs,
|
||||||
|
onOpenXrayLogs,
|
||||||
|
onOpenConfig,
|
||||||
|
onOpenBackup,
|
||||||
|
onOpenSystemHistory,
|
||||||
|
onOpenXrayMetrics,
|
||||||
|
onOpenPanelUpdate,
|
||||||
|
onOpenVersionSwitch,
|
||||||
|
}: OverviewActionBarProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const stateText = t(XRAY_STATE_KEYS[status.xray.state] ?? 'pages.index.xrayStatusUnknown');
|
||||||
|
const hasVersion = !!status.xray.version && status.xray.version !== 'Unknown';
|
||||||
|
const size = isMobile ? ('small' as const) : ('middle' as const);
|
||||||
|
|
||||||
|
const actionGroups: BarAction[][] = [
|
||||||
|
[
|
||||||
|
{ key: 'restart', icon: <ReloadOutlined />, text: t('pages.index.restartXray'), onClick: onRestartXray, primary: true },
|
||||||
|
{ key: 'stop', icon: <PoweroffOutlined />, text: t('pages.index.stopXray'), onClick: onStopXray },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ key: 'logs', icon: <BarsOutlined />, text: t('pages.index.logs'), onClick: onOpenLogs },
|
||||||
|
...(accessLogEnable
|
||||||
|
? [{ key: 'accessLogs', icon: <FileTextOutlined />, text: t('pages.index.accessLogs'), onClick: onOpenXrayLogs }]
|
||||||
|
: []),
|
||||||
|
{ key: 'config', icon: <ControlOutlined />, text: t('pages.index.config'), onClick: onOpenConfig },
|
||||||
|
{ key: 'backup', icon: <CloudServerOutlined />, text: t('pages.index.backupTitle'), onClick: onOpenBackup },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ key: 'history', icon: <AreaChartOutlined />, text: t('pages.index.systemHistoryTitle'), onClick: onOpenSystemHistory },
|
||||||
|
{ key: 'metrics', icon: <ArrowUpOutlined />, text: t('pages.index.xrayMetricsTitle'), onClick: onOpenXrayMetrics },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
const statePill = (
|
||||||
|
<span className="ov-state" data-state={status.xray.state}>
|
||||||
|
<span className="ov-state-dot" style={{ color: status.xray.color }} />
|
||||||
|
<span>{`${t('pages.index.xrayStatus')} · ${stateText}`}</span>
|
||||||
|
{hasVersion && (
|
||||||
|
<Tooltip title={t('pages.index.xraySwitch')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ov-state-version"
|
||||||
|
onClick={onOpenVersionSwitch}
|
||||||
|
>
|
||||||
|
{`v${status.xray.version}`}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ov-bar">
|
||||||
|
{status.xray.state === 'error' && status.xray.errorMsg ? (
|
||||||
|
<Tooltip title={<span className="ov-error-detail">{status.xray.errorMsg}</span>}>
|
||||||
|
{statePill}
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
statePill
|
||||||
|
)}
|
||||||
|
|
||||||
|
{updateAvailable ? (
|
||||||
|
<Tag
|
||||||
|
className="ov-update-tag"
|
||||||
|
color="warning"
|
||||||
|
icon={<CloudDownloadOutlined />}
|
||||||
|
onClick={onOpenPanelUpdate}
|
||||||
|
>
|
||||||
|
{`${t('update')} ${formatPanelVersion(latestVersion)}`}
|
||||||
|
</Tag>
|
||||||
|
) : (
|
||||||
|
<Tooltip title={t('pages.index.updatePanel')}>
|
||||||
|
<button type="button" className="ov-panel-version ov-mono" onClick={onOpenPanelUpdate}>
|
||||||
|
{formatPanelVersion(panelVersion)}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="ov-bar-actions">
|
||||||
|
{actionGroups.map((group, groupIndex) => (
|
||||||
|
<Fragment key={group[0].key}>
|
||||||
|
{groupIndex > 0 && <span className="ov-bar-sep" />}
|
||||||
|
{group.map((action) => (
|
||||||
|
<Button
|
||||||
|
key={action.key}
|
||||||
|
type={action.primary ? undefined : 'text'}
|
||||||
|
color={action.primary ? 'primary' : undefined}
|
||||||
|
variant={action.primary ? 'outlined' : undefined}
|
||||||
|
size={size}
|
||||||
|
icon={action.icon}
|
||||||
|
aria-label={action.text}
|
||||||
|
onClick={action.onClick}
|
||||||
|
>
|
||||||
|
{isMobile ? undefined : action.text}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
.status-card .text-center {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card .ant-progress-text,
|
|
||||||
.status-card .ant-progress-indicator {
|
|
||||||
font-size: 12px !important;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Card, Col, Progress, Row, Tooltip } from 'antd';
|
|
||||||
import { AreaChartOutlined } from '@ant-design/icons';
|
|
||||||
|
|
||||||
import { CPUFormatter, SizeFormatter } from '@/utils';
|
|
||||||
import { useTheme } from '@/hooks/useTheme';
|
|
||||||
import type { Status } from '@/models/status';
|
|
||||||
import './StatusCard.css';
|
|
||||||
|
|
||||||
interface StatusCardProps {
|
|
||||||
status: Status;
|
|
||||||
isMobile: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function StatusCard({ status, isMobile }: StatusCardProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { isDark, isUltra } = useTheme();
|
|
||||||
const gaugeSize = isMobile ? 60 : 90;
|
|
||||||
const strokeWidth = isMobile ? 7 : 5;
|
|
||||||
const railColor = isDark
|
|
||||||
? isUltra ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.16)'
|
|
||||||
: 'rgba(0, 0, 0, 0.08)';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card hoverable className="status-card">
|
|
||||||
<Row gutter={[0, isMobile ? 16 : 0]}>
|
|
||||||
<Col xs={24} md={12}>
|
|
||||||
<Row>
|
|
||||||
<Col span={12} className="text-center">
|
|
||||||
<Progress
|
|
||||||
type="dashboard"
|
|
||||||
status="normal"
|
|
||||||
strokeColor={status.cpu.color}
|
|
||||||
railColor={railColor}
|
|
||||||
strokeWidth={strokeWidth}
|
|
||||||
percent={status.cpu.percent}
|
|
||||||
size={gaugeSize}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<b>{t('pages.index.cpu')}:</b> {CPUFormatter.cpuCoreFormat(status.cpuCores)}
|
|
||||||
<Tooltip
|
|
||||||
title={
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<b>{t('pages.index.logicalProcessors')}:</b> {status.logicalPro}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<b>{t('pages.index.frequency')}:</b>{' '}
|
|
||||||
{CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<AreaChartOutlined />
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
<Col span={12} className="text-center">
|
|
||||||
<Progress
|
|
||||||
type="dashboard"
|
|
||||||
status="normal"
|
|
||||||
strokeColor={status.mem.color}
|
|
||||||
railColor={railColor}
|
|
||||||
strokeWidth={strokeWidth}
|
|
||||||
percent={status.mem.percent}
|
|
||||||
size={gaugeSize}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<b>{t('pages.index.memory')}:</b> {SizeFormatter.sizeFormat(status.mem.current)} /{' '}
|
|
||||||
{SizeFormatter.sizeFormat(status.mem.total)}
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
<Col xs={24} md={12}>
|
|
||||||
<Row>
|
|
||||||
<Col span={12} className="text-center">
|
|
||||||
<Progress
|
|
||||||
type="dashboard"
|
|
||||||
status="normal"
|
|
||||||
strokeColor={status.swap.color}
|
|
||||||
railColor={railColor}
|
|
||||||
strokeWidth={strokeWidth}
|
|
||||||
percent={status.swap.percent}
|
|
||||||
size={gaugeSize}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<b>{t('pages.index.swap')}:</b> {SizeFormatter.sizeFormat(status.swap.current)} /{' '}
|
|
||||||
{SizeFormatter.sizeFormat(status.swap.total)}
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
<Col span={12} className="text-center">
|
|
||||||
<Progress
|
|
||||||
type="dashboard"
|
|
||||||
status="normal"
|
|
||||||
strokeColor={status.disk.color}
|
|
||||||
railColor={railColor}
|
|
||||||
strokeWidth={strokeWidth}
|
|
||||||
percent={status.disk.percent}
|
|
||||||
size={gaugeSize}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<b>{t('pages.index.storage')}:</b> {SizeFormatter.sizeFormat(status.disk.current)} /{' '}
|
|
||||||
{SizeFormatter.sizeFormat(status.disk.total)}
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card, Tooltip } from 'antd';
|
||||||
|
import {
|
||||||
|
ClockCircleOutlined,
|
||||||
|
DatabaseOutlined,
|
||||||
|
EyeInvisibleOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
GlobalOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { SizeFormatter, TimeFormatter } from '@/utils';
|
||||||
|
import { activateOnKey } from '@/utils/a11y';
|
||||||
|
import type { Status } from '@/models/status';
|
||||||
|
|
||||||
|
interface SystemStripProps {
|
||||||
|
status: Status;
|
||||||
|
showIp: boolean;
|
||||||
|
onToggleIp: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SystemStrip({ status, showIp, onToggleIp }: SystemStripProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card hoverable styles={{ body: { padding: 0 } }}>
|
||||||
|
<div className="ov-strip-grid">
|
||||||
|
<div className="ov-strip-cell">
|
||||||
|
<div className="ov-kicker ov-kicker-icon">
|
||||||
|
<ClockCircleOutlined />
|
||||||
|
{t('pages.index.uptime')}
|
||||||
|
</div>
|
||||||
|
<div className="ov-strip-split">
|
||||||
|
<div>
|
||||||
|
<div className="ov-strip-sub">Xray</div>
|
||||||
|
<div className="ov-strip-value">{TimeFormatter.formatSecond(status.appStats.uptime)}</div>
|
||||||
|
</div>
|
||||||
|
<span className="ov-strip-split-sep" />
|
||||||
|
<div>
|
||||||
|
<div className="ov-strip-sub">OS</div>
|
||||||
|
<div className="ov-strip-value">{TimeFormatter.formatSecond(status.uptime)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ov-strip-cell">
|
||||||
|
<div className="ov-kicker ov-kicker-icon">
|
||||||
|
<DatabaseOutlined />
|
||||||
|
{t('pages.index.panel')}
|
||||||
|
</div>
|
||||||
|
<div className="ov-strip-split">
|
||||||
|
<div>
|
||||||
|
<div className="ov-strip-sub">{t('pages.index.memory')}</div>
|
||||||
|
<div className="ov-strip-value">{SizeFormatter.sizeFormat(status.appStats.mem)}</div>
|
||||||
|
</div>
|
||||||
|
<span className="ov-strip-split-sep" />
|
||||||
|
<div>
|
||||||
|
<div className="ov-strip-sub">{t('pages.index.threads')}</div>
|
||||||
|
<div className="ov-strip-value">{status.appStats.threads}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ov-strip-cell">
|
||||||
|
<div className="ov-kicker ov-kicker-icon">
|
||||||
|
<GlobalOutlined />
|
||||||
|
{t('pages.index.ipAddresses')}
|
||||||
|
<Tooltip title={t('pages.index.toggleIpVisibility')}>
|
||||||
|
{showIp ? (
|
||||||
|
<EyeOutlined
|
||||||
|
className="ip-toggle-icon"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={t('pages.index.toggleIpVisibility')}
|
||||||
|
onClick={onToggleIp}
|
||||||
|
onKeyDown={activateOnKey(onToggleIp)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EyeInvisibleOutlined
|
||||||
|
className="ip-toggle-icon"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={t('pages.index.toggleIpVisibility')}
|
||||||
|
onClick={onToggleIp}
|
||||||
|
onKeyDown={activateOnKey(onToggleIp)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div className={`ov-ip${showIp ? '' : ' ip-hidden'}`}>
|
||||||
|
<div className="ov-mono">{status.publicIP.ipv4}</div>
|
||||||
|
<div className="ov-mono ov-ip-v6">{status.publicIP.ipv6}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card, theme } from 'antd';
|
||||||
|
import { ArrowDownOutlined, ArrowUpOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { SizeFormatter } from '@/utils';
|
||||||
|
import { Sparkline } from '@/components/viz';
|
||||||
|
import type { Status } from '@/models/status';
|
||||||
|
import { mean, peak } from './useOverviewHistory';
|
||||||
|
|
||||||
|
interface ThroughputCardProps {
|
||||||
|
status: Status;
|
||||||
|
up: number[];
|
||||||
|
down: number[];
|
||||||
|
labels: string[];
|
||||||
|
isMobile: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ThroughputCard({ status, up, down, labels, isMobile }: ThroughputCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const accent = token.colorPrimary;
|
||||||
|
const downColor = token.colorTextTertiary;
|
||||||
|
|
||||||
|
const referenceLines = useMemo(
|
||||||
|
() => [
|
||||||
|
{ y: status.netIO.down, color: downColor, dash: '2 4' },
|
||||||
|
{ y: status.netIO.up, color: accent, dash: '2 4' },
|
||||||
|
],
|
||||||
|
[status.netIO.up, status.netIO.down, accent, downColor],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card hoverable styles={{ body: { padding: 0 } }}>
|
||||||
|
<div className="ov-wide-head">
|
||||||
|
<div>
|
||||||
|
<div className="ov-kicker">{t('pages.index.overallSpeed')}</div>
|
||||||
|
<div className="ov-sub">
|
||||||
|
{`${t('pages.index.throughputSub')} · ${t('pages.index.peak')} ${SizeFormatter.speedFormat(peak(down))}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ov-wide-legend">
|
||||||
|
<div className="ov-legend-label">
|
||||||
|
<ArrowUpOutlined style={{ color: accent }} />
|
||||||
|
{t('pages.index.upload')}
|
||||||
|
<span className="ov-legend-num">{SizeFormatter.speedFormat(status.netIO.up)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="ov-legend-label">
|
||||||
|
<ArrowDownOutlined style={{ color: downColor }} />
|
||||||
|
{t('pages.index.download')}
|
||||||
|
<span className="ov-legend-num">{SizeFormatter.speedFormat(status.netIO.down)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ov-wide-chart">
|
||||||
|
<Sparkline
|
||||||
|
data={up}
|
||||||
|
data2={down}
|
||||||
|
labels={labels}
|
||||||
|
height={isMobile ? 140 : 186}
|
||||||
|
strokeWidth={1.75}
|
||||||
|
fillOpacity={0.24}
|
||||||
|
showTooltip
|
||||||
|
showLegend={false}
|
||||||
|
valueMax={null}
|
||||||
|
stroke={accent}
|
||||||
|
stroke2={downColor}
|
||||||
|
name1={t('pages.index.upload')}
|
||||||
|
name2={t('pages.index.download')}
|
||||||
|
yFormatter={SizeFormatter.speedFormat}
|
||||||
|
referenceLines={referenceLines}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ov-wide-foot">
|
||||||
|
<div>
|
||||||
|
<div className="ov-kicker">{t('pages.index.sent')}</div>
|
||||||
|
<div className="ov-foot-value">{SizeFormatter.sizeFormat(status.netTraffic.sent)}</div>
|
||||||
|
</div>
|
||||||
|
<span className="ov-foot-sep" />
|
||||||
|
<div>
|
||||||
|
<div className="ov-kicker">{t('pages.index.received')}</div>
|
||||||
|
<div className="ov-foot-value">{SizeFormatter.sizeFormat(status.netTraffic.recv)}</div>
|
||||||
|
</div>
|
||||||
|
<span className="ov-foot-sep" />
|
||||||
|
<div>
|
||||||
|
<div className="ov-kicker">{t('pages.index.avgWindow')}</div>
|
||||||
|
<div className="ov-foot-value">
|
||||||
|
<span className="ov-foot-part">{`↑ ${SizeFormatter.speedFormat(mean(up))}`}</span>{' '}
|
||||||
|
<span className="ov-foot-part">{`↓ ${SizeFormatter.speedFormat(mean(down))}`}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Card, theme } from 'antd';
|
||||||
|
|
||||||
|
import { Sparkline } from '@/components/viz';
|
||||||
|
import { mean, peak } from './useOverviewHistory';
|
||||||
|
|
||||||
|
interface VitalTileProps {
|
||||||
|
icon: ReactNode;
|
||||||
|
label: string;
|
||||||
|
percent: number;
|
||||||
|
statusColor: string;
|
||||||
|
detail: string;
|
||||||
|
footLeft: string;
|
||||||
|
footRight: string;
|
||||||
|
data: number[];
|
||||||
|
isMobile: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VitalTile({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
percent,
|
||||||
|
statusColor,
|
||||||
|
detail,
|
||||||
|
footLeft,
|
||||||
|
footRight,
|
||||||
|
data,
|
||||||
|
isMobile,
|
||||||
|
}: VitalTileProps) {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const meanColor = token.colorTextTertiary;
|
||||||
|
|
||||||
|
const referenceLines = useMemo(
|
||||||
|
() => (data.length > 1 ? [{ y: mean(data), dash: '3 4', color: meanColor }] : []),
|
||||||
|
[data, meanColor],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card hoverable className="ov-tile" styles={{ body: { padding: 0 } }}>
|
||||||
|
<div className="ov-tile-head">
|
||||||
|
<span className="ov-tile-icon">{icon}</span>
|
||||||
|
<span className="ov-kicker">{label}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ov-tile-value">
|
||||||
|
<span className="ov-tile-number">{percent.toFixed(1)}</span>
|
||||||
|
<span className="ov-tile-unit">%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ov-tile-detail">{detail}</div>
|
||||||
|
|
||||||
|
<div className="ov-tile-foot">
|
||||||
|
<span>{footLeft}</span>
|
||||||
|
<span>{footRight}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ov-tile-chart">
|
||||||
|
<Sparkline
|
||||||
|
data={data}
|
||||||
|
height={isMobile ? 48 : 62}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
fillOpacity={0.3}
|
||||||
|
showGrid={false}
|
||||||
|
showMarker={false}
|
||||||
|
valueMax={peak(data) > 0 ? null : 100}
|
||||||
|
stroke={statusColor}
|
||||||
|
referenceLines={referenceLines}
|
||||||
|
yFormatter={(v) => `${v.toFixed(0)}%`}
|
||||||
|
name1={label}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
.xray-status-card .action {
|
|
||||||
cursor: pointer;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-line {
|
|
||||||
display: block;
|
|
||||||
max-width: 400px;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cursor-pointer {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Badge, Card, Col, Popover, Row, Space, Tag } from 'antd';
|
|
||||||
import {
|
|
||||||
BarsOutlined,
|
|
||||||
PoweroffOutlined,
|
|
||||||
ReloadOutlined,
|
|
||||||
ToolOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
|
|
||||||
import type { Status } from '@/models/status';
|
|
||||||
import { activateOnKey } from '@/utils/a11y';
|
|
||||||
import './XrayStatusCard.css';
|
|
||||||
|
|
||||||
interface XrayStatusCardProps {
|
|
||||||
status: Status;
|
|
||||||
isMobile: boolean;
|
|
||||||
accessLogEnable: boolean;
|
|
||||||
onStopXray: () => void;
|
|
||||||
onRestartXray: () => void;
|
|
||||||
onOpenLogs: () => void;
|
|
||||||
onOpenXrayLogs: () => void;
|
|
||||||
onOpenVersionSwitch: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const XRAY_STATE_KEYS: Record<string, string> = {
|
|
||||||
running: 'pages.index.xrayStatusRunning',
|
|
||||||
stop: 'pages.index.xrayStatusStop',
|
|
||||||
error: 'pages.index.xrayStatusError',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function XrayStatusCard({
|
|
||||||
status,
|
|
||||||
isMobile,
|
|
||||||
accessLogEnable,
|
|
||||||
onStopXray,
|
|
||||||
onRestartXray,
|
|
||||||
onOpenLogs,
|
|
||||||
onOpenXrayLogs,
|
|
||||||
onOpenVersionSwitch,
|
|
||||||
}: XrayStatusCardProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const stateText = t(XRAY_STATE_KEYS[status.xray.state] ?? 'pages.index.xrayStatusUnknown');
|
|
||||||
|
|
||||||
const title = (
|
|
||||||
<Space>
|
|
||||||
<span>{t('pages.index.xrayStatus')}</span>
|
|
||||||
{isMobile && status.xray.version && status.xray.version !== 'Unknown' && (
|
|
||||||
<Tag color="green">v{status.xray.version}</Tag>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
);
|
|
||||||
|
|
||||||
const errorLines = useMemo(
|
|
||||||
() => (status.xray.errorMsg || '').split('\n'),
|
|
||||||
[status.xray.errorMsg],
|
|
||||||
);
|
|
||||||
|
|
||||||
const extra =
|
|
||||||
status.xray.state !== 'error' ? (
|
|
||||||
<Badge status="processing" text={stateText} color={status.xray.color} />
|
|
||||||
) : (
|
|
||||||
<Popover
|
|
||||||
title={
|
|
||||||
<Row align="middle" justify="space-between">
|
|
||||||
<Col>
|
|
||||||
<span>{t('pages.index.xrayStatusError')}</span>
|
|
||||||
</Col>
|
|
||||||
<Col>
|
|
||||||
<BarsOutlined className="cursor-pointer" role="button" tabIndex={0} aria-label={t('pages.index.logs')} onClick={onOpenLogs} onKeyDown={activateOnKey(onOpenLogs)} />
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
}
|
|
||||||
content={
|
|
||||||
<>
|
|
||||||
{errorLines.map((line, i) => (
|
|
||||||
<span key={i} className="error-line">
|
|
||||||
{line}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Badge status="processing" text={stateText} color={status.xray.color} />
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
|
|
||||||
const actions = [
|
|
||||||
// the xray log viewer reads the access log file, so the button only makes
|
|
||||||
// sense when one is configured (unlike IP limit, which no longer needs it)
|
|
||||||
...(accessLogEnable
|
|
||||||
? [
|
|
||||||
<Space className="action" key="xraylogs" role="button" tabIndex={0} aria-label={t('pages.index.accessLogs')} onClick={onOpenXrayLogs} onKeyDown={activateOnKey(onOpenXrayLogs)}>
|
|
||||||
<BarsOutlined />
|
|
||||||
{!isMobile && <span>{t('pages.index.accessLogs')}</span>}
|
|
||||||
</Space>,
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
<Space className="action" key="stop" role="button" tabIndex={0} aria-label={t('pages.index.stopXray')} onClick={onStopXray} onKeyDown={activateOnKey(onStopXray)}>
|
|
||||||
<PoweroffOutlined />
|
|
||||||
{!isMobile && <span>{t('pages.index.stopXray')}</span>}
|
|
||||||
</Space>,
|
|
||||||
<Space className="action" key="restart" role="button" tabIndex={0} aria-label={t('pages.index.restartXray')} onClick={onRestartXray} onKeyDown={activateOnKey(onRestartXray)}>
|
|
||||||
<ReloadOutlined />
|
|
||||||
{!isMobile && <span>{t('pages.index.restartXray')}</span>}
|
|
||||||
</Space>,
|
|
||||||
<Space className="action" key="switch" role="button" tabIndex={0} aria-label={t('pages.index.xraySwitch')} onClick={onOpenVersionSwitch} onKeyDown={activateOnKey(onOpenVersionSwitch)}>
|
|
||||||
<ToolOutlined />
|
|
||||||
{!isMobile && (
|
|
||||||
<span>
|
|
||||||
{status.xray.version && status.xray.version !== 'Unknown'
|
|
||||||
? `v${status.xray.version}`
|
|
||||||
: t('pages.index.xraySwitch')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Space>,
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card hoverable title={title} extra={extra} actions={actions} className="xray-status-card" />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { HttpUtil, TimeFormatter } from '@/utils';
|
||||||
|
import type { Status } from '@/models/status';
|
||||||
|
|
||||||
|
const OVERVIEW_WINDOW = 72;
|
||||||
|
const SEED_BUCKET_SECONDS = 2;
|
||||||
|
|
||||||
|
const SERIES_KEYS = ['cpu', 'mem', 'swap', 'diskUsage', 'netUp', 'netDown', 'tcpCount', 'udpCount'] as const;
|
||||||
|
|
||||||
|
export type OverviewSeriesKey = (typeof SERIES_KEYS)[number];
|
||||||
|
|
||||||
|
export interface OverviewHistory {
|
||||||
|
series: Record<OverviewSeriesKey, number[]>;
|
||||||
|
labels: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HistoryPoint {
|
||||||
|
t: number;
|
||||||
|
v: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HistoryWindow {
|
||||||
|
series: Record<OverviewSeriesKey, number[]>;
|
||||||
|
times: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptySeries(): Record<OverviewSeriesKey, number[]> {
|
||||||
|
return Object.fromEntries(SERIES_KEYS.map((key) => [key, [] as number[]])) as Record<OverviewSeriesKey, number[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyWindow(): HistoryWindow {
|
||||||
|
return { series: emptySeries(), times: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleOf(status: Status): Record<OverviewSeriesKey, number> {
|
||||||
|
return {
|
||||||
|
cpu: status.cpu.percent,
|
||||||
|
mem: status.mem.percent,
|
||||||
|
swap: status.swap.percent,
|
||||||
|
diskUsage: status.disk.percent,
|
||||||
|
netUp: status.netIO.up,
|
||||||
|
netDown: status.netIO.down,
|
||||||
|
tcpCount: status.tcpCount,
|
||||||
|
udpCount: status.udpCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tailWindow<T>(values: T[]): T[] {
|
||||||
|
return values.slice(-OVERVIEW_WINDOW);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mean(values: number[]): number {
|
||||||
|
if (values.length === 0) return 0;
|
||||||
|
let total = 0;
|
||||||
|
for (const v of values) total += v;
|
||||||
|
return total / values.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function peak(values: number[]): number {
|
||||||
|
let max = 0;
|
||||||
|
for (const v of values) if (v > max) max = v;
|
||||||
|
return max;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* the seed bucket must be in the backend's allowedHistoryBuckets whitelist;
|
||||||
|
2s is the smallest and matches the status poll cadence */
|
||||||
|
export function useOverviewHistory(status: Status, hasData: boolean): OverviewHistory {
|
||||||
|
const [trend, setTrend] = useState<HistoryWindow>(emptyWindow);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const seed = async () => {
|
||||||
|
const responses = new Map<OverviewSeriesKey, HistoryPoint[]>();
|
||||||
|
await Promise.all(
|
||||||
|
SERIES_KEYS.map(async (key) => {
|
||||||
|
const msg = await HttpUtil.get<HistoryPoint[]>(
|
||||||
|
`/panel/api/server/history/${key}/${SEED_BUCKET_SECONDS}`,
|
||||||
|
undefined,
|
||||||
|
{ silent: true },
|
||||||
|
);
|
||||||
|
if (msg?.success && Array.isArray(msg.obj)) responses.set(key, msg.obj);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (cancelled || responses.size === 0) return;
|
||||||
|
|
||||||
|
let axis: HistoryPoint[] = [];
|
||||||
|
for (const points of responses.values()) {
|
||||||
|
if (points.length > axis.length) axis = points;
|
||||||
|
}
|
||||||
|
axis = tailWindow(axis);
|
||||||
|
if (axis.length === 0) return;
|
||||||
|
|
||||||
|
const seedTimes = axis.map((p) => Number(p.t) || 0);
|
||||||
|
const seedSeries = emptySeries();
|
||||||
|
for (const key of SERIES_KEYS) {
|
||||||
|
const byTs = new Map<number, number>();
|
||||||
|
for (const p of responses.get(key) ?? []) byTs.set(Number(p.t) || 0, Number(p.v) || 0);
|
||||||
|
seedSeries[key] = seedTimes.map((ts) => byTs.get(ts) ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTrend((prev) => {
|
||||||
|
const merged = emptyWindow();
|
||||||
|
merged.times = tailWindow(seedTimes.concat(prev.times));
|
||||||
|
for (const key of SERIES_KEYS) {
|
||||||
|
merged.series[key] = tailWindow(seedSeries[key].concat(prev.series[key]));
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
seed().catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasData) return;
|
||||||
|
setTrend((prev) => {
|
||||||
|
const point = sampleOf(status);
|
||||||
|
const next = emptyWindow();
|
||||||
|
next.times = tailWindow(prev.times.concat(Math.floor(Date.now() / 1000)));
|
||||||
|
for (const key of SERIES_KEYS) {
|
||||||
|
next.series[key] = tailWindow(prev.series[key].concat(point[key]));
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [status, hasData]);
|
||||||
|
|
||||||
|
const labels = useMemo(() => trend.times.map(TimeFormatter.formatClock), [trend.times]);
|
||||||
|
|
||||||
|
return useMemo(() => ({ series: trend.series, labels }), [trend.series, labels]);
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ function defaultValues(): NodeFormValues {
|
|||||||
port: 2053,
|
port: 2053,
|
||||||
basePath: '/',
|
basePath: '/',
|
||||||
apiToken: '',
|
apiToken: '',
|
||||||
|
hasStoredToken: false,
|
||||||
enable: true,
|
enable: true,
|
||||||
allowPrivateAddress: false,
|
allowPrivateAddress: false,
|
||||||
tlsVerifyMode: 'verify',
|
tlsVerifyMode: 'verify',
|
||||||
@@ -107,6 +108,8 @@ export default function NodeFormModal({
|
|||||||
scheme: (node.scheme as 'http' | 'https') || base.scheme,
|
scheme: (node.scheme as 'http' | 'https') || base.scheme,
|
||||||
inboundSyncMode: (node.inboundSyncMode as 'all' | 'selected') || base.inboundSyncMode,
|
inboundSyncMode: (node.inboundSyncMode as 'all' | 'selected') || base.inboundSyncMode,
|
||||||
inboundTags: node.inboundTags ?? [],
|
inboundTags: node.inboundTags ?? [],
|
||||||
|
apiToken: '',
|
||||||
|
hasStoredToken: node.hasApiToken ?? false,
|
||||||
}
|
}
|
||||||
: base;
|
: base;
|
||||||
if (next.scheme === 'http') next.tlsVerifyMode = 'skip';
|
if (next.scheme === 'http') next.tlsVerifyMode = 'skip';
|
||||||
@@ -120,8 +123,11 @@ export default function NodeFormModal({
|
|||||||
[mode, t],
|
[mode, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const editingWithToken = mode === 'edit' && Boolean(node?.hasApiToken);
|
||||||
|
|
||||||
function buildPayload(values: NodeFormValues): Partial<NodeRecord> {
|
function buildPayload(values: NodeFormValues): Partial<NodeRecord> {
|
||||||
return {
|
const token = values.apiToken.trim();
|
||||||
|
const payload: Partial<NodeRecord> = {
|
||||||
id: values.id || 0,
|
id: values.id || 0,
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
remark: values.remark?.trim() || '',
|
remark: values.remark?.trim() || '',
|
||||||
@@ -129,7 +135,6 @@ export default function NodeFormModal({
|
|||||||
address: values.address.trim(),
|
address: values.address.trim(),
|
||||||
port: values.port,
|
port: values.port,
|
||||||
basePath: values.basePath.trim() || '/',
|
basePath: values.basePath.trim() || '/',
|
||||||
apiToken: values.apiToken.trim(),
|
|
||||||
enable: values.enable,
|
enable: values.enable,
|
||||||
allowPrivateAddress: values.allowPrivateAddress,
|
allowPrivateAddress: values.allowPrivateAddress,
|
||||||
tlsVerifyMode: values.tlsVerifyMode,
|
tlsVerifyMode: values.tlsVerifyMode,
|
||||||
@@ -138,10 +143,12 @@ export default function NodeFormModal({
|
|||||||
inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [],
|
inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [],
|
||||||
outboundTag: values.outboundTag || '',
|
outboundTag: values.outboundTag || '',
|
||||||
};
|
};
|
||||||
|
if (token) payload.apiToken = token;
|
||||||
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onTest() {
|
async function onTest() {
|
||||||
if (!(await methods.trigger(['address', 'port']))) return;
|
if (!(await methods.trigger(['name', 'address', 'port']))) return;
|
||||||
setTesting(true);
|
setTesting(true);
|
||||||
setTestResult(null);
|
setTestResult(null);
|
||||||
try {
|
try {
|
||||||
@@ -158,7 +165,7 @@ export default function NodeFormModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onFetchPin() {
|
async function onFetchPin() {
|
||||||
if (!(await methods.trigger(['address', 'port']))) return;
|
if (!(await methods.trigger(['name', 'address', 'port']))) return;
|
||||||
setFetchingPin(true);
|
setFetchingPin(true);
|
||||||
try {
|
try {
|
||||||
const payload = buildPayload(methods.getValues());
|
const payload = buildPayload(methods.getValues());
|
||||||
@@ -369,8 +376,11 @@ export default function NodeFormModal({
|
|||||||
name="apiToken"
|
name="apiToken"
|
||||||
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.apiToken) }}
|
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.apiToken) }}
|
||||||
tooltip={t('pages.nodes.apiTokenHint')}
|
tooltip={t('pages.nodes.apiTokenHint')}
|
||||||
|
extra={editingWithToken ? t('pages.nodes.apiTokenKeepHint') : undefined}
|
||||||
>
|
>
|
||||||
<Input.Password placeholder={t('pages.nodes.apiTokenPlaceholder')} />
|
<Input.Password
|
||||||
|
placeholder={editingWithToken ? t('pages.nodes.apiTokenKeepHint') : t('pages.nodes.apiTokenPlaceholder')}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
|
import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
|
||||||
import { MailOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
|
import { MailOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
|
||||||
import { HttpUtil } from '@/utils';
|
import { HttpUtil } from '@/utils';
|
||||||
|
import { onNumber } from '@/utils/onNumber';
|
||||||
import type { AllSetting } from '@/models/setting';
|
import type { AllSetting } from '@/models/setting';
|
||||||
import { SettingListItem } from '@/components/ui';
|
import { DefaultSettingTag, SettingListItem } from '@/components/ui';
|
||||||
import { EmailNotifications } from '@/components/ui/notifications/EmailNotifications';
|
import { EmailNotifications } from '@/components/ui/notifications/EmailNotifications';
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { catTabLabel } from './catTabLabel';
|
import { catTabLabel } from './catTabLabel';
|
||||||
@@ -62,9 +63,9 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
|
|||||||
onChange={(e) => updateSetting({ smtpHost: e.target.value })} />
|
onChange={(e) => updateSetting({ smtpHost: e.target.value })} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.smtpPort')} description={t('pages.settings.smtpPortDesc')}>
|
<SettingListItem paddings="small" title={t('pages.settings.smtpPort')} badge={<DefaultSettingTag settingKey="smtpPort" value={allSetting.smtpPort} />} description={t('pages.settings.smtpPortDesc')}>
|
||||||
<InputNumber value={allSetting.smtpPort} min={1} max={65535} style={{ width: '100%' }}
|
<InputNumber value={allSetting.smtpPort} min={1} max={65535} style={{ width: '100%' }}
|
||||||
onChange={(v) => updateSetting({ smtpPort: Number(v) || 587 })} />
|
onChange={onNumber((v) => updateSetting({ smtpPort: v }))} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.smtpUsername')} description={t('pages.settings.smtpUsernameDesc')}>
|
<SettingListItem paddings="small" title={t('pages.settings.smtpUsername')} description={t('pages.settings.smtpUsernameDesc')}>
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import {
|
|||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { AllSetting } from '@/models/setting';
|
import type { AllSetting } from '@/models/setting';
|
||||||
import { HttpUtil, LanguageManager } from '@/utils';
|
import { HttpUtil, LanguageManager } from '@/utils';
|
||||||
import { SettingListItem } from '@/components/ui';
|
import { onNumber } from '@/utils/onNumber';
|
||||||
|
import { DefaultSettingTag, SettingListItem } from '@/components/ui';
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { catTabLabel } from './catTabLabel';
|
import { catTabLabel } from './catTabLabel';
|
||||||
import { sanitizePath } from './uriPath';
|
import { sanitizePath } from './uriPath';
|
||||||
@@ -168,18 +169,18 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
|||||||
<Input value={allSetting.webDomain} onChange={(e) => updateSetting({ webDomain: e.target.value })} />
|
<Input value={allSetting.webDomain} onChange={(e) => updateSetting({ webDomain: e.target.value })} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.panelPort')} description={t('pages.settings.panelPortDesc')}>
|
<SettingListItem paddings="small" title={t('pages.settings.panelPort')} badge={<DefaultSettingTag settingKey="webPort" value={allSetting.webPort} />} description={t('pages.settings.panelPortDesc')}>
|
||||||
<InputNumber value={allSetting.webPort} min={1} max={65535} style={{ width: '100%' }}
|
<InputNumber value={allSetting.webPort} min={1} max={65535} style={{ width: '100%' }}
|
||||||
onChange={(v) => updateSetting({ webPort: Number(v) || 0 })} />
|
onChange={onNumber((v) => updateSetting({ webPort: v }))} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.panelUrlPath')} description={t('pages.settings.panelUrlPathDesc')}>
|
<SettingListItem paddings="small" title={t('pages.settings.panelUrlPath')} description={t('pages.settings.panelUrlPathDesc')}>
|
||||||
<Input value={allSetting.webBasePath} onChange={(e) => updateSetting({ webBasePath: sanitizePath(e.target.value) })} />
|
<Input value={allSetting.webBasePath} onChange={(e) => updateSetting({ webBasePath: sanitizePath(e.target.value) })} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.sessionMaxAge')} description={t('pages.settings.sessionMaxAgeDesc')}>
|
<SettingListItem paddings="small" title={t('pages.settings.sessionMaxAge')} badge={<DefaultSettingTag settingKey="sessionMaxAge" value={allSetting.sessionMaxAge} />} description={t('pages.settings.sessionMaxAgeDesc')}>
|
||||||
<InputNumber value={allSetting.sessionMaxAge} min={60} max={525600} style={{ width: '100%' }}
|
<InputNumber value={allSetting.sessionMaxAge} min={60} max={525600} style={{ width: '100%' }}
|
||||||
onChange={(v) => updateSetting({ sessionMaxAge: Number(v) || 0 })} />
|
onChange={onNumber((v) => updateSetting({ sessionMaxAge: v }))} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
|
||||||
<SettingListItem
|
<SettingListItem
|
||||||
@@ -206,9 +207,9 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
|||||||
/>
|
/>
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.pageSize')} description={t('pages.settings.pageSizeDesc')}>
|
<SettingListItem paddings="small" title={t('pages.settings.pageSize')} badge={<DefaultSettingTag settingKey="pageSize" value={allSetting.pageSize} />} description={t('pages.settings.pageSizeDesc')}>
|
||||||
<InputNumber value={allSetting.pageSize} min={0} max={1000} step={5} style={{ width: '100%' }}
|
<InputNumber value={allSetting.pageSize} min={0} max={1000} step={5} style={{ width: '100%' }}
|
||||||
onChange={(v) => updateSetting({ pageSize: Number(v) || 0 })} />
|
onChange={onNumber((v) => updateSetting({ pageSize: v }))} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.restartXrayOnClientDisable')} description={t('pages.settings.restartXrayOnClientDisableDesc')}>
|
<SettingListItem paddings="small" title={t('pages.settings.restartXrayOnClientDisable')} description={t('pages.settings.restartXrayOnClientDisableDesc')}>
|
||||||
@@ -232,13 +233,13 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
|||||||
label: catTabLabel(<BellOutlined />, t('pages.settings.notifications'), isMobile),
|
label: catTabLabel(<BellOutlined />, t('pages.settings.notifications'), isMobile),
|
||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.expireTimeDiff')} description={t('pages.settings.expireTimeDiffDesc')}>
|
<SettingListItem paddings="small" title={t('pages.settings.expireTimeDiff')} badge={<DefaultSettingTag settingKey="expireDiff" value={allSetting.expireDiff} />} description={t('pages.settings.expireTimeDiffDesc')}>
|
||||||
<InputNumber value={allSetting.expireDiff} min={0} style={{ width: '100%' }}
|
<InputNumber value={allSetting.expireDiff} min={0} style={{ width: '100%' }}
|
||||||
onChange={(v) => updateSetting({ expireDiff: Number(v) || 0 })} />
|
onChange={onNumber((v) => updateSetting({ expireDiff: v }))} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.trafficDiff')} description={t('pages.settings.trafficDiffDesc')}>
|
<SettingListItem paddings="small" title={t('pages.settings.trafficDiff')} badge={<DefaultSettingTag settingKey="trafficDiff" value={allSetting.trafficDiff} />} description={t('pages.settings.trafficDiffDesc')}>
|
||||||
<InputNumber value={allSetting.trafficDiff} min={0} max={100} style={{ width: '100%' }}
|
<InputNumber value={allSetting.trafficDiff} min={0} max={100} style={{ width: '100%' }}
|
||||||
onChange={(v) => updateSetting({ trafficDiff: Number(v) || 0 })} />
|
onChange={onNumber((v) => updateSetting({ trafficDiff: v }))} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -306,9 +307,9 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
|||||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.host')}>
|
<SettingListItem paddings="small" title={t('pages.settings.ldap.host')}>
|
||||||
<Input value={allSetting.ldapHost} onChange={(e) => updateSetting({ ldapHost: e.target.value })} />
|
<Input value={allSetting.ldapHost} onChange={(e) => updateSetting({ ldapHost: e.target.value })} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.port')}>
|
<SettingListItem paddings="small" title={t('pages.settings.ldap.port')} badge={<DefaultSettingTag settingKey="ldapPort" value={allSetting.ldapPort} />}>
|
||||||
<InputNumber value={allSetting.ldapPort} min={1} max={65535} style={{ width: '100%' }}
|
<InputNumber value={allSetting.ldapPort} min={1} max={65535} style={{ width: '100%' }}
|
||||||
onChange={(v) => updateSetting({ ldapPort: Number(v) || 0 })} />
|
onChange={onNumber((v) => updateSetting({ ldapPort: v }))} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.useTls')}>
|
<SettingListItem paddings="small" title={t('pages.settings.ldap.useTls')}>
|
||||||
<Switch checked={allSetting.ldapUseTLS} onChange={(v) => updateSetting({ ldapUseTLS: v })} />
|
<Switch checked={allSetting.ldapUseTLS} onChange={(v) => updateSetting({ ldapUseTLS: v })} />
|
||||||
@@ -385,17 +386,17 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
|||||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.autoDelete')}>
|
<SettingListItem paddings="small" title={t('pages.settings.ldap.autoDelete')}>
|
||||||
<Switch checked={allSetting.ldapAutoDelete} onChange={(v) => updateSetting({ ldapAutoDelete: v })} />
|
<Switch checked={allSetting.ldapAutoDelete} onChange={(v) => updateSetting({ ldapAutoDelete: v })} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultTotalGb')}>
|
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultTotalGb')} badge={<DefaultSettingTag settingKey="ldapDefaultTotalGB" value={allSetting.ldapDefaultTotalGB} />}>
|
||||||
<InputNumber value={allSetting.ldapDefaultTotalGB} min={0} style={{ width: '100%' }}
|
<InputNumber value={allSetting.ldapDefaultTotalGB} min={0} style={{ width: '100%' }}
|
||||||
onChange={(v) => updateSetting({ ldapDefaultTotalGB: Number(v) || 0 })} />
|
onChange={onNumber((v) => updateSetting({ ldapDefaultTotalGB: v }))} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultExpiryDays')}>
|
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultExpiryDays')} badge={<DefaultSettingTag settingKey="ldapDefaultExpiryDays" value={allSetting.ldapDefaultExpiryDays} />}>
|
||||||
<InputNumber value={allSetting.ldapDefaultExpiryDays} min={0} style={{ width: '100%' }}
|
<InputNumber value={allSetting.ldapDefaultExpiryDays} min={0} style={{ width: '100%' }}
|
||||||
onChange={(v) => updateSetting({ ldapDefaultExpiryDays: Number(v) || 0 })} />
|
onChange={onNumber((v) => updateSetting({ ldapDefaultExpiryDays: v }))} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultIpLimit')}>
|
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultIpLimit')} badge={<DefaultSettingTag settingKey="ldapDefaultLimitIP" value={allSetting.ldapDefaultLimitIP} />}>
|
||||||
<InputNumber value={allSetting.ldapDefaultLimitIP} min={0} style={{ width: '100%' }}
|
<InputNumber value={allSetting.ldapDefaultLimitIP} min={0} style={{ width: '100%' }}
|
||||||
onChange={(v) => updateSetting({ ldapDefaultLimitIP: Number(v) || 0 })} />
|
onChange={onNumber((v) => updateSetting({ ldapDefaultLimitIP: v }))} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { AllSetting } from '@/models/setting';
|
import type { AllSetting } from '@/models/setting';
|
||||||
|
import { onNumber } from '@/utils/onNumber';
|
||||||
import { SettingListItem } from '@/components/ui';
|
import { SettingListItem } from '@/components/ui';
|
||||||
import { GoRegexInput } from '@/components/form';
|
import { GoRegexInput } from '@/components/form';
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
@@ -279,11 +280,11 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
|
|||||||
<div className="format-settings">
|
<div className="format-settings">
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.subFormats.concurrency')}>
|
<SettingListItem paddings="small" title={t('pages.settings.subFormats.concurrency')}>
|
||||||
<InputNumber value={muxObj.concurrency} min={-1} max={1024} style={{ width: '100%' }}
|
<InputNumber value={muxObj.concurrency} min={-1} max={1024} style={{ width: '100%' }}
|
||||||
onChange={(v) => setMuxField('concurrency', Number(v) || 0)} />
|
onChange={onNumber((v) => setMuxField('concurrency', v))} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.subFormats.xudpConcurrency')}>
|
<SettingListItem paddings="small" title={t('pages.settings.subFormats.xudpConcurrency')}>
|
||||||
<InputNumber value={muxObj.xudpConcurrency} min={-1} max={1024} style={{ width: '100%' }}
|
<InputNumber value={muxObj.xudpConcurrency} min={-1} max={1024} style={{ width: '100%' }}
|
||||||
onChange={(v) => setMuxField('xudpConcurrency', Number(v) || 0)} />
|
onChange={onNumber((v) => setMuxField('xudpConcurrency', v))} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.subFormats.xudpUdp443')}>
|
<SettingListItem paddings="small" title={t('pages.settings.subFormats.xudpUdp443')}>
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Alert, Button, Input, InputNumber, Switch, Tabs } from 'antd';
|
import { Alert, Button, Input, InputNumber, Switch, Tabs } from 'antd';
|
||||||
import { BranchesOutlined, CompassOutlined, IdcardOutlined, InfoCircleOutlined, NodeIndexOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons';
|
import { BranchesOutlined, CompassOutlined, IdcardOutlined, InfoCircleOutlined, NodeIndexOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router';
|
||||||
import type { AllSetting } from '@/models/setting';
|
import type { AllSetting } from '@/models/setting';
|
||||||
import { SettingListItem } from '@/components/ui';
|
import { onNumber } from '@/utils/onNumber';
|
||||||
|
import { DefaultSettingTag, SettingListItem } from '@/components/ui';
|
||||||
import { RemarkTemplateField } from '@/components/form';
|
import { RemarkTemplateField } from '@/components/form';
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { catTabLabel } from './catTabLabel';
|
import { catTabLabel } from './catTabLabel';
|
||||||
@@ -55,9 +56,9 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
|
|||||||
<SettingListItem paddings="small" title={t('pages.settings.subDomain')} description={t('pages.settings.subDomainDesc')}>
|
<SettingListItem paddings="small" title={t('pages.settings.subDomain')} description={t('pages.settings.subDomainDesc')}>
|
||||||
<Input value={allSetting.subDomain} onChange={(e) => updateSetting({ subDomain: e.target.value })} />
|
<Input value={allSetting.subDomain} onChange={(e) => updateSetting({ subDomain: e.target.value })} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.subPort')} description={t('pages.settings.subPortDesc')}>
|
<SettingListItem paddings="small" title={t('pages.settings.subPort')} badge={<DefaultSettingTag settingKey="subPort" value={allSetting.subPort} />} description={t('pages.settings.subPortDesc')}>
|
||||||
<InputNumber value={allSetting.subPort} min={1} max={65535} style={{ width: '100%' }}
|
<InputNumber value={allSetting.subPort} min={1} max={65535} style={{ width: '100%' }}
|
||||||
onChange={(v) => updateSetting({ subPort: Number(v) || 0 })} />
|
onChange={onNumber((v) => updateSetting({ subPort: v }))} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.subPath')} description={t('pages.settings.subPathDesc')}>
|
<SettingListItem paddings="small" title={t('pages.settings.subPath')} description={t('pages.settings.subPathDesc')}>
|
||||||
<Input
|
<Input
|
||||||
@@ -93,10 +94,20 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
|
|||||||
maxLength={256}
|
maxLength={256}
|
||||||
/>
|
/>
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
<SettingListItem
|
||||||
|
paddings="small"
|
||||||
|
title={t('pages.settings.subShowIdentityOnAllLinks')}
|
||||||
|
description={t('pages.settings.subShowIdentityOnAllLinksDesc')}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={allSetting.subShowIdentityOnAllLinks}
|
||||||
|
onChange={(v) => updateSetting({ subShowIdentityOnAllLinks: v })}
|
||||||
|
/>
|
||||||
|
</SettingListItem>
|
||||||
|
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.subUpdates')} description={t('pages.settings.subUpdatesDesc')}>
|
<SettingListItem paddings="small" title={t('pages.settings.subUpdates')} badge={<DefaultSettingTag settingKey="subUpdates" value={allSetting.subUpdates} />} description={t('pages.settings.subUpdatesDesc')}>
|
||||||
<InputNumber value={allSetting.subUpdates} min={0} max={525600} style={{ width: '100%' }}
|
<InputNumber value={allSetting.subUpdates} min={0} max={525600} style={{ width: '100%' }}
|
||||||
onChange={(v) => updateSetting({ subUpdates: Number(v) || 0 })} />
|
onChange={onNumber((v) => updateSetting({ subUpdates: v }))} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from '
|
|||||||
import { BellOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
|
import { BellOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
|
||||||
import { LanguageManager } from '@/utils';
|
import { LanguageManager } from '@/utils';
|
||||||
import { HttpUtil } from '@/utils';
|
import { HttpUtil } from '@/utils';
|
||||||
|
import { onNumber } from '@/utils/onNumber';
|
||||||
import type { AllSetting } from '@/models/setting';
|
import type { AllSetting } from '@/models/setting';
|
||||||
import { SettingListItem } from '@/components/ui';
|
import { SettingListItem } from '@/components/ui';
|
||||||
import { TelegramNotifications } from '@/components/ui/notifications/TelegramNotifications';
|
import { TelegramNotifications } from '@/components/ui/notifications/TelegramNotifications';
|
||||||
@@ -122,9 +123,10 @@ function NotifyTimeField({ value, onChange }: { value: string; onChange: (v: str
|
|||||||
<Space.Compact style={{ width: '100%' }}>
|
<Space.Compact style={{ width: '100%' }}>
|
||||||
<InputNumber
|
<InputNumber
|
||||||
min={1}
|
min={1}
|
||||||
|
precision={0}
|
||||||
style={{ width: '50%' }}
|
style={{ width: '50%' }}
|
||||||
value={state.num}
|
value={state.num}
|
||||||
onChange={(v) => update({ num: Math.max(1, Number(v) || 1) })}
|
onChange={onNumber((v) => update({ num: Math.max(1, v) }))}
|
||||||
aria-label={t('pages.settings.notifyTime.interval')}
|
aria-label={t('pages.settings.notifyTime.interval')}
|
||||||
/>
|
/>
|
||||||
<Select<Unit>
|
<Select<Unit>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
AppleOutlined,
|
AppleOutlined,
|
||||||
CopyOutlined,
|
CopyOutlined,
|
||||||
DownOutlined,
|
DownOutlined,
|
||||||
|
DownloadOutlined,
|
||||||
MoonFilled,
|
MoonFilled,
|
||||||
MoonOutlined,
|
MoonOutlined,
|
||||||
QrcodeOutlined,
|
QrcodeOutlined,
|
||||||
@@ -64,6 +65,8 @@ const subEmail = [...new Set(linkEmails.filter(Boolean))].join(', ');
|
|||||||
const datepicker = subData.datepicker || 'gregorian';
|
const datepicker = subData.datepicker || 'gregorian';
|
||||||
const announce = subData.announce || '';
|
const announce = subData.announce || '';
|
||||||
|
|
||||||
|
const appendRawView = (url: string) => `${url}${url.includes('?') ? '&' : '?'}view=raw`;
|
||||||
|
|
||||||
const isUnlimited = totalByte <= 0 && expireMs === 0;
|
const isUnlimited = totalByte <= 0 && expireMs === 0;
|
||||||
const isActive = (() => {
|
const isActive = (() => {
|
||||||
if (!enabled) return false;
|
if (!enabled) return false;
|
||||||
@@ -354,6 +357,15 @@ export default function SubPage() {
|
|||||||
{sId}
|
{sId}
|
||||||
</a>
|
</a>
|
||||||
<div className="sub-link-actions">
|
<div className="sub-link-actions">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
href={appendRawView(subJsonUrl)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
aria-label={t('download')}
|
||||||
|
title={t('download')}
|
||||||
|
/>
|
||||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copy(subJsonUrl)} aria-label={t('copy')} title={t('copy')} />
|
<Button size="small" icon={<CopyOutlined />} onClick={() => copy(subJsonUrl)} aria-label={t('copy')} title={t('copy')} />
|
||||||
<Popover
|
<Popover
|
||||||
trigger="click"
|
trigger="click"
|
||||||
@@ -386,6 +398,15 @@ export default function SubPage() {
|
|||||||
{sId}
|
{sId}
|
||||||
</a>
|
</a>
|
||||||
<div className="sub-link-actions">
|
<div className="sub-link-actions">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
href={appendRawView(subClashUrl)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
aria-label={t('download')}
|
||||||
|
title={t('download')}
|
||||||
|
/>
|
||||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copy(subClashUrl)} aria-label={t('copy')} title={t('copy')} />
|
<Button size="small" icon={<CopyOutlined />} onClick={() => copy(subClashUrl)} aria-label={t('copy')} title={t('copy')} />
|
||||||
<Popover
|
<Popover
|
||||||
trigger="click"
|
trigger="click"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Alert, Empty, Input, InputNumber, Select, Space, Switch, Tag } from 'antd';
|
import { Alert, Empty, Input, InputNumber, Select, Space, Switch, Tag } from 'antd';
|
||||||
|
|
||||||
|
import { onNumber } from '@/utils/onNumber';
|
||||||
import { SettingListItem } from '@/components/ui';
|
import { SettingListItem } from '@/components/ui';
|
||||||
import {
|
import {
|
||||||
BurstObservatorySchema,
|
BurstObservatorySchema,
|
||||||
@@ -195,7 +196,7 @@ export default function ObservatorySettingsTab({
|
|||||||
<InputNumber
|
<InputNumber
|
||||||
min={1}
|
min={1}
|
||||||
value={burst.pingConfig.sampling}
|
value={burst.pingConfig.sampling}
|
||||||
onChange={(v) => patchPingConfig({ sampling: typeof v === 'number' ? v : burst.pingConfig.sampling })}
|
onChange={onNumber((v) => patchPingConfig({ sampling: v }))}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
|||||||
@@ -52,6 +52,18 @@ function countLoopbackRefs(settings: XraySettingsValue, targetTag: string): numb
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isGeneralRoutingRule(rule: Record<string, unknown>): boolean {
|
||||||
|
const inboundTag = rule.inboundTag;
|
||||||
|
return !Array.isArray(inboundTag) || inboundTag.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstGeneralRoutingRuleIndex(rules: Array<Record<string, unknown>>): number {
|
||||||
|
for (let i = 0; i < rules.length; i += 1) {
|
||||||
|
if (isGeneralRoutingRule(rules[i])) return i;
|
||||||
|
}
|
||||||
|
return rules.length;
|
||||||
|
}
|
||||||
|
|
||||||
export function ensureBalancerLoopback(
|
export function ensureBalancerLoopback(
|
||||||
settings: XraySettingsValue,
|
settings: XraySettingsValue,
|
||||||
targetBalancerTag: string,
|
targetBalancerTag: string,
|
||||||
@@ -72,13 +84,19 @@ export function ensureBalancerLoopback(
|
|||||||
if (!settings.routing) settings.routing = { rules: [], balancers: [] };
|
if (!settings.routing) settings.routing = { rules: [], balancers: [] };
|
||||||
if (!Array.isArray(settings.routing.rules)) settings.routing.rules = [];
|
if (!Array.isArray(settings.routing.rules)) settings.routing.rules = [];
|
||||||
|
|
||||||
const existingRuleIdx = (settings.routing.rules as Array<{ inboundTag?: string[] }>).findIndex(
|
const rules = settings.routing.rules as Array<Record<string, unknown>>;
|
||||||
(r) => Array.isArray(r.inboundTag) && r.inboundTag.includes(lbTag),
|
|
||||||
|
const existingRuleIdx = rules.findIndex(
|
||||||
|
(r) => Array.isArray(r.inboundTag) && (r.inboundTag as string[]).includes(lbTag),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingRuleIdx >= 0) {
|
if (existingRuleIdx >= 0) {
|
||||||
(settings.routing.rules as Record<string, unknown>[])[existingRuleIdx].balancerTag = targetBalancerTag;
|
const existing = rules[existingRuleIdx];
|
||||||
|
existing.balancerTag = targetBalancerTag;
|
||||||
|
rules.splice(existingRuleIdx, 1);
|
||||||
|
rules.splice(firstGeneralRoutingRuleIndex(rules), 0, existing);
|
||||||
} else {
|
} else {
|
||||||
(settings.routing.rules as Record<string, unknown>[]).push({
|
rules.splice(firstGeneralRoutingRuleIndex(rules), 0, {
|
||||||
type: 'field',
|
type: 'field',
|
||||||
inboundTag: [lbTag],
|
inboundTag: [lbTag],
|
||||||
balancerTag: targetBalancerTag,
|
balancerTag: targetBalancerTag,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
import { onNumber } from '@/utils/onNumber';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Alert, Button, Input, InputNumber, Modal, Select, Space, Switch, Tabs } from 'antd';
|
import { Alert, Button, Input, InputNumber, Modal, Select, Space, Switch, Tabs } from 'antd';
|
||||||
import {
|
import {
|
||||||
@@ -216,10 +217,10 @@ export default function BasicsTab({
|
|||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
value={directHappyEyeballs.tryDelayMs}
|
value={directHappyEyeballs.tryDelayMs}
|
||||||
placeholder="150"
|
placeholder="150"
|
||||||
onChange={(v) => setDirectHappyEyeballs({
|
onChange={onNumber((v) => setDirectHappyEyeballs({
|
||||||
...directHappyEyeballs,
|
...directHappyEyeballs,
|
||||||
tryDelayMs: typeof v === 'number' ? v : 0,
|
tryDelayMs: v,
|
||||||
})}
|
}))}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Alert, Button, Empty, Input, InputNumber, Modal, Select, Space, Switch, Table, Tabs } from 'antd';
|
import { Alert, Button, Empty, Input, InputNumber, Modal, Select, Space, Switch, Table, Tabs } from 'antd';
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { onNumber } from '@/utils/onNumber';
|
||||||
import { SettingListItem } from '@/components/ui';
|
import { SettingListItem } from '@/components/ui';
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { catTabLabel } from '@/pages/settings/catTabLabel';
|
import { catTabLabel } from '@/pages/settings/catTabLabel';
|
||||||
@@ -41,6 +42,23 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
|
|||||||
|
|
||||||
const dns = (templateSettings?.dns as DnsConfig | undefined) ?? null;
|
const dns = (templateSettings?.dns as DnsConfig | undefined) ?? null;
|
||||||
const dnsEnabled = !!dns;
|
const dnsEnabled = !!dns;
|
||||||
|
const sourceHosts = dns?.hosts;
|
||||||
|
const incomingHosts = JSON.stringify(sourceHosts ?? {});
|
||||||
|
const lastWrittenHostsRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dnsEnabled) {
|
||||||
|
lastWrittenHostsRef.current = '{}';
|
||||||
|
setHostsList([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (incomingHosts === lastWrittenHostsRef.current) return;
|
||||||
|
lastWrittenHostsRef.current = incomingHosts;
|
||||||
|
setHostsList(Object.entries(sourceHosts ?? {}).map(([domain, values]) => ({
|
||||||
|
domain,
|
||||||
|
values: Array.isArray(values) ? [...values] : [String(values)],
|
||||||
|
})));
|
||||||
|
}, [dnsEnabled, incomingHosts, sourceHosts]);
|
||||||
|
|
||||||
const mutate = useCallback(
|
const mutate = useCallback(
|
||||||
(mutator: (next: XraySettingsValue) => void) => {
|
(mutator: (next: XraySettingsValue) => void) => {
|
||||||
@@ -78,32 +96,18 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!dns) {
|
|
||||||
setHostsList([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const src = dns.hosts || {};
|
|
||||||
setHostsList(
|
|
||||||
Object.entries(src).map(([domain, val]) => ({
|
|
||||||
domain,
|
|
||||||
values: Array.isArray(val) ? [...val] : [String(val)],
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [dnsEnabled]);
|
|
||||||
|
|
||||||
function syncHosts(next: HostRow[]) {
|
function syncHosts(next: HostRow[]) {
|
||||||
|
const obj: Record<string, string | string[]> = {};
|
||||||
|
for (const row of next) {
|
||||||
|
if (!row.domain) continue;
|
||||||
|
const vals = (row.values || []).filter(Boolean);
|
||||||
|
if (vals.length === 0) continue;
|
||||||
|
obj[row.domain] = vals.length === 1 ? vals[0] : vals;
|
||||||
|
}
|
||||||
|
lastWrittenHostsRef.current = JSON.stringify(obj);
|
||||||
setHostsList(next);
|
setHostsList(next);
|
||||||
mutate((tt) => {
|
mutate((tt) => {
|
||||||
if (!tt.dns) return;
|
if (!tt.dns) return;
|
||||||
const obj: Record<string, string | string[]> = {};
|
|
||||||
for (const row of next) {
|
|
||||||
if (!row.domain) continue;
|
|
||||||
const vals = (row.values || []).filter(Boolean);
|
|
||||||
if (vals.length === 0) continue;
|
|
||||||
obj[row.domain] = vals.length === 1 ? vals[0] : vals;
|
|
||||||
}
|
|
||||||
if (Object.keys(obj).length > 0) {
|
if (Object.keys(obj).length > 0) {
|
||||||
(tt.dns as DnsConfig).hosts = obj;
|
(tt.dns as DnsConfig).hosts = obj;
|
||||||
} else if ('hosts' in (tt.dns as DnsConfig)) {
|
} else if ('hosts' in (tt.dns as DnsConfig)) {
|
||||||
@@ -311,7 +315,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
|
|||||||
min={0}
|
min={0}
|
||||||
step={60}
|
step={60}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
onChange={(v) => setDnsField('serveExpiredTTL', Number(v) || 0)}
|
onChange={onNumber((v) => setDnsField('serveExpiredTTL', v))}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user