Merge remote-tracking branch 'upstream/main' into sync-3.6.0

# Conflicts:
#	.github/workflows/claude-bot.yml
#	.github/workflows/release.yml
#	DockerInit.sh
#	frontend/package-lock.json
#	frontend/package.json
#	frontend/src/hooks/useClients.ts
#	frontend/src/layouts/AppSidebar.tsx
#	frontend/src/main.tsx
#	internal/config/version
#	internal/database/model/model.go
#	internal/web/service/client_wireguard.go
#	internal/web/service/inbound.go
This commit is contained in:
Kuzz007
2026-08-01 21:59:29 +03:00
250 changed files with 12904 additions and 5650 deletions
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -124,7 +124,7 @@ jobs:
cd x-ui/bin
# 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
fetch ${Xray_URL}Xray-linux-64.zip
unzip Xray-linux-64.zip
+2 -1
View File
@@ -2,7 +2,8 @@
.idea/
.vscode/
.cursor/
.claude/*
.specify/
.claude/
.cache/
.sync*
+131
View File
@@ -96,6 +96,22 @@
"options": {
"cwd": "${workspaceFolder}"
},
"linux": {
"options": {
"cwd": "${workspaceFolder}",
"env": {
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
}
}
},
"osx": {
"options": {
"cwd": "${workspaceFolder}",
"env": {
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
}
}
},
"problemMatcher": [
"$go"
]
@@ -111,6 +127,22 @@
"options": {
"cwd": "${workspaceFolder}"
},
"linux": {
"options": {
"cwd": "${workspaceFolder}",
"env": {
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
}
}
},
"osx": {
"options": {
"cwd": "${workspaceFolder}",
"env": {
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
}
}
},
"problemMatcher": [
"$go"
]
@@ -125,6 +157,22 @@
"options": {
"cwd": "${workspaceFolder}"
},
"linux": {
"options": {
"cwd": "${workspaceFolder}",
"env": {
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
}
}
},
"osx": {
"options": {
"cwd": "${workspaceFolder}",
"env": {
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
}
}
},
"problemMatcher": [
"$go"
]
@@ -140,10 +188,93 @@
"options": {
"cwd": "${workspaceFolder}"
},
"linux": {
"options": {
"cwd": "${workspaceFolder}",
"env": {
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
}
}
},
"osx": {
"options": {
"cwd": "${workspaceFolder}",
"env": {
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
}
}
},
"problemMatcher": [
"$go"
]
},
{
"label": "go: install golangci-lint",
"type": "shell",
"command": "go",
"args": [
"install",
"github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest"
],
"options": {
"cwd": "${workspaceFolder}"
},
"linux": {
"options": {
"cwd": "${workspaceFolder}",
"env": {
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
}
}
},
"osx": {
"options": {
"cwd": "${workspaceFolder}",
"env": {
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
}
}
},
"problemMatcher": []
},
{
"label": "go: install modernize",
"type": "shell",
"command": "go",
"args": [
"install",
"golang.org/x/tools/gopls/internal/analysis/modernize/cmd/modernize@latest"
],
"options": {
"cwd": "${workspaceFolder}"
},
"linux": {
"options": {
"cwd": "${workspaceFolder}",
"env": {
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
}
}
},
"osx": {
"options": {
"cwd": "${workspaceFolder}",
"env": {
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
}
}
},
"problemMatcher": []
},
{
"label": "go: install tools",
"dependsOrder": "sequence",
"dependsOn": [
"go: install golangci-lint",
"go: install modernize"
],
"problemMatcher": []
},
{
"label": "frontend: ncu -u",
"type": "shell",
+15 -2
View File
@@ -57,8 +57,15 @@ file locations when it can answer in one hop.
`frontend/scripts/build-openapi.mjs`.
## Hard rules (non-negotiable)
- NO `//` line comments in committed Go/TS. Names carry meaning; rename instead
of annotating. Exempt: `//go:build`, `//go:generate`, and other directives.
- Fix size must match bug size. Find the root cause, then make the SMALLEST
change that removes it — a one-line guard beats a new subsystem. A small bug
does not earn new columns, jobs, abstractions, config knobs or helper layers.
If a fix genuinely needs new architecture, say so and get agreement first;
never ship it unasked next to the fix.
- Comments in committed Go/TS: 2 lines MAX per comment block. Make the name
carry the meaning first and rename rather than annotate; spend the 2 lines on
the *why* a name cannot hold — an invariant, an issue number, a non-obvious
constraint. Exempt: `//go:build`, `//go:generate`, and other directives.
HTML `<!-- -->` is fine. (A linter cannot enforce this — you must.)
- New `g.POST`/`g.GET` in `internal/web/controller/` REQUIRES a matching entry
in `frontend/src/pages/api-docs/endpoints.ts`, then `make gen` (or
@@ -83,6 +90,12 @@ file locations when it can answer in one hop.
`database.InitDB(filepath.Join(t.TempDir(), "x-ui.db"))` +
`t.Cleanup(func() { _ = database.CloseDB() })`; `httptest` for HTTP.
`internal/sub`'s `initSubDB(t)` is the template.
- A test must fail without its fix. Write it, revert the fix, watch it go red,
restore. A test that passes either way is worse than no test: it certifies
nothing and then gets cited as proof the fix works.
- Test what can actually break. No test for a getter, a constant, a rename, a
pure map lookup, or inputs the function can never receive. One real test that
drives the bug through the actual code path beats five that restate the code.
- Code must pass `golangci-lint run` (gofumpt + goimports formatting): `make lint`.
## Frontend conventions (summary; full version in frontend/CLAUDE.md)
+3 -3
View File
@@ -63,7 +63,7 @@ Two key ideas that explain most of the complexity:
**Frontend (`frontend/`):**
- **React 19** + **Ant Design 6** + **Vite 8** + **TypeScript**.
- Data layer: **TanStack Query** (`@tanstack/react-query`) over the native **Fetch API**; **Zod 4** schemas.
- Router: **react-router-dom 7**. Charts: **uPlot** (`frontend/src/components/viz/Sparkline.tsx`). Editor: **CodeMirror 6**.
- Router: **react-router 8**. Charts: **uPlot** (`frontend/src/components/viz/Sparkline.tsx`). Editor: **CodeMirror 6**.
- **Build output goes to `internal/web/dist/`** (see `vite.config.js``outDir`) and is
embedded into the Go binary with `go:embed`. Three HTML entries: `index.html` (panel SPA),
`login.html`, `subpage.html`. The Go server serves the SPA; there is no separate frontend
@@ -368,8 +368,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 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 |
| `@daily` | `clear_logs_job`, `periodic_traffic_reset_job("daily")` | IP-limit and Xray access/error log cleanup; traffic resets |
| `@weekly` / `@monthly` | `periodic_traffic_reset_job(...)` | Weekly/monthly 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` | `periodic_traffic_reset_job("weekly")` | Weekly traffic resets |
| default `@every 1m` | `ldap_sync_job` | Only if LDAP 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 |
+3
View File
@@ -40,6 +40,9 @@ See [Clients](/docs/config/clients).
Optionally cap total traffic and set an expiry date for the inbound, and choose a
periodic **traffic reset** schedule: `never` (default), `hourly`, `daily`,
`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>
</Steps>
+1 -1
View File
@@ -19,7 +19,7 @@ browser in full.
| `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**. |
| `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. |
After changing the port or base path, the panel URL becomes
+7
View File
@@ -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.
- **Wrong flow.** REALITY + XTLS-Vision needs `flow = xtls-rprx-vision` on both
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>
+3
View File
@@ -40,6 +40,9 @@ TLS یا REALITY) را انتخاب کنید. به [انتقال‌ها](/docs/c
به‌صورت اختیاری می‌توانید کل ترافیک را محدود کنید و یک تاریخ انقضا برای ورودی
تعیین کنید، و یک زمان‌بندی **بازنشانی ترافیک** دوره‌ای انتخاب کنید: `never`
(پیش‌فرض)، `hourly`، `daily`، `weekly` یا `monthly`.
برای بازنشانی `monthly`، روزی از ۱ تا ۳۱ انتخاب کنید. اگر آن روز در ماهی کوتاه‌تر
وجود نداشته باشد، بازنشانی در آخرین روز همان ماه انجام می‌شود.
</Step>
</Steps>
+1 -1
View File
@@ -19,7 +19,7 @@ icon: SlidersHorizontal
| `webBasePath` | `/` | مسیر URLی که پنل زیر آن ارائه می‌شود (همیشه به شکل `/…/` نرمال‌سازی می‌شود). |
| `webCertFile` / `webKeyFile` | _(هیچ‌کدام)_ | گواهی + کلید TLS. وقتی هر دو تنظیم شوند، پنل با **HTTPS** ارائه می‌شود. |
| `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 با نام مشخص. |
پس از تغییر پورت یا مسیر پایه، آدرس پنل به‌صورت
+7
View File
@@ -118,6 +118,13 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
- **نشت کلید خصوصی.** فقط و فقط **کلید عمومی** را میان کلاینت‌ها توزیع کنید.
- **جریان نادرست.** REALITY + XTLS-Vision به `flow = xtls-rprx-vision` هم در ورودیِ
مدخل کلاینت و هم در لینک اشتراک‌گذاری نیاز دارد.
- **هسته‌های قدیمی کلاینت به‌طور پیش‌فرض رد می‌شوند.** خالی گذاشتن
**حداقل نسخه کلاینت** به معنای «بدون محدودیت» نیست: Xray-core به حداقل داخلیِ
نسخهٔ هسته‌ای که اجرا می‌کنید (در نسخه‌های فعلی 26.3.27) بازمی‌گردد تا اثر انگشت‌های TLS کلاینت‌ها تازه
بمانند؛ در نتیجه هسته‌های شخص ثالث مانند Mihomo و sing-box حتی با پیکربندی
کاملاً درست در تأیید REALITY شکست می‌خورند — کلاینت‌ها تایم‌اوت می‌بینند و فقط
اپلیکیشن‌های مبتنی بر Xray-core وصل می‌شوند. تنها در صورت نیاز به پشتیبانی از
آن‌ها مقدار `1.0.0` را تنظیم کنید؛ این کار اثر انگشت‌های قدیمی را هم می‌پذیرد.
</Callout>
+3
View File
@@ -41,6 +41,9 @@ icon: ArrowDownToLine
При необходимости ограничьте общий объём трафика и установите дату истечения для
входящего подключения, а также выберите расписание периодического **сброса трафика**:
`never` (по умолчанию), `hourly`, `daily`, `weekly` или `monthly`.
Для сброса `monthly` выберите день от 1 до 31. Если выбранного дня нет в более
коротком месяце, сброс выполняется в последний день этого месяца.
</Step>
</Steps>
+1 -1
View File
@@ -19,7 +19,7 @@ icon: SlidersHorizontal
| `webBasePath` | `/` | URL-путь, по которому обслуживается панель (всегда нормализуется к `/…/`). |
| `webCertFile` / `webKeyFile` | _(нет)_ | Сертификат TLS + ключ. Когда заданы оба, панель обслуживается по **HTTPS**. |
| `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. |
После изменения порта или базового пути URL панели становится
+8
View File
@@ -123,6 +123,14 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
ключ.
- **Неправильный поток.** Для REALITY + XTLS-Vision нужен `flow = xtls-rprx-vision`
как в записи клиента входящего подключения, так и в ссылке для подключения.
- **Старые ядра клиентов отклоняются по умолчанию.** Пустое поле
**Мин. версия клиента** не означает «без ограничений»: Xray-core использует
встроенный минимум используемой сборки ядра (26.3.27 в текущих релизах),
который поддерживает свежесть
TLS-отпечатков клиентов, поэтому сторонние ядра, такие как Mihomo и sing-box,
не проходят проверку REALITY даже при корректной конфигурации — клиенты видят
таймауты, а подключаются только приложения на базе Xray-core. Ставьте `1.0.0`,
только если они вам необходимы; это также допустит устаревшие отпечатки.
</Callout>
+3
View File
@@ -37,6 +37,9 @@ icon: ArrowDownToLine
可选地为入站设置总流量上限和到期日期,并选择一个周期性的**流量重置**计划:
`never`(默认)、`hourly`、`daily`、`weekly` 或 `monthly`。
选择 `monthly` 时,可以指定每月 1 至 31 日重置。如果当月没有指定日期,
则在该月最后一天重置。
</Step>
</Steps>
+1 -1
View File
@@ -15,7 +15,7 @@ icon: SlidersHorizontal
| `webBasePath` | `/` | 面板对外提供服务所使用的 URL 路径(始终规范化为 `/…/`)。 |
| `webCertFile` / `webKeyFile` | _(无)_ | TLS 证书 + 密钥。两者都设置后,面板将以 **HTTPS** 提供服务。 |
| `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、地理/订阅拉取)。 |
更改端口或基础路径后,面板 URL 将变为
+1
View File
@@ -105,6 +105,7 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
- **SNI 不匹配。** SNI / server names 必须与目标站点的真实证书匹配,否则握手会暴露伪装。
- **私钥泄露。** 永远只把**公钥**分发给客户端。
- **流控设置错误。** REALITY + XTLS-Vision 要求在入站的客户端条目和分享链接上都设置 `flow = xtls-rprx-vision`。
- **旧客户端内核默认被拒。** **最小客户端版本**留空并不是“不限制”:Xray-core 会退回到所运行内核版本的内置最低值(当前版本为 26.3.27)以保证客户端 TLS 指纹的新鲜度,因此 Mihomo、sing-box 等第三方内核即使配置完全正确也会导致 REALITY 验证失败——表现为客户端超时,只有基于 Xray-core 的应用能连上。只有在必须支持它们时才填 `1.0.0`;这同时也会放行过时的指纹。
</Callout>
+177 -149
View File
@@ -6,6 +6,7 @@ settings:
overrides:
postcss@<8.5.10: ^8.5.15
sharp@<0.35.0: ^0.35.3
importers:
@@ -16,19 +17,19 @@ importers:
version: 3.1.18
fumadocs-core:
specifier: ^16.11.5
version: 16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3)
version: 16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3)
fumadocs-docgen:
specifier: ^3.1.0
version: 3.1.0(@types/estree@1.0.9)(@types/hast@3.0.5)(@types/mdast@4.0.4)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3))(mdast-util-mdx@3.0.0(supports-color@7.2.0))
version: 3.1.0(@types/estree@1.0.9)(@types/hast@3.0.5)(@types/mdast@4.0.4)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3))(mdast-util-mdx@3.0.0(supports-color@7.2.0))
fumadocs-mdx:
specifier: ^15.2.0
version: 15.2.0(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(supports-color@7.2.0)(vite@8.1.0(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))
version: 15.2.0(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(supports-color@7.2.0)(vite@8.1.0(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))
fumadocs-openapi:
specifier: ^11.2.2
version: 11.2.2(7c1fd77811020e629e283908335462bb)
version: 11.2.2(905c31216873909f632674fe18e3aac7)
fumadocs-ui:
specifier: ^16.11.5
version: 16.11.5(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)
version: 16.11.5(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)
lucide-react:
specifier: ^1.25.0
version: 1.25.0(react@19.2.8)
@@ -37,7 +38,7 @@ importers:
version: 11.16.0
next:
specifier: 16.2.11
version: 16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
version: 16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -518,152 +519,161 @@ packages:
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.34.5':
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
'@img/sharp-darwin-arm64@0.35.3':
resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.34.5':
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
'@img/sharp-darwin-x64@0.35.3':
resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.2.4':
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
'@img/sharp-freebsd-wasm32@0.35.3':
resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==}
engines: {node: '>=20.9.0'}
os: [freebsd]
'@img/sharp-libvips-darwin-arm64@1.3.2':
resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.2.4':
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
'@img/sharp-libvips-darwin-x64@1.3.2':
resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.2.4':
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
'@img/sharp-libvips-linux-arm64@1.3.2':
resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
'@img/sharp-libvips-linux-arm@1.3.2':
resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
'@img/sharp-libvips-linux-ppc64@1.3.2':
resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
'@img/sharp-libvips-linux-riscv64@1.3.2':
resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
'@img/sharp-libvips-linux-s390x@1.3.2':
resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
'@img/sharp-libvips-linux-x64@1.3.2':
resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
'@img/sharp-libvips-linuxmusl-arm64@1.3.2':
resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
'@img/sharp-libvips-linuxmusl-x64@1.3.2':
resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
'@img/sharp-linux-arm64@0.35.3':
resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
'@img/sharp-linux-arm@0.35.3':
resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==}
engines: {node: '>=20.9.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
'@img/sharp-linux-ppc64@0.35.3':
resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==}
engines: {node: '>=20.9.0'}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
'@img/sharp-linux-riscv64@0.35.3':
resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==}
engines: {node: '>=20.9.0'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
'@img/sharp-linux-s390x@0.35.3':
resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==}
engines: {node: '>=20.9.0'}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
'@img/sharp-linux-x64@0.35.3':
resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
'@img/sharp-linuxmusl-arm64@0.35.3':
resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
'@img/sharp-linuxmusl-x64@0.35.3':
resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
'@img/sharp-wasm32@0.35.3':
resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==}
engines: {node: '>=20.9.0'}
'@img/sharp-webcontainers-wasm32@0.35.3':
resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==}
engines: {node: '>=20.9.0'}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.34.5':
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
'@img/sharp-win32-arm64@0.35.3':
resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.34.5':
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
'@img/sharp-win32-ia32@0.35.3':
resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==}
engines: {node: ^20.9.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.34.5':
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
'@img/sharp-win32-x64@0.35.3':
resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [win32]
@@ -3968,9 +3978,14 @@ packages:
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
engines: {node: '>= 0.4'}
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
sharp@0.35.3:
resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==}
engines: {node: '>=20.9.0'}
peerDependencies:
'@types/node': '*'
peerDependenciesMeta:
'@types/node':
optional: true
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
@@ -4725,7 +4740,7 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.17
'@fumadocs/api-docs@0.2.1(4cf0c3492febbe4240fa9ee6953191d3)':
'@fumadocs/api-docs@0.2.1(4e04a1f3fe664854324546db6fca6427)':
dependencies:
'@base-ui/react': 1.6.0(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@fuma-translate/react': 1.0.2(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -4733,8 +4748,8 @@ snapshots:
'@scalar/json-magic': 0.12.19
class-variance-authority: 0.7.1
cnfast: 0.0.8
fumadocs-core: 16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3)
fumadocs-ui: 16.11.5(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)
fumadocs-core: 16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3)
fumadocs-ui: 16.11.5(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)
github-slugger: 2.0.0
lucide-react: 1.25.0(react@19.2.8)
react: 19.2.8
@@ -4785,98 +4800,108 @@ snapshots:
'@img/colour@1.1.0':
optional: true
'@img/sharp-darwin-arm64@0.34.5':
'@img/sharp-darwin-arm64@0.35.3':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.4
'@img/sharp-libvips-darwin-arm64': 1.3.2
optional: true
'@img/sharp-darwin-x64@0.34.5':
'@img/sharp-darwin-x64@0.35.3':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.4
'@img/sharp-libvips-darwin-x64': 1.3.2
optional: true
'@img/sharp-libvips-darwin-arm64@1.2.4':
'@img/sharp-freebsd-wasm32@0.35.3':
dependencies:
'@img/sharp-wasm32': 0.35.3
optional: true
'@img/sharp-libvips-darwin-x64@1.2.4':
'@img/sharp-libvips-darwin-arm64@1.3.2':
optional: true
'@img/sharp-libvips-linux-arm64@1.2.4':
'@img/sharp-libvips-darwin-x64@1.3.2':
optional: true
'@img/sharp-libvips-linux-arm@1.2.4':
'@img/sharp-libvips-linux-arm64@1.3.2':
optional: true
'@img/sharp-libvips-linux-ppc64@1.2.4':
'@img/sharp-libvips-linux-arm@1.3.2':
optional: true
'@img/sharp-libvips-linux-riscv64@1.2.4':
'@img/sharp-libvips-linux-ppc64@1.3.2':
optional: true
'@img/sharp-libvips-linux-s390x@1.2.4':
'@img/sharp-libvips-linux-riscv64@1.3.2':
optional: true
'@img/sharp-libvips-linux-x64@1.2.4':
'@img/sharp-libvips-linux-s390x@1.3.2':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
'@img/sharp-libvips-linux-x64@1.3.2':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
'@img/sharp-libvips-linuxmusl-arm64@1.3.2':
optional: true
'@img/sharp-linux-arm64@0.34.5':
'@img/sharp-libvips-linuxmusl-x64@1.3.2':
optional: true
'@img/sharp-linux-arm64@0.35.3':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.4
'@img/sharp-libvips-linux-arm64': 1.3.2
optional: true
'@img/sharp-linux-arm@0.34.5':
'@img/sharp-linux-arm@0.35.3':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.4
'@img/sharp-libvips-linux-arm': 1.3.2
optional: true
'@img/sharp-linux-ppc64@0.34.5':
'@img/sharp-linux-ppc64@0.35.3':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.2.4
'@img/sharp-libvips-linux-ppc64': 1.3.2
optional: true
'@img/sharp-linux-riscv64@0.34.5':
'@img/sharp-linux-riscv64@0.35.3':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.2.4
'@img/sharp-libvips-linux-riscv64': 1.3.2
optional: true
'@img/sharp-linux-s390x@0.34.5':
'@img/sharp-linux-s390x@0.35.3':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.2.4
'@img/sharp-libvips-linux-s390x': 1.3.2
optional: true
'@img/sharp-linux-x64@0.34.5':
'@img/sharp-linux-x64@0.35.3':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.4
'@img/sharp-libvips-linux-x64': 1.3.2
optional: true
'@img/sharp-linuxmusl-arm64@0.34.5':
'@img/sharp-linuxmusl-arm64@0.35.3':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
'@img/sharp-libvips-linuxmusl-arm64': 1.3.2
optional: true
'@img/sharp-linuxmusl-x64@0.34.5':
'@img/sharp-linuxmusl-x64@0.35.3':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
'@img/sharp-libvips-linuxmusl-x64': 1.3.2
optional: true
'@img/sharp-wasm32@0.34.5':
'@img/sharp-wasm32@0.35.3':
dependencies:
'@emnapi/runtime': 1.11.2
optional: true
'@img/sharp-win32-arm64@0.34.5':
'@img/sharp-webcontainers-wasm32@0.35.3':
dependencies:
'@img/sharp-wasm32': 0.35.3
optional: true
'@img/sharp-win32-ia32@0.34.5':
'@img/sharp-win32-arm64@0.35.3':
optional: true
'@img/sharp-win32-x64@0.34.5':
'@img/sharp-win32-ia32@0.35.3':
optional: true
'@img/sharp-win32-x64@0.35.3':
optional: true
'@jridgewell/gen-mapping@0.3.13':
@@ -6975,7 +7000,7 @@ snapshots:
fsevents@2.3.3:
optional: true
fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3):
fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3):
dependencies:
'@orama/orama': 3.1.18
estree-util-value-to-estree: 3.5.0
@@ -7001,18 +7026,18 @@ snapshots:
'@types/mdast': 4.0.4
'@types/react': 19.2.17
lucide-react: 1.25.0(react@19.2.8)
next: 16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
next: 16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
zod: 4.4.3
transitivePeerDependencies:
- supports-color
fumadocs-docgen@3.1.0(@types/estree@1.0.9)(@types/hast@3.0.5)(@types/mdast@4.0.4)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3))(mdast-util-mdx@3.0.0(supports-color@7.2.0)):
fumadocs-docgen@3.1.0(@types/estree@1.0.9)(@types/hast@3.0.5)(@types/mdast@4.0.4)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3))(mdast-util-mdx@3.0.0(supports-color@7.2.0)):
dependencies:
estree-util-to-js: 2.0.0
estree-util-value-to-estree: 3.5.0
fumadocs-core: 16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3)
fumadocs-core: 16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3)
npm-to-yarn: 3.1.0
oxc-transform: 0.138.0
unified: 11.0.5
@@ -7025,14 +7050,14 @@ snapshots:
'@types/mdast': 4.0.4
mdast-util-mdx: 3.0.0(supports-color@7.2.0)
fumadocs-mdx@15.2.0(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(supports-color@7.2.0)(vite@8.1.0(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)):
fumadocs-mdx@15.2.0(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(supports-color@7.2.0)(vite@8.1.0(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)):
dependencies:
'@mdx-js/mdx': 3.1.1(supports-color@7.2.0)
'@standard-schema/spec': 1.1.0
chokidar: 5.0.0
esbuild: 0.28.1
estree-util-value-to-estree: 3.5.0
fumadocs-core: 16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3)
fumadocs-core: 16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3)
github-slugger: 2.0.0
magic-string: 0.30.21
mdast-util-mdx: 3.0.0(supports-color@7.2.0)
@@ -7051,25 +7076,25 @@ snapshots:
'@types/mdast': 4.0.4
'@types/mdx': 2.0.14
'@types/react': 19.2.17
next: 16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
next: 16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react: 19.2.8
rolldown: 1.1.5
vite: 8.1.0(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)
transitivePeerDependencies:
- supports-color
fumadocs-openapi@11.2.2(7c1fd77811020e629e283908335462bb):
fumadocs-openapi@11.2.2(905c31216873909f632674fe18e3aac7):
dependencies:
'@fuma-translate/react': 1.0.2(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@fumadocs/api-docs': 0.2.1(4cf0c3492febbe4240fa9ee6953191d3)
'@fumadocs/api-docs': 0.2.1(4e04a1f3fe664854324546db6fca6427)
'@fumari/json-schema-ts': 1.0.2
'@fumari/stf': 1.1.0(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@scalar/json-magic': 0.12.19
chokidar: 5.0.0
class-variance-authority: 0.7.1
cnfast: 0.0.8
fumadocs-core: 16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3)
fumadocs-ui: 16.11.5(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)
fumadocs-core: 16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3)
fumadocs-ui: 16.11.5(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)
github-slugger: 2.0.0
hast-util-to-jsx-runtime: 2.3.6(supports-color@7.2.0)
lucide-react: 1.25.0(react@19.2.8)
@@ -7086,7 +7111,7 @@ snapshots:
- date-fns
- supports-color
fumadocs-ui@16.11.5(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3):
fumadocs-ui@16.11.5(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3):
dependencies:
'@fuma-translate/react': 1.0.2(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@fumadocs/tailwind': 0.1.1(tailwindcss@4.3.3)
@@ -7102,7 +7127,7 @@ snapshots:
'@radix-ui/react-tabs': 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
class-variance-authority: 0.7.1
cnfast: 0.0.8
fumadocs-core: 16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3)
fumadocs-core: 16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3)
lucide-react: 1.25.0(react@19.2.8)
motion: 12.42.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
next-themes: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -7116,7 +7141,7 @@ snapshots:
optionalDependencies:
'@types/mdx': 2.0.14
'@types/react': 19.2.17
next: 16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
next: 16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
transitivePeerDependencies:
- '@emotion/is-prop-valid'
- '@types/react-dom'
@@ -8187,7 +8212,7 @@ snapshots:
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies:
'@next/env': 16.2.11
'@swc/helpers': 0.5.15
@@ -8206,9 +8231,10 @@ snapshots:
'@next/swc-linux-x64-musl': 16.2.11
'@next/swc-win32-arm64-msvc': 16.2.11
'@next/swc-win32-x64-msvc': 16.2.11
sharp: 0.34.5
sharp: 0.35.3(@types/node@26.1.1)
transitivePeerDependencies:
- '@babel/core'
- '@types/node'
- babel-plugin-macros
node-exports-info@1.6.2:
@@ -8663,36 +8689,38 @@ snapshots:
es-errors: 1.3.0
es-object-atoms: 1.1.2
sharp@0.34.5:
sharp@0.35.3(@types/node@26.1.1):
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.8.5
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
'@img/sharp-libvips-darwin-arm64': 1.2.4
'@img/sharp-libvips-darwin-x64': 1.2.4
'@img/sharp-libvips-linux-arm': 1.2.4
'@img/sharp-libvips-linux-arm64': 1.2.4
'@img/sharp-libvips-linux-ppc64': 1.2.4
'@img/sharp-libvips-linux-riscv64': 1.2.4
'@img/sharp-libvips-linux-s390x': 1.2.4
'@img/sharp-libvips-linux-x64': 1.2.4
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
'@img/sharp-linux-arm': 0.34.5
'@img/sharp-linux-arm64': 0.34.5
'@img/sharp-linux-ppc64': 0.34.5
'@img/sharp-linux-riscv64': 0.34.5
'@img/sharp-linux-s390x': 0.34.5
'@img/sharp-linux-x64': 0.34.5
'@img/sharp-linuxmusl-arm64': 0.34.5
'@img/sharp-linuxmusl-x64': 0.34.5
'@img/sharp-wasm32': 0.34.5
'@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
'@img/sharp-darwin-arm64': 0.35.3
'@img/sharp-darwin-x64': 0.35.3
'@img/sharp-freebsd-wasm32': 0.35.3
'@img/sharp-libvips-darwin-arm64': 1.3.2
'@img/sharp-libvips-darwin-x64': 1.3.2
'@img/sharp-libvips-linux-arm': 1.3.2
'@img/sharp-libvips-linux-arm64': 1.3.2
'@img/sharp-libvips-linux-ppc64': 1.3.2
'@img/sharp-libvips-linux-riscv64': 1.3.2
'@img/sharp-libvips-linux-s390x': 1.3.2
'@img/sharp-libvips-linux-x64': 1.3.2
'@img/sharp-libvips-linuxmusl-arm64': 1.3.2
'@img/sharp-libvips-linuxmusl-x64': 1.3.2
'@img/sharp-linux-arm': 0.35.3
'@img/sharp-linux-arm64': 0.35.3
'@img/sharp-linux-ppc64': 0.35.3
'@img/sharp-linux-riscv64': 0.35.3
'@img/sharp-linux-s390x': 0.35.3
'@img/sharp-linux-x64': 0.35.3
'@img/sharp-linuxmusl-arm64': 0.35.3
'@img/sharp-linuxmusl-x64': 0.35.3
'@img/sharp-webcontainers-wasm32': 0.35.3
'@img/sharp-win32-arm64': 0.35.3
'@img/sharp-win32-ia32': 0.35.3
'@img/sharp-win32-x64': 0.35.3
'@types/node': 26.1.1
optional: true
shebang-command@2.0.0:
+1
View File
@@ -6,6 +6,7 @@ allowBuilds:
# release — fixes GHSA-qx2v-qp2m-jg93 / CVE-2026-41305 (vulnerable < 8.5.10).
overrides:
'postcss@<8.5.10': '^8.5.15'
'sharp@<0.35.0': '^0.35.3'
minimumReleaseAgeExclude:
- '@mermaid-js/parser@1.2.0'
- mermaid@11.16.0
+8
View File
@@ -411,6 +411,9 @@
"maximum": 65535,
"minimum": 1,
"type": "integer"
},
"subShowIdentityOnAllLinks": {
"type": "boolean"
}
},
"required": [
@@ -479,6 +482,7 @@
"subPort",
"subProfileUrl",
"subRoutingRules",
"subShowIdentityOnAllLinks",
"subSupportUrl",
"subThemeDir",
"subTitle",
@@ -916,6 +920,9 @@
"maximum": 65535,
"minimum": 1,
"type": "integer"
},
"subShowIdentityOnAllLinks": {
"type": "boolean"
}
},
"required": [
@@ -991,6 +998,7 @@
"subPort",
"subProfileUrl",
"subRoutingRules",
"subShowIdentityOnAllLinks",
"subSupportUrl",
"subThemeDir",
"subTitle",
+6 -5
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useLayoutEffect } from 'react';
import type { Decorator, Preview } from '@storybook/react-vite';
import { ConfigProvider } from 'antd';
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';
useEffect(() => {
document.body.setAttribute('class', dark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
useLayoutEffect(() => {
document.body.classList.remove('dark', 'light');
document.body.classList.add(dark ? 'dark' : 'light');
document.documentElement.removeAttribute('data-theme');
}, [dark]);
return (
<ConfigProvider theme={buildAntdThemeConfig(dark, false)}>
+33
View File
@@ -53,4 +53,37 @@ export default [
'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',
},
},
];
+19 -16
View File
@@ -1,7 +1,7 @@
{
"name": "3x-ui-frontend",
"private": true,
"version": "0.4.3",
"version": "0.6.0",
"type": "module",
"description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
"engines": {
@@ -30,11 +30,11 @@
"@ant-design/icons": "^6.3.2",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@hookform/resolvers": "^5.4.0",
"@hookform/resolvers": "^5.5.7",
"@noble/hashes": "^2.2.0",
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-query-devtools": "^5.101.4",
"antd": "^6.5.1",
"antd": "^6.5.2",
"codemirror": "^6.0.2",
"dayjs": "^1.11.21",
"i18next": "^26.3.6",
@@ -42,8 +42,8 @@
"persian-calendar-suite": "^1.5.5",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-hook-form": "^7.82.0",
"react-i18next": "^17.0.10",
"react-hook-form": "^7.83.0",
"react-i18next": "^17.0.11",
"react-router": "^8.3.0",
"swagger-ui-react": "^5.32.11",
"uplot": "^1.6.32",
@@ -51,10 +51,10 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@storybook/addon-a11y": "^10.5.3",
"@storybook/addon-docs": "^10.5.3",
"@storybook/addon-vitest": "^10.5.3",
"@storybook/react-vite": "^10.5.3",
"@storybook/addon-a11y": "^10.5.5",
"@storybook/addon-docs": "^10.5.5",
"@storybook/addon-vitest": "^10.5.5",
"@storybook/react-vite": "^10.5.5",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.17",
@@ -63,17 +63,17 @@
"@vitejs/plugin-react": "^6.0.4",
"@vitest/browser-playwright": "4.1.10",
"@vitest/coverage-v8": "^4.1.10",
"eslint": "^10.7.0",
"eslint": "^10.8.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.7.0",
"globals": "^17.8.0",
"husky": "^9.1.7",
"jsdom": "^29.1.1",
"lint-staged": "^17.1.1",
"jsdom": "^30.0.1",
"lint-staged": "^17.2.0",
"msw": "^2.15.0",
"playwright": "^1.61.1",
"storybook": "^10.5.3",
"typescript": "^6.0.3",
"playwright": "^1.62.0",
"storybook": "^10.5.5",
"typescript": "6.0.3",
"typescript-eslint": "^8.65.0",
"vite": "8.1.5",
"vitest": "^4.1.10"
@@ -93,6 +93,9 @@
},
"swagger-ui-react": {
"js-yaml": "^4.2.0"
},
"@typeschema/valibot": {
"valibot": "^1.1.0"
}
},
"allowScripts": {
+126 -2
View File
@@ -270,6 +270,9 @@
"subRoutingRules": {
"type": "string"
},
"subShowIdentityOnAllLinks": {
"type": "boolean"
},
"subSupportUrl": {
"type": "string"
},
@@ -441,6 +444,7 @@
"subPort",
"subProfileUrl",
"subRoutingRules",
"subShowIdentityOnAllLinks",
"subSupportUrl",
"subThemeDir",
"subTitle",
@@ -737,6 +741,9 @@
"subRoutingRules": {
"type": "string"
},
"subShowIdentityOnAllLinks": {
"type": "boolean"
},
"subSupportUrl": {
"type": "string"
},
@@ -915,6 +922,7 @@
"subPort",
"subProfileUrl",
"subRoutingRules",
"subShowIdentityOnAllLinks",
"subSupportUrl",
"subThemeDir",
"subTitle",
@@ -1899,6 +1907,13 @@
],
"type": "string"
},
"trafficResetDay": {
"description": "Day of month for monthly traffic resets",
"example": 1,
"maximum": 31,
"minimum": 1,
"type": "integer"
},
"up": {
"description": "Upload traffic in bytes",
"format": "int64",
@@ -1925,6 +1940,7 @@
"tag",
"total",
"trafficReset",
"trafficResetDay",
"up"
],
"type": "object"
@@ -3299,6 +3315,7 @@
"tag": "in-443-tcp",
"total": 0,
"trafficReset": "never",
"trafficResetDay": 1,
"up": 0
}
]
@@ -4112,6 +4129,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": {
"get": {
"tags": [
@@ -5793,7 +5840,7 @@
"tags": [
"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",
"parameters": [
{
@@ -5909,12 +5956,18 @@
"summary": {
"total": 2000,
"active": 1850,
"onlineCount": 1,
"depletedCount": 0,
"expiringCount": 0,
"deactiveCount": 150,
"online": [
"alice@example.com"
],
"depleted": [],
"expiring": [],
"deactive": []
"deactive": [
"bob@example.com"
]
}
}
}
@@ -5965,6 +6018,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": {
"post": {
"tags": [
@@ -9830,6 +9924,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": {
"post": {
"tags": [
+24 -2
View File
@@ -88,6 +88,28 @@ function encodeForm(data: unknown): string {
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(
method: string,
url: string,
@@ -121,8 +143,8 @@ async function performFetch(
}
const query = encodeForm(options.params);
const fullUrl = basePathPrefix + url + (query ? `?${query}` : '');
const signal = options.timeout ? AbortSignal.timeout(options.timeout) : options.signal;
const fullUrl = basePathPrefix + appendQuery(url, query);
const signal = requestSignal(options);
return fetch(fullUrl, { method: upper, headers, body, credentials: 'same-origin', signal });
}
+37 -20
View File
@@ -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 { HttpUtil, Msg } from '@/utils';
@@ -6,8 +6,13 @@ import { parseMsg } from '@/utils/zodValidate';
import { AllSetting } from '@/models/setting';
import { AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
import { keys } from '@/api/queryKeys';
import { useServerDraft } from '@/hooks/useServerDraft';
type SettingSavePayload = Partial<AllSetting> & Record<string, unknown>;
type SettingSaveResult = {
msg: Msg<unknown>;
saved?: AllSetting;
};
async function fetchAllSetting(): Promise<AllSettingInput | null> {
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() {
const queryClient = useQueryClient();
const [draft, setDraft] = useState<AllSetting>(() => new AllSetting());
const [extraSpinning, setExtraSpinning] = useState(false);
const query = useQuery({
@@ -28,41 +32,54 @@ export function useAllSettings() {
});
const server = useMemo(() => new AllSetting(query.data), [query.data]);
useEffect(() => {
if (query.data !== undefined) {
setDraft(new AllSetting(query.data));
}
}, [query.data]);
const { draft, setDraft, isDirty, markSaved } = useServerDraft(
query.data === undefined ? undefined : server,
(setting) => new AllSetting(setting),
(left, right) => left.equals(right),
);
const allSetting = draft ?? server;
const updateSetting = useCallback((patch: Partial<AllSetting>) => {
setDraft((prev) => {
const next = new AllSetting(prev);
const next = new AllSetting(prev ?? server);
Object.assign(next, patch);
return next;
});
}, []);
}, [server, setDraft]);
const saveMut = useMutation({
mutationFn: async (next: SettingSavePayload): Promise<Msg<unknown>> => {
const payload = { ...next };
const body = AllSettingSchema.partial().safeParse(payload);
mutationFn: async ({ payload, saved }: { payload: SettingSavePayload; saved?: AllSetting }): Promise<SettingSaveResult> => {
const next = { ...payload };
const body = AllSettingSchema.partial().safeParse(next);
if (!body.success) {
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) => {
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.settings.all() });
onSuccess: ({ msg, saved }) => {
if (!msg?.success) return;
if (saved) markSaved(saved);
queryClient.invalidateQueries({ queryKey: keys.settings.all() });
},
});
const saveAll = useCallback(() => saveMut.mutateAsync({ ...draft }), [saveMut, draft]);
const savePayload = useCallback((payload: SettingSavePayload) => saveMut.mutateAsync(payload), [saveMut]);
const saveDisabled = useMemo(() => server.equals(draft), [server, draft]);
const saveAll = useCallback(async () => {
const saved = new AllSetting(allSetting);
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 {
allSetting: draft,
allSetting,
updateSetting,
fetched: query.data !== undefined,
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,
});
}
+1
View File
@@ -17,6 +17,7 @@ export const keys = {
root: () => ['settings'] as const,
all: () => ['settings', 'all'] as const,
defaults: () => ['settings', 'defaults'] as const,
factoryDefaults: () => ['settings', 'factoryDefaults'] as const,
},
inbounds: {
root: () => ['inbounds'] as const,
@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Popover, Progress } from 'antd';
@@ -17,7 +17,11 @@ export interface ClientTrafficCellProps {
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,
down = 0,
total = 0,
@@ -83,4 +87,6 @@ export default function ClientTrafficCell({
</div>
</Popover>
);
}
});
export default ClientTrafficCell;
@@ -16,6 +16,10 @@ body.light .config-block .ant-tag.ant-tag-filled.ant-tag-gold {
color: #874d00;
}
body.light .config-block-text {
color: #595959;
}
.config-block .ant-collapse-extra {
display: flex;
align-items: center;
@@ -121,7 +121,9 @@ export default function DateTimePicker({
<DatePicker
value={value}
onChange={(next) => onChange(next || null)}
onCalendarChange={(next) => onChange((Array.isArray(next) ? next[0] : next) || null)}
showTime={showTime ? { format: 'HH:mm:ss' } : false}
needConfirm={false}
format={format}
placeholder={placeholder}
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 {
paddings?: 'small' | 'default';
title?: ReactNode;
badge?: ReactNode;
description?: ReactNode;
children?: ReactNode;
control?: ReactNode;
@@ -13,6 +14,7 @@ interface SettingListItemProps {
export default function SettingListItem({
paddings = 'default',
title,
badge,
description,
children,
control,
@@ -28,7 +30,12 @@ export default function SettingListItem({
<Row gutter={[8, 16]} style={{ width: '100%' }}>
<Col xs={24} lg={12}>
<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>}
</div>
</Col>
+1
View File
@@ -1,3 +1,4 @@
export { default as InputAddon } from './InputAddon';
export { default as InfinityIcon } from './InfinityIcon';
export { default as SettingListItem } from './SettingListItem';
export { default as DefaultSettingTag } from './DefaultSettingTag';
+17 -9
View File
@@ -48,6 +48,7 @@ interface SparklineProps {
yTickStep?: number;
tickCountX?: number;
showTooltip?: boolean;
showLegend?: boolean;
valueMin?: number;
valueMax?: number | null;
yFormatter?: (v: number) => string;
@@ -80,13 +81,23 @@ interface SparklineView {
extremaPoints: ExtremaResult | null;
}
function hexToRgba(hex: string, alpha: number): string {
let h = hex.trim();
function hexToRgba(color: string, alpha: number): string {
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.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);
if (Number.isNaN(int)) return hex;
if (Number.isNaN(int)) return trimmed;
const r = (int >> 16) & 255;
const g = (int >> 8) & 255;
const b = int & 255;
@@ -129,6 +140,7 @@ export default function Sparkline(props: SparklineProps) {
yTickStep = 25,
tickCountX = 4,
showTooltip = false,
showLegend = true,
valueMin = 0,
valueMax = 100,
yFormatter = (v: number) => `${Math.round(v)}%`,
@@ -542,10 +554,6 @@ export default function Sparkline(props: SparklineProps) {
);
}, [points, hasSeries2, hasSeries3, valueMin, valueMax]);
useEffect(() => {
plotRef.current?.redraw(false);
});
useEffect(() => {
const redraw = () => plotRef.current?.redraw(false);
const moBody = new MutationObserver(redraw);
@@ -570,7 +578,7 @@ export default function Sparkline(props: SparklineProps) {
</span>
</div>
)}
{legendItems.length > 0 && (
{showLegend && legendItems.length > 0 && (
<div className="sparkline-legend" aria-hidden="true">
{legendItems.map((s) => (
<span key={s.name} className="extrema-item" style={{ color: s.color }}> {s.name}</span>
+3
View File
@@ -75,6 +75,7 @@ export const EXAMPLES: Record<string, unknown> = {
"subPort": 1,
"subProfileUrl": "",
"subRoutingRules": "",
"subShowIdentityOnAllLinks": false,
"subSupportUrl": "",
"subThemeDir": "",
"subTitle": "",
@@ -186,6 +187,7 @@ export const EXAMPLES: Record<string, unknown> = {
"subPort": 1,
"subProfileUrl": "",
"subRoutingRules": "",
"subShowIdentityOnAllLinks": false,
"subSupportUrl": "",
"subThemeDir": "",
"subTitle": "",
@@ -460,6 +462,7 @@ export const EXAMPLES: Record<string, unknown> = {
"tag": "in-443-tcp",
"total": 0,
"trafficReset": "never",
"trafficResetDay": 1,
"up": 0
},
"InboundClientIps": {
+16
View File
@@ -244,6 +244,9 @@ export const SCHEMAS: Record<string, unknown> = {
"subRoutingRules": {
"type": "string"
},
"subShowIdentityOnAllLinks": {
"type": "boolean"
},
"subSupportUrl": {
"type": "string"
},
@@ -415,6 +418,7 @@ export const SCHEMAS: Record<string, unknown> = {
"subPort",
"subProfileUrl",
"subRoutingRules",
"subShowIdentityOnAllLinks",
"subSupportUrl",
"subThemeDir",
"subTitle",
@@ -711,6 +715,9 @@ export const SCHEMAS: Record<string, unknown> = {
"subRoutingRules": {
"type": "string"
},
"subShowIdentityOnAllLinks": {
"type": "boolean"
},
"subSupportUrl": {
"type": "string"
},
@@ -889,6 +896,7 @@ export const SCHEMAS: Record<string, unknown> = {
"subPort",
"subProfileUrl",
"subRoutingRules",
"subShowIdentityOnAllLinks",
"subSupportUrl",
"subThemeDir",
"subTitle",
@@ -1873,6 +1881,13 @@ export const SCHEMAS: Record<string, unknown> = {
],
"type": "string"
},
"trafficResetDay": {
"description": "Day of month for monthly traffic resets",
"example": 1,
"maximum": 31,
"minimum": 1,
"type": "integer"
},
"up": {
"description": "Upload traffic in bytes",
"format": "int64",
@@ -1899,6 +1914,7 @@ export const SCHEMAS: Record<string, unknown> = {
"tag",
"total",
"trafficReset",
"trafficResetDay",
"up"
],
"type": "object"
+3
View File
@@ -83,6 +83,7 @@ export interface AllSetting {
subPort: number;
subProfileUrl: string;
subRoutingRules: string;
subShowIdentityOnAllLinks: boolean;
subSupportUrl: string;
subThemeDir: string;
subTitle: string;
@@ -195,6 +196,7 @@ export interface AllSettingView {
subPort: number;
subProfileUrl: string;
subRoutingRules: string;
subShowIdentityOnAllLinks: boolean;
subSupportUrl: string;
subThemeDir: string;
subTitle: string;
@@ -435,6 +437,7 @@ export interface Inbound {
tag: string;
total: number;
trafficReset: string;
trafficResetDay: number;
up: number;
}
+3
View File
@@ -99,6 +99,7 @@ export const AllSettingSchema = z.object({
subPort: z.number().int().min(1).max(65535),
subProfileUrl: z.string(),
subRoutingRules: z.string(),
subShowIdentityOnAllLinks: z.boolean(),
subSupportUrl: z.string(),
subThemeDir: z.string(),
subTitle: z.string(),
@@ -212,6 +213,7 @@ export const AllSettingViewSchema = z.object({
subPort: z.number().int().min(1).max(65535),
subProfileUrl: z.string(),
subRoutingRules: z.string(),
subShowIdentityOnAllLinks: z.boolean(),
subSupportUrl: z.string(),
subThemeDir: z.string(),
subTitle: z.string(),
@@ -465,6 +467,7 @@ export const InboundSchema = z.object({
tag: z.string(),
total: z.number().int(),
trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']),
trafficResetDay: z.number().int().min(1).max(31),
up: z.number().int(),
});
export type Inbound = z.infer<typeof InboundSchema>;
+116 -25
View File
@@ -73,7 +73,9 @@ export interface ClientQueryParams {
const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 };
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 {
@@ -114,7 +116,63 @@ export function computeClientsSummary(
if (nearExpiry || nearLimit) expiring.push(email);
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 {
@@ -142,7 +200,7 @@ async function fetchClientPage(params: ClientQueryParams): Promise<ClientPageRes
const qs = buildQS(params);
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');
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');
return validated.obj;
}
@@ -161,17 +219,31 @@ async function fetchDefaults(): Promise<Record<string, unknown>> {
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 [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
// (the common React pattern) without triggering a re-fetch when nothing
// actually changed.
const setQuery = useCallback((next: ClientQueryParams) => {
setQueryState((prev) => {
if (
prev.page === next.page
prev
&& prev.page === next.page
&& prev.pageSize === next.pageSize
&& (prev.search ?? '') === (next.search ?? '')
&& (prev.filter ?? '') === (next.filter ?? '')
@@ -193,8 +265,9 @@ export function useClients() {
}, []);
const listQuery = useQuery({
queryKey: keys.clients.list(query),
queryFn: () => fetchClientPage(query),
queryKey: keys.clients.list(query ?? DEFAULT_QUERY),
queryFn: () => fetchClientPage(query ?? DEFAULT_QUERY),
enabled: withList && query !== null,
staleTime: Infinity,
// 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).
@@ -205,6 +278,7 @@ export function useClients() {
const inboundOptionsQuery = useQuery({
queryKey: keys.inbounds.options(),
queryFn: fetchInboundOptions,
enabled: withList,
staleTime: Infinity,
});
@@ -222,6 +296,7 @@ export function useClients() {
const validated = parseMsg(msg, OnlinesSchema, 'clients/onlines');
return Array.isArray(validated.obj) ? validated.obj : [];
},
enabled: withList,
staleTime: Infinity,
});
@@ -231,7 +306,11 @@ export function useClients() {
const allGroups = listQuery.data?.groups ?? [];
const fetched = listQuery.data !== undefined || listQuery.isError;
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
// table overlay so the 5s background poll doesn't flash it.
const transitioning = listQuery.isPlaceholderData;
@@ -264,11 +343,12 @@ export function useClients() {
const expireDiff = ((defaults.expireDiff as number) ?? 0) * 86400000;
const trafficDiff = ((defaults.trafficDiff as number) ?? 0) * 1073741824;
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 [clientSpeed, setClientSpeed] = useState<Record<string, ClientSpeedEntry>>({});
// AmneziaWG/MTProto run entirely outside xray-core, so their live speed
@@ -283,12 +363,10 @@ export function useClients() {
// exactly one job.
const [amneziawgClientSpeed, setAmneziawgClientSpeed] = useState<Record<string, ClientSpeedEntry>>({});
const [mtprotoClientSpeed, setMtprotoClientSpeed] = useState<Record<string, ClientSpeedEntry>>({});
const summary = useMemo<ClientsSummary>(() => {
const serverSummary = listQuery.data?.summary ?? DEFAULT_SUMMARY;
if (allClientStats.length === 0) return serverSummary;
const live = computeClientsSummary(allClientStats, new Set(onlines), expireDiff, trafficDiff);
return { ...live, total: serverSummary.total || live.total };
}, [allClientStats, onlines, expireDiff, trafficDiff, listQuery.data?.summary]);
const summary = useMemo<ClientsSummary>(
() => pickClientsSummary(listQuery.data?.summary ?? DEFAULT_SUMMARY, allClientStats, new Set(onlines), expireDiff, trafficDiff),
[allClientStats, onlines, expireDiff, trafficDiff, listQuery.data?.summary],
);
const invalidateAll = useCallback(
() => {
@@ -572,15 +650,23 @@ export function useClients() {
queryClient.setQueryData(keys.clients.onlines(), p.onlineClients);
}
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> = {};
for (const ct of p.clientTraffics) {
if (!ct || !ct.email) continue;
const up = ct.up || 0;
const down = ct.down || 0;
if (up === 0 && down === 0) continue;
next[ct.email] = {
up: (ct.up || 0) / TRAFFIC_POLL_INTERVAL_S,
down: (ct.down || 0) / TRAFFIC_POLL_INTERVAL_S,
up: up / TRAFFIC_POLL_INTERVAL_S,
down: down / TRAFFIC_POLL_INTERVAL_S,
};
}
setClientSpeed(next);
setClientSpeed((prev) => (sameSpeedMap(prev, next) ? prev : next));
}
// Mirrors the block above exactly, but as two independent, protocol-only
// maps (see the amneziawgClientSpeed/mtprotoClientSpeed declaration).
@@ -610,12 +696,17 @@ export function useClients() {
if (!payload || typeof payload !== 'object') return;
const p = payload as { clients?: ClientStatRow[]; snapshot?: boolean };
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>();
for (const row of p.clients) {
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;
let touched = false;
const next = prev.items.slice();
@@ -663,7 +754,6 @@ export function useClients() {
setQuery,
inbounds,
onlines,
loading,
transitioning,
fetched,
fetchError,
@@ -673,6 +763,7 @@ export function useClients() {
expireDiff,
trafficDiff,
pageSize,
settingsReady,
refresh,
create,
bulkCreate,
+37
View File
@@ -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 };
}
+24 -4
View File
@@ -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 { theme as antdTheme } from 'antd';
import type { ThemeConfig } from 'antd';
@@ -13,14 +13,18 @@ function readBool(key: string, fallback: boolean): 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) {
document.documentElement.setAttribute('data-theme', 'ultra-dark');
} else {
document.documentElement.removeAttribute('data-theme');
}
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.
@@ -92,9 +96,24 @@ const LIGHT_BUTTON_TOKENS = {
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 {
if (!isDark) {
return {
...SHARED_STYLE_CONFIG,
algorithm: antdTheme.defaultAlgorithm,
token: LIGHT_CONTRAST_TOKENS,
components: {
@@ -104,6 +123,7 @@ export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeCo
};
}
return {
...SHARED_STYLE_CONFIG,
algorithm: antdTheme.darkAlgorithm,
token: isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS,
components: {
@@ -142,7 +162,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const [isDark, setIsDark] = useState<boolean>(initialDark);
const [isUltra, setIsUltra] = useState<boolean>(initialUltra);
useEffect(() => {
useLayoutEffect(() => {
applyDom(isDark, isUltra);
localStorage.setItem(STORAGE_DARK, String(isDark));
localStorage.setItem(STORAGE_ULTRA, String(isUltra));
+25 -26
View File
@@ -14,7 +14,6 @@ import {
type OutboundTrafficRow,
} from '@/schemas/xray';
const DIRTY_POLL_MS = 1000;
const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
// One HTTP-mode batch request tests this many outbounds through a single
// 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.
const HTTP_BATCH_CHUNK = 16;
function normalizeOutboundTestUrl(url: string) {
return url || DEFAULT_TEST_URL;
}
export function isUdpOutbound(outbound: unknown): boolean {
const o = outbound as { protocol?: string; streamSettings?: { network?: string } } | null | undefined;
const p = o?.protocol;
@@ -125,10 +128,11 @@ export function useXraySetting(): UseXraySettingResult {
staleTime: Infinity,
});
const [saveDisabled, setSaveDisabled] = useState(true);
const [xraySetting, setXraySettingState] = useState('');
const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null);
const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
const [savedXraySetting, setSavedXraySetting] = useState('');
const [savedOutboundTestUrl, setSavedOutboundTestUrl] = useState(DEFAULT_TEST_URL);
const [inboundTags, setInboundTags] = useState<string[]>([]);
const [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
const [subscriptionOutbounds, setSubscriptionOutbounds] = useState<unknown[]>([]);
@@ -139,38 +143,40 @@ export function useXraySetting(): UseXraySettingResult {
const [subscriptionTestStates, setSubscriptionTestStates] = useState<Record<string, OutboundTestState>>({});
const [testingAll, setTestingAll] = useState(false);
const oldXraySettingRef = useRef('');
const oldOutboundTestUrlRef = useRef('');
const syncingRef = useRef(false);
const xraySettingRef = useRef('');
const outboundTestUrlRef = useRef(outboundTestUrl);
const savedXraySettingRef = useRef(savedXraySetting);
const savedOutboundTestUrlRef = useRef(savedOutboundTestUrl);
const templateSettingsRef = useRef<XraySettingsValue | null>(null);
const subscriptionOutboundsRef = useRef<unknown[]>([]);
xraySettingRef.current = xraySetting;
outboundTestUrlRef.current = outboundTestUrl;
savedXraySettingRef.current = savedXraySetting;
savedOutboundTestUrlRef.current = savedOutboundTestUrl;
templateSettingsRef.current = templateSettings;
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(() => {
if (!configQuery.data) return;
const obj = configQuery.data;
const pretty = JSON.stringify(obj.xraySetting, null, 2);
syncingRef.current = true;
setXraySettingState(pretty);
setTemplateSettingsState(obj.xraySetting);
oldXraySettingRef.current = pretty;
syncingRef.current = false;
const nextUrl = normalizeOutboundTestUrl(obj.outboundTestUrl || '');
setInboundTags(obj.inboundTags || []);
setClientReverseTags(obj.clientReverseTags || []);
setSubscriptionOutbounds(obj.subscriptionOutbounds || []);
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);
oldOutboundTestUrlRef.current = nextUrl;
setSaveDisabled(true);
setSavedOutboundTestUrl(nextUrl);
}, [configQuery.data]);
const fetched = configQuery.data !== undefined || configQuery.isError;
@@ -220,7 +226,7 @@ export function useXraySetting(): UseXraySettingResult {
const saveMut = useMutation({
mutationFn: async () => {
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', {
xraySetting: sentXraySetting,
outboundTestUrl: sentTestUrl,
@@ -229,9 +235,8 @@ export function useXraySetting(): UseXraySettingResult {
},
onSuccess: ({ msg, sentXraySetting, sentTestUrl }) => {
if (!msg?.success) return;
oldXraySettingRef.current = sentXraySetting;
oldOutboundTestUrlRef.current = sentTestUrl;
setSaveDisabled(true);
setSavedXraySetting(sentXraySetting);
setSavedOutboundTestUrl(sentTestUrl);
queryClient.invalidateQueries({ queryKey: keys.xray.config() });
},
});
@@ -425,14 +430,8 @@ export function useXraySetting(): UseXraySettingResult {
}
}, [testingAll, testOutbound, testSubscriptionOutbound, postOutboundTestBatch]);
useEffect(() => {
const timer = window.setInterval(() => {
const dirtyXray = oldXraySettingRef.current !== xraySettingRef.current;
const dirtyUrl = oldOutboundTestUrlRef.current !== outboundTestUrlRef.current;
setSaveDisabled(!(dirtyXray || dirtyUrl));
}, DIRTY_POLL_MS);
return () => window.clearInterval(timer);
}, []);
const saveDisabled = savedXraySetting === xraySetting
&& savedOutboundTestUrl === normalizeOutboundTestUrl(outboundTestUrl);
const outboundsTraffic = useMemo(() => trafficQuery.data ?? [], [trafficQuery.data]);
+69 -20
View File
@@ -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 {
position: sticky;
top: 0;
@@ -5,6 +12,16 @@
align-self: flex-start;
}
.ant-sidebar:not(.sidebar-pinned) > .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,
.drawer-brand {
font-weight: 600;
@@ -18,16 +35,12 @@
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 14px 16px 14px 24px;
height: 58px;
padding: 0 16px 0 24px;
border-bottom: 1px solid var(--ant-color-border-secondary);
user-select: none;
}
.sider-brand-collapsed {
justify-content: center;
font-size: 16px;
padding: 14px 4px;
letter-spacing: 0;
white-space: nowrap;
overflow: hidden;
}
.brand-block {
@@ -37,17 +50,21 @@
line-height: 1.1;
}
.sider-brand-collapsed .brand-block {
flex: 0 0 auto;
}
.brand-actions {
display: inline-flex;
align-items: center;
gap: 2px;
gap: 0;
flex-shrink: 0;
}
.brand-actions .sidebar-pin,
.brand-actions .sidebar-docs,
.brand-actions .sidebar-donate,
.brand-actions .sidebar-theme-cycle {
width: 26px;
height: 26px;
}
.sidebar-donate {
background: transparent;
border: none;
@@ -221,6 +238,34 @@
padding: 8px 8px 12px;
}
.sidebar-pin {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--ant-color-text-secondary);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.2s, transform 0.15s, color 0.2s;
}
.sidebar-pin:hover,
.sidebar-pin:focus-visible {
background-color: color-mix(in srgb, var(--ant-color-primary) 10%, transparent);
color: var(--ant-color-primary);
transform: scale(1.08);
outline: none;
}
.sidebar-pin .anticon {
font-size: 16px;
}
.sider-version {
display: flex;
align-items: center;
@@ -236,6 +281,11 @@
transition: color 0.2s;
}
.ant-layout-sider-collapsed .sider-version {
justify-content: center;
padding: 8px 0;
}
.sider-version .anticon {
font-size: 16px;
}
@@ -246,11 +296,6 @@
outline: none;
}
.sider-version.is-collapsed {
justify-content: center;
padding: 8px 0;
}
.drawer-footer {
flex: 0 0 auto;
padding: 8px 8px 12px;
@@ -261,8 +306,7 @@
display: inline-flex;
}
.ant-sidebar > .ant-layout-sider .ant-layout-sider-children,
.ant-sidebar > .ant-layout-sider .ant-layout-sider-trigger {
.ant-sidebar > .ant-layout-sider .ant-layout-sider-children {
display: none;
}
@@ -272,6 +316,11 @@
min-width: 0 !important;
width: 0 !important;
}
.ant-sidebar {
flex: 0 0 0;
width: 0;
}
}
body.dark .ant-drawer-content,
+77 -32
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ComponentType } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ComponentType, CSSProperties } from 'react';
import { useLocation, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { Drawer, Layout, Menu } from 'antd';
@@ -23,6 +23,8 @@ import {
MessageOutlined,
MoonFilled,
MoonOutlined,
PushpinFilled,
PushpinOutlined,
ReadOutlined,
SafetyOutlined,
SettingOutlined,
@@ -39,11 +41,15 @@ import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import { useAllSettings } from '@/api/queries/useAllSettings';
import './AppSidebar.css';
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
const DONATE_URL = 'https://donate.sanaei.dev/';
const DOCS_URL = 'https://docs.sanaei.dev/';
const REPO_URL = 'https://github.com/Kuzz007/3x-ui';
const LOGOUT_KEY = '__logout__';
const RAIL_WIDTH = 72;
const SIDER_WIDTH = 220;
const SIDEBAR_PINNED_KEY = 'sidebar-pinned';
let hoveredAcrossRemounts = false;
type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'hosts' | 'logout' | 'apidocs' | 'outbound' | 'routing';
@@ -62,14 +68,6 @@ const iconByName: Record<IconName, ComponentType> = {
routing: SwapOutlined,
};
function readCollapsed(): boolean {
try {
return JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false');
} catch {
return false;
}
}
function DonateButton({ ariaLabel }: { ariaLabel: string }) {
return (
<a
@@ -108,7 +106,7 @@ function VersionBadge({ version, collapsed }: { version: string; collapsed?: boo
href={REPO_URL}
target="_blank"
rel="noopener noreferrer"
className={`sider-version${collapsed ? ' is-collapsed' : ''}`}
className="sider-version"
aria-label={`GitHub ${label}`}
title={label}
>
@@ -140,6 +138,20 @@ function ThemeCycleButton({ id, isDark, isUltra, onCycle, ariaLabel }: {
);
}
function readSidebarPinned() {
try {
return localStorage.getItem(SIDEBAR_PINNED_KEY) === 'true';
} catch {
return false;
}
}
function saveSidebarPinned(pinned: boolean) {
try {
localStorage.setItem(SIDEBAR_PINNED_KEY, String(pinned));
} catch {}
}
export default function AppSidebar() {
const { t } = useTranslation();
const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
@@ -148,8 +160,34 @@ export default function AppSidebar() {
const { allSetting } = useAllSettings();
const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable);
const [collapsed, setCollapsed] = useState<boolean>(() => readCollapsed());
const [hovered, setHovered] = useState(() => hoveredAcrossRemounts);
const [pinned, setPinned] = useState(readSidebarPinned);
const [drawerOpen, setDrawerOpen] = useState(false);
const railCollapsed = !hovered && !pinned;
const railStyle = useMemo(
() => ({ '--sider-rail': `${pinned ? SIDER_WIDTH : RAIL_WIDTH}px` }) as CSSProperties,
[pinned],
);
const rootRef = useRef<HTMLDivElement>(null);
const updateHovered = useCallback((value: boolean) => {
hoveredAcrossRemounts = value;
setHovered(value);
}, []);
const togglePinned = useCallback(() => {
const next = !pinned;
saveSidebarPinned(next);
setPinned(next);
}, [pinned]);
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 panelVersion = window.X_UI_CUR_VER || '';
@@ -218,7 +256,7 @@ export default function AppSidebar() {
if (tab.key === '/xray') {
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]);
@@ -235,13 +273,6 @@ export default function AppSidebar() {
openLink(String(key));
}, [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) => {
pauseAnimationsUntilLeave(id);
if (!isDark) {
@@ -256,21 +287,35 @@ export default function AppSidebar() {
}, [isDark, isUltra, toggleTheme, toggleUltra]);
return (
<div className="ant-sidebar">
<div
ref={rootRef}
className={`ant-sidebar${pinned ? ' sidebar-pinned' : ''}`}
style={railStyle}
onMouseEnter={() => updateHovered(true)}
onMouseLeave={() => updateHovered(false)}
>
<Layout.Sider
theme={currentTheme}
width={220}
collapsible
collapsed={collapsed}
breakpoint="md"
onCollapse={onSiderCollapse}
width={SIDER_WIDTH}
collapsedWidth={RAIL_WIDTH}
collapsed={railCollapsed}
>
<div className={`sider-brand${collapsed ? ' sider-brand-collapsed' : ''}`}>
<div className="sider-brand">
<div className="brand-block">
<span className="brand-text">{collapsed ? '3X' : '3X-UI'}</span>
<span className="brand-text">{railCollapsed ? '3X' : '3X-UI'}</span>
</div>
{!collapsed && (
{!railCollapsed && (
<div className="brand-actions">
<button
type="button"
className="sidebar-pin"
aria-label={t('menu.pinSidebar')}
aria-pressed={pinned}
title={t(pinned ? 'menu.unpinSidebar' : 'menu.pinSidebar')}
onClick={togglePinned}
>
{pinned ? <PushpinFilled /> : <PushpinOutlined />}
</button>
<DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
<ThemeCycleButton
@@ -287,7 +332,7 @@ export default function AppSidebar() {
theme={currentTheme}
mode="inline"
selectedKeys={[selectedKey]}
openKeys={collapsed ? undefined : openKeys}
openKeys={railCollapsed ? undefined : openKeys}
onOpenChange={(keys) => setOpenKeys(keys as string[])}
className="sider-nav"
items={toMenuItems(navItems)}
@@ -302,7 +347,7 @@ export default function AppSidebar() {
onClick={onMenuClick}
/>
<div className="sider-footer">
<VersionBadge version={panelVersion} collapsed={collapsed} />
<VersionBadge version={panelVersion} collapsed={railCollapsed} />
</div>
</Layout.Sider>
@@ -82,12 +82,43 @@ function defaultTcpMaskSettings(type: string): Record<string, unknown> {
case 'header-custom':
return { clients: [], servers: [] };
case 'xmc':
return { hostname: '', usernames: [], password: RandomUtil.randomLowerAndNum(16) };
return { hostname: '', profiles: [defaultXmcProfile()], password: RandomUtil.randomLowerAndNum(16) };
default:
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
// 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
@@ -171,8 +202,8 @@ function defaultUdpHop(): Record<string, unknown> {
export default function FinalMaskForm({ name, network, protocol, form, showAll = false }: FinalMaskFormProps) {
const base = asPath(name);
// Migrate legacy single-range fragment masks to the per-segment arrays once
// on mount so configs saved before #6334 render in the list UI.
// Migrate legacy TCP mask shapes once on mount so configs saved before
// #6334 (fragment ranges) and #6487 (xmc profiles) render in the list UI.
const migratedRef = useRef(false);
useEffect(() => {
if (migratedRef.current) return;
@@ -183,8 +214,12 @@ export default function FinalMaskForm({ name, network, protocol, form, showAll =
const next = tcp.map((mask) => {
if (!mask || typeof mask !== 'object') return mask;
const m = mask as Record<string, unknown>;
if (m.type !== 'fragment' || !m.settings || typeof m.settings !== 'object') return mask;
const { next: migrated, changed } = migrateFragmentSettings(m.settings as Record<string, unknown>);
if (m.type !== 'fragment' && m.type !== 'xmc') return mask;
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;
anyChanged = true;
return { ...m, settings: migrated };
@@ -380,13 +415,7 @@ function TcpMaskItem({
<Form.Item label="Hostname" name={[fieldName, 'settings', 'hostname']}>
<Input placeholder="Server address mimicked in the handshake" />
</Form.Item>
<Form.Item
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>
<XmcProfilesList tcpFieldName={fieldName} />
<Form.Item label="Password" required>
<Space.Compact block>
<Form.Item
@@ -528,6 +557,92 @@ function getDeep(obj: unknown, path: (string | number)[]): unknown {
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({
tcpFieldName, form, absoluteSettingsPath,
}: {
@@ -1031,12 +1146,18 @@ function ItemEditor({
onRemove?: () => void;
}) {
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) => {
if (v === 'base64') {
form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64());
} else if (v === 'array') {
form.setFieldValue([...absoluteItemPath, 'rand'], delayMode === 'string' ? '1-8192' : 0);
form.setFieldValue([...absoluteItemPath, 'packet'], []);
form.setFieldValue([...absoluteItemPath, 'packet'], undefined);
} else {
form.setFieldValue([...absoluteItemPath, 'packet'], '');
}
+1 -1
View File
@@ -175,7 +175,7 @@ export function createDefaultShadowsocksInboundSettings(
// constructor — the field discriminates v1 vs v2 inside the same settings
// shape. Callers that explicitly want v1 pass `{ version: 1 }`.
export interface HysteriaInboundSeed {
version?: number;
version?: 2;
}
export function createDefaultHysteriaInboundSettings(
@@ -42,6 +42,7 @@ export interface RawInboundRow {
enable?: boolean;
expiryTime?: number;
trafficReset?: string;
trafficResetDay?: number;
lastTrafficResetTime?: number;
nodeId?: number | null;
shareAddrStrategy?: string;
@@ -61,6 +62,7 @@ export interface WireInboundPayload {
enable: boolean;
expiryTime: number;
trafficReset: TrafficReset;
trafficResetDay: number;
lastTrafficResetTime: number;
listen: string;
port: number;
@@ -203,6 +205,7 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
down: row.down ?? 0,
total: row.total ?? 0,
trafficReset: coerceTrafficReset(row.trafficReset),
trafficResetDay: Math.min(31, Math.max(1, row.trafficResetDay ?? 1)),
lastTrafficResetTime: row.lastTrafficResetTime ?? 0,
nodeId: row.nodeId ?? null,
shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy),
@@ -346,6 +349,7 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
enable: values.enable,
expiryTime: values.expiryTime,
trafficReset: values.trafficReset,
trafficResetDay: values.trafficResetDay,
lastTrafficResetTime: values.lastTrafficResetTime,
listen: values.listen,
port: values.port,
+17 -9
View File
@@ -43,8 +43,7 @@ function xhttpHostFallback(xhttp: XHttpStreamSettings | undefined): string {
// Pull the bidirectional SplitHTTPConfig fields out of xhttp into a
// compact extra payload. Server-only fields (noSSEHeader, scMaxBufferedPosts,
// scStreamUpServerSecs, serverMaxHeaderBytes) are excluded — the client
// reading the share link wouldn't honor them. Mirrors the legacy
// Inbound.buildXhttpExtra exactly so the shadow link snapshots line up.
// reading the share link wouldn't honor them.
function buildXhttpExtra(xhttp: XHttpStreamSettings | undefined): Record<string, unknown> | null {
if (!xhttp) return null;
const extra: Record<string, unknown> = {};
@@ -86,6 +85,15 @@ function buildXhttpExtra(xhttp: XHttpStreamSettings | undefined): Record<string,
const v = xhttp[k];
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
// SplitHTTPConfig.headers expects, dropping Host (already on the URL).
@@ -705,11 +713,12 @@ function hysteriaPinHex(pin: string): string {
}
}
// Hysteria share link: hysteria://<auth>@<host>:<port>?<query>#<remark>.
// The URL scheme is "hysteria2" when settings.version === 2 (hysteria v2
// AKA hysteria2), "hysteria" otherwise. Salamander obfuscation pulls its
// password from finalmask.udp[type=salamander] when present; the broader
// finalmask payload still rides under `fm` like the other links.
// Hysteria share link: hysteria2://<auth>@<host>:<port>?<query>#<remark>.
// The scheme is always hysteria2 — xray-core builds version 2 only, so the
// settings schema pins it there and the subscription server emits the same
// scheme. Salamander obfuscation pulls its password from
// 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,
// which isn't a field on TlsStreamSettings.Settings — the guard is always
@@ -728,8 +737,7 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string {
const stream = inbound.streamSettings;
if (!stream || stream.security !== 'tls') return '';
const settings = inbound.settings;
const scheme = settings.version === 2 ? 'hysteria2' : 'hysteria';
const scheme = 'hysteria2';
const params = new URLSearchParams();
params.set('security', 'tls');
@@ -104,6 +104,63 @@ export function validateRealityTarget(target: string): string | 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 {
const lift = (legacy: string, renamed: string) => {
const v = obj[legacy];
+1 -1
View File
@@ -1,5 +1,5 @@
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router';
import { RouterProvider } from 'react-router/dom';
import { message } from 'antd';
import 'antd/dist/reset.css';
import '@/styles/utils.css';
+3
View File
@@ -30,6 +30,7 @@ export type DBInboundInit = Partial<{
enable: boolean;
expiryTime: number;
trafficReset: string;
trafficResetDay: number;
lastTrafficResetTime: number;
listen: string;
port: number;
@@ -76,6 +77,7 @@ export class DBInbound {
enable: boolean;
expiryTime: number;
trafficReset: string;
trafficResetDay: number;
lastTrafficResetTime: number;
listen: string;
@@ -105,6 +107,7 @@ export class DBInbound {
this.enable = true;
this.expiryTime = 0;
this.trafficReset = "never";
this.trafficResetDay = 1;
this.lastTrafficResetTime = 0;
this.listen = "";
+3 -2
View File
@@ -14,6 +14,7 @@ export class AllSetting {
expireDiff = 0;
trafficDiff = 0;
remarkTemplate = '{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D';
subShowIdentityOnAllLinks = false;
datepicker: 'gregorian' | 'jalalian' = 'gregorian';
tgBotEnable = false;
tgBotToken = '';
@@ -90,7 +91,7 @@ export class AllSetting {
ldapDefaultTotalGB = 0;
ldapDefaultExpiryDays = 0;
ldapDefaultLimitIP = 0;
tgEnabledEvents = '';
tgEnabledEvents = 'login.attempt,cpu.high';
smtpEnable = false;
smtpHost = '';
smtpPort = 587;
@@ -100,7 +101,7 @@ export class AllSetting {
smtpFromName = '';
smtpTo = '';
smtpEncryptionType = 'starttls';
smtpEnabledEvents = '';
smtpEnabledEvents = 'login.attempt,cpu.high';
smtpCpu = 80;
smtpMemory = 80;
outboundDownThreshold = 3;
+8 -3
View File
@@ -1,5 +1,10 @@
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 {
current: number;
total: number;
@@ -16,9 +21,9 @@ export class CurTotal {
get color(): string {
const p = this.percent;
if (p < 80) return '#1677ff';
if (p < 90) return '#faad14';
return '#ff4d4f';
if (p < USAGE_WARN_PERCENT) return '#1677ff';
if (p < USAGE_CRIT_PERCENT) return USAGE_WARN_COLOR;
return USAGE_CRIT_COLOR;
}
}
+22 -2
View File
@@ -254,6 +254,11 @@ export const sections: readonly Section[] = [
description:
'System status, log retrieval, certificate generators, Xray binary management, and backup/restore. All under /panel/api/server.',
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',
path: '/panel/api/server/status',
@@ -559,7 +564,7 @@ export const sections: readonly Section[] = [
{
method: 'GET',
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: [
{ 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.' },
@@ -570,7 +575,7 @@ export const sections: readonly Section[] = [
{ name: 'order', in: 'query', type: 'string', desc: 'ascend or descend.' },
],
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',
@@ -582,6 +587,16 @@ export const sections: readonly Section[] = [
response:
'{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5],\n "externalLinks": [{ "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',
path: '/panel/api/clients/add',
@@ -1154,6 +1169,11 @@ export const sections: readonly Section[] = [
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.',
},
{
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',
path: '/panel/api/setting/update',
@@ -56,7 +56,7 @@ export default function ClientBulkAddModal({
}: ClientBulkAddModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const { bulkCreate } = useClients();
const { bulkCreate } = useClients({ list: false });
const methods = useForm<ClientBulkAddFormValues>({ defaultValues: EMPTY });
const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' });
@@ -16,6 +16,13 @@
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 {
display: flex;
flex-wrap: wrap;
+128 -99
View File
@@ -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 {
Badge,
@@ -16,7 +16,6 @@ import {
Result,
Row,
Select,
Space,
Spin,
Statistic,
Switch,
@@ -80,12 +79,14 @@ const BulkAttachInboundsModal = lazy(() => import('./BulkAttachInboundsModal'));
const BulkDetachInboundsModal = lazy(() => import('./BulkDetachInboundsModal'));
const TextModal = lazy(() => import('@/components/feedback/TextModal'));
const PromptModal = lazy(() => import('@/components/feedback/PromptModal'));
import { ClientInboundChips, ClientRowActions } from './RowCells';
import { emptyFilters, activeFilterCount } from './filters';
import type { ClientFilters } from './filters';
import './ClientsPage.css';
const FILTER_STATE_KEY = 'clientsFilterState';
const DISABLED_PAGE_SIZE = 200;
const DEFAULT_TABLE_PAGE_SIZE = 25;
function UngroupIcon() {
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';
interface PersistedFilterState {
searchKey: string;
filters: ClientFilters;
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> = {
@@ -148,6 +166,9 @@ const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
tunnel: 'orange',
};
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 {
try {
@@ -165,9 +186,10 @@ function readFilterState(): PersistedFilterState {
groups: Array.isArray(fromRaw.groups) ? fromRaw.groups : [],
},
sort: typeof raw.sort === 'string' ? raw.sort : '',
pageSize: typeof raw.pageSize === 'number' && raw.pageSize > 0 ? raw.pageSize : null,
};
} catch {
return { searchKey: '', filters: emptyFilters(), sort: '' };
return { searchKey: '', filters: emptyFilters(), sort: '', pageSize: null };
}
}
@@ -206,11 +228,11 @@ export default function ClientsPage() {
const {
clients, total, filtered,
summary: serverSummary,
summary,
allGroups,
setQuery,
inbounds, onlines, loading, transitioning, fetched, fetchError, subSettings,
tgBotEnable, expireDiff, trafficDiff, pageSize,
inbounds, onlines, transitioning, fetched, fetchError, subSettings,
tgBotEnable, expireDiff, trafficDiff, pageSize, settingsReady,
create, update, remove, bulkDelete, bulkAdjust, bulkEnable, bulkDisable, bulkAddToGroup, bulkRemoveFromGroup, attach, setExternalLinks, bulkAttach, detach, bulkDetach,
resetTraffic, resetAllTraffics, delDepleted, delOrphans, exportClients, importClients, setEnable,
clientSpeed,
@@ -266,14 +288,31 @@ export default function ClientsPage() {
const [sortColumn, setSortColumn] = useState<string | null>(initialSort.column);
const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(initialSort.order);
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
// keystroke; the search box still feels instant locally.
const [debouncedSearch, setDebouncedSearch] = useState(searchKey);
useEffect(() => {
localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({ searchKey, filters, sort: sortValueFor(sortColumn, sortOrder) }));
}, [searchKey, filters, sortColumn, sortOrder]);
localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
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(() => {
const handle = window.setTimeout(() => setDebouncedSearch(searchKey), 300);
@@ -304,6 +343,10 @@ export default function ClientsPage() {
}, [filters.nodeIds, filters.inboundIds, inbounds]);
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({
page: currentPage,
pageSize: tablePageSize,
@@ -322,13 +365,21 @@ export default function ClientsPage() {
sort: sortColumn || 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);
useEffect(() => {
setTablePageSize(pageSize > 0 ? pageSize : DISABLED_PAGE_SIZE);
}, [pageSize]);
// Row handlers take an email and look the row up here at call time. Keying
// them on the record object instead would defeat the memoised cells: every
// 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 inboundsById = useMemo(() => {
@@ -386,9 +437,6 @@ export default function ClientsPage() {
// a rename.
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
// order, so we just hand it through.
const sortedClients = filteredClients;
@@ -458,7 +506,9 @@ export default function ClientsPage() {
setFormOpen(true);
}
async function onEdit(row: ClientRecord) {
const onEdit = useCallback(async (email: string) => {
const row = rowsByEmail.current.get(email);
if (!row) return;
setFormMode('edit');
// Paged list omits per-client secrets to keep the row payload tiny;
// edit needs them, so fetch the full record first.
@@ -469,9 +519,11 @@ export default function ClientsPage() {
setEditingAttachedIds([...ids]);
setEditingExternalLinks(Array.isArray(full?.externalLinks) ? [...full.externalLinks] : []);
setFormOpen(true);
}
}, [hydrate]);
function onDelete(row: ClientRecord) {
const onDelete = useCallback((email: string) => {
const row = rowsByEmail.current.get(email);
if (!row) return;
modal.confirm({
title: t('pages.clients.deleteConfirmTitle', { email: row.email }),
content: t('pages.clients.deleteConfirmContent'),
@@ -483,9 +535,10 @@ export default function ClientsPage() {
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) {
messageApi.warning(t('pages.clients.resetNotPossible'));
return;
@@ -500,19 +553,33 @@ export default function ClientsPage() {
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);
setInfoClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
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);
setQrClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
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 }) => {
setTextTitle(opts.title);
@@ -747,7 +814,7 @@ export default function ClientsPage() {
const onTableChange: NonNullable<TableProps<ClientRecord>['onChange']> = (pag) => {
if (pag?.current) setCurrentPage(pag.current);
if (pag?.pageSize) setTablePageSize(pag.pageSize);
if (pag?.pageSize) setPageSizeChoice(pag.pageSize);
};
const columns = useMemo<ColumnsType<ClientRecord>>(() => [
@@ -756,23 +823,14 @@ export default function ClientsPage() {
key: 'actions',
width: 200,
render: (_v, record) => (
<Space size={4}>
<Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} onClick={() => onShowQr(record)} />
</Tooltip>
<Tooltip title={t('pages.clients.clientInfo')}>
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<InfoCircleOutlined />} aria-label={t('pages.clients.clientInfo')} onClick={() => onShowInfo(record)} />
</Tooltip>
<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>
<ClientRowActions
email={record.email}
onShowQr={onShowQr}
onShowInfo={onShowInfo}
onResetTraffic={onResetTraffic}
onEdit={onEdit}
onDelete={onDelete}
/>
),
},
{
@@ -854,42 +912,13 @@ export default function ClientsPage() {
key: 'inboundIds',
width: 170,
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 (
<>
{visible.map((id) => chip(id, true))}
{overflow.length > 0 && (
<Popover
trigger="click"
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>
)}
</>
<ClientInboundChips
ids={record.inboundIds || EMPTY_INBOUND_IDS}
inboundsById={inboundsById}
protocolColors={INBOUND_PROTOCOL_COLORS}
chipLimit={INBOUND_CHIP_LIMIT}
/>
);
},
},
@@ -998,7 +1027,7 @@ export default function ClientsPage() {
status="error"
title={t('somethingWentWrong')}
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]}>
@@ -1011,37 +1040,37 @@ export default function ClientsPage() {
<Col xs={12} sm={8} md={4}>
<Popover
title={t('online')}
open={summary.online.length ? undefined : false}
content={<div className="client-email-list">{summary.online.map((e) => <div key={e}>{e}</div>)}</div>}
open={summary.onlineCount ? undefined : false}
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>
</Col>
<Col xs={12} sm={8} md={4}>
<Popover
title={t('depleted')}
open={summary.depleted.length ? undefined : false}
content={<div className="client-email-list">{summary.depleted.map((e) => <div key={e}>{e}</div>)}</div>}
open={summary.depletedCount ? undefined : false}
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>
</Col>
<Col xs={12} sm={8} md={4}>
<Popover
title={t('depletingSoon')}
open={summary.expiring.length ? undefined : false}
content={<div className="client-email-list">{summary.expiring.map((e) => <div key={e}>{e}</div>)}</div>}
open={summary.expiringCount ? undefined : false}
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>
</Col>
<Col xs={12} sm={8} md={4}>
<Popover
title={t('disabled')}
open={summary.deactive.length ? undefined : false}
content={<div className="client-email-list">{summary.deactive.map((e) => <div key={e}>{e}</div>)}</div>}
open={summary.deactiveCount ? undefined : false}
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>
</Col>
<Col xs={12} sm={8} md={4}>
@@ -1368,7 +1397,7 @@ export default function ClientsPage() {
showTotal={(n) => `${n}`}
onChange={(p, s) => {
setCurrentPage(p);
if (s && s !== tablePageSize) setTablePageSize(s);
if (s && s !== tablePageSize) setPageSizeChoice(s);
}}
/>
</div>
@@ -1395,8 +1424,8 @@ export default function ClientsPage() {
role="button"
tabIndex={0}
aria-label={t('pages.clients.clientInfo')}
onClick={() => onShowInfo(row)}
onKeyDown={activateOnKey(() => onShowInfo(row))}
onClick={() => onShowInfo(row.email)}
onKeyDown={activateOnKey(() => onShowInfo(row.email))}
/>
</Tooltip>
<Switch
@@ -1413,23 +1442,23 @@ export default function ClientsPage() {
{
key: 'qr',
label: <><QrcodeOutlined /> {t('pages.clients.qrCode')}</>,
onClick: () => onShowQr(row),
onClick: () => onShowQr(row.email),
},
{
key: 'reset',
label: <><RetweetOutlined /> {t('pages.inbounds.resetTraffic')}</>,
onClick: () => onResetTraffic(row),
onClick: () => onResetTraffic(row.email),
},
{
key: 'edit',
label: <><EditOutlined /> {t('edit')}</>,
onClick: () => onEdit(row),
onClick: () => onEdit(row.email),
},
{
key: 'delete',
danger: true,
label: <><DeleteOutlined /> {t('delete')}</>,
onClick: () => onDelete(row),
onClick: () => onDelete(row.email),
},
],
}}
+154
View File
@@ -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>
)}
</>
);
});
+1 -1
View File
@@ -93,7 +93,7 @@ export default function GroupsPage() {
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const queryClient = useQueryClient();
const { subSettings, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, bulkDelete } = useClients();
const { subSettings, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, bulkDelete } = useClients({ list: false });
const groupsQuery = useQuery({
queryKey: keys.clients.groups(),
@@ -33,6 +33,7 @@ import {
isSS2022,
} from '@/lib/xray/protocol-capabilities';
import {
InboundDbFieldsSchema,
InboundFormBaseSchema,
InboundFormSchema,
type InboundFormValues,
@@ -256,6 +257,7 @@ export default function InboundFormModal({
const wTunnelNetwork = useWatch({ control, name: 'settings.allowedNetwork' });
const wTotal = (useWatch({ control, name: 'total' }) 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 lastWrittenTagRef = useRef('');
const currentTagInput = (): InboundTagInput => ({
@@ -697,6 +699,16 @@ export default function InboundFormModal({
/>
</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
label={
<Tooltip title={t('pages.inbounds.leaveBlankToNeverExpire')}>
@@ -1,11 +1,16 @@
import { useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Collapse, Descriptions, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { RadarChartOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { FormField } from '@/components/form/rhf';
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 RealityTargetScannerModal from './RealityTargetScannerModal';
@@ -39,7 +44,14 @@ export default function RealityForm({
clearMldsa65,
}: RealityFormProps) {
const { t } = useTranslation();
const { getFieldState, trigger } = useFormContext();
const [scannerOpen, setScannerOpen] = useState(false);
const maxClientVerPath = 'streamSettings.realitySettings.maxClientVer';
const revalidateMaxClientVer = () => {
if (getFieldState(maxClientVerPath).error) {
void trigger(maxClientVerPath);
}
};
return (
<>
<FormField
@@ -127,14 +139,31 @@ export default function RealityForm({
<FormField
name={['streamSettings', 'realitySettings', '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" />
</FormField>
<FormField
name={['streamSettings', 'realitySettings', '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>
<Form.Item label={t('pages.inbounds.form.shortIds')}>
<Space.Compact block style={{ display: 'flex' }}>
@@ -265,11 +265,11 @@ export default function XhttpForm() {
>
<Select
options={[
{ value: '', label: 'Default (body)' },
{ value: '', label: 'Default (auto)' },
{ value: 'auto', label: 'auto' },
{ value: 'body', label: 'body' },
{ value: 'header', label: 'header' },
{ value: 'cookie', label: 'cookie' },
{ value: 'query', label: 'query' },
]}
/>
</FormField>
@@ -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>
);
}
+448 -29
View File
@@ -1,51 +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) {
.index-page .content-area {
padding: 12px;
padding-top: 64px;
}
.index-page {
--ov-gap: 8px;
--ov-pad: 14px;
}
}
.index-page .action {
cursor: pointer;
justify-content: center;
max-width: 100%;
flex-wrap: nowrap;
.ov-page {
display: flex;
flex-direction: column;
gap: var(--ov-gap);
}
.index-page .action > span:not(.anticon) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
/* — action bar — */
.ov-bar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.index-page .action-update {
color: var(--ant-color-warning);
font-weight: 600;
}
.index-page .action-update .anticon {
color: var(--ant-color-warning);
}
.index-page .history-tag {
cursor: pointer;
.ov-state {
display: inline-flex;
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;
}
.index-page .ip-toggle-icon {
cursor: pointer;
font-size: 16px;
.ov-error-detail {
white-space: pre-wrap;
word-break: break-word;
}
.index-page .ip-hidden .ant-statistic-content-value {
filter: blur(6px);
.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;
white-space: nowrap;
}
@media (max-width: 560px) {
.ov-wide-foot {
flex-direction: column;
gap: 10px;
}
.ov-wide-foot .ov-foot-sep {
display: none;
}
}
/* — system strip — */
/* 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;
}
.index-page .ip-visible .ant-statistic-content-value {
filter: none;
.ov-ip-v6 {
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);
}
+124 -309
View File
@@ -1,53 +1,29 @@
import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, ConfigProvider, Layout, Modal, Result, Spin, message } from 'antd';
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,
TelegramFilled,
CloudDownloadOutlined,
DashboardOutlined,
DatabaseOutlined,
HddOutlined,
SwapOutlined,
} from '@ant-design/icons';
import { HttpUtil, SizeFormatter, TimeFormatter, ClipboardManager, FileManager } from '@/utils';
import { formatPanelVersion } from '@/lib/panel-version';
import { activateOnKey } from '@/utils/a11y';
import { HttpUtil, CPUFormatter, SizeFormatter, ClipboardManager, FileManager } from '@/utils';
import { USAGE_CRIT_COLOR, USAGE_CRIT_PERCENT, USAGE_WARN_COLOR, USAGE_WARN_PERCENT } from '@/models/status';
import { useTheme } from '@/hooks/useTheme';
import { useStatusQuery } from '@/api/queries/useStatusQuery';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import AppSidebar from '@/layouts/AppSidebar';
import { LazyMount } from '@/components/utility';
import { setMessageInstance } from '@/utils/messageBus';
import StatusCard from './StatusCard';
import XrayStatusCard from './XrayStatusCard';
import OverviewActionBar from './OverviewActionBar';
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';
const JsonEditor = lazy(() => import('@/components/form/JsonEditor'));
const PanelUpdateModal = lazy(() => import('./PanelUpdateModal'));
@@ -90,6 +66,8 @@ export default function IndexPage() {
const [loading, setLoading] = useState(false);
const [loadingTip, setLoadingTip] = useState(t('loading'));
const history = useOverviewHistory(status, fetched && !fetchError);
useEffect(() => {
HttpUtil.post<{ accessLogEnable?: boolean; devChannelEnable?: boolean }>(
'/panel/api/setting/defaultSettings',
@@ -127,10 +105,6 @@ export default function IndexPage() {
await refresh();
}, [refresh]);
function openPanelVersion() {
setPanelUpdateOpen(true);
}
async function handleChannelChange(dev: boolean) {
const res = await HttpUtil.post('/panel/api/server/setUpdateChannel', { dev });
if (!res?.success) return;
@@ -139,10 +113,6 @@ export default function IndexPage() {
if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
}
function openTelegram() {
window.open('https://t.me/XrayUI', '_blank', 'noopener,noreferrer');
}
async function openConfig() {
setLoading(true);
try {
@@ -165,6 +135,23 @@ export default function IndexPage() {
}
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 (
<ConfigProvider theme={antdThemeConfig}>
@@ -190,277 +177,105 @@ export default function IndexPage() {
extra={<Button type="primary" onClick={refresh}>{t('refresh')}</Button>}
/>
) : (
<Row gutter={[isMobile ? 8 : 16, 12]}>
<Col span={24}>
<StatusCard status={status} isMobile={isMobile} />
</Col>
<div className="ov-page">
<OverviewActionBar
status={status}
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}>
<XrayStatusCard
status={status}
{health && (
<div className="ov-health" style={{ color: health.color }}>
<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}
accessLogEnable={accessLogEnable}
onStopXray={stopXray}
onRestartXray={restartXray}
onOpenXrayLogs={() => setXrayLogsOpen(true)}
onOpenLogs={() => setLogsOpen(true)}
onOpenVersionSwitch={() => setVersionOpen(true)}
/>
</Col>
<Col xs={24} lg={12}>
<Card
title={t('menu.link')}
hoverable
actions={[
<Space className="action" key="logs" role="button" tabIndex={0} aria-label={t('pages.index.logs')} onClick={() => setLogsOpen(true)} onKeyDown={activateOnKey(() => setLogsOpen(true))}>
<BarsOutlined />
{!isMobile && <span>{t('pages.index.logs')}</span>}
</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>,
]}
<VitalTile
icon={<DatabaseOutlined />}
label={t('pages.index.memory')}
percent={status.mem.percent}
statusColor={status.mem.color}
detail={`${SizeFormatter.sizeFormat(status.mem.current)} / ${SizeFormatter.sizeFormat(status.mem.total)}`}
footLeft={`${t('pages.index.avg')} ${mean(history.series.mem).toFixed(0)}%`}
footRight={`${t('pages.index.peak')} ${peak(history.series.mem).toFixed(0)}%`}
data={history.series.mem}
isMobile={isMobile}
/>
</Col>
<Col xs={24} lg={12}>
<Card
title={
<Space>
<span>3X-UI</span>
{isMobile && displayVersion && (
<Tag color={panelUpdateInfo.updateAvailable ? 'orange' : 'green'}>
{panelUpdateInfo.updateAvailable
? 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 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>,
]}
<VitalTile
icon={<SwapOutlined />}
label={t('pages.index.swap')}
percent={status.swap.percent}
statusColor={status.swap.color}
detail={`${SizeFormatter.sizeFormat(status.swap.current)} / ${SizeFormatter.sizeFormat(status.swap.total)}`}
footLeft={`${t('pages.index.avg')} ${mean(history.series.swap).toFixed(1)}%`}
footRight={`${t('pages.index.peak')} ${peak(history.series.swap).toFixed(0)}%`}
data={history.series.swap}
isMobile={isMobile}
/>
</Col>
<Col xs={24} lg={12}>
<Card
title={t('pages.index.charts')}
hoverable
actions={[
<Space
className="action"
key="sys-history"
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>,
]}
<VitalTile
icon={<HddOutlined />}
label={t('pages.index.storage')}
percent={status.disk.percent}
statusColor={status.disk.color}
detail={`${SizeFormatter.sizeFormat(status.disk.current)} / ${SizeFormatter.sizeFormat(totalDisk)}`}
footLeft={`${t('pages.index.free')} ${SizeFormatter.sizeFormat(freeDisk)}`}
footRight={`${t('pages.index.avg')} ${mean(history.series.diskUsage).toFixed(1)}%`}
data={history.series.diskUsage}
isMobile={isMobile}
/>
</Col>
</div>
<Col xs={24} lg={12}>
<Card title={t('pages.index.operationHours')} hoverable>
<Row gutter={isMobile ? [8, 8] : 0}>
<Col span={12}>
<Statistic
title="Xray"
value={TimeFormatter.formatSecond(status.appStats.uptime)}
prefix={<ThunderboltOutlined />}
/>
</Col>
<Col span={12}>
<Statistic
title="OS"
value={TimeFormatter.formatSecond(status.uptime)}
prefix={<DesktopOutlined />}
/>
</Col>
</Row>
</Card>
</Col>
<div className="ov-mid">
<ThroughputCard
status={status}
up={history.series.netUp}
down={history.series.netDown}
labels={history.labels}
isMobile={isMobile}
/>
<ConnectionsCard
status={status}
tcp={history.series.tcpCount}
udp={history.series.udpCount}
labels={history.labels}
isMobile={isMobile}
/>
</div>
<Col xs={24} lg={12}>
<Card title={t('usage')} hoverable>
<Row gutter={isMobile ? [8, 8] : 0}>
<Col span={12}>
<Statistic
title={t('pages.index.memory')}
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>
<SystemStrip
status={status}
showIp={showIp}
onToggleIp={() => setShowIp((v) => !v)}
/>
</div>
)}
</Spin>
</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>
);
}
-9
View File
@@ -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;
}
-115
View File
@@ -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>
);
}
+97
View File
@@ -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>
);
}
+75
View File
@@ -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;
}
-123
View File
@@ -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]);
}
+4 -3
View File
@@ -3,8 +3,9 @@ import { useTranslation } from 'react-i18next';
import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
import { MailOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
import { HttpUtil } from '@/utils';
import { onNumber } from '@/utils/onNumber';
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 { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
@@ -62,9 +63,9 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
onChange={(e) => updateSetting({ smtpHost: e.target.value })} />
</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%' }}
onChange={(v) => updateSetting({ smtpPort: Number(v) || 587 })} />
onChange={onNumber((v) => updateSetting({ smtpPort: v }))} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.smtpUsername')} description={t('pages.settings.smtpUsernameDesc')}>
+20 -19
View File
@@ -17,7 +17,8 @@ import {
} from '@ant-design/icons';
import type { AllSetting } from '@/models/setting';
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 { catTabLabel } from './catTabLabel';
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 })} />
</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%' }}
onChange={(v) => updateSetting({ webPort: Number(v) || 0 })} />
onChange={onNumber((v) => updateSetting({ webPort: v }))} />
</SettingListItem>
<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) })} />
</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%' }}
onChange={(v) => updateSetting({ sessionMaxAge: Number(v) || 0 })} />
onChange={onNumber((v) => updateSetting({ sessionMaxAge: v }))} />
</SettingListItem>
<SettingListItem
@@ -206,9 +207,9 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
/>
</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%' }}
onChange={(v) => updateSetting({ pageSize: Number(v) || 0 })} />
onChange={onNumber((v) => updateSetting({ pageSize: v }))} />
</SettingListItem>
<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),
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%' }}
onChange={(v) => updateSetting({ expireDiff: Number(v) || 0 })} />
onChange={onNumber((v) => updateSetting({ expireDiff: v }))} />
</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%' }}
onChange={(v) => updateSetting({ trafficDiff: Number(v) || 0 })} />
onChange={onNumber((v) => updateSetting({ trafficDiff: v }))} />
</SettingListItem>
</>
),
@@ -306,9 +307,9 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
<SettingListItem paddings="small" title={t('pages.settings.ldap.host')}>
<Input value={allSetting.ldapHost} onChange={(e) => updateSetting({ ldapHost: e.target.value })} />
</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%' }}
onChange={(v) => updateSetting({ ldapPort: Number(v) || 0 })} />
onChange={onNumber((v) => updateSetting({ ldapPort: v }))} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.useTls')}>
<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')}>
<Switch checked={allSetting.ldapAutoDelete} onChange={(v) => updateSetting({ ldapAutoDelete: v })} />
</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%' }}
onChange={(v) => updateSetting({ ldapDefaultTotalGB: Number(v) || 0 })} />
onChange={onNumber((v) => updateSetting({ ldapDefaultTotalGB: v }))} />
</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%' }}
onChange={(v) => updateSetting({ ldapDefaultExpiryDays: Number(v) || 0 })} />
onChange={onNumber((v) => updateSetting({ ldapDefaultExpiryDays: v }))} />
</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%' }}
onChange={(v) => updateSetting({ ldapDefaultLimitIP: Number(v) || 0 })} />
onChange={onNumber((v) => updateSetting({ ldapDefaultLimitIP: v }))} />
</SettingListItem>
</>
),
@@ -17,6 +17,7 @@ import {
SettingOutlined,
} from '@ant-design/icons';
import type { AllSetting } from '@/models/setting';
import { onNumber } from '@/utils/onNumber';
import { SettingListItem } from '@/components/ui';
import { GoRegexInput } from '@/components/form';
import { useMediaQuery } from '@/hooks/useMediaQuery';
@@ -279,11 +280,11 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
<div className="format-settings">
<SettingListItem paddings="small" title={t('pages.settings.subFormats.concurrency')}>
<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 paddings="small" title={t('pages.settings.subFormats.xudpConcurrency')}>
<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 paddings="small" title={t('pages.settings.subFormats.xudpUdp443')}>
<Select
@@ -3,7 +3,8 @@ import { BranchesOutlined, CompassOutlined, IdcardOutlined, InfoCircleOutlined,
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
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 { useMediaQuery } from '@/hooks/useMediaQuery';
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')}>
<Input value={allSetting.subDomain} onChange={(e) => updateSetting({ subDomain: e.target.value })} />
</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%' }}
onChange={(v) => updateSetting({ subPort: Number(v) || 0 })} />
onChange={onNumber((v) => updateSetting({ subPort: v }))} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subPath')} description={t('pages.settings.subPathDesc')}>
<Input
@@ -93,10 +94,20 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
maxLength={256}
/>
</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%' }}
onChange={(v) => updateSetting({ subUpdates: Number(v) || 0 })} />
onChange={onNumber((v) => updateSetting({ subUpdates: v }))} />
</SettingListItem>
</>
),
+3 -1
View File
@@ -4,6 +4,7 @@ import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from '
import { BellOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
import { LanguageManager } from '@/utils';
import { HttpUtil } from '@/utils';
import { onNumber } from '@/utils/onNumber';
import type { AllSetting } from '@/models/setting';
import { SettingListItem } from '@/components/ui';
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%' }}>
<InputNumber
min={1}
precision={0}
style={{ width: '50%' }}
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')}
/>
<Select<Unit>
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Empty, Input, InputNumber, Select, Space, Switch, Tag } from 'antd';
import { onNumber } from '@/utils/onNumber';
import { SettingListItem } from '@/components/ui';
import {
BurstObservatorySchema,
@@ -195,7 +196,7 @@ export default function ObservatorySettingsTab({
<InputNumber
min={1}
value={burst.pingConfig.sampling}
onChange={(v) => patchPingConfig({ sampling: typeof v === 'number' ? v : burst.pingConfig.sampling })}
onChange={onNumber((v) => patchPingConfig({ sampling: v }))}
style={{ width: '100%' }}
/>
</SettingListItem>
+4 -3
View File
@@ -1,4 +1,5 @@
import { useCallback } from 'react';
import { onNumber } from '@/utils/onNumber';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Input, InputNumber, Modal, Select, Space, Switch, Tabs } from 'antd';
import {
@@ -216,10 +217,10 @@ export default function BasicsTab({
style={{ width: '100%' }}
value={directHappyEyeballs.tryDelayMs}
placeholder="150"
onChange={(v) => setDirectHappyEyeballs({
onChange={onNumber((v) => setDirectHappyEyeballs({
...directHappyEyeballs,
tryDelayMs: typeof v === 'number' ? v : 0,
})}
tryDelayMs: v,
}))}
/>
}
/>
+28 -24
View File
@@ -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 { Alert, Button, Empty, Input, InputNumber, Modal, Select, Space, Switch, Table, Tabs } from 'antd';
import {
@@ -11,6 +11,7 @@ import {
SettingOutlined,
} from '@ant-design/icons';
import { onNumber } from '@/utils/onNumber';
import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
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 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(
(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[]) {
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);
mutate((tt) => {
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) {
(tt.dns as DnsConfig).hosts = obj;
} else if ('hosts' in (tt.dns as DnsConfig)) {
@@ -311,7 +315,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
min={0}
step={60}
style={{ width: '100%' }}
onChange={(v) => setDnsField('serveExpiredTTL', Number(v) || 0)}
onChange={onNumber((v) => setDnsField('serveExpiredTTL', v))}
/>
}
/>
@@ -4,6 +4,8 @@ import { Button, Dropdown, Input, InputNumber, Space } from 'antd';
import { MoreOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { onNumber } from '@/utils/onNumber';
import { addrFor, domainsFor, expectedIPsFor } from './helpers';
import type { DnsServerValue } from './DnsServerModal';
@@ -113,7 +115,7 @@ export function useFakednsColumns({
aria-label={t('pages.xray.fakedns.poolSize')}
min={1}
size="small"
onChange={(v) => updateFakednsField(index, 'poolSize', Number(v) || 0)}
onChange={onNumber((v) => updateFakednsField(index, 'poolSize', v))}
/>
),
},
@@ -38,6 +38,7 @@ import {
} from '@ant-design/icons';
import { HttpUtil } from '@/utils';
import { onNumber } from '@/utils/onNumber';
import PromptModal from '@/components/feedback/PromptModal';
import TextModal from '@/components/feedback/TextModal';
@@ -626,14 +627,14 @@ export default function OutboundsTab({
<InputNumber
min={0}
value={intervalHours}
onChange={(v) => setIntervalHM(Number(v) || 0, intervalMinutes)}
onChange={onNumber((v) => setIntervalHM(v, intervalMinutes))}
style={{ width: 80 }}
/> {t('pages.xray.outboundSub.hours')}
<InputNumber
min={0}
max={59}
value={intervalMinutes}
onChange={(v) => setIntervalHM(intervalHours, Number(v) || 0)}
onChange={onNumber((v) => setIntervalHM(intervalHours, v))}
style={{ width: 80 }}
/> {t('pages.xray.outboundSub.minutes')}
</Space>
@@ -240,11 +240,11 @@ export default function XhttpForm({ onXmuxToggle }: XhttpFormProps) {
>
<Select
options={[
{ value: '', label: 'Default (body)' },
{ value: '', label: 'Default (auto)' },
{ value: 'auto', label: 'auto' },
{ value: 'body', label: 'body' },
{ value: 'header', label: 'header' },
{ value: 'cookie', label: 'cookie' },
{ value: 'query', label: 'query' },
]}
/>
</FormField>
+6
View File
@@ -96,9 +96,15 @@ export const InboundOptionSchema = z.object({
export const InboundOptionsSchema = z.array(InboundOptionSchema);
// The *Count fields are exact; the email arrays stop at the server's cap and
// only feed the hover popovers, so never derive a counter from their length.
export const ClientsSummarySchema = z.object({
total: z.number(),
active: z.number(),
onlineCount: z.number().optional().default(0),
depletedCount: z.number().optional().default(0),
expiringCount: z.number().optional().default(0),
deactiveCount: z.number().optional().default(0),
online: nullableStringArray,
depleted: nullableStringArray,
expiring: nullableStringArray,
@@ -22,6 +22,7 @@ export const InboundDbFieldsSchema = z.object({
down: z.number().int().min(0).default(0),
total: z.number().int().min(0).default(0),
trafficReset: TrafficResetSchema.default('never'),
trafficResetDay: z.number().int().min(1).max(31).default(1),
lastTrafficResetTime: z.number().int().default(0),
nodeId: z.number().int().nullable().optional(),
shareAddrStrategy: ShareAddrStrategySchema.default('node'),
@@ -1,8 +1,9 @@
import { z } from 'zod';
// Hysteria v1 inbound (legacy — upstream xray-core kept v1 support but the
// panel defaults to v2). Each client supplies an `auth` token instead of a
// UUID/password.
// Hysteria inbound. Each client supplies an `auth` token instead of a
// UUID/password. xray-core builds version 2 only — it answers anything else
// with "version != 2" and rejects the entire config, so a legacy row is
// coerced rather than carried through.
export const HysteriaClientSchema = z.object({
auth: z.string().min(1),
email: z.string().min(1),
@@ -20,7 +21,7 @@ export const HysteriaClientSchema = z.object({
export type HysteriaClient = z.infer<typeof HysteriaClientSchema>;
export const HysteriaInboundSettingsSchema = z.object({
version: z.number().int().min(1).default(2),
version: z.preprocess(() => 2, z.literal(2)).default(2),
clients: z.array(HysteriaClientSchema).default([]),
});
export type HysteriaInboundSettings = z.infer<typeof HysteriaInboundSettingsSchema>;
@@ -31,12 +31,14 @@ export const XHttpXmuxSchema = z.object({
export type XHttpXmux = z.infer<typeof XHttpXmuxSchema>;
// Seed for freshly enabling XMUX on a config that had no xmux block:
// mirrors xray-core v26.6.27's own anti-RKN maxConnections=6 fallback
// rather than the concurrency strategy.
// mirrors xray-core's own maxConnections fallback rather than the
// concurrency strategy. v26.7.28 lowered that fallback from 6 to 3 for
// anti-TSPU, so track it here to keep a fresh panel config matching what
// the core would have picked on its own.
export const XMUX_FRESH_DEFAULTS: XHttpXmux = {
...XHttpXmuxSchema.parse({}),
maxConcurrency: '',
maxConnections: 6,
maxConnections: 3,
};
// Predefined sessionIDTable names xray-core accepts as a shorthand for a
+5
View File
@@ -18,6 +18,7 @@ export const AllSettingSchema = z.object({
expireDiff: nonNegativeInt.optional(),
trafficDiff: nonNegativeInt.max(100).optional(),
remarkTemplate: z.string().optional(),
subShowIdentityOnAllLinks: z.boolean().optional(),
datepicker: z.enum(['gregorian', 'jalalian']).optional(),
tgBotEnable: z.boolean().optional(),
tgBotToken: z.string().optional(),
@@ -102,3 +103,7 @@ export const AllSettingSchema = z.object({
}).loose();
export type AllSettingInput = z.infer<typeof AllSettingSchema>;
export const FactoryDefaultsSchema = z.record(z.string(), z.string());
export type FactoryDefaults = z.infer<typeof FactoryDefaultsSchema>;
@@ -14,6 +14,7 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses combined byte-stably 1`
"tcp": [
{
"settings": {
"length": "10-20",
"packets": "1-3",
},
"type": "fragment",
@@ -145,9 +146,7 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-mask byte-stably 1`
[
{
"delay": 0,
"packet": [
"GET / HTTP/1.1",
],
"packet": "GET / HTTP/1.1",
"type": "str",
},
],
@@ -157,9 +156,7 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-mask byte-stably 1`
[
{
"delay": 0,
"packet": [
"HTTP/1.1 200 OK",
],
"packet": "HTTP/1.1 200 OK",
"type": "str",
},
],
@@ -171,8 +168,13 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-mask byte-stably 1`
"settings": {
"hostname": "mc.example.com",
"password": "s3cr3t",
"usernames": [
"Dream",
"profiles": [
{
"texturesSignature": "Zm9yLWZpeHR1cmUtdXNlLW9ubHktbm90LWEtcmVhbC1tb2phbmctc2lnbmF0dXJl",
"texturesValue": "eyJ0aW1lc3RhbXAiOjE3MDAwMDAwMDAwMDAsInByb2ZpbGVJZCI6ImVjNzBiY2FmNzAyZjRiYjhiNDhkMjc2ZmE1MmE3ODBjIn0=",
"username": "Dream",
"uuid": "ec70bcaf-702f-4bb8-b48d-276fa52a780c",
},
],
},
"type": "xmc",
@@ -219,13 +221,11 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses udp-mask byte-stably 1`
{
"delay": "10-16",
"rand": "10-20",
"type": "rand",
"type": "array",
},
{
"delay": "5",
"packet": [
"ping",
],
"packet": "ping",
"type": "str",
},
],
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`InboundSchema (full) fixtures > parses hysteria-v1-tls byte-stably 1`] = `
exports[`InboundSchema (full) fixtures > parses hysteria-tls byte-stably 1`] = `
{
"down": 0,
"enable": true,
@@ -9,7 +9,7 @@ exports[`InboundSchema (full) fixtures > parses hysteria-v1-tls byte-stably 1`]
"listen": "",
"port": 36715,
"protocol": "hysteria",
"remark": "gina-hysteria-v1",
"remark": "gina-hysteria",
"settings": {
"clients": [
{
@@ -25,7 +25,7 @@ exports[`InboundSchema (full) fixtures > parses hysteria-v1-tls byte-stably 1`]
"totalGB": 0,
},
],
"version": 1,
"version": 2,
},
"shareAddr": "",
"shareAddrStrategy": "node",
@@ -78,7 +78,7 @@ exports[`InboundSchema (full) fixtures > parses hysteria-v1-tls byte-stably 1`]
},
},
},
"tag": "inbound-hysteria-v1",
"tag": "inbound-hysteria",
"total": 0,
"up": 0,
}
@@ -1,8 +1,8 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`genHysteriaLink > hysteria-v1-tls: byte-stable 1`] = `"hysteria://hyst-v1-auth-XYZ@example.test:36715?security=tls&fp=chrome&alpn=h3&sni=hysteria.example.test#parity-test"`;
exports[`genHysteriaLink > hysteria-tls: byte-stable 1`] = `"hysteria2://hyst-v1-auth-XYZ@example.test:36715?security=tls&fp=chrome&alpn=h3&sni=hysteria.example.test#parity-test"`;
exports[`genInboundLinks orchestrator > hysteria-v1-tls: byte-stable 1`] = `"hysteria://hyst-v1-auth-XYZ@override.test:36715?security=tls&fp=chrome&alpn=h3&sni=hysteria.example.test#parity-test"`;
exports[`genInboundLinks orchestrator > hysteria-tls: byte-stable 1`] = `"hysteria2://hyst-v1-auth-XYZ@override.test:36715?security=tls&fp=chrome&alpn=h3&sni=hysteria.example.test#parity-test"`;
exports[`genInboundLinks orchestrator > shadowsocks-tcp-2022: byte-stable 1`] = `"ss://2022-blake3-aes-256-gcm:ZmFrZS1zZXJ2ZXItcGFzc3dvcmQtMDAwMQ%3D%3D:dGVzdC1jbGllbnQtcGFzc3dvcmQtMQ%3D%3D@override.test:8388?type=tcp#parity-test"`;
@@ -37,7 +37,7 @@ exports[`InboundSettingsSchema fixtures > parses hysteria-basic byte-stably 1`]
"totalGB": 0,
},
],
"version": 1,
"version": 2,
},
}
`;
@@ -100,7 +100,7 @@ exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-padding byte-stably
"xPaddingBytes": "500-1500",
"xPaddingHeader": "X-Pad",
"xPaddingKey": "secret-key",
"xPaddingMethod": "random",
"xPaddingMethod": "tokenish",
"xPaddingObfsMode": true,
"xPaddingPlacement": "header",
},
@@ -114,7 +114,7 @@ exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-placement byte-stab
"enableXmux": false,
"headers": {},
"host": "edge.example.test",
"mode": "auto",
"mode": "packet-up",
"noGRPCHeader": false,
"noSSEHeader": false,
"path": "/sp",
@@ -131,7 +131,7 @@ exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-placement byte-stab
"sessionIDTable": "",
"uplinkChunkSize": 0,
"uplinkDataKey": "u",
"uplinkDataPlacement": "query",
"uplinkDataPlacement": "cookie",
"uplinkHTTPMethod": "",
"xPaddingBytes": "100-1000",
"xPaddingHeader": "",
@@ -184,7 +184,7 @@ exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-tuning byte-stably
"hMaxRequestTimes": "600-900",
"hMaxReusableSecs": "1800-3000",
"maxConcurrency": "16-32",
"maxConnections": 4,
"maxConnections": 0,
},
},
}
+67
View File
@@ -0,0 +1,67 @@
import { fireEvent, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { afterEach, expect, test, vi } from 'vitest';
import AppSidebar from '@/layouts/AppSidebar';
import { renderWithProviders } from './test-utils';
vi.mock('@/api/queries/useAllSettings', () => ({
useAllSettings: () => ({ allSetting: {} }),
}));
afterEach(() => {
localStorage.clear();
});
function renderSidebar() {
return renderWithProviders(
<MemoryRouter>
<AppSidebar />
</MemoryRouter>,
);
}
test('keeps the sidebar expanded after pinning it from the header and restores the choice', () => {
const first = renderSidebar();
const sidebar = first.container.querySelector('.ant-layout-sider');
const sidebarRoot = first.container.querySelector('.ant-sidebar');
expect(sidebar?.classList.contains('ant-layout-sider-collapsed')).toBe(true);
fireEvent.mouseEnter(sidebarRoot!);
const pinButton = screen.getByRole('button', { name: 'Pin sidebar' });
expect(pinButton.closest('.brand-actions')).not.toBeNull();
fireEvent.click(pinButton);
fireEvent.mouseLeave(sidebarRoot!);
expect(sidebar?.classList.contains('ant-layout-sider-collapsed')).toBe(false);
expect(sidebarRoot?.getAttribute('style')).toContain('--sider-rail: 220px');
expect(localStorage.getItem('sidebar-pinned')).toBe('true');
first.unmount();
const second = renderSidebar();
const restoredSidebar = second.container.querySelector('.ant-layout-sider');
const restoredSidebarRoot = second.container.querySelector('.ant-sidebar');
expect(restoredSidebar?.classList.contains('ant-layout-sider-collapsed')).toBe(false);
expect(restoredSidebarRoot?.getAttribute('style')).toContain('--sider-rail: 220px');
expect(screen.getByRole('button', { name: 'Pin sidebar' })).not.toBeNull();
});
test('returns to the compact rail after unpinning', () => {
const view = renderSidebar();
const sidebar = view.container.querySelector('.ant-layout-sider');
const sidebarRoot = view.container.querySelector('.ant-sidebar');
fireEvent.mouseEnter(sidebarRoot!);
fireEvent.click(screen.getByRole('button', { name: 'Pin sidebar' }));
fireEvent.click(screen.getByRole('button', { name: 'Pin sidebar' }));
fireEvent.mouseLeave(sidebarRoot!);
expect(sidebar?.classList.contains('ant-layout-sider-collapsed')).toBe(true);
expect(sidebarRoot?.getAttribute('style')).toContain('--sider-rail: 72px');
expect(localStorage.getItem('sidebar-pinned')).toBe('false');
});
@@ -0,0 +1,127 @@
import type { ReactNode } from 'react';
import { renderHook, waitFor, act } from '@testing-library/react';
import { QueryClientProvider } from '@tanstack/react-query';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useClients } from '@/hooks/useClients';
import { makeTestQueryClient } from '@/test/test-utils';
import { HttpUtil, Msg } from '@/utils';
afterEach(() => {
vi.restoreAllMocks();
});
const emptyPage = {
items: [],
total: 0,
filtered: 0,
page: 1,
pageSize: 25,
groups: [],
summary: {
total: 0,
active: 0,
onlineCount: 0,
depletedCount: 0,
expiringCount: 0,
deactiveCount: 0,
online: [],
depleted: [],
expiring: [],
deactive: [],
},
};
function mockPanel(defaults: Record<string, unknown>) {
const pagedUrls: string[] = [];
vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => {
if (url.includes('/clients/list/paged')) {
pagedUrls.push(url);
return new Msg(true, '', emptyPage);
}
if (url.includes('/inbounds/options')) return new Msg(true, '', []);
return new Msg(true, '', null);
});
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url: string) => {
if (url.includes('/setting/defaultSettings')) return new Msg(true, '', defaults);
if (url.includes('/clients/onlines')) return new Msg(true, '', []);
return new Msg(true, '', null);
});
return pagedUrls;
}
function wrapperFor() {
const queryClient = makeTestQueryClient();
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
describe('useClients query gating', () => {
it('does not fetch the list until the page supplies a query', async () => {
const pagedUrls = mockPanel({ pageSize: 25 });
const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
await waitFor(() => expect(result.current.settingsReady).toBe(true));
// The page has not called setQuery yet, so nothing should have gone out —
// this is what used to cost a thrown-away round trip on every page load.
expect(pagedUrls).toEqual([]);
expect(result.current.fetched).toBe(false);
});
it('issues exactly one request for a page load that settles on one query', async () => {
const pagedUrls = mockPanel({ pageSize: 50 });
const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
await waitFor(() => expect(result.current.settingsReady).toBe(true));
act(() => {
result.current.setQuery({ page: 1, pageSize: 50, sort: 'createdAt', order: 'ascend' });
});
await waitFor(() => expect(result.current.fetched).toBe(true));
expect(pagedUrls).toHaveLength(1);
expect(pagedUrls[0]).toContain('pageSize=50');
expect(pagedUrls[0]).toContain('sort=createdAt');
});
it('fetches as soon as a query arrives, without waiting for the settings', async () => {
// The page remembers the previous visit's page size in localStorage, so on a
// return visit it can supply a query on the first render. The hook must not
// hold that back behind /setting/defaultSettings, or the two round trips
// serialise and the list lands ~160ms later than it needs to.
const pagedUrls = mockPanel({ pageSize: 25 });
const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
act(() => {
result.current.setQuery({ page: 1, pageSize: 25, sort: 'createdAt', order: 'ascend' });
});
await waitFor(() => expect(pagedUrls).toHaveLength(1));
});
it('reports settingsReady even when the settings request fails, so the page can still render', async () => {
vi.spyOn(HttpUtil, 'get').mockResolvedValue(new Msg(true, '', emptyPage));
vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(false, 'boom', null));
const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
await waitFor(() => expect(result.current.settingsReady).toBe(true));
});
it('skips the list, options and onlines queries for mutation-only callers', async () => {
const pagedUrls = mockPanel({ pageSize: 25 });
const postSpy = vi.mocked(HttpUtil.post);
const { result } = renderHook(() => useClients({ list: false }), { wrapper: wrapperFor() });
await waitFor(() => expect(result.current.settingsReady).toBe(true));
act(() => {
result.current.setQuery({ page: 1, pageSize: 25, sort: 'createdAt', order: 'ascend' });
});
await waitFor(() => expect(result.current.settingsReady).toBe(true));
expect(pagedUrls).toEqual([]);
// subSettings still needs defaultSettings; onlines must not be polled.
const posted = postSpy.mock.calls.map((c) => String(c[0]));
expect(posted.some((u) => u.includes('/setting/defaultSettings'))).toBe(true);
expect(posted.some((u) => u.includes('/clients/onlines'))).toBe(false);
expect(vi.mocked(HttpUtil.get).mock.calls.map((c) => String(c[0]))).toEqual([]);
});
});
@@ -0,0 +1,117 @@
import { useState } from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { ClientInboundChips, ClientRowActions } from '@/pages/clients/RowCells';
import type { InboundOption } from '@/hooks/useClients';
const PROTOCOL_COLORS = { vless: 'blue', trojan: 'volcano' };
// Counts how often the cell reads the inbound map, which happens once per chip
// per render. A traffic push re-renders the row, so if the cell is not memoised
// this climbs every five seconds for every visible row.
function countingInboundMap(source: Record<number, InboundOption>) {
const reads = { count: 0 };
const proxy = new Proxy(source, {
get(target, key) {
if (typeof key === 'string' && /^\d+$/.test(key)) reads.count += 1;
return target[key as unknown as number];
},
});
return { proxy, reads };
}
const INBOUNDS: Record<number, InboundOption> = {
1: { id: 1, tag: 'in-vless', remark: 'DE', protocol: 'vless' },
2: { id: 2, tag: 'in-trojan', remark: 'NL', protocol: 'trojan' },
};
function Harness({ children }: { children: (bump: () => void) => React.ReactNode }) {
const [, setTick] = useState(0);
return <>{children(() => setTick((n) => n + 1))}</>;
}
describe('clients table row cells', () => {
it('does not re-render the inbound chips when the row re-renders with the same attachments', async () => {
const { proxy, reads } = countingInboundMap(INBOUNDS);
const ids = [1, 2];
let bump: () => void = () => {};
render(
<Harness>
{(doBump) => {
bump = doBump;
return (
<ClientInboundChips ids={ids} inboundsById={proxy} protocolColors={PROTOCOL_COLORS} chipLimit={1} />
);
}}
</Harness>,
);
const afterFirstRender = reads.count;
expect(afterFirstRender).toBeGreaterThan(0);
// Three simulated traffic pushes: the parent re-renders, the props do not change.
for (let i = 0; i < 3; i++) bump();
await Promise.resolve();
expect(reads.count).toBe(afterFirstRender);
});
it('re-renders the chips when the attachments actually change', async () => {
const { proxy, reads } = countingInboundMap(INBOUNDS);
function Swapper() {
const [ids, setIds] = useState<number[]>([1]);
return (
<>
<button type="button" onClick={() => setIds([1, 2])}>swap</button>
<ClientInboundChips ids={ids} inboundsById={proxy} protocolColors={PROTOCOL_COLORS} chipLimit={1} />
</>
);
}
render(<Swapper />);
const before = reads.count;
await userEvent.click(screen.getByRole('button', { name: 'swap' }));
expect(reads.count).toBeGreaterThan(before);
});
it('keeps the row actions wired to the right client across re-renders', async () => {
const onShowQr = vi.fn();
const onEdit = vi.fn();
const noop = vi.fn();
let bump: () => void = () => {};
render(
<Harness>
{(doBump) => {
bump = doBump;
return (
<ClientRowActions
email="alice@x"
onShowQr={onShowQr}
onShowInfo={noop}
onResetTraffic={noop}
onEdit={onEdit}
onDelete={noop}
/>
);
}}
</Harness>,
);
for (let i = 0; i < 3; i++) bump();
// Queried by position rather than label: the suite loads the real en-US
// bundle, so the aria-labels are translated strings, not keys. Order is
// QR, info, reset traffic, edit, delete.
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(5);
await userEvent.click(buttons[0]);
await userEvent.click(buttons[3]);
expect(onShowQr).toHaveBeenCalledExactlyOnceWith('alice@x');
expect(onEdit).toHaveBeenCalledExactlyOnceWith('alice@x');
});
});

Some files were not shown because too many files have changed in this diff Show More