mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-28 19:03:39 +03:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b395a1b951 | ||
|
|
cda7f2ac17 | ||
|
|
798e18b6ee | ||
|
|
1fd2c1333c | ||
|
|
ffe661d212 | ||
|
|
3f0b7fbe97 | ||
|
|
c5b5606bf5 | ||
|
|
bee8288d41 | ||
|
|
99df5d70a8 | ||
|
|
72b97efa8a | ||
|
|
0829f1ecd4 | ||
|
|
058c030e81 | ||
|
|
c03ecfe638 | ||
|
|
c5dc84d314 | ||
|
|
aefee2c15f | ||
|
|
b42a4d93fc | ||
|
|
8046d1519d | ||
|
|
2fea71387b | ||
|
|
530e338c66 | ||
|
|
bf1b488a63 | ||
|
|
8d6d845262 | ||
|
|
72b68cce22 | ||
|
|
a07b68894c | ||
|
|
9e005ffcf9 | ||
|
|
486ac9c28d | ||
|
|
1a096d72f1 | ||
|
|
9d9737f470 | ||
|
|
272854df91 | ||
|
|
3046d96145 | ||
|
|
7ade9d9a1f | ||
|
|
d347605233 | ||
|
|
76043fe306 | ||
|
|
be5425cbed | ||
|
|
e23599cb18 | ||
|
|
93eda06878 | ||
|
|
7680e27d1d | ||
|
|
6286bb8676 | ||
|
|
2bba1d21d2 | ||
|
|
f1e433e839 | ||
|
|
43288e6686 | ||
|
|
9d2a4f217e | ||
|
|
222e000b3b | ||
|
|
0296b2abd0 | ||
|
|
980511bcad | ||
|
|
96a5c73e02 | ||
|
|
3675f88caf | ||
|
|
5eb80eca8e | ||
|
|
313d041db3 | ||
|
|
3c5e9fa774 | ||
|
|
31d7ed5103 | ||
|
|
3f787ae169 | ||
|
|
20edaee8ed | ||
|
|
dc37f9b731 | ||
|
|
19e88c4610 | ||
|
|
b196f481a8 | ||
|
|
1f90d2a6ee | ||
|
|
cfe1b25ca0 |
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
@@ -11,4 +11,4 @@ issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: mhsanaei
|
||||
custom: https://nowpayments.io/donation/hsanaei
|
||||
custom: https://donate.sanaei.dev/
|
||||
|
||||
141
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
141
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -1,77 +1,164 @@
|
||||
name: Bug report
|
||||
description: Create a report to help us improve
|
||||
title: "Bug report"
|
||||
labels: ["bug"]
|
||||
description: Report something that is broken or behaving unexpectedly
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "needs triage"]
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for reporting a bug! Please fill out the following information.
|
||||
Thanks for taking the time to file a bug. A complete report helps us
|
||||
reproduce and fix it quickly. Please **search [existing issues](../issues?q=is%3Aissue)**
|
||||
before opening a new one — duplicates will be closed.
|
||||
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: A clear and concise description of what the bug is.
|
||||
placeholder: My problem is...
|
||||
description: A clear and concise description of what went wrong.
|
||||
placeholder: When I … the panel does … but I expected it to …
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: how-repeat-problem
|
||||
attributes:
|
||||
label: How to repeat the problem?
|
||||
description: Sequence of actions that allow you to reproduce the bug
|
||||
label: How to reproduce the problem
|
||||
description: Numbered steps starting from a clean state. The clearer the steps, the faster the fix.
|
||||
placeholder: |
|
||||
1. Open `Inbounds` page
|
||||
2. ...
|
||||
1. Open the `Inbounds` page
|
||||
2. Create a new VLESS inbound with …
|
||||
3. Click `Save`
|
||||
4. Observe …
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-action
|
||||
attributes:
|
||||
label: Expected action
|
||||
description: What's going to happen
|
||||
placeholder: Must be...
|
||||
label: Expected behavior
|
||||
placeholder: I expected the panel to …
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: received-action
|
||||
attributes:
|
||||
label: Received action
|
||||
description: What's really happening
|
||||
placeholder: It's actually happening...
|
||||
label: Actual behavior
|
||||
placeholder: Instead, the panel …
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant logs
|
||||
description: |
|
||||
Panel logs (`journalctl -u x-ui -n 200`) and/or the browser DevTools
|
||||
console output. **Redact** tokens, real domains, IPs, and client UUIDs.
|
||||
render: shell
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: Drag images directly into this field. Redact any sensitive data.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: xui-version
|
||||
attributes:
|
||||
label: 3x-ui Version
|
||||
description: Which version of 3x-ui are you using?
|
||||
placeholder: 2.X.X
|
||||
label: 3x-ui version
|
||||
description: Shown at the top of the panel sidebar.
|
||||
placeholder: 3.1.0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: xray-version
|
||||
attributes:
|
||||
label: Xray-core Version
|
||||
description: Which version of Xray-core are you using?
|
||||
placeholder: 2.X.X
|
||||
label: Xray-core version
|
||||
description: Visible on the `Xray Settings` page.
|
||||
placeholder: 25.x.x
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: install-method
|
||||
attributes:
|
||||
label: How did you install 3x-ui?
|
||||
options:
|
||||
- install.sh script
|
||||
- Docker / Docker Compose
|
||||
- Manual build from source
|
||||
- Other (please describe in the bug body)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating system
|
||||
description: Distribution and version.
|
||||
placeholder: Ubuntu 24.04 / Debian 12 / CentOS Stream 9 …
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Which parts of the panel are affected?
|
||||
multiple: true
|
||||
options:
|
||||
- Frontend (UI / panel pages)
|
||||
- Backend (API endpoints, login, settings)
|
||||
- Xray config generation
|
||||
- Subscription (share links / Clash / JSON)
|
||||
- Statistics / traffic counters
|
||||
- Database / migrations
|
||||
- Install / upgrade script
|
||||
- Docker image
|
||||
- Multi-node (sub-nodes)
|
||||
- Telegram bot
|
||||
- Other
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: browser
|
||||
attributes:
|
||||
label: Browser (only if it is a UI bug)
|
||||
placeholder: Chrome 132 / Firefox 134 / Safari 18 / mobile Chrome …
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: reverse-proxy
|
||||
attributes:
|
||||
label: Is the panel behind a reverse proxy or CDN?
|
||||
options:
|
||||
- "No — direct access"
|
||||
- "Yes — Nginx"
|
||||
- "Yes — Caddy"
|
||||
- "Yes — Cloudflare (proxied DNS)"
|
||||
- "Yes — Cloudflare Tunnel"
|
||||
- "Yes — other"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
description: Please check all the checkboxes
|
||||
label: Before submitting
|
||||
options:
|
||||
- label: This bug report is written entirely in English.
|
||||
- label: I searched [existing issues](../issues?q=is%3Aissue) and this bug has not been reported.
|
||||
required: true
|
||||
- label: I am running the latest released version of 3x-ui (or have verified the bug still exists on it).
|
||||
required: true
|
||||
- label: This bug report is written in English.
|
||||
required: true
|
||||
- label: I have redacted any sensitive data (tokens, real domains, client UUIDs).
|
||||
required: true
|
||||
- label: This bug report is new and no one has reported it before me.
|
||||
required: true
|
||||
11
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
11
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 📖 Project Wiki
|
||||
url: https://github.com/MHSanaei/3x-ui/wiki
|
||||
about: Setup, install, configuration, and "how do I…" guides live here. Please check before opening a question.
|
||||
- name: 🔍 Search existing issues
|
||||
url: https://github.com/MHSanaei/3x-ui/issues?q=is%3Aissue
|
||||
about: Your bug, feature, or question may already be tracked. Comment on the existing one rather than opening a duplicate.
|
||||
- name: 🚀 Latest release
|
||||
url: https://github.com/MHSanaei/3x-ui/releases/latest
|
||||
about: Reproduce on the latest version before reporting — many bugs are already fixed in the most recent release.
|
||||
141
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
141
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@@ -1,39 +1,102 @@
|
||||
name: Feature request
|
||||
description: Suggest an idea for this project
|
||||
title: "Feature request"
|
||||
labels: ["enhancement"]
|
||||
|
||||
body:
|
||||
- type: textarea
|
||||
id: is-related-problem
|
||||
attributes:
|
||||
label: Is your feature request related to a problem?
|
||||
description: A clear and concise description of what the problem is.
|
||||
placeholder: I'm always frustrated when...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Describe the solution you'd like
|
||||
description: A clear and concise description of what you want to happen.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Describe alternatives you've considered
|
||||
description: A clear and concise description of any alternative solutions or features you've considered.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
description: Please check all the checkboxes
|
||||
options:
|
||||
- label: This feature report is written entirely in English.
|
||||
required: true
|
||||
name: Feature request
|
||||
description: Suggest an idea or improvement for 3x-ui
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement", "needs triage"]
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to suggest a feature. Please **search
|
||||
[existing issues](../issues?q=is%3Aissue)** first — duplicates will be closed.
|
||||
|
||||
- type: textarea
|
||||
id: is-related-problem
|
||||
attributes:
|
||||
label: Is your feature request related to a problem?
|
||||
description: A clear and concise description of the problem you're hitting today.
|
||||
placeholder: I'm always frustrated when …
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Describe the solution you'd like
|
||||
description: |
|
||||
What should the panel do? Be specific — UI placement, API shape,
|
||||
config keys, expected behavior under edge cases.
|
||||
placeholder: |
|
||||
On the Inbounds page, add a button that …
|
||||
The backend should expose a new endpoint at …
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives you've considered
|
||||
description: Other ways you tried to solve this, and why they fell short.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: use-case
|
||||
attributes:
|
||||
label: Concrete use case
|
||||
description: |
|
||||
Walk us through a real scenario where this feature would help.
|
||||
Numbers help (e.g. "I manage 200 clients across 5 inbounds and …").
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: mockup
|
||||
attributes:
|
||||
label: Mockups, screenshots, or examples
|
||||
description: |
|
||||
Drag images, paste config snippets, or link to similar features in
|
||||
other tools. Helps us understand the shape of the request.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Which parts of the panel would this affect?
|
||||
multiple: true
|
||||
options:
|
||||
- Frontend (UI / panel pages)
|
||||
- Backend (API endpoints, login, settings)
|
||||
- Xray config generation
|
||||
- Subscription (share links / Clash / JSON)
|
||||
- Statistics / traffic counters
|
||||
- Database / migrations
|
||||
- Install / upgrade script
|
||||
- Docker image
|
||||
- Multi-node (sub-nodes)
|
||||
- Telegram bot
|
||||
- Other
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: contribution
|
||||
attributes:
|
||||
label: Are you willing to help?
|
||||
description: Optional — but maintainers prioritize requests with community help.
|
||||
options:
|
||||
- label: I'd like to implement this feature myself and open a PR.
|
||||
required: false
|
||||
- label: I can help test once a PR is open.
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Before submitting
|
||||
options:
|
||||
- label: I searched [existing issues](../issues?q=is%3Aissue) and this feature has not been requested.
|
||||
required: true
|
||||
- label: This feature request is written in English.
|
||||
required: true
|
||||
|
||||
82
.github/ISSUE_TEMPLATE/question.yaml
vendored
82
.github/ISSUE_TEMPLATE/question.yaml
vendored
@@ -1,22 +1,90 @@
|
||||
name: Question
|
||||
description: Describe this issue template's purpose here.
|
||||
title: "Question"
|
||||
description: Ask how to do something with 3x-ui
|
||||
title: "[Question]: "
|
||||
labels: ["question"]
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Before opening a question, please:
|
||||
|
||||
- Read the [project Wiki](https://github.com/MHSanaei/3x-ui/wiki) —
|
||||
most setup, install, and "how do I …" answers live there.
|
||||
- Search [existing issues](../issues?q=is%3Aissue) — the question
|
||||
may already have been asked.
|
||||
|
||||
Use this template only when you have a usage question that the docs
|
||||
and previous issues don't answer. If you found something **broken**,
|
||||
open a Bug report instead.
|
||||
|
||||
- type: textarea
|
||||
id: question
|
||||
attributes:
|
||||
label: Question
|
||||
placeholder: I have a question, ..., how can I solve it?
|
||||
label: What are you trying to do?
|
||||
description: Describe the goal, not just the symptom. The clearer the goal, the better the answer.
|
||||
placeholder: |
|
||||
I'm trying to … so that …
|
||||
I expected the panel to … but I'm not sure how to configure it.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: tried
|
||||
attributes:
|
||||
label: What have you already tried?
|
||||
description: Pages of the Wiki you read, settings you toggled, commands you ran.
|
||||
placeholder: |
|
||||
- Read the `Reverse Proxy` page of the Wiki
|
||||
- Tried setting `xrayBasePath` to `/proxy` — got 404
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: xui-version
|
||||
attributes:
|
||||
label: 3x-ui version
|
||||
description: Shown at the top of the panel sidebar.
|
||||
placeholder: 3.1.0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: install-method
|
||||
attributes:
|
||||
label: How did you install 3x-ui?
|
||||
options:
|
||||
- install.sh script
|
||||
- Docker / Docker Compose
|
||||
- Manual build from source
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating system
|
||||
placeholder: Ubuntu 24.04 / Debian 12 / CentOS Stream 9 …
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots or config snippets
|
||||
description: Drag images or paste relevant config. Redact tokens, real domains, client UUIDs.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
description: Please check all the checkboxes
|
||||
label: Before submitting
|
||||
options:
|
||||
- label: This question is written entirely in English.
|
||||
- label: I read the [Wiki](https://github.com/MHSanaei/3x-ui/wiki) and searched [existing issues](../issues?q=is%3Aissue).
|
||||
required: true
|
||||
- label: This is a usage question, not a bug report.
|
||||
required: true
|
||||
- label: This question is written in English.
|
||||
required: true
|
||||
|
||||
158
.github/copilot-instructions.md
vendored
158
.github/copilot-instructions.md
vendored
@@ -1,158 +0,0 @@
|
||||
# 3X-UI Development Guide
|
||||
|
||||
## Project Overview
|
||||
3X-UI is a web-based control panel for managing Xray-core servers. It's a Go application using Gin web framework with embedded static assets and SQLite database. The panel manages VPN/proxy inbounds, monitors traffic, and provides Telegram bot integration.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
- **main.go**: Entry point that initializes database, web server, and subscription server. Handles graceful shutdown via SIGHUP/SIGTERM signals
|
||||
- **web/**: Primary web server with Gin router, HTML templates, and static assets embedded via `//go:embed`
|
||||
- **xray/**: Xray-core process management and API communication for traffic monitoring
|
||||
- **database/**: GORM-based SQLite database with models in `database/model/`
|
||||
- **sub/**: Subscription server running alongside main web server (separate port)
|
||||
- **web/service/**: Business logic layer containing InboundService, SettingService, TgBot, etc.
|
||||
- **web/controller/**: HTTP handlers using Gin context (`*gin.Context`)
|
||||
- **web/job/**: Cron-based background jobs for traffic monitoring, CPU checks, LDAP sync
|
||||
|
||||
### Key Architectural Patterns
|
||||
1. **Embedded Resources**: All web assets (HTML, CSS, JS, translations) are embedded at compile time using `embed.FS`:
|
||||
- `web/assets` → `assetsFS`
|
||||
- `web/html` → `htmlFS`
|
||||
- `web/translation` → `i18nFS`
|
||||
|
||||
2. **Dual Server Design**: Main web panel + subscription server run concurrently, managed by `web/global` package
|
||||
|
||||
3. **Xray Integration**: Panel generates `config.json` for Xray binary, communicates via gRPC API for real-time traffic stats
|
||||
|
||||
4. **Signal-Based Restart**: SIGHUP triggers graceful restart. **Critical**: Always call `service.StopBot()` before restart to prevent Telegram bot 409 conflicts
|
||||
|
||||
5. **Database Seeders**: Uses `HistoryOfSeeders` model to track one-time migrations (e.g., password bcrypt migration)
|
||||
|
||||
## Development Workflows
|
||||
|
||||
### Building & Running
|
||||
```bash
|
||||
# Build (creates bin/3x-ui.exe)
|
||||
go run tasks.json → "go: build" task
|
||||
|
||||
# Run with debug logging
|
||||
XUI_DEBUG=true go run ./main.go
|
||||
# Or use task: "go: run"
|
||||
|
||||
# Test
|
||||
go test ./...
|
||||
```
|
||||
|
||||
### Command-Line Operations
|
||||
The main.go accepts flags for admin tasks:
|
||||
- `-reset` - Reset all panel settings to defaults
|
||||
- `-show` - Display current settings (port, paths)
|
||||
- Use these by running the binary directly, not via web interface
|
||||
|
||||
### Database Management
|
||||
- DB path: Configured via `config.GetDBPath()`, typically `/etc/x-ui/x-ui.db`
|
||||
- Models: Located in `database/model/model.go` - Auto-migrated on startup
|
||||
- Seeders: Use `HistoryOfSeeders` to prevent re-running migrations
|
||||
- Default credentials: admin/admin (hashed with bcrypt)
|
||||
|
||||
### Telegram Bot Development
|
||||
- Bot instance in `web/service/tgbot.go` (3700+ lines)
|
||||
- Uses `telego` library with long polling
|
||||
- **Critical Pattern**: Must call `service.StopBot()` before any server restart to prevent 409 bot conflicts
|
||||
- Bot handlers use `telegohandler.BotHandler` for routing
|
||||
- i18n via embedded `i18nFS` passed to bot startup
|
||||
|
||||
## Code Conventions
|
||||
|
||||
### Service Layer Pattern
|
||||
Services inject dependencies (like xray.XrayAPI) and operate on GORM models:
|
||||
```go
|
||||
type InboundService struct {
|
||||
xrayApi xray.XrayAPI
|
||||
}
|
||||
|
||||
func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) {
|
||||
// Business logic here
|
||||
}
|
||||
```
|
||||
|
||||
### Controller Pattern
|
||||
Controllers use Gin context and inherit from BaseController:
|
||||
```go
|
||||
func (a *InboundController) getInbounds(c *gin.Context) {
|
||||
// Use I18nWeb(c, "key") for translations
|
||||
// Check auth via checkLogin middleware
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Management
|
||||
- Environment vars: `XUI_DEBUG`, `XUI_LOG_LEVEL`, `XUI_MAIN_FOLDER`
|
||||
- Config embedded files: `config/version`, `config/name`
|
||||
- Use `config.GetLogLevel()`, `config.GetDBPath()` helpers
|
||||
|
||||
### Internationalization
|
||||
- Translation files: `web/translation/<lang>.json` (one nested-namespace file per locale,
|
||||
e.g. `en-US.json`). Vue SPA imports these via `import.meta.glob` from `frontend/src/i18n/`,
|
||||
and the Go binary embeds the same files via `web/web.go`'s `//go:embed translation/*`.
|
||||
- Access from Go via `locale.I18n(locale.Web, "pages.login.loginAgain")` (see
|
||||
`web/locale/locale.go`); access from Vue via `useI18n()` and `t('pages.login.loginAgain')`.
|
||||
- Use `locale.I18nType` enum (Web, Bot).
|
||||
|
||||
## External Dependencies & Integration
|
||||
|
||||
### Xray-core
|
||||
- Binary management: Download platform-specific binary (`xray-{os}-{arch}`) to bin folder
|
||||
- Config generation: Panel creates `config.json` dynamically from inbound/outbound settings
|
||||
- Process control: Start/stop via `xray/process.go`
|
||||
- gRPC API: Real-time stats via `xray/api.go` using `google.golang.org/grpc`
|
||||
|
||||
### Critical External Paths
|
||||
- Xray binary: `{bin_folder}/xray-{os}-{arch}`
|
||||
- Xray config: `{bin_folder}/config.json`
|
||||
- GeoIP/GeoSite: `{bin_folder}/geoip.dat`, `geosite.dat`
|
||||
- Logs: `{log_folder}/3xipl.log`, `3xipl-banned.log`
|
||||
|
||||
### Job Scheduling
|
||||
Uses `robfig/cron/v3` for periodic tasks:
|
||||
- Traffic monitoring: `xray_traffic_job.go`
|
||||
- CPU alerts: `check_cpu_usage.go`
|
||||
- IP tracking: `check_client_ip_job.go`
|
||||
- LDAP sync: `ldap_sync_job.go`
|
||||
|
||||
Jobs registered in `web/web.go` during server initialization
|
||||
|
||||
## Deployment & Scripts
|
||||
|
||||
### Installation Script Pattern
|
||||
Both `install.sh` and `x-ui.sh` follow these patterns:
|
||||
- Multi-distro support via `$release` variable (ubuntu, debian, centos, arch, etc.)
|
||||
- Port detection with `is_port_in_use()` using ss/netstat/lsof
|
||||
- Systemd service management with distro-specific unit files (`.service.debian`, `.service.arch`, `.service.rhel`)
|
||||
|
||||
### Docker Build
|
||||
Multi-stage Dockerfile:
|
||||
1. **Builder**: CGO-enabled build, runs `DockerInit.sh` to download Xray binary
|
||||
2. **Final**: Alpine-based with fail2ban pre-configured
|
||||
|
||||
### Key File Locations (Production)
|
||||
- Binary: `/usr/local/x-ui/`
|
||||
- Database: `/etc/x-ui/x-ui.db`
|
||||
- Logs: `/var/log/x-ui/`
|
||||
- Service: `/etc/systemd/system/x-ui.service.*`
|
||||
|
||||
## Testing & Debugging
|
||||
- Set `XUI_DEBUG=true` for detailed logging
|
||||
- Check Xray process: `x-ui.sh` script provides menu for status/logs
|
||||
- Database inspection: Direct SQLite access to x-ui.db
|
||||
- Traffic debugging: Check `3xipl.log` for IP limit tracking
|
||||
- Telegram bot: Logs show bot initialization and command handling
|
||||
|
||||
## Common Gotchas
|
||||
1. **Bot Restart**: Always stop Telegram bot before server restart to avoid 409 conflict
|
||||
2. **Embedded Assets**: Changes to HTML/CSS require recompilation (not hot-reload)
|
||||
3. **Password Migration**: Seeder system tracks bcrypt migration - check `HistoryOfSeeders` table
|
||||
4. **Port Binding**: Subscription server uses different port from main panel
|
||||
5. **Xray Binary**: Must match OS/arch exactly - managed by installer scripts
|
||||
6. **Session Management**: Uses `gin-contrib/sessions` with cookie store
|
||||
7. **IP Limitation**: Implements "last IP wins" - when client exceeds LimitIP, oldest connections are automatically disconnected via Xray API to allow newest IPs
|
||||
63
.github/pull_request_template.md
vendored
Normal file
63
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
## Summary
|
||||
|
||||
<!-- What does this PR do? One or two sentences. -->
|
||||
|
||||
## Why
|
||||
|
||||
<!--
|
||||
What problem does this solve, or what use case does it enable?
|
||||
Link related issues here: "Closes #123", "Refs #456".
|
||||
-->
|
||||
|
||||
## Type of change
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Refactoring (no behavior change)
|
||||
- [ ] Documentation
|
||||
- [ ] Tests only
|
||||
- [ ] Build / CI / tooling
|
||||
- [ ] Other
|
||||
|
||||
## Areas affected
|
||||
|
||||
- [ ] Frontend (UI / panel pages)
|
||||
- [ ] Backend (API endpoints, login, settings)
|
||||
- [ ] Xray config generation
|
||||
- [ ] Subscription (share links / Clash / JSON)
|
||||
- [ ] Statistics / traffic counters
|
||||
- [ ] Database / migrations
|
||||
- [ ] Install / upgrade script
|
||||
- [ ] Docker image
|
||||
- [ ] Multi-node (sub-nodes)
|
||||
- [ ] Telegram bot
|
||||
|
||||
## How was this tested?
|
||||
|
||||
<!--
|
||||
Concrete steps the reviewer can repeat. For UI changes: which page,
|
||||
which actions, which browser. For backend: which endpoint, which payload,
|
||||
which response. Mention any new unit/integration tests added.
|
||||
-->
|
||||
|
||||
## Screenshots / recordings
|
||||
|
||||
<!-- Required for UI changes. Drag images or GIFs here. Remove if N/A. -->
|
||||
|
||||
## Breaking changes
|
||||
|
||||
<!--
|
||||
Does this change require existing users to update their config, run a
|
||||
migration, or change their API calls? If yes, describe the migration path.
|
||||
Write "None" if there are no breaking changes.
|
||||
-->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I tested the change locally and confirmed the described behavior.
|
||||
- [ ] I added or updated tests for the new behavior (when applicable).
|
||||
- [ ] `go build ./...` and the test suite pass locally.
|
||||
- [ ] For frontend changes: `npm run lint`, `npm run typecheck`, and `npm run build` pass.
|
||||
- [ ] I updated the Wiki / README / API docs if user-facing behavior changed.
|
||||
- [ ] My commits follow the project's existing message style.
|
||||
- [ ] I have no unrelated changes mixed into this PR.
|
||||
20
.github/pull_request_template.yml
vendored
20
.github/pull_request_template.yml
vendored
@@ -1,20 +0,0 @@
|
||||
## What is the pull request?
|
||||
|
||||
<!-- Briefly describe the changes introduced by this pull request -->
|
||||
|
||||
## Which part of the application is affected by the change?
|
||||
|
||||
- [ ] Frontend
|
||||
- [ ] Backend
|
||||
|
||||
## Type of Changes
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Refactoring
|
||||
- [ ] Other
|
||||
|
||||
## Screenshots
|
||||
|
||||
<!-- Add screenshots to illustrate the changes -->
|
||||
<!-- Remove this section if it is not applicable. -->
|
||||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -81,6 +81,12 @@ jobs:
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
working-directory: frontend
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
working-directory: frontend
|
||||
- name: Test
|
||||
run: npm test
|
||||
working-directory: frontend
|
||||
- name: Build
|
||||
run: npm run build
|
||||
working-directory: frontend
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.1.0
|
||||
3.2.0
|
||||
208
database/db.go
208
database/db.go
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"path"
|
||||
"slices"
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/util/crypto"
|
||||
"github.com/mhsanaei/3x-ui/v3/util/random"
|
||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
@@ -68,6 +70,7 @@ func initModels() error {
|
||||
&model.ApiToken{},
|
||||
&model.ClientRecord{},
|
||||
&model.ClientInbound{},
|
||||
&model.ClientGroup{},
|
||||
&model.InboundFallback{},
|
||||
}
|
||||
for _, mdl := range models {
|
||||
@@ -142,11 +145,11 @@ func runSeeders(isUsersEmpty bool) error {
|
||||
}
|
||||
|
||||
if empty && isUsersEmpty {
|
||||
hashSeeder := &model.HistoryOfSeeders{
|
||||
SeederName: "UserPasswordHash",
|
||||
}
|
||||
if err := db.Create(hashSeeder).Error; err != nil {
|
||||
return err
|
||||
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix", "InboundClientSubIdFix"}
|
||||
for _, name := range seeders {
|
||||
if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return seedApiTokens()
|
||||
}
|
||||
@@ -195,9 +198,170 @@ func runSeeders(isUsersEmpty bool) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !slices.Contains(seedersHistory, "InboundClientsArrayFix") {
|
||||
if err := normalizeInboundClientsArray(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !slices.Contains(seedersHistory, "InboundClientTgIdFix") {
|
||||
if err := normalizeInboundClientTgId(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !slices.Contains(seedersHistory, "InboundClientSubIdFix") {
|
||||
if err := normalizeInboundClientSubId(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeInboundClientTgId() error {
|
||||
var inbounds []model.Inbound
|
||||
if err := db.Find(&inbounds).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, inbound := range inbounds {
|
||||
if strings.TrimSpace(inbound.Settings) == "" {
|
||||
continue
|
||||
}
|
||||
var settings map[string]any
|
||||
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
|
||||
log.Printf("InboundClientTgIdFix: skip inbound %d (invalid settings json): %v", inbound.Id, err)
|
||||
continue
|
||||
}
|
||||
clients, ok := settings["clients"].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
mutated := false
|
||||
for i, raw := range clients {
|
||||
obj, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
tgRaw, present := obj["tgId"]
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
v, isFloat := tgRaw.(float64)
|
||||
if isFloat && !math.IsNaN(v) && !math.IsInf(v, 0) && v == math.Trunc(v) {
|
||||
continue
|
||||
}
|
||||
obj["tgId"] = int64(0)
|
||||
clients[i] = obj
|
||||
mutated = true
|
||||
}
|
||||
if !mutated {
|
||||
continue
|
||||
}
|
||||
settings["clients"] = clients
|
||||
newSettings, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
log.Printf("InboundClientTgIdFix: skip inbound %d (marshal failed): %v", inbound.Id, err)
|
||||
continue
|
||||
}
|
||||
if err := tx.Model(&model.Inbound{}).Where("id = ?", inbound.Id).
|
||||
Update("settings", string(newSettings)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&model.HistoryOfSeeders{SeederName: "InboundClientTgIdFix"}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeInboundClientSubId() error {
|
||||
var inbounds []model.Inbound
|
||||
if err := db.Find(&inbounds).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, inbound := range inbounds {
|
||||
if strings.TrimSpace(inbound.Settings) == "" {
|
||||
continue
|
||||
}
|
||||
var settings map[string]any
|
||||
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
|
||||
log.Printf("InboundClientSubIdFix: skip inbound %d (invalid settings json): %v", inbound.Id, err)
|
||||
continue
|
||||
}
|
||||
clients, ok := settings["clients"].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
mutated := false
|
||||
for i, raw := range clients {
|
||||
obj, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
existing, _ := obj["subId"].(string)
|
||||
if strings.TrimSpace(existing) != "" {
|
||||
continue
|
||||
}
|
||||
obj["subId"] = random.NumLower(16)
|
||||
clients[i] = obj
|
||||
mutated = true
|
||||
}
|
||||
if !mutated {
|
||||
continue
|
||||
}
|
||||
settings["clients"] = clients
|
||||
newSettings, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
log.Printf("InboundClientSubIdFix: skip inbound %d (marshal failed): %v", inbound.Id, err)
|
||||
continue
|
||||
}
|
||||
if err := tx.Model(&model.Inbound{}).Where("id = ?", inbound.Id).
|
||||
Update("settings", string(newSettings)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&model.HistoryOfSeeders{SeederName: "InboundClientSubIdFix"}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeInboundClientsArray() error {
|
||||
var inbounds []model.Inbound
|
||||
if err := db.Find(&inbounds).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, inbound := range inbounds {
|
||||
if strings.TrimSpace(inbound.Settings) == "" {
|
||||
continue
|
||||
}
|
||||
var settings map[string]any
|
||||
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
|
||||
log.Printf("InboundClientsArrayFix: skip inbound %d (invalid settings json): %v", inbound.Id, err)
|
||||
continue
|
||||
}
|
||||
raw, exists := settings["clients"]
|
||||
if !exists || raw != nil {
|
||||
continue
|
||||
}
|
||||
settings["clients"] = []any{}
|
||||
newSettings, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
log.Printf("InboundClientsArrayFix: skip inbound %d (marshal failed): %v", inbound.Id, err)
|
||||
continue
|
||||
}
|
||||
if err := tx.Model(&model.Inbound{}).Where("id = ?", inbound.Id).
|
||||
Update("settings", string(newSettings)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&model.HistoryOfSeeders{SeederName: "InboundClientsArrayFix"}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// normalizeClientJSONFields coerces loosely-typed numeric fields in a raw
|
||||
// settings.clients entry so json.Unmarshal into model.Client doesn't fail
|
||||
// when older rows wrote tgId/limitIp/totalGB/etc. as strings. Empty strings
|
||||
@@ -237,6 +401,14 @@ func seedClientsFromInboundJSON() error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
byEmail := map[string]*model.ClientRecord{}
|
||||
|
||||
var existing []model.ClientRecord
|
||||
if err := tx.Find(&existing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range existing {
|
||||
byEmail[existing[i].Email] = &existing[i]
|
||||
}
|
||||
|
||||
for _, inbound := range inbounds {
|
||||
if strings.TrimSpace(inbound.Settings) == "" {
|
||||
continue
|
||||
@@ -400,9 +572,19 @@ func InitDB(dbPath string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(8)
|
||||
sqlDB.SetMaxIdleConns(4)
|
||||
var maxOpen, maxIdle int
|
||||
switch config.GetDBKind() {
|
||||
case "postgres":
|
||||
maxOpen = envInt("XUI_DB_MAX_OPEN_CONNS", 25)
|
||||
maxIdle = envInt("XUI_DB_MAX_IDLE_CONNS", 25)
|
||||
default:
|
||||
maxOpen = envInt("XUI_DB_MAX_OPEN_CONNS", 8)
|
||||
maxIdle = envInt("XUI_DB_MAX_IDLE_CONNS", 4)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(maxOpen)
|
||||
sqlDB.SetMaxIdleConns(maxIdle)
|
||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||
sqlDB.SetConnMaxIdleTime(30 * time.Minute)
|
||||
|
||||
if err := initModels(); err != nil {
|
||||
return err
|
||||
@@ -419,6 +601,18 @@ func InitDB(dbPath string) error {
|
||||
return runSeeders(isUsersEmpty)
|
||||
}
|
||||
|
||||
func envInt(key string, def int) int {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n <= 0 {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// CloseDB closes the database connection if it exists.
|
||||
func CloseDB() error {
|
||||
if db != nil {
|
||||
|
||||
155
database/db_seed_test.go
Normal file
155
database/db_seed_test.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
)
|
||||
|
||||
func TestSeedClientsFromInboundJSON_IsIdempotentAgainstExistingClients(t *testing.T) {
|
||||
dbDir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
if err := InitDB(filepath.Join(dbDir, "3x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB failed: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = CloseDB() })
|
||||
|
||||
settings, err := json.Marshal(map[string]any{
|
||||
"clients": []any{
|
||||
map[string]any{
|
||||
"id": "ce8d33df-3a64-4f10-8f9b-91c3a8e0c001",
|
||||
"email": "alice@example.com",
|
||||
"enable": true,
|
||||
"flow": "",
|
||||
"subId": "alice-sub",
|
||||
"comment": "from-inbound-json",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal settings: %v", err)
|
||||
}
|
||||
inbound := model.Inbound{
|
||||
UserId: 1,
|
||||
Port: 12345,
|
||||
Protocol: model.VLESS,
|
||||
Settings: string(settings),
|
||||
Tag: "test-inbound",
|
||||
}
|
||||
if err := db.Create(&inbound).Error; err != nil {
|
||||
t.Fatalf("seed inbound: %v", err)
|
||||
}
|
||||
|
||||
preExisting := &model.ClientRecord{
|
||||
Email: "alice@example.com",
|
||||
UUID: "ce8d33df-3a64-4f10-8f9b-91c3a8e0c001",
|
||||
SubID: "alice-sub",
|
||||
Enable: true,
|
||||
Comment: "added-via-api",
|
||||
}
|
||||
if err := db.Create(preExisting).Error; err != nil {
|
||||
t.Fatalf("seed client row: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Where("seeder_name = ?", "ClientsTable").Delete(&model.HistoryOfSeeders{}).Error; err != nil {
|
||||
t.Fatalf("clear ClientsTable history: %v", err)
|
||||
}
|
||||
|
||||
if err := seedClientsFromInboundJSON(); err != nil {
|
||||
t.Fatalf("seedClientsFromInboundJSON should be idempotent against existing rows, got: %v", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&model.ClientRecord{}).Where("email = ?", "alice@example.com").Count(&count).Error; err != nil {
|
||||
t.Fatalf("count clients: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("alice@example.com should resolve to exactly one row, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeInboundClientSubId_FillsMissingAndPreservesExisting(t *testing.T) {
|
||||
dbDir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
if err := InitDB(filepath.Join(dbDir, "3x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB failed: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = CloseDB() })
|
||||
|
||||
settings, err := json.Marshal(map[string]any{
|
||||
"clients": []any{
|
||||
map[string]any{
|
||||
"id": "00000000-0000-0000-0000-000000000001",
|
||||
"email": "missing-sub@example.com",
|
||||
"subId": "",
|
||||
},
|
||||
map[string]any{
|
||||
"id": "00000000-0000-0000-0000-000000000002",
|
||||
"email": "no-sub-key@example.com",
|
||||
},
|
||||
map[string]any{
|
||||
"id": "00000000-0000-0000-0000-000000000003",
|
||||
"email": "has-sub@example.com",
|
||||
"subId": "keep-me-1234",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal settings: %v", err)
|
||||
}
|
||||
inbound := model.Inbound{
|
||||
UserId: 1,
|
||||
Port: 23456,
|
||||
Protocol: model.VLESS,
|
||||
Settings: string(settings),
|
||||
Tag: "subid-fix-inbound",
|
||||
}
|
||||
if err := db.Create(&inbound).Error; err != nil {
|
||||
t.Fatalf("seed inbound: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Where("seeder_name = ?", "InboundClientSubIdFix").Delete(&model.HistoryOfSeeders{}).Error; err != nil {
|
||||
t.Fatalf("clear seeder history: %v", err)
|
||||
}
|
||||
|
||||
if err := normalizeInboundClientSubId(); err != nil {
|
||||
t.Fatalf("normalizeInboundClientSubId: %v", err)
|
||||
}
|
||||
|
||||
var reloaded model.Inbound
|
||||
if err := db.First(&reloaded, inbound.Id).Error; err != nil {
|
||||
t.Fatalf("reload inbound: %v", err)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(reloaded.Settings), &parsed); err != nil {
|
||||
t.Fatalf("unmarshal settings: %v", err)
|
||||
}
|
||||
clients, ok := parsed["clients"].([]any)
|
||||
if !ok || len(clients) != 3 {
|
||||
t.Fatalf("expected 3 clients, got %v", parsed["clients"])
|
||||
}
|
||||
|
||||
subIdPattern := regexp.MustCompile(`^[0-9a-z]{16}$`)
|
||||
for i := 0; i < 2; i++ {
|
||||
obj := clients[i].(map[string]any)
|
||||
sub, _ := obj["subId"].(string)
|
||||
if !subIdPattern.MatchString(sub) {
|
||||
t.Fatalf("client %d: expected 16-char [0-9a-z] subId, got %q", i, sub)
|
||||
}
|
||||
}
|
||||
preserved := clients[2].(map[string]any)["subId"].(string)
|
||||
if preserved != "keep-me-1234" {
|
||||
t.Fatalf("expected existing subId preserved, got %q", preserved)
|
||||
}
|
||||
|
||||
var historyCount int64
|
||||
if err := db.Model(&model.HistoryOfSeeders{}).Where("seeder_name = ?", "InboundClientSubIdFix").Count(&historyCount).Error; err != nil {
|
||||
t.Fatalf("count seeder history: %v", err)
|
||||
}
|
||||
if historyCount != 1 {
|
||||
t.Fatalf("expected one InboundClientSubIdFix history row, got %d", historyCount)
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,11 @@ import (
|
||||
// Protocol represents the protocol type for Xray inbounds.
|
||||
type Protocol string
|
||||
|
||||
// Protocol constants for different Xray inbound protocols
|
||||
// Protocol constants for different Xray inbound protocols.
|
||||
// Hysteria v2 is not a distinct protocol — it is plain "hysteria"
|
||||
// with streamSettings.version = 2. The share-link URI scheme
|
||||
// "hysteria2://" is independent of this and is still emitted by the
|
||||
// link generator when the stream version is 2.
|
||||
const (
|
||||
VMESS Protocol = "vmess"
|
||||
VLESS Protocol = "vless"
|
||||
@@ -25,16 +29,8 @@ const (
|
||||
Mixed Protocol = "mixed"
|
||||
WireGuard Protocol = "wireguard"
|
||||
Hysteria Protocol = "hysteria"
|
||||
Hysteria2 Protocol = "hysteria2"
|
||||
)
|
||||
|
||||
// IsHysteria returns true for both "hysteria" and "hysteria2".
|
||||
// Use instead of a bare ==model.Hysteria check: a v2 inbound stored
|
||||
// with the literal v2 string would otherwise fall through (#4081).
|
||||
func IsHysteria(p Protocol) bool {
|
||||
return p == Hysteria || p == Hysteria2
|
||||
}
|
||||
|
||||
// User represents a user account in the 3x-ui panel.
|
||||
type User struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
@@ -45,22 +41,22 @@ type User struct {
|
||||
|
||||
// Inbound represents an Xray inbound configuration with traffic statistics and settings.
|
||||
type Inbound struct {
|
||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier
|
||||
UserId int `json:"-"` // Associated user ID
|
||||
Up int64 `json:"up" form:"up"` // Upload traffic in bytes
|
||||
Down int64 `json:"down" form:"down"` // Download traffic in bytes
|
||||
Total int64 `json:"total" form:"total"` // Total traffic limit in bytes
|
||||
Remark string `json:"remark" form:"remark"` // Human-readable remark
|
||||
Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1"` // Whether the inbound is enabled
|
||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
||||
TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2"` // Traffic reset schedule
|
||||
LastTrafficResetTime int64 `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"` // Last traffic reset timestamp
|
||||
ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"` // Client traffic statistics
|
||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier
|
||||
UserId int `json:"-"` // Associated user ID
|
||||
Up int64 `json:"up" form:"up"` // Upload traffic in bytes
|
||||
Down int64 `json:"down" form:"down"` // Download traffic in bytes
|
||||
Total int64 `json:"total" form:"total"` // Total traffic limit in bytes
|
||||
Remark string `json:"remark" form:"remark"` // Human-readable remark
|
||||
Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1"` // Whether the inbound is enabled
|
||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
||||
TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule
|
||||
LastTrafficResetTime int64 `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"` // Last traffic reset timestamp
|
||||
ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"` // Client traffic statistics
|
||||
|
||||
// Xray configuration fields
|
||||
Listen string `json:"listen" form:"listen"`
|
||||
Port int `json:"port" form:"port"`
|
||||
Protocol Protocol `json:"protocol" form:"protocol"`
|
||||
Port int `json:"port" form:"port" validate:"gte=1,lte=65535"`
|
||||
Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel"`
|
||||
Settings string `json:"settings" form:"settings"`
|
||||
StreamSettings string `json:"streamSettings" form:"streamSettings"`
|
||||
Tag string `json:"tag" form:"tag" gorm:"unique"`
|
||||
@@ -223,17 +219,144 @@ func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
|
||||
}
|
||||
listen = fmt.Sprintf("\"%v\"", listen)
|
||||
protocol := string(i.Protocol)
|
||||
settings := i.Settings
|
||||
switch i.Protocol {
|
||||
case Shadowsocks:
|
||||
if healed, ok := HealShadowsocksClientMethods(settings); ok {
|
||||
settings = healed
|
||||
}
|
||||
case VMESS:
|
||||
if stripped, ok := StripVmessClientSecurity(settings); ok {
|
||||
settings = stripped
|
||||
}
|
||||
case VLESS:
|
||||
if stripped, ok := StripVlessInboundEncryption(settings); ok {
|
||||
settings = stripped
|
||||
}
|
||||
}
|
||||
return &xray.InboundConfig{
|
||||
Listen: json_util.RawMessage(listen),
|
||||
Port: i.Port,
|
||||
Protocol: protocol,
|
||||
Settings: json_util.RawMessage(i.Settings),
|
||||
Settings: json_util.RawMessage(settings),
|
||||
StreamSettings: json_util.RawMessage(i.StreamSettings),
|
||||
Tag: i.Tag,
|
||||
Sniffing: json_util.RawMessage(i.Sniffing),
|
||||
}
|
||||
}
|
||||
|
||||
func StripVmessClientSecurity(settings string) (string, bool) {
|
||||
if settings == "" {
|
||||
return settings, false
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
|
||||
return settings, false
|
||||
}
|
||||
clients, ok := parsed["clients"].([]any)
|
||||
if !ok {
|
||||
return settings, false
|
||||
}
|
||||
changed := false
|
||||
for i := range clients {
|
||||
cm, ok := clients[i].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, has := cm["security"]; has {
|
||||
delete(cm, "security")
|
||||
clients[i] = cm
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return settings, false
|
||||
}
|
||||
out, err := json.MarshalIndent(parsed, "", " ")
|
||||
if err != nil {
|
||||
return settings, false
|
||||
}
|
||||
return string(out), true
|
||||
}
|
||||
|
||||
func StripVlessInboundEncryption(settings string) (string, bool) {
|
||||
if settings == "" {
|
||||
return settings, false
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
|
||||
return settings, false
|
||||
}
|
||||
if _, has := parsed["encryption"]; !has {
|
||||
return settings, false
|
||||
}
|
||||
delete(parsed, "encryption")
|
||||
out, err := json.MarshalIndent(parsed, "", " ")
|
||||
if err != nil {
|
||||
return settings, false
|
||||
}
|
||||
return string(out), true
|
||||
}
|
||||
|
||||
// HealShadowsocksClientMethods normalises the per-client `method` field
|
||||
// on a shadowsocks inbound's settings JSON before it leaves for xray-core:
|
||||
// - Legacy ciphers (aes-*, chacha20-*): every client must carry a
|
||||
// per-user `method` matching the inbound's top-level method, otherwise
|
||||
// xray fails with "unsupported cipher method:".
|
||||
// - Shadowsocks 2022 (2022-blake3-*): xray's multi-user code rejects the
|
||||
// inbound with "users must have empty method" when a client carries
|
||||
// one — strip stale entries left over from a switch off a legacy
|
||||
// cipher.
|
||||
// Returns the rewritten settings string and true when anything changed.
|
||||
func HealShadowsocksClientMethods(settings string) (string, bool) {
|
||||
if settings == "" {
|
||||
return settings, false
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
|
||||
return settings, false
|
||||
}
|
||||
method, _ := parsed["method"].(string)
|
||||
clients, ok := parsed["clients"].([]any)
|
||||
if !ok {
|
||||
return settings, false
|
||||
}
|
||||
is2022 := strings.HasPrefix(method, "2022-blake3-")
|
||||
changed := false
|
||||
for i := range clients {
|
||||
cm, ok := clients[i].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if is2022 {
|
||||
if _, hasKey := cm["method"]; hasKey {
|
||||
delete(cm, "method")
|
||||
clients[i] = cm
|
||||
changed = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if method == "" {
|
||||
continue
|
||||
}
|
||||
existing, _ := cm["method"].(string)
|
||||
if existing == method {
|
||||
continue
|
||||
}
|
||||
cm["method"] = method
|
||||
clients[i] = cm
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return settings, false
|
||||
}
|
||||
out, err := json.MarshalIndent(parsed, "", " ")
|
||||
if err != nil {
|
||||
return settings, false
|
||||
}
|
||||
return string(out), true
|
||||
}
|
||||
|
||||
// Setting stores key-value configuration settings for the 3x-ui panel.
|
||||
type Setting struct {
|
||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
||||
@@ -247,13 +370,13 @@ type Setting struct {
|
||||
// status fields below.
|
||||
type Node struct {
|
||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" form:"name" gorm:"uniqueIndex"`
|
||||
Name string `json:"name" form:"name" gorm:"uniqueIndex" validate:"required"`
|
||||
Remark string `json:"remark" form:"remark"`
|
||||
Scheme string `json:"scheme" form:"scheme"`
|
||||
Address string `json:"address" form:"address"`
|
||||
Port int `json:"port" form:"port"`
|
||||
Scheme string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https"`
|
||||
Address string `json:"address" form:"address" validate:"required"`
|
||||
Port int `json:"port" form:"port" validate:"gte=1,lte=65535"`
|
||||
BasePath string `json:"basePath" form:"basePath"`
|
||||
ApiToken string `json:"apiToken" form:"apiToken"`
|
||||
ApiToken string `json:"apiToken" form:"apiToken" validate:"required"`
|
||||
Enable bool `json:"enable" form:"enable" gorm:"default:true"`
|
||||
AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
|
||||
|
||||
@@ -310,6 +433,7 @@ type Client struct {
|
||||
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
|
||||
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
|
||||
SubID string `json:"subId" form:"subId"` // Subscription identifier
|
||||
Group string `json:"group,omitempty" form:"group"` // Logical grouping label
|
||||
Comment string `json:"comment" form:"comment"` // Client comment
|
||||
Reset int `json:"reset" form:"reset"` // Reset period in days
|
||||
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
|
||||
@@ -331,6 +455,7 @@ type ClientRecord struct {
|
||||
ExpiryTime int64 `json:"expiryTime" gorm:"column:expiry_time"`
|
||||
Enable bool `json:"enable" gorm:"default:true"`
|
||||
TgID int64 `json:"tgId" gorm:"column:tg_id"`
|
||||
Group string `json:"group" gorm:"column:group_name;default:''"`
|
||||
Comment string `json:"comment"`
|
||||
Reset int `json:"reset" gorm:"default:0"`
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
||||
@@ -339,6 +464,15 @@ type ClientRecord struct {
|
||||
|
||||
func (ClientRecord) TableName() string { return "clients" }
|
||||
|
||||
type ClientGroup struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" gorm:"uniqueIndex;not null"`
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
|
||||
}
|
||||
|
||||
func (ClientGroup) TableName() string { return "client_groups" }
|
||||
|
||||
// MarshalJSON emits the reverse column as a nested JSON object rather than an
|
||||
// escaped JSON-text string, matching the same convention Inbound uses for its
|
||||
// JSON-text columns. Empty storage renders as null.
|
||||
@@ -379,11 +513,6 @@ type ClientInbound struct {
|
||||
|
||||
func (ClientInbound) TableName() string { return "client_inbounds" }
|
||||
|
||||
// InboundFallback is one routing rule on a master inbound's
|
||||
// settings.fallbacks array. The master is always a VLESS or Trojan
|
||||
// inbound on TCP transport with TLS or Reality. The child is any other
|
||||
// inbound — its listen+port becomes the fallback dest, with optional
|
||||
// SNI/ALPN/path match criteria pulled from the same row.
|
||||
type InboundFallback struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
MasterId int `json:"masterId" gorm:"index;not null;column:master_id"`
|
||||
@@ -391,6 +520,7 @@ type InboundFallback struct {
|
||||
Name string `json:"name"`
|
||||
Alpn string `json:"alpn"`
|
||||
Path string `json:"path"`
|
||||
Dest string `json:"dest"`
|
||||
Xver int `json:"xver"`
|
||||
SortOrder int `json:"sortOrder" gorm:"default:0;column:sort_order"`
|
||||
}
|
||||
@@ -411,6 +541,7 @@ func (c *Client) ToRecord() *ClientRecord {
|
||||
ExpiryTime: c.ExpiryTime,
|
||||
Enable: c.Enable,
|
||||
TgID: c.TgID,
|
||||
Group: c.Group,
|
||||
Comment: c.Comment,
|
||||
Reset: c.Reset,
|
||||
CreatedAt: c.CreatedAt,
|
||||
@@ -438,6 +569,7 @@ func (r *ClientRecord) ToClient() *Client {
|
||||
ExpiryTime: r.ExpiryTime,
|
||||
Enable: r.Enable,
|
||||
TgID: r.TgID,
|
||||
Group: r.Group,
|
||||
Comment: r.Comment,
|
||||
Reset: r.Reset,
|
||||
CreatedAt: r.CreatedAt,
|
||||
@@ -562,6 +694,12 @@ func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientM
|
||||
existing.Comment = incoming.Comment
|
||||
}
|
||||
}
|
||||
if existing.Group != incoming.Group && incoming.Group != "" {
|
||||
if incomingNewer || existing.Group == "" {
|
||||
keep("group", existing.Group, incoming.Group, incoming.Group)
|
||||
existing.Group = incoming.Group
|
||||
}
|
||||
}
|
||||
if existing.Enable != incoming.Enable {
|
||||
if incoming.Enable {
|
||||
if !existing.Enable {
|
||||
|
||||
@@ -188,22 +188,3 @@ func TestInboundClientIpsUnmarshalJSONAcceptsBothShapes(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsHysteria(t *testing.T) {
|
||||
cases := []struct {
|
||||
in Protocol
|
||||
want bool
|
||||
}{
|
||||
{Hysteria, true},
|
||||
{Hysteria2, true},
|
||||
{VLESS, false},
|
||||
{Shadowsocks, false},
|
||||
{Protocol(""), false},
|
||||
{Protocol("hysteria3"), false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := IsHysteria(c.in); got != c.want {
|
||||
t.Errorf("IsHysteria(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
# 3x-ui frontend
|
||||
|
||||
React 19 + Ant Design 6 + TypeScript + Vite 8. Multi-page app — one HTML
|
||||
entry per panel route — built into `../web/dist/` and embedded into the
|
||||
Go binary via `embed.FS`.
|
||||
React 19 + Ant Design 6 + TypeScript + Vite 8. Three SPA bundles —
|
||||
`index.html` (admin panel SPA, all `/panel/*` routes), `login.html`
|
||||
(login + 2FA), and `subpage.html` (public subscription viewer). All
|
||||
three are built into `../web/dist/` and embedded into the Go binary
|
||||
via `embed.FS`.
|
||||
|
||||
State is split between local `useState`, TanStack Query for server
|
||||
state, and `useTheme` / `useWebSocket` contexts. Form validation,
|
||||
API parsing, and the xray config model all run through a single
|
||||
shared Zod schema tree (see [Schemas](#schemas)).
|
||||
|
||||
## Dev
|
||||
|
||||
@@ -11,15 +18,44 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Vite serves on `http://localhost:5173/`. API calls and `/panel/*` routes
|
||||
proxy to the Go panel at `http://localhost:2053/`, so start the Go panel
|
||||
first (`go run main.go`) and then Vite.
|
||||
|
||||
The proxy auto-rewrites `/panel`, `/panel/settings`, `/panel/inbounds`,
|
||||
`/panel/xray` to the matching Vite-served HTML in dev mode (see
|
||||
`MIGRATED_ROUTES` in `vite.config.js`), so the sidebar's
|
||||
Vite serves on `http://localhost:5173/`. API calls and `/panel/*`
|
||||
routes proxy to the Go panel at `http://localhost:2053/`, so start
|
||||
the Go panel first (`go run main.go`) and then Vite. The proxy
|
||||
auto-rewrites `/panel`, `/panel/settings`, `/panel/inbounds`,
|
||||
`/panel/xray` to the matching Vite-served HTML, so the sidebar's
|
||||
production-style links work without round-tripping through Go.
|
||||
|
||||
## Scripts
|
||||
|
||||
| Command | What |
|
||||
|---|---|
|
||||
| `npm run dev` | Vite dev server with API + WS proxy to Go |
|
||||
| `npm run build` | Regenerates OpenAPI + Zod, then builds into `../web/dist/` |
|
||||
| `npm run preview` | Serve the built bundle locally |
|
||||
| `npm run typecheck` | `tsc --noEmit` (strict, no emit) |
|
||||
| `npm run lint` | ESLint flat config (`@typescript-eslint` + `react-hooks`) |
|
||||
| `npm run test` | Vitest single run (schema fixtures, link parsers, …) |
|
||||
| `npm run test:watch` | Vitest watch mode |
|
||||
| `npm run gen:api` | Build `public/openapi.json` from `pages/api-docs/endpoints.ts` |
|
||||
| `npm run gen:zod` | Run the Go-side openapigen tool → `src/generated/{zod,types}.ts` |
|
||||
|
||||
CI runs `typecheck`, `lint`, `test`, and `build` on every PR
|
||||
(see `../.github/workflows/ci.yml`).
|
||||
|
||||
### One-off: scan for deprecated APIs
|
||||
|
||||
Run this command to sweep the codebase for usages of APIs marked
|
||||
with the JSDoc `@deprecated` tag (AntD prop renames, Zod renames,
|
||||
removed Web APIs, etc.):
|
||||
|
||||
```sh
|
||||
npx eslint --config eslint.deprecated.config.js src
|
||||
```
|
||||
|
||||
It's a type-aware ESLint run against `eslint.deprecated.config.js`
|
||||
and is not wired into `npm run lint` because typed linting triples
|
||||
the wall-clock time.
|
||||
|
||||
## Production build
|
||||
|
||||
```sh
|
||||
@@ -27,57 +63,139 @@ npm run build
|
||||
```
|
||||
|
||||
Outputs to `../web/dist/` (HTML at the root, hashed JS/CSS under
|
||||
`assets/`). The Go binary embeds this directory at compile time and
|
||||
`web/controller/dist.go` serves the per-page HTML.
|
||||
|
||||
## Type check and lint
|
||||
|
||||
```sh
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
```
|
||||
|
||||
`tsc --noEmit` against `tsconfig.json` (strict mode, `jsx: "react-jsx"`,
|
||||
`@/*` → `src/*` alias). ESLint 10 with `eslint.config.js` (flat config)
|
||||
— `@eslint/js` recommended plus `typescript-eslint` and
|
||||
`eslint-plugin-react-hooks` rules.
|
||||
`assets/`). `manualChunks` splits AntD, icons, codemirror, and
|
||||
react-query into separate vendor bundles to keep the per-page
|
||||
initial JS small. The Go binary embeds this directory at compile
|
||||
time and `web/controller/dist.go` serves the per-page HTML.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── *.html # Vite entry HTML, one per panel route
|
||||
├── index.html, login.html, subpage.html # 3 Vite entries
|
||||
├── tsconfig.json
|
||||
├── eslint.config.js
|
||||
├── eslint.deprecated.config.js # On-demand type-aware lint config that flags
|
||||
│ # usages of APIs marked with JSDoc @deprecated
|
||||
├── vitest.config.ts
|
||||
├── vite.config.js
|
||||
├── scripts/
|
||||
│ └── build-openapi.mjs # endpoints.ts → openapi.json
|
||||
└── src/
|
||||
├── entries/ # Per-page bootstrap (createRoot + render)
|
||||
├── pages/ # One folder per route, each with the page
|
||||
│ ├── index/ # component + helpers + sub-components
|
||||
│ ├── login/
|
||||
│ ├── inbounds/
|
||||
│ ├── clients/
|
||||
│ ├── xray/
|
||||
│ ├── nodes/
|
||||
│ ├── settings/
|
||||
│ ├── api-docs/
|
||||
│ └── sub/
|
||||
├── components/ # Cross-page React components
|
||||
├── hooks/ # Reusable hooks (useTheme, useWebSocket, …)
|
||||
├── api/ # Axios setup, CSRF interceptor, WebSocket
|
||||
├── i18n/ # react-i18next init (locales live in web/translation/)
|
||||
├── models/ # Inbound, Outbound, Status, … domain classes
|
||||
├── styles/ # Shared CSS modules (page-cards, …)
|
||||
└── utils/ # HttpUtil, ObjectUtil, LanguageManager, …
|
||||
├── entries/ # Per-page bootstrap (createRoot + render)
|
||||
├── main.tsx # Shared root for the admin SPA (index.html)
|
||||
├── routes.tsx # react-router routes mounted under /panel/
|
||||
├── pages/ # One folder per route, page component + helpers
|
||||
│ ├── index/, login/, inbounds/, clients/, xray/, nodes/,
|
||||
│ ├── settings/, api-docs/, sub/
|
||||
├── layouts/ # AdminLayout (sidebar + header + outlet)
|
||||
├── components/ # Cross-page React components
|
||||
├── hooks/ # useClients, useTheme, useWebSocket, …
|
||||
├── api/ # Axios + CSRF interceptor, TanStack Query bridge,
|
||||
│ # WebSocket client + queryClient.ts
|
||||
├── i18n/ # react-i18next init (locales in web/translation/)
|
||||
├── lib/xray/ # Pure functions: link generation, defaults,
|
||||
│ # form ⇄ wire adapters, protocol capabilities
|
||||
├── schemas/ # Zod source-of-truth (see "Schemas" below)
|
||||
├── generated/ # Code-generated zod + ts types from Go
|
||||
│ # (DO NOT hand-edit — regenerated by gen:zod)
|
||||
├── models/ # Thin legacy types still in transit
|
||||
│ # (DBInbound, Status, AllSetting, reality-targets)
|
||||
├── styles/ # Shared CSS modules
|
||||
├── test/ # Vitest specs + golden fixtures
|
||||
│ ├── *.test.ts
|
||||
│ ├── __snapshots__/
|
||||
│ └── golden/fixtures/ # Per-(protocol × network × security) JSON
|
||||
└── utils/ # HttpUtil, ClipboardManager, SizeFormatter, …
|
||||
```
|
||||
|
||||
## Schemas
|
||||
|
||||
`src/schemas/` is the single source of truth for the xray
|
||||
configuration model. Every API response is parsed through it,
|
||||
every form field is validated against it, and TypeScript types
|
||||
are inferred via `z.infer<typeof X>` — never hand-written.
|
||||
|
||||
```
|
||||
schemas/
|
||||
├── primitives/ # Atomic reusable schemas (port, protocol, sniffing, …)
|
||||
├── api/ # Backend response shapes (e.g. SlimInboundSchema)
|
||||
├── forms/ # User-facing form shapes (narrower than api/)
|
||||
├── protocols/
|
||||
│ ├── inbound/ # Per-protocol settings (vmess, vless, trojan, …)
|
||||
│ ├── outbound/
|
||||
│ ├── stream/ # Network transports (tcp, ws, grpc, xhttp, kcp, …)
|
||||
│ └── security/ # TLS, Reality, none
|
||||
├── client.ts, dns.ts, routing.ts, setting.ts, status.ts, xray.ts
|
||||
└── _envelope.ts # Generic `Msg<T>` envelope wrapper
|
||||
```
|
||||
|
||||
Patterns:
|
||||
|
||||
- **Discriminated unions** for polymorphic data — inbound `settings`
|
||||
is `z.discriminatedUnion('protocol', […])`, same for stream and
|
||||
security.
|
||||
- **Three validation layers**, non-overlapping:
|
||||
- API boundary: `parseMsg(msg, schema, ctx)` inside TanStack
|
||||
Query `queryFn` — warn-only in prod, throws in dev
|
||||
- Form input: `antdRule(schema.shape.field)` on every `<Form.Item>` —
|
||||
blocks submit + per-field inline error
|
||||
- Wire request: `Schema.parse(payload)` inside `mutationFn` — throws,
|
||||
because a malformed payload here is always a developer bug
|
||||
- **No `.loose()` or `[key: string]: any`** in production schemas.
|
||||
`@typescript-eslint/no-explicit-any: error` is enforced.
|
||||
|
||||
## Form pattern (Pattern A)
|
||||
|
||||
All non-trivial modals use this single pattern:
|
||||
|
||||
```tsx
|
||||
const [form] = Form.useForm<InboundFormValues>();
|
||||
|
||||
const onFinish = async () => {
|
||||
const values = await form.validateFields();
|
||||
await createInbound.mutateAsync(values);
|
||||
};
|
||||
|
||||
<Form form={form} onFinish={onFinish}>
|
||||
<Form.Item
|
||||
name="port"
|
||||
label="Port"
|
||||
rules={[antdRule(InboundFormSchema.shape.port, t)]}
|
||||
>
|
||||
<InputNumber min={1} max={65535} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
```
|
||||
|
||||
No `safeParse`-on-submit handlers, no `useRef<any>` for form
|
||||
references, no inline `z.string().min(1)` in rules. Conditional
|
||||
fields use `<Form.Item dependencies={...} shouldUpdate>` with the
|
||||
nested protocol schema.
|
||||
|
||||
## Testing
|
||||
|
||||
Vitest runs everything under `src/test/`. Schemas have **golden
|
||||
fixture suites** — one JSON per `(protocol × network × security)`
|
||||
combination round-tripped through `schema.parse` → link generator
|
||||
→ snapshot. Regenerate snapshots after intentional changes:
|
||||
|
||||
```sh
|
||||
npx vitest run -u
|
||||
```
|
||||
|
||||
Fixtures live in `src/test/golden/fixtures/` and are auto-discovered
|
||||
via `import.meta.glob`.
|
||||
|
||||
## Adding a new page
|
||||
|
||||
1. Add `frontend/<page>.html` referencing `/src/entries/<page>.tsx`.
|
||||
2. Add `src/entries/<page>.tsx` that imports the page component and
|
||||
mounts it with `createRoot(...).render(...)`.
|
||||
3. Add the page component under `src/pages/<page>/`.
|
||||
4. Register the entry in `rollupOptions.input` in `vite.config.js`.
|
||||
5. If the page is reachable from the sidebar at `/panel/<route>`, add
|
||||
it to `MIGRATED_ROUTES` so the dev proxy serves the Vite HTML.
|
||||
6. Wire the Go controller to `serveDistPage(c, "<page>.html")`.
|
||||
Most new routes go inside the admin SPA (`index.html`) via
|
||||
`routes.tsx` — no new HTML or Vite entry needed.
|
||||
|
||||
1. Add the page component under `src/pages/<page>/`.
|
||||
2. Register it in `src/routes.tsx` under the `/panel/...` tree.
|
||||
3. If you need a brand-new top-level bundle (login-style standalone
|
||||
page), add the HTML at `frontend/<page>.html`, an entry at
|
||||
`src/entries/<page>.tsx`, and register it in `rollupOptions.input`
|
||||
in `vite.config.js`. Then add the Go controller call to
|
||||
`serveDistPage(c, "<page>.html")`.
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>API Docs</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/api-docs.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Clients</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/clients.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -6,26 +6,6 @@ import globals from 'globals';
|
||||
export default [
|
||||
{ ignores: ['node_modules/**', '../web/dist/**'] },
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ['**/*.js'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['warn', {
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
}],
|
||||
'no-empty': ['error', { allowEmptyCatch: true }],
|
||||
'no-case-declarations': 'off',
|
||||
},
|
||||
},
|
||||
...tseslint.configs.recommended.map((config) => ({
|
||||
...config,
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
@@ -49,17 +29,13 @@ export default [
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
}],
|
||||
// Zod migration goal (Step 7): every production module is held to
|
||||
// strict no-explicit-any. The two legacy class files at the bottom
|
||||
// of the rule list keep their existing file-level eslint-disable
|
||||
// until DBInbound is migrated off Inbound.toInbound() — see the
|
||||
// migration spec Non-Goals section.
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'no-empty': ['error', { allowEmptyCatch: true }],
|
||||
|
||||
// react-hooks v7 introduces several new rules driven by the React
|
||||
// Compiler. The migration uses several legitimate patterns those
|
||||
// rules flag (initial-fetch in useEffect, dirty-check derived
|
||||
// state, `Date.now()` inside derive helpers, inline arrow event
|
||||
// handlers, in-place mutation of imported Outbound class
|
||||
// instances in the OutboundFormModal). We're not running the
|
||||
// compiler, so the memoization-preservation warnings have no
|
||||
// effect on runtime — turning them off until the codebase
|
||||
// stabilises.
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
'react-hooks/purity': 'off',
|
||||
'react-hooks/react-compiler': 'off',
|
||||
|
||||
26
frontend/eslint.deprecated.config.js
Normal file
26
frontend/eslint.deprecated.config.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import tseslint from 'typescript-eslint';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
|
||||
export default [
|
||||
{ ignores: ['node_modules/**', '../web/dist/**', 'src/generated/**'] },
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
plugins: {
|
||||
'@typescript-eslint': tseslint.plugin,
|
||||
'react-hooks': reactHooks,
|
||||
},
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-deprecated': 'warn',
|
||||
},
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Inbounds</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/inbounds.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,11 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Overview</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/index.tsx"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Nodes</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/nodes.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3368
frontend/package-lock.json
generated
3368
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "3x-ui-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
|
||||
"engines": {
|
||||
@@ -10,37 +10,56 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build": "npm run gen:api && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"gen:api": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/build-openapi.mjs",
|
||||
"gen:zod": "cd .. && go run ./tools/openapigen"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.2.3",
|
||||
"@ant-design/icons": "^6.2.5",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@tanstack/react-query": "^5.100.14",
|
||||
"@tanstack/react-query-devtools": "^5.100.14",
|
||||
"antd": "^6.4.3",
|
||||
"axios": "^1.16.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"dayjs": "^1.11.20",
|
||||
"i18next": "^26.2.0",
|
||||
"dayjs": "^1.11.21",
|
||||
"i18next": "^26.3.0",
|
||||
"otpauth": "^9.5.1",
|
||||
"persian-calendar-suite": "^1.5.5",
|
||||
"qs": "^6.15.2",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.8"
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-router-dom": "^7.15.1",
|
||||
"recharts": "^3.8.1",
|
||||
"swagger-ui-react": "^5.32.6",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/swagger-ui-react": "^5.18.0",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"eslint": "^10.4.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"globals": "^17.6.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.4",
|
||||
"vite": "8.0.13"
|
||||
"typescript-eslint": "^8.60.0",
|
||||
"vite": "8.0.14",
|
||||
"vitest": "^4.1.7"
|
||||
},
|
||||
"overrides": {
|
||||
"react-copy-to-clipboard": "^5.1.1",
|
||||
"react-inspector": "^9.0.0",
|
||||
"react-debounce-input": {
|
||||
"react": "^19.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
5650
frontend/public/openapi.json
Normal file
5650
frontend/public/openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
218
frontend/scripts/build-openapi.mjs
Normal file
218
frontend/scripts/build-openapi.mjs
Normal file
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env node
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
import { sections } from '../src/pages/api-docs/endpoints.ts';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const outPath = join(__dirname, '..', 'public', 'openapi.json');
|
||||
|
||||
const PANEL_VERSION = process.env.X_UI_VERSION || '3.x';
|
||||
|
||||
const SECURITY_SCHEMES = {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
description: 'API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`.',
|
||||
},
|
||||
cookieAuth: {
|
||||
type: 'apiKey',
|
||||
in: 'cookie',
|
||||
name: '3x-ui',
|
||||
description: 'Session cookie set by POST /login. Browser-only.',
|
||||
},
|
||||
};
|
||||
|
||||
function ginPathToOpenApi(path) {
|
||||
return path.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, '{$1}');
|
||||
}
|
||||
|
||||
function extractPathParams(openApiPath) {
|
||||
const params = [];
|
||||
const re = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
||||
let m;
|
||||
while ((m = re.exec(openApiPath)) !== null) params.push(m[1]);
|
||||
return params;
|
||||
}
|
||||
|
||||
function mapType(t) {
|
||||
const v = String(t || '').toLowerCase();
|
||||
if (v === 'number' || v === 'integer' || v === 'int') return 'integer';
|
||||
if (v === 'float' || v === 'double') return 'number';
|
||||
if (v === 'boolean' || v === 'bool') return 'boolean';
|
||||
if (v === 'array') return 'array';
|
||||
if (v === 'object') return 'object';
|
||||
return 'string';
|
||||
}
|
||||
|
||||
function tryParseJson(raw) {
|
||||
if (typeof raw !== 'string') return undefined;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function paramToOpenApi(p) {
|
||||
const out = {
|
||||
name: p.name,
|
||||
in: p.in,
|
||||
required: p.in === 'path' ? true : !p.optional,
|
||||
description: p.desc || '',
|
||||
schema: { type: mapType(p.type) },
|
||||
};
|
||||
if (p.defaultValue !== undefined) out.schema.default = p.defaultValue;
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildOperation(ep, tag) {
|
||||
const op = {
|
||||
tags: [tag],
|
||||
summary: ep.summary || '',
|
||||
operationId: `${ep.method.toLowerCase()}_${ep.path.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_|_$/g, '')}`,
|
||||
};
|
||||
if (ep.description) op.description = ep.description;
|
||||
if (ep.deprecated) op.deprecated = true;
|
||||
|
||||
const params = [];
|
||||
const bodyParams = [];
|
||||
for (const p of ep.params || []) {
|
||||
if (p.in === 'body') {
|
||||
bodyParams.push(p);
|
||||
} else if (p.in === 'path' || p.in === 'query' || p.in === 'header') {
|
||||
params.push(paramToOpenApi(p));
|
||||
}
|
||||
}
|
||||
|
||||
const openApiPath = ginPathToOpenApi(ep.path);
|
||||
const declared = new Set(params.filter((x) => x.in === 'path').map((x) => x.name));
|
||||
for (const name of extractPathParams(openApiPath)) {
|
||||
if (declared.has(name)) continue;
|
||||
params.push({
|
||||
name,
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: '',
|
||||
schema: { type: 'string' },
|
||||
});
|
||||
}
|
||||
|
||||
if (params.length > 0) op.parameters = params;
|
||||
|
||||
if (ep.body || bodyParams.length > 0) {
|
||||
const example = tryParseJson(ep.body);
|
||||
const properties = {};
|
||||
const required = [];
|
||||
for (const bp of bodyParams) {
|
||||
properties[bp.name] = {
|
||||
type: mapType(bp.type),
|
||||
description: bp.desc || '',
|
||||
};
|
||||
if (!bp.optional) required.push(bp.name);
|
||||
}
|
||||
const schema = bodyParams.length > 0
|
||||
? { type: 'object', properties, ...(required.length > 0 ? { required } : {}) }
|
||||
: { type: 'object' };
|
||||
|
||||
op.requestBody = {
|
||||
required: required.length > 0 || bodyParams.length === 0,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema,
|
||||
...(example !== undefined ? { example } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const responses = {};
|
||||
const successExample = tryParseJson(ep.response);
|
||||
responses['200'] = {
|
||||
description: 'Successful response',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
msg: { type: 'string' },
|
||||
obj: {},
|
||||
},
|
||||
},
|
||||
...(successExample !== undefined ? { example: successExample } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const errExample = tryParseJson(ep.errorResponse);
|
||||
if (errExample !== undefined || ep.errorStatus) {
|
||||
const code = String(ep.errorStatus || 400);
|
||||
responses[code] = {
|
||||
description: 'Error response',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
msg: { type: 'string' },
|
||||
},
|
||||
},
|
||||
...(errExample !== undefined ? { example: errExample } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
op.responses = responses;
|
||||
return op;
|
||||
}
|
||||
|
||||
function buildSpec() {
|
||||
const paths = {};
|
||||
for (const section of sections) {
|
||||
const tag = section.title;
|
||||
for (const ep of section.endpoints) {
|
||||
const openApiPath = ginPathToOpenApi(ep.path);
|
||||
if (!paths[openApiPath]) paths[openApiPath] = {};
|
||||
paths[openApiPath][ep.method.toLowerCase()] = buildOperation(ep, tag);
|
||||
}
|
||||
}
|
||||
|
||||
const tags = sections.map((s) => ({
|
||||
name: s.title,
|
||||
description: s.description || '',
|
||||
}));
|
||||
|
||||
return {
|
||||
openapi: '3.0.3',
|
||||
info: {
|
||||
title: '3X-UI Panel API',
|
||||
version: PANEL_VERSION,
|
||||
description:
|
||||
'Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes.',
|
||||
},
|
||||
servers: [
|
||||
{ url: '/', description: 'Current panel (basePath aware)' },
|
||||
],
|
||||
components: {
|
||||
securitySchemes: SECURITY_SCHEMES,
|
||||
},
|
||||
security: [{ bearerAuth: [] }, { cookieAuth: [] }],
|
||||
tags,
|
||||
paths,
|
||||
};
|
||||
}
|
||||
|
||||
const spec = buildSpec();
|
||||
writeFileSync(outPath, JSON.stringify(spec, null, 2) + '\n');
|
||||
|
||||
const pathCount = Object.keys(spec.paths).length;
|
||||
let opCount = 0;
|
||||
for (const ops of Object.values(spec.paths)) opCount += Object.keys(ops).length;
|
||||
console.log(`[openapi] wrote ${outPath}`);
|
||||
console.log(`[openapi] paths: ${pathCount}, operations: ${opCount}, tags: ${spec.tags.length}`);
|
||||
|
||||
void pathToFileURL;
|
||||
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Settings</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/settings.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
16
frontend/src/api/QueryProvider.tsx
Normal file
16
frontend/src/api/QueryProvider.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
|
||||
import { queryClient } from '@/queryClient';
|
||||
|
||||
export function QueryProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
{import.meta.env.DEV && (
|
||||
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-left" />
|
||||
)}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,21 @@
|
||||
import axios from 'axios';
|
||||
import type { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
|
||||
import qs from 'qs';
|
||||
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
|
||||
const CSRF_TOKEN_PATH = '/csrf-token';
|
||||
|
||||
let csrfToken = null;
|
||||
let csrfFetchPromise = null;
|
||||
let csrfToken: string | null = null;
|
||||
let csrfFetchPromise: Promise<string | null> | null = null;
|
||||
let sessionExpired = false;
|
||||
|
||||
function readMetaToken() {
|
||||
type CsrfAwareConfig = InternalAxiosRequestConfig & { __csrfRetried?: boolean };
|
||||
|
||||
function readMetaToken(): string | null {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null;
|
||||
}
|
||||
|
||||
async function fetchCsrfToken() {
|
||||
async function fetchCsrfToken(): Promise<string | null> {
|
||||
try {
|
||||
const basePath = window.X_UI_BASE_PATH;
|
||||
const url = (typeof basePath === 'string' && basePath !== '' && basePath !== '/'
|
||||
@@ -24,14 +27,14 @@ async function fetchCsrfToken() {
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
const json = (await res.json()) as { success?: boolean; obj?: unknown } | null;
|
||||
return json?.success && typeof json.obj === 'string' ? json.obj : null;
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCsrfToken() {
|
||||
async function ensureCsrfToken(): Promise<string | null> {
|
||||
if (csrfToken) return csrfToken;
|
||||
const meta = readMetaToken();
|
||||
if (meta) {
|
||||
@@ -45,14 +48,11 @@ async function ensureCsrfToken() {
|
||||
return csrfToken;
|
||||
}
|
||||
|
||||
// Apply the panel's axios defaults + interceptors. Call once at app
|
||||
// startup before any HTTP call goes out.
|
||||
export function setupAxios() {
|
||||
export function setupAxios(): void {
|
||||
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
|
||||
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
|
||||
// Read base path from window object or fallback to meta tag (for Cloudflare Rocket Loader compatibility)
|
||||
let basePath = window.X_UI_BASE_PATH;
|
||||
let basePath: string | null | undefined = window.X_UI_BASE_PATH;
|
||||
if (!basePath) {
|
||||
const metaTag = document.querySelector('meta[name="base-path"]');
|
||||
basePath = metaTag ? metaTag.getAttribute('content') : null;
|
||||
@@ -61,22 +61,19 @@ export function setupAxios() {
|
||||
axios.defaults.baseURL = basePath;
|
||||
}
|
||||
|
||||
// Seed the cache from the meta tag if a server-rendered page injected
|
||||
// one — saves a round trip on legacy templates that still embed it.
|
||||
csrfToken = readMetaToken();
|
||||
|
||||
axios.interceptors.request.use(
|
||||
async (config) => {
|
||||
config.headers = config.headers || {};
|
||||
async (config: InternalAxiosRequestConfig) => {
|
||||
const method = (config.method || 'get').toUpperCase();
|
||||
if (!SAFE_METHODS.has(method)) {
|
||||
const token = await ensureCsrfToken();
|
||||
if (token) config.headers['X-CSRF-Token'] = token;
|
||||
if (token) config.headers.set('X-CSRF-Token', token);
|
||||
}
|
||||
if (config.data instanceof FormData) {
|
||||
config.headers['Content-Type'] = 'multipart/form-data';
|
||||
config.headers.set('Content-Type', 'multipart/form-data');
|
||||
} else {
|
||||
const declaredType = String(config.headers['Content-Type'] || config.headers['content-type'] || '');
|
||||
const declaredType = String(config.headers.get('Content-Type') || config.headers.get('content-type') || '');
|
||||
if (declaredType.toLowerCase().startsWith('application/json')) {
|
||||
if (config.data !== undefined && typeof config.data !== 'string') {
|
||||
config.data = JSON.stringify(config.data);
|
||||
@@ -87,12 +84,12 @@ export function setupAxios() {
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error),
|
||||
(error: unknown) => Promise.reject(error),
|
||||
);
|
||||
|
||||
axios.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
(response: AxiosResponse) => response,
|
||||
async (error: AxiosError) => {
|
||||
const status = error.response?.status;
|
||||
if (status === 401) {
|
||||
if (!sessionExpired) {
|
||||
@@ -100,21 +97,19 @@ export function setupAxios() {
|
||||
const basePath = window.X_UI_BASE_PATH || '/';
|
||||
window.location.replace(basePath);
|
||||
}
|
||||
return new Promise(() => { });
|
||||
return new Promise(() => {});
|
||||
}
|
||||
// 403 with a stale/missing CSRF token: drop the cache, re-fetch, retry once.
|
||||
const cfg = error.config;
|
||||
const cfg = error.config as CsrfAwareConfig | undefined;
|
||||
if (status === 403 && cfg && !cfg.__csrfRetried) {
|
||||
csrfToken = null;
|
||||
cfg.__csrfRetried = true;
|
||||
const token = await ensureCsrfToken();
|
||||
if (token) {
|
||||
cfg.headers = cfg.headers || {};
|
||||
cfg.headers['X-CSRF-Token'] = token;
|
||||
const declaredType = String(cfg.headers['Content-Type'] || cfg.headers['content-type'] || '');
|
||||
cfg.headers.set('X-CSRF-Token', token);
|
||||
const declaredType = String(cfg.headers.get('Content-Type') || cfg.headers.get('content-type') || '');
|
||||
if (typeof cfg.data === 'string') {
|
||||
if (declaredType.toLowerCase().startsWith('application/json')) {
|
||||
try { cfg.data = JSON.parse(cfg.data); } catch (_e) { /* keep as-is */ }
|
||||
try { cfg.data = JSON.parse(cfg.data); } catch {}
|
||||
} else {
|
||||
cfg.data = qs.parse(cfg.data);
|
||||
}
|
||||
9
frontend/src/api/invalidationTracker.ts
Normal file
9
frontend/src/api/invalidationTracker.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
let lastLocalInvalidateAt = 0;
|
||||
|
||||
export function markLocalInvalidate(): void {
|
||||
lastLocalInvalidateAt = Date.now();
|
||||
}
|
||||
|
||||
export function isRecentLocalInvalidate(windowMs = 1500): boolean {
|
||||
return Date.now() - lastLocalInvalidateAt < windowMs;
|
||||
}
|
||||
69
frontend/src/api/queries/useAllSettings.ts
Normal file
69
frontend/src/api/queries/useAllSettings.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { HttpUtil, Msg } from '@/utils';
|
||||
import { parseMsg } from '@/utils/zodValidate';
|
||||
import { AllSetting } from '@/models/setting';
|
||||
import { AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
|
||||
async function fetchAllSetting(): Promise<AllSettingInput | null> {
|
||||
const msg = await HttpUtil.post('/panel/setting/all', undefined, { silent: true });
|
||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch settings');
|
||||
const validated = parseMsg(msg, AllSettingSchema, 'setting/all');
|
||||
return validated.obj;
|
||||
}
|
||||
|
||||
export function useAllSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
const [draft, setDraft] = useState<AllSetting>(() => new AllSetting());
|
||||
const [extraSpinning, setExtraSpinning] = useState(false);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: keys.settings.all(),
|
||||
queryFn: fetchAllSetting,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const server = useMemo(() => new AllSetting(query.data), [query.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (query.data !== undefined) {
|
||||
setDraft(new AllSetting(query.data));
|
||||
}
|
||||
}, [query.data]);
|
||||
|
||||
const updateSetting = useCallback((patch: Partial<AllSetting>) => {
|
||||
setDraft((prev) => {
|
||||
const next = new AllSetting(prev);
|
||||
Object.assign(next, patch);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async (next: AllSetting): Promise<Msg<unknown>> => {
|
||||
const body = AllSettingSchema.partial().safeParse(next);
|
||||
if (!body.success) {
|
||||
console.warn('[zod] setting/update body failed validation', body.error.issues);
|
||||
}
|
||||
return HttpUtil.post('/panel/setting/update', body.success ? body.data : next);
|
||||
},
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.settings.all() });
|
||||
},
|
||||
});
|
||||
|
||||
const saveAll = useCallback(() => saveMut.mutateAsync(draft), [saveMut, draft]);
|
||||
const saveDisabled = useMemo(() => server.equals(draft), [server, draft]);
|
||||
|
||||
return {
|
||||
allSetting: draft,
|
||||
updateSetting,
|
||||
fetched: query.data !== undefined,
|
||||
spinning: extraSpinning || saveMut.isPending,
|
||||
setSpinning: setExtraSpinning,
|
||||
saveDisabled,
|
||||
saveAll,
|
||||
};
|
||||
}
|
||||
58
frontend/src/api/queries/useNodeMutations.ts
Normal file
58
frontend/src/api/queries/useNodeMutations.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { HttpUtil, Msg } from '@/utils';
|
||||
import { parseMsg } from '@/utils/zodValidate';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||
import { ProbeResultSchema, type ProbeResult } from '@/schemas/node';
|
||||
|
||||
export type { ProbeResult };
|
||||
|
||||
export function useNodeMutations() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.nodes.root() });
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (payload: Partial<NodeRecord>) =>
|
||||
HttpUtil.post('/panel/api/nodes/add', payload),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||
});
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ id, payload }: { id: number; payload: Partial<NodeRecord> }) =>
|
||||
HttpUtil.post(`/panel/api/nodes/update/${id}`, payload),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||
});
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: (id: number) =>
|
||||
HttpUtil.post(`/panel/api/nodes/del/${id}`),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||
});
|
||||
|
||||
const setEnableMut = useMutation({
|
||||
mutationFn: ({ id, enable }: { id: number; enable: boolean }) =>
|
||||
HttpUtil.post(`/panel/api/nodes/setEnable/${id}`, { enable }),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||
});
|
||||
|
||||
const probeMut = useMutation({
|
||||
mutationFn: async (id: number): Promise<Msg<ProbeResult>> => {
|
||||
const raw = await HttpUtil.post(`/panel/api/nodes/probe/${id}`);
|
||||
return parseMsg(raw, ProbeResultSchema, 'nodes/probe');
|
||||
},
|
||||
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||
});
|
||||
|
||||
return {
|
||||
create: (payload: Partial<NodeRecord>) => createMut.mutateAsync(payload),
|
||||
update: (id: number, payload: Partial<NodeRecord>) => updateMut.mutateAsync({ id, payload }),
|
||||
remove: (id: number) => removeMut.mutateAsync(id),
|
||||
setEnable: (id: number, enable: boolean) => setEnableMut.mutateAsync({ id, enable }),
|
||||
probe: (id: number) => probeMut.mutateAsync(id),
|
||||
testConnection: async (payload: Partial<NodeRecord>): Promise<Msg<ProbeResult>> => {
|
||||
const raw = await HttpUtil.post('/panel/api/nodes/test', payload);
|
||||
return parseMsg(raw, ProbeResultSchema, 'nodes/test');
|
||||
},
|
||||
};
|
||||
}
|
||||
81
frontend/src/api/queries/useNodesQuery.ts
Normal file
81
frontend/src/api/queries/useNodesQuery.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { parseMsg } from '@/utils/zodValidate';
|
||||
import { NodeListSchema } from '@/schemas/node';
|
||||
import type { NodeRecord } from '@/schemas/node';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
|
||||
export type { NodeRecord };
|
||||
|
||||
export interface NodeTotals {
|
||||
total: number;
|
||||
online: number;
|
||||
offline: number;
|
||||
avgLatency: number;
|
||||
inbounds: number;
|
||||
clients: number;
|
||||
onlineClients: number;
|
||||
depleted: number;
|
||||
}
|
||||
|
||||
async function fetchNodes(): Promise<NodeRecord[]> {
|
||||
const msg = await HttpUtil.get('/panel/api/nodes/list', undefined, { silent: true });
|
||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch nodes');
|
||||
const validated = parseMsg(msg, NodeListSchema, 'nodes/list');
|
||||
return Array.isArray(validated.obj) ? validated.obj : [];
|
||||
}
|
||||
|
||||
export function useNodesQuery() {
|
||||
const query = useQuery({
|
||||
queryKey: keys.nodes.list(),
|
||||
queryFn: fetchNodes,
|
||||
});
|
||||
|
||||
const nodes = useMemo(() => query.data ?? [], [query.data]);
|
||||
|
||||
const totals = useMemo<NodeTotals>(() => {
|
||||
let online = 0;
|
||||
let offline = 0;
|
||||
let latencySum = 0;
|
||||
let latencyCount = 0;
|
||||
let inbounds = 0;
|
||||
let clients = 0;
|
||||
let onlineClients = 0;
|
||||
let depleted = 0;
|
||||
for (const n of nodes) {
|
||||
inbounds += n.inboundCount || 0;
|
||||
clients += n.clientCount || 0;
|
||||
onlineClients += n.onlineCount || 0;
|
||||
depleted += n.depletedCount || 0;
|
||||
if (!n.enable) continue;
|
||||
if (n.status === 'online') {
|
||||
online += 1;
|
||||
if (n.latencyMs && n.latencyMs > 0) {
|
||||
latencySum += n.latencyMs;
|
||||
latencyCount += 1;
|
||||
}
|
||||
} else if (n.status === 'offline') {
|
||||
offline += 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
total: nodes.length,
|
||||
online,
|
||||
offline,
|
||||
avgLatency: latencyCount > 0 ? Math.round(latencySum / latencyCount) : 0,
|
||||
inbounds,
|
||||
clients,
|
||||
onlineClients,
|
||||
depleted,
|
||||
};
|
||||
}, [nodes]);
|
||||
|
||||
return {
|
||||
nodes,
|
||||
totals,
|
||||
loading: query.isFetching,
|
||||
fetched: query.data !== undefined,
|
||||
};
|
||||
}
|
||||
36
frontend/src/api/queries/useStatusQuery.ts
Normal file
36
frontend/src/api/queries/useStatusQuery.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { parseMsg } from '@/utils/zodValidate';
|
||||
import { Status } from '@/models/status';
|
||||
import { StatusSchema } from '@/schemas/status';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
|
||||
async function fetchStatus(): Promise<Status> {
|
||||
const msg = await HttpUtil.get('/panel/api/server/status', undefined, { silent: true });
|
||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch status');
|
||||
const validated = parseMsg(msg, StatusSchema, 'server/status');
|
||||
return new Status(validated.obj);
|
||||
}
|
||||
|
||||
export function useStatusQuery() {
|
||||
const query = useQuery({
|
||||
queryKey: keys.server.status(),
|
||||
queryFn: fetchStatus,
|
||||
refetchInterval: POLL_INTERVAL_MS,
|
||||
refetchIntervalInBackground: false,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const status = useMemo(() => query.data ?? new Status(), [query.data]);
|
||||
const refresh = async () => { await query.refetch(); };
|
||||
|
||||
return {
|
||||
status,
|
||||
fetched: query.data !== undefined,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
32
frontend/src/api/queryKeys.ts
Normal file
32
frontend/src/api/queryKeys.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export const keys = {
|
||||
server: {
|
||||
status: () => ['server', 'status'] as const,
|
||||
},
|
||||
nodes: {
|
||||
root: () => ['nodes'] as const,
|
||||
list: () => ['nodes', 'list'] as const,
|
||||
},
|
||||
settings: {
|
||||
root: () => ['settings'] as const,
|
||||
all: () => ['settings', 'all'] as const,
|
||||
defaults: () => ['settings', 'defaults'] as const,
|
||||
},
|
||||
inbounds: {
|
||||
root: () => ['inbounds'] as const,
|
||||
slim: () => ['inbounds', 'slim'] as const,
|
||||
options: () => ['inbounds', 'options'] as const,
|
||||
},
|
||||
clients: {
|
||||
root: () => ['clients'] as const,
|
||||
list: (params: unknown) => ['clients', 'list', params] as const,
|
||||
all: () => ['clients', 'all'] as const,
|
||||
onlines: () => ['clients', 'onlines'] as const,
|
||||
lastOnline: () => ['clients', 'lastOnline'] as const,
|
||||
groups: () => ['clients', 'groups'] as const,
|
||||
},
|
||||
xray: {
|
||||
root: () => ['xray'] as const,
|
||||
config: () => ['xray', 'config'] as const,
|
||||
outboundsTraffic: () => ['xray', 'outboundsTraffic'] as const,
|
||||
},
|
||||
} as const;
|
||||
@@ -1,231 +0,0 @@
|
||||
/**
|
||||
* WebSocket client for real-time panel updates.
|
||||
*
|
||||
* Public API (kept stable for index.html / inbounds.html / xray.html):
|
||||
* - connect() — open the connection (idempotent)
|
||||
* - disconnect() — close and stop reconnecting
|
||||
* - on(event, callback) — subscribe to event
|
||||
* - off(event, callback) — unsubscribe
|
||||
* - send(data) — send JSON to the server
|
||||
* - isConnected — boolean, current state
|
||||
* - reconnectAttempts — number, attempts since last success
|
||||
* - maxReconnectAttempts — number, give-up threshold
|
||||
*
|
||||
* Built-in events:
|
||||
* 'connected', 'disconnected', 'error', 'message',
|
||||
* plus any server-emitted message type (status, traffic, client_stats, ...).
|
||||
*/
|
||||
export class WebSocketClient {
|
||||
static #MAX_PAYLOAD_BYTES = 10 * 1024 * 1024; // 10 MB, mirrors hub maxMessageSize.
|
||||
static #BASE_RECONNECT_MS = 1000;
|
||||
static #MAX_RECONNECT_MS = 30_000;
|
||||
// After exhausting maxReconnectAttempts we switch to a polite slow-retry
|
||||
// cadence rather than giving up forever — a panel that recovers an hour
|
||||
// later should reconnect without a manual page reload.
|
||||
static #SLOW_RETRY_MS = 60_000;
|
||||
|
||||
constructor(basePath = '') {
|
||||
this.basePath = basePath;
|
||||
this.maxReconnectAttempts = 10;
|
||||
this.reconnectAttempts = 0;
|
||||
this.isConnected = false;
|
||||
|
||||
this.ws = null;
|
||||
this.shouldReconnect = true;
|
||||
this.reconnectTimer = null;
|
||||
this.listeners = new Map(); // event → Set<callback>
|
||||
}
|
||||
|
||||
// Open the connection. Safe to call repeatedly — no-op if already
|
||||
// open/connecting. Re-enables reconnects if previously disabled. Cancels
|
||||
// any pending reconnect timer so an external connect() can't race a
|
||||
// delayed retry into spawning a second socket.
|
||||
connect() {
|
||||
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
this.shouldReconnect = true;
|
||||
this.#cancelReconnect();
|
||||
this.#openSocket();
|
||||
}
|
||||
|
||||
// Close the connection and stop any pending reconnect attempt. Resets the
|
||||
// attempt counter so a future connect() starts fresh from the small backoff.
|
||||
disconnect() {
|
||||
this.shouldReconnect = false;
|
||||
this.#cancelReconnect();
|
||||
this.reconnectAttempts = 0;
|
||||
if (this.ws) {
|
||||
try { this.ws.close(1000, 'client disconnect'); } catch { /* ignore */ }
|
||||
this.ws = null;
|
||||
}
|
||||
this.isConnected = false;
|
||||
}
|
||||
|
||||
// Subscribe to an event. Re-subscribing the same callback is a no-op.
|
||||
on(event, callback) {
|
||||
if (typeof callback !== 'function') return;
|
||||
let set = this.listeners.get(event);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.listeners.set(event, set);
|
||||
}
|
||||
set.add(callback);
|
||||
}
|
||||
|
||||
// Unsubscribe from an event.
|
||||
off(event, callback) {
|
||||
const set = this.listeners.get(event);
|
||||
if (!set) return;
|
||||
set.delete(callback);
|
||||
if (set.size === 0) this.listeners.delete(event);
|
||||
}
|
||||
|
||||
// Send JSON to the server. Drops silently if not connected — callers
|
||||
// should rely on connect()/server pushes rather than client-initiated sends.
|
||||
send(data) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
// ───── internals ─────
|
||||
|
||||
#openSocket() {
|
||||
const url = this.#buildUrl();
|
||||
let socket;
|
||||
try {
|
||||
socket = new WebSocket(url);
|
||||
} catch (err) {
|
||||
console.error('WebSocket: failed to construct connection', err);
|
||||
this.#emit('error', err);
|
||||
this.#scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
this.ws = socket;
|
||||
|
||||
// Every handler must check `this.ws !== socket` first. A previous socket
|
||||
// can still fire events (especially `close`) after we've moved on to a
|
||||
// new one — e.g. connect() called while the old socket is in CLOSING
|
||||
// state. Without the guard, a stale close would null out the freshly
|
||||
// opened socket and silently break send().
|
||||
socket.addEventListener('open', () => {
|
||||
if (this.ws !== socket) return;
|
||||
this.isConnected = true;
|
||||
this.reconnectAttempts = 0;
|
||||
this.#emit('connected');
|
||||
});
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
if (this.ws !== socket) return;
|
||||
this.#onMessage(event);
|
||||
});
|
||||
|
||||
socket.addEventListener('error', (event) => {
|
||||
if (this.ws !== socket) return;
|
||||
// Browsers fire 'error' before 'close' on failure. We surface it for
|
||||
// consumers (so polling fallbacks can engage) but don't log every blip
|
||||
// — bad networks would flood the console otherwise.
|
||||
this.#emit('error', event);
|
||||
});
|
||||
|
||||
socket.addEventListener('close', () => {
|
||||
if (this.ws !== socket) return;
|
||||
this.isConnected = false;
|
||||
this.ws = null;
|
||||
this.#emit('disconnected');
|
||||
if (this.shouldReconnect) this.#scheduleReconnect();
|
||||
});
|
||||
}
|
||||
|
||||
#buildUrl() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
// basePath comes from window.X_UI_BASE_PATH which is only injected
|
||||
// by the Go binary in production. In dev (Vite serves directly) the
|
||||
// global is missing and basePath would be '' — without the fallback to
|
||||
// '/' we'd build `ws://host:portws` (no separator) and the WebSocket
|
||||
// constructor throws a SyntaxError.
|
||||
let basePath = this.basePath || '/';
|
||||
if (!basePath.startsWith('/')) basePath = '/' + basePath;
|
||||
if (!basePath.endsWith('/')) basePath += '/';
|
||||
return `${protocol}//${window.location.host}${basePath}ws`;
|
||||
}
|
||||
|
||||
#onMessage(event) {
|
||||
const data = event.data;
|
||||
// Reject oversized payloads up front. We compare actual UTF-8 byte
|
||||
// length (via Blob.size) against the limit — string.length counts
|
||||
// UTF-16 code units, which can undercount real bytes by up to 4× for
|
||||
// payloads with non-ASCII characters and bypass the cap.
|
||||
if (typeof data === 'string') {
|
||||
const byteLen = new Blob([data]).size;
|
||||
if (byteLen > WebSocketClient.#MAX_PAYLOAD_BYTES) {
|
||||
console.error(`WebSocket: payload too large (${byteLen} bytes), closing`);
|
||||
try { this.ws?.close(1009, 'message too big'); } catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
}
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(data);
|
||||
} catch (err) {
|
||||
console.error('WebSocket: invalid JSON message', err);
|
||||
return;
|
||||
}
|
||||
if (!message || typeof message !== 'object' || typeof message.type !== 'string') {
|
||||
console.error('WebSocket: malformed message envelope');
|
||||
return;
|
||||
}
|
||||
this.#emit(message.type, message.payload, message.time);
|
||||
this.#emit('message', message);
|
||||
}
|
||||
|
||||
#emit(event, ...args) {
|
||||
const set = this.listeners.get(event);
|
||||
if (!set) return;
|
||||
for (const callback of set) {
|
||||
try {
|
||||
callback(...args);
|
||||
} catch (err) {
|
||||
console.error(`WebSocket: handler for "${event}" threw`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#scheduleReconnect() {
|
||||
if (!this.shouldReconnect) return;
|
||||
this.#cancelReconnect();
|
||||
|
||||
let base;
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.reconnectAttempts += 1;
|
||||
// Exponential backoff inside the active window.
|
||||
const exp = WebSocketClient.#BASE_RECONNECT_MS * 2 ** (this.reconnectAttempts - 1);
|
||||
base = Math.min(WebSocketClient.#MAX_RECONNECT_MS, exp);
|
||||
} else {
|
||||
// Active window exhausted — keep trying once a minute. The page-level
|
||||
// polling fallback runs in parallel; this just brings WS back when the
|
||||
// network recovers.
|
||||
base = WebSocketClient.#SLOW_RETRY_MS;
|
||||
}
|
||||
// ±25% jitter so reloads after a panel restart don't reconnect in lockstep.
|
||||
const delay = base * (0.75 + Math.random() * 0.5);
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
// clearTimeout doesn't cancel a callback that has already fired but
|
||||
// whose macrotask hasn't run yet — re-check shouldReconnect here so
|
||||
// disconnect() called in that window can't be overridden.
|
||||
if (!this.shouldReconnect) return;
|
||||
this.#openSocket();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
#cancelReconnect() {
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
192
frontend/src/api/websocket.ts
Normal file
192
frontend/src/api/websocket.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
type WebSocketListener = (...args: unknown[]) => void;
|
||||
|
||||
interface WebSocketMessage {
|
||||
type: string;
|
||||
payload?: unknown;
|
||||
time?: unknown;
|
||||
}
|
||||
|
||||
export class WebSocketClient {
|
||||
static #MAX_PAYLOAD_BYTES = 10 * 1024 * 1024;
|
||||
static #BASE_RECONNECT_MS = 1000;
|
||||
static #MAX_RECONNECT_MS = 30_000;
|
||||
static #SLOW_RETRY_MS = 60_000;
|
||||
|
||||
basePath: string;
|
||||
maxReconnectAttempts: number;
|
||||
reconnectAttempts: number;
|
||||
isConnected: boolean;
|
||||
|
||||
private ws: WebSocket | null;
|
||||
private shouldReconnect: boolean;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null;
|
||||
private listeners: Map<string, Set<WebSocketListener>>;
|
||||
|
||||
constructor(basePath = '') {
|
||||
this.basePath = basePath;
|
||||
this.maxReconnectAttempts = 10;
|
||||
this.reconnectAttempts = 0;
|
||||
this.isConnected = false;
|
||||
|
||||
this.ws = null;
|
||||
this.shouldReconnect = true;
|
||||
this.reconnectTimer = null;
|
||||
this.listeners = new Map();
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
this.shouldReconnect = true;
|
||||
this.#cancelReconnect();
|
||||
this.#openSocket();
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.shouldReconnect = false;
|
||||
this.#cancelReconnect();
|
||||
this.reconnectAttempts = 0;
|
||||
if (this.ws) {
|
||||
try { this.ws.close(1000, 'client disconnect'); } catch {}
|
||||
this.ws = null;
|
||||
}
|
||||
this.isConnected = false;
|
||||
}
|
||||
|
||||
on(event: string, callback: WebSocketListener): void {
|
||||
if (typeof callback !== 'function') return;
|
||||
let set = this.listeners.get(event);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.listeners.set(event, set);
|
||||
}
|
||||
set.add(callback);
|
||||
}
|
||||
|
||||
off(event: string, callback: WebSocketListener): void {
|
||||
const set = this.listeners.get(event);
|
||||
if (!set) return;
|
||||
set.delete(callback);
|
||||
if (set.size === 0) this.listeners.delete(event);
|
||||
}
|
||||
|
||||
send(data: unknown): void {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
#openSocket(): void {
|
||||
const url = this.#buildUrl();
|
||||
let socket: WebSocket;
|
||||
try {
|
||||
socket = new WebSocket(url);
|
||||
} catch (err) {
|
||||
console.error('WebSocket: failed to construct connection', err);
|
||||
this.#emit('error', err);
|
||||
this.#scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
this.ws = socket;
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
if (this.ws !== socket) return;
|
||||
this.isConnected = true;
|
||||
this.reconnectAttempts = 0;
|
||||
this.#emit('connected');
|
||||
});
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
if (this.ws !== socket) return;
|
||||
this.#onMessage(event);
|
||||
});
|
||||
|
||||
socket.addEventListener('error', (event) => {
|
||||
if (this.ws !== socket) return;
|
||||
this.#emit('error', event);
|
||||
});
|
||||
|
||||
socket.addEventListener('close', () => {
|
||||
if (this.ws !== socket) return;
|
||||
this.isConnected = false;
|
||||
this.ws = null;
|
||||
this.#emit('disconnected');
|
||||
if (this.shouldReconnect) this.#scheduleReconnect();
|
||||
});
|
||||
}
|
||||
|
||||
#buildUrl(): string {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
let basePath = this.basePath || '/';
|
||||
if (!basePath.startsWith('/')) basePath = '/' + basePath;
|
||||
if (!basePath.endsWith('/')) basePath += '/';
|
||||
return `${protocol}//${window.location.host}${basePath}ws`;
|
||||
}
|
||||
|
||||
#onMessage(event: MessageEvent): void {
|
||||
const data = event.data;
|
||||
if (typeof data === 'string') {
|
||||
const byteLen = new Blob([data]).size;
|
||||
if (byteLen > WebSocketClient.#MAX_PAYLOAD_BYTES) {
|
||||
console.error(`WebSocket: payload too large (${byteLen} bytes), closing`);
|
||||
try { this.ws?.close(1009, 'message too big'); } catch {}
|
||||
return;
|
||||
}
|
||||
}
|
||||
let message: unknown;
|
||||
try {
|
||||
message = JSON.parse(typeof data === 'string' ? data : '');
|
||||
} catch (err) {
|
||||
console.error('WebSocket: invalid JSON message', err);
|
||||
return;
|
||||
}
|
||||
if (!message || typeof message !== 'object' || typeof (message as { type?: unknown }).type !== 'string') {
|
||||
console.error('WebSocket: malformed message envelope');
|
||||
return;
|
||||
}
|
||||
const msg = message as WebSocketMessage;
|
||||
this.#emit(msg.type, msg.payload, msg.time);
|
||||
this.#emit('message', msg);
|
||||
}
|
||||
|
||||
#emit(event: string, ...args: unknown[]): void {
|
||||
const set = this.listeners.get(event);
|
||||
if (!set) return;
|
||||
for (const callback of set) {
|
||||
try {
|
||||
callback(...args);
|
||||
} catch (err) {
|
||||
console.error(`WebSocket: handler for "${event}" threw`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#scheduleReconnect(): void {
|
||||
if (!this.shouldReconnect) return;
|
||||
this.#cancelReconnect();
|
||||
|
||||
let base: number;
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.reconnectAttempts += 1;
|
||||
const exp = WebSocketClient.#BASE_RECONNECT_MS * 2 ** (this.reconnectAttempts - 1);
|
||||
base = Math.min(WebSocketClient.#MAX_RECONNECT_MS, exp);
|
||||
} else {
|
||||
base = WebSocketClient.#SLOW_RETRY_MS;
|
||||
}
|
||||
const delay = base * (0.75 + Math.random() * 0.5);
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
if (!this.shouldReconnect) return;
|
||||
this.#openSocket();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
#cancelReconnect(): void {
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
79
frontend/src/api/websocketBridge.ts
Normal file
79
frontend/src/api/websocketBridge.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { WebSocketClient } from '@/api/websocket';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
import { isRecentLocalInvalidate } from '@/api/invalidationTracker';
|
||||
|
||||
type Handler = (payload: unknown) => void;
|
||||
|
||||
interface SharedClient {
|
||||
connect(): void;
|
||||
on(event: string, fn: Handler): void;
|
||||
off(event: string, fn: Handler): void;
|
||||
}
|
||||
|
||||
let sharedClient: SharedClient | null = null;
|
||||
|
||||
function getSharedClient(): SharedClient {
|
||||
if (sharedClient) return sharedClient;
|
||||
const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
|
||||
sharedClient = new WebSocketClient(basePath) as SharedClient;
|
||||
return sharedClient;
|
||||
}
|
||||
|
||||
let invalidateTimer: number | null = null;
|
||||
|
||||
export function useWebSocketBridge() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
const client = getSharedClient();
|
||||
|
||||
const onInvalidate: Handler = (payload) => {
|
||||
const p = payload as { type?: string } | undefined;
|
||||
if (!p || (p.type !== 'inbounds' && p.type !== 'clients')) return;
|
||||
if (invalidateTimer != null) clearTimeout(invalidateTimer);
|
||||
invalidateTimer = window.setTimeout(() => {
|
||||
invalidateTimer = null;
|
||||
if (isRecentLocalInvalidate()) return;
|
||||
if (p.type === 'inbounds') {
|
||||
queryClient.invalidateQueries({ queryKey: ['inbounds'] });
|
||||
} else {
|
||||
queryClient.invalidateQueries({ queryKey: ['clients'] });
|
||||
}
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const onOutbounds: Handler = (payload) => {
|
||||
queryClient.setQueryData(keys.xray.outboundsTraffic(), payload);
|
||||
};
|
||||
|
||||
const onNodes: Handler = (payload) => {
|
||||
if (!Array.isArray(payload)) return;
|
||||
queryClient.setQueryData(keys.nodes.list(), payload);
|
||||
};
|
||||
|
||||
const onInbounds: Handler = (payload) => {
|
||||
if (!Array.isArray(payload)) return;
|
||||
queryClient.setQueryData(keys.inbounds.slim(), payload);
|
||||
};
|
||||
|
||||
client.on('invalidate', onInvalidate);
|
||||
client.on('outbounds', onOutbounds);
|
||||
client.on('nodes', onNodes);
|
||||
client.on('inbounds', onInbounds);
|
||||
client.connect();
|
||||
|
||||
return () => {
|
||||
client.off('invalidate', onInvalidate);
|
||||
client.off('outbounds', onOutbounds);
|
||||
client.off('nodes', onNodes);
|
||||
client.off('inbounds', onInbounds);
|
||||
if (invalidateTimer != null) {
|
||||
clearTimeout(invalidateTimer);
|
||||
invalidateTimer = null;
|
||||
}
|
||||
};
|
||||
}, [queryClient]);
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
color: var(--ant-color-text);
|
||||
}
|
||||
|
||||
.sider-brand {
|
||||
@@ -18,8 +18,8 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 14px 14px;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
|
||||
padding: 14px 16px 14px 24px;
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -32,29 +32,12 @@
|
||||
|
||||
.brand-block {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.brand-version {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
opacity: 0.6;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.sider-brand-collapsed .brand-block {
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@@ -74,7 +57,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
color: var(--ant-color-text-secondary);
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.2s, transform 0.15s, color 0.2s;
|
||||
@@ -102,7 +85,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
color: var(--ant-color-text-secondary);
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.2s, transform 0.15s, color 0.2s;
|
||||
@@ -110,15 +93,14 @@
|
||||
|
||||
.sidebar-theme-cycle:hover,
|
||||
.sidebar-theme-cycle:focus-visible {
|
||||
background-color: rgba(64, 150, 255, 0.1);
|
||||
color: #4096ff;
|
||||
background-color: color-mix(in srgb, var(--ant-color-primary) 12%, transparent);
|
||||
color: var(--ant-color-primary);
|
||||
transform: scale(1.08);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sidebar-theme-cycle svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
.sidebar-theme-cycle .anticon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.drawer-header-actions {
|
||||
@@ -151,7 +133,7 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||
}
|
||||
|
||||
.drawer-close {
|
||||
@@ -165,12 +147,12 @@
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
color: var(--ant-color-text-secondary);
|
||||
}
|
||||
|
||||
.drawer-close:hover,
|
||||
.drawer-close:focus-visible {
|
||||
background: rgba(128, 128, 128, 0.18);
|
||||
background: var(--ant-color-fill-tertiary);
|
||||
}
|
||||
|
||||
.drawer-menu .ant-menu-item {
|
||||
@@ -186,7 +168,7 @@
|
||||
|
||||
.drawer-utility {
|
||||
margin-top: auto;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-top: 1px solid var(--ant-color-border-secondary);
|
||||
}
|
||||
|
||||
.ant-sidebar > .ant-layout-sider .ant-layout-sider-children {
|
||||
@@ -204,7 +186,47 @@
|
||||
|
||||
.sider-utility {
|
||||
flex: 0 0 auto;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-top: 1px solid var(--ant-color-border-secondary);
|
||||
}
|
||||
|
||||
.sider-footer {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 8px 12px;
|
||||
}
|
||||
|
||||
.sider-version {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
color: var(--ant-color-text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.sider-version .anticon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.sider-version:hover,
|
||||
.sider-version:focus-visible {
|
||||
color: var(--ant-color-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sider-version.is-collapsed {
|
||||
justify-content: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 8px 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -225,55 +247,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
body.dark .drawer-brand,
|
||||
body.dark .sider-brand {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
body.dark .ant-drawer-content,
|
||||
body.dark .ant-drawer-body {
|
||||
background-color: #15161a;
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .drawer-brand,
|
||||
html[data-theme='ultra-dark'] .sider-brand {
|
||||
color: #ffffff;
|
||||
html[data-theme="ultra-dark"] body.dark .ant-drawer-content,
|
||||
html[data-theme="ultra-dark"] body.dark .ant-drawer-body {
|
||||
background-color: #050507;
|
||||
}
|
||||
|
||||
body.dark .drawer-close {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .drawer-close {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
body.dark .sidebar-theme-cycle {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .sidebar-theme-cycle {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
body.dark .sidebar-donate {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .sidebar-donate {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
body.dark .ant-drawer .ant-drawer-content,
|
||||
body.dark .ant-drawer .ant-drawer-body {
|
||||
background: #252526 !important;
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-content,
|
||||
html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-body {
|
||||
background: #0a0a0a !important;
|
||||
body.dark .ant-drawer-body .drawer-menu,
|
||||
body.dark .ant-drawer-body .drawer-menu.ant-menu-dark,
|
||||
body.dark .ant-drawer-body .drawer-menu .ant-menu-item,
|
||||
body.dark .ant-drawer-body .drawer-menu .ant-menu-sub,
|
||||
body.dark .ant-drawer-body .drawer-menu .ant-menu-item-group-list {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.sider-nav .ant-menu-item-selected,
|
||||
.sider-utility .ant-menu-item-selected,
|
||||
.drawer-menu .ant-menu-item-selected {
|
||||
background-color: rgba(64, 150, 255, 0.2) !important;
|
||||
color: #4096ff !important;
|
||||
background-color: color-mix(in srgb, var(--ant-color-primary) 20%, transparent) !important;
|
||||
color: var(--ant-color-primary) !important;
|
||||
}
|
||||
|
||||
.sider-nav .ant-menu-item-active:not(.ant-menu-item-selected),
|
||||
@@ -282,6 +278,6 @@ html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-body {
|
||||
.sider-nav .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
|
||||
.sider-utility .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
|
||||
.drawer-menu .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover {
|
||||
background-color: rgba(64, 150, 255, 0.1) !important;
|
||||
color: #4096ff !important;
|
||||
background-color: color-mix(in srgb, var(--ant-color-primary) 10%, transparent) !important;
|
||||
color: var(--ant-color-primary) !important;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import type { ComponentType } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Drawer, Layout, Menu } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
@@ -8,13 +9,18 @@ import {
|
||||
ClusterOutlined,
|
||||
CloseOutlined,
|
||||
DashboardOutlined,
|
||||
GithubOutlined,
|
||||
HeartOutlined,
|
||||
ImportOutlined,
|
||||
LogoutOutlined,
|
||||
MenuOutlined,
|
||||
MoonFilled,
|
||||
MoonOutlined,
|
||||
SettingOutlined,
|
||||
SunOutlined,
|
||||
TagsOutlined,
|
||||
TeamOutlined,
|
||||
ToolOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
@@ -23,18 +29,16 @@ import './AppSidebar.css';
|
||||
|
||||
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
|
||||
const DONATE_URL = 'https://donate.sanaei.dev/';
|
||||
const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
|
||||
const LOGOUT_KEY = '__logout__';
|
||||
|
||||
interface AppSidebarProps {
|
||||
basePath?: string;
|
||||
requestUri?: string;
|
||||
}
|
||||
|
||||
type IconName = 'dashboard' | 'user' | 'team' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs';
|
||||
type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs';
|
||||
|
||||
const iconByName: Record<IconName, ComponentType> = {
|
||||
dashboard: DashboardOutlined,
|
||||
user: UserOutlined,
|
||||
inbound: ImportOutlined,
|
||||
team: TeamOutlined,
|
||||
groups: TagsOutlined,
|
||||
setting: SettingOutlined,
|
||||
tool: ToolOutlined,
|
||||
cluster: ClusterOutlined,
|
||||
@@ -65,6 +69,24 @@ function DonateButton({ ariaLabel }: { ariaLabel: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function VersionBadge({ version, collapsed }: { version: string; collapsed?: boolean }) {
|
||||
if (!version) return null;
|
||||
const label = `v${version}`;
|
||||
return (
|
||||
<a
|
||||
href={REPO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`sider-version${collapsed ? ' is-collapsed' : ''}`}
|
||||
aria-label={`GitHub ${label}`}
|
||||
title={label}
|
||||
>
|
||||
<GithubOutlined />
|
||||
{!collapsed && <span className="sider-version-text">{label}</span>}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeCycleButton({ id, isDark, isUltra, onCycle, ariaLabel }: {
|
||||
id: string;
|
||||
isDark: boolean;
|
||||
@@ -72,6 +94,7 @@ function ThemeCycleButton({ id, isDark, isUltra, onCycle, ariaLabel }: {
|
||||
onCycle: () => void;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
const icon = !isDark ? <SunOutlined /> : !isUltra ? <MoonOutlined /> : <MoonFilled />;
|
||||
return (
|
||||
<button
|
||||
id={id}
|
||||
@@ -81,50 +104,40 @@ function ThemeCycleButton({ id, isDark, isUltra, onCycle, ariaLabel }: {
|
||||
title={ariaLabel}
|
||||
onClick={onCycle}
|
||||
>
|
||||
{!isDark ? (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||
</svg>
|
||||
) : !isUltra ? (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
<path fill="none" d="M19 3l0.7 1.4 1.4 0.7-1.4 0.7L19 7.2l-0.7-1.4-1.4-0.7 1.4-0.7z" />
|
||||
</svg>
|
||||
)}
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppSidebar({ basePath = '', requestUri = '' }: AppSidebarProps) {
|
||||
export default function AppSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const [collapsed, setCollapsed] = useState<boolean>(() => readCollapsed());
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const prefix = basePath.startsWith('/') ? basePath : `/${basePath || ''}`;
|
||||
const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light';
|
||||
const panelVersion = window.X_UI_CUR_VER || '';
|
||||
|
||||
const tabs = useMemo<{ key: string; icon: IconName; title: string }[]>(() => [
|
||||
{ key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
|
||||
{ key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
|
||||
{ key: `${prefix}panel/clients`, icon: 'team', title: t('menu.clients') },
|
||||
{ key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') },
|
||||
{ key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
|
||||
{ key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
|
||||
{ key: `${prefix}panel/api-docs`, icon: 'apidocs', title: t('menu.apiDocs') },
|
||||
{ key: 'logout', icon: 'logout', title: t('logout') },
|
||||
], [prefix, t]);
|
||||
{ key: '/', icon: 'dashboard', title: t('menu.dashboard') },
|
||||
{ key: '/inbounds', icon: 'inbound', title: t('menu.inbounds') },
|
||||
{ key: '/clients', icon: 'team', title: t('menu.clients') },
|
||||
{ key: '/groups', icon: 'groups', title: t('menu.groups') },
|
||||
{ key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
|
||||
{ key: '/settings', icon: 'setting', title: t('menu.settings') },
|
||||
{ key: '/xray', icon: 'tool', title: t('menu.xray') },
|
||||
{ key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') },
|
||||
{ key: LOGOUT_KEY, icon: 'logout', title: t('logout') },
|
||||
], [t]);
|
||||
|
||||
const navItems = useMemo(() => tabs.filter((tab) => tab.icon !== 'logout'), [tabs]);
|
||||
const utilItems = useMemo(() => tabs.filter((tab) => tab.icon === 'logout'), [tabs]);
|
||||
|
||||
const selectedKey = pathname === '' ? '/' : pathname;
|
||||
|
||||
const toMenuItems = useCallback((items: typeof tabs): MenuProps['items'] =>
|
||||
items.map((tab) => {
|
||||
const Icon = iconByName[tab.icon];
|
||||
@@ -137,17 +150,13 @@ export default function AppSidebar({ basePath = '', requestUri = '' }: AppSideba
|
||||
[]);
|
||||
|
||||
const openLink = useCallback(async (key: string) => {
|
||||
if (key === 'logout') {
|
||||
if (key === LOGOUT_KEY) {
|
||||
await HttpUtil.post('/logout');
|
||||
window.location.href = basePath || '/';
|
||||
window.location.href = window.X_UI_BASE_PATH || '/';
|
||||
return;
|
||||
}
|
||||
if (key.startsWith('http')) {
|
||||
window.open(key);
|
||||
} else {
|
||||
window.location.href = key;
|
||||
}
|
||||
}, [basePath]);
|
||||
navigate(key);
|
||||
}, [navigate]);
|
||||
|
||||
const onMenuClick = useCallback<NonNullable<MenuProps['onClick']>>(({ key }) => {
|
||||
openLink(String(key));
|
||||
@@ -185,9 +194,6 @@ export default function AppSidebar({ basePath = '', requestUri = '' }: AppSideba
|
||||
<div className={`sider-brand${collapsed ? ' sider-brand-collapsed' : ''}`}>
|
||||
<div className="brand-block">
|
||||
<span className="brand-text">{collapsed ? '3X' : '3X-UI'}</span>
|
||||
{!collapsed && panelVersion && (
|
||||
<span className="brand-version">v{panelVersion}</span>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="brand-actions">
|
||||
@@ -205,7 +211,7 @@ export default function AppSidebar({ basePath = '', requestUri = '' }: AppSideba
|
||||
<Menu
|
||||
theme={currentTheme}
|
||||
mode="inline"
|
||||
selectedKeys={[requestUri]}
|
||||
selectedKeys={[selectedKey]}
|
||||
className="sider-nav"
|
||||
items={toMenuItems(navItems)}
|
||||
onClick={onMenuClick}
|
||||
@@ -213,11 +219,14 @@ export default function AppSidebar({ basePath = '', requestUri = '' }: AppSideba
|
||||
<Menu
|
||||
theme={currentTheme}
|
||||
mode="inline"
|
||||
selectedKeys={[requestUri]}
|
||||
selectedKeys={[selectedKey]}
|
||||
className="sider-utility"
|
||||
items={toMenuItems(utilItems)}
|
||||
onClick={onMenuClick}
|
||||
/>
|
||||
<div className="sider-footer">
|
||||
<VersionBadge version={panelVersion} collapsed={collapsed} />
|
||||
</div>
|
||||
</Layout.Sider>
|
||||
|
||||
<Drawer
|
||||
@@ -236,7 +245,6 @@ export default function AppSidebar({ basePath = '', requestUri = '' }: AppSideba
|
||||
<div className="drawer-header">
|
||||
<div className="brand-block">
|
||||
<span className="drawer-brand">3X-UI</span>
|
||||
{panelVersion && <span className="brand-version">v{panelVersion}</span>}
|
||||
</div>
|
||||
<div className="drawer-header-actions">
|
||||
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
|
||||
@@ -260,7 +268,7 @@ export default function AppSidebar({ basePath = '', requestUri = '' }: AppSideba
|
||||
<Menu
|
||||
theme={currentTheme}
|
||||
mode="inline"
|
||||
selectedKeys={[requestUri]}
|
||||
selectedKeys={[selectedKey]}
|
||||
className="drawer-menu drawer-nav"
|
||||
items={toMenuItems(navItems)}
|
||||
onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }}
|
||||
@@ -268,11 +276,14 @@ export default function AppSidebar({ basePath = '', requestUri = '' }: AppSideba
|
||||
<Menu
|
||||
theme={currentTheme}
|
||||
mode="inline"
|
||||
selectedKeys={[requestUri]}
|
||||
selectedKeys={[selectedKey]}
|
||||
className="drawer-menu drawer-utility"
|
||||
items={toMenuItems(utilItems)}
|
||||
onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }}
|
||||
/>
|
||||
<div className="drawer-footer">
|
||||
<VersionBadge version={panelVersion} />
|
||||
</div>
|
||||
</Drawer>
|
||||
|
||||
{!drawerOpen && (
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
.ant-statistic-content {
|
||||
font-size: 17px !important;
|
||||
line-height: 1.4 !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ant-statistic-content-value,
|
||||
.ant-statistic-content-prefix,
|
||||
.ant-statistic-content-suffix {
|
||||
font-size: 17px !important;
|
||||
}
|
||||
|
||||
.ant-statistic-content-prefix {
|
||||
margin-inline-end: 8px !important;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ant-statistic-content-prefix .anticon {
|
||||
font-size: 17px !important;
|
||||
}
|
||||
|
||||
.ant-statistic-content-suffix {
|
||||
font-size: 12px !important;
|
||||
opacity: 0.55;
|
||||
margin-inline-start: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ant-statistic-title {
|
||||
font-size: 11px !important;
|
||||
margin-bottom: 6px !important;
|
||||
letter-spacing: 0.6px;
|
||||
text-transform: uppercase;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
body.dark .ant-statistic-content {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
body.dark .ant-statistic-title {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .ant-statistic-content {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .ant-statistic-title {
|
||||
color: rgba(255, 255, 255, 0.70);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Statistic } from 'antd';
|
||||
import './CustomStatistic.css';
|
||||
|
||||
interface CustomStatisticProps {
|
||||
title?: string;
|
||||
value?: string | number;
|
||||
prefix?: ReactNode;
|
||||
suffix?: ReactNode;
|
||||
}
|
||||
|
||||
export default function CustomStatistic({ title = '', value = '', prefix, suffix }: CustomStatisticProps) {
|
||||
return <Statistic title={title} value={value} prefix={prefix} suffix={suffix} />;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
141
frontend/src/components/HeaderMapEditor.tsx
Normal file
141
frontend/src/components/HeaderMapEditor.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button, Input, Space } from 'antd';
|
||||
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
|
||||
import InputAddon from '@/components/InputAddon';
|
||||
|
||||
// Reusable header-map editor. Handles the two wire shapes Xray uses for
|
||||
// HTTP-style header maps:
|
||||
//
|
||||
// v1: { 'Content-Type': 'application/json', 'X-Custom': 'value' }
|
||||
// Used by WS / HTTPUpgrade / Hysteria masquerade. One value per
|
||||
// name.
|
||||
//
|
||||
// v2: { 'Accept': ['text/html', 'application/json'],
|
||||
// 'X-Forwarded': ['1.2.3.4'] }
|
||||
// Used by TCP HTTP camouflage request/response. Each header can
|
||||
// repeat (RFC 7230 §3.2.2).
|
||||
//
|
||||
// Internal state is always the flat list-of-rows shape regardless of
|
||||
// mode. Conversion to/from the wire shape happens at the value/onChange
|
||||
// boundary so consumers can bind straight to a Form.Item without any
|
||||
// extra transforms on their side.
|
||||
|
||||
export type HeaderMapMode = 'v1' | 'v2';
|
||||
|
||||
export type HeaderMapValue =
|
||||
| Record<string, string>
|
||||
| Record<string, string[]>
|
||||
| undefined;
|
||||
|
||||
interface HeaderRow {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface HeaderMapEditorProps {
|
||||
mode: HeaderMapMode;
|
||||
value?: HeaderMapValue;
|
||||
onChange?: (next: Record<string, string> | Record<string, string[]>) => void;
|
||||
}
|
||||
|
||||
function mapToRows(value: HeaderMapValue): HeaderRow[] {
|
||||
if (!value || typeof value !== 'object') return [];
|
||||
const out: HeaderRow[] = [];
|
||||
for (const [name, raw] of Object.entries(value)) {
|
||||
if (Array.isArray(raw)) {
|
||||
for (const v of raw) {
|
||||
out.push({ name, value: typeof v === 'string' ? v : String(v) });
|
||||
}
|
||||
} else if (typeof raw === 'string') {
|
||||
out.push({ name, value: raw });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function rowsToMap(rows: HeaderRow[], mode: HeaderMapMode): Record<string, string> | Record<string, string[]> {
|
||||
if (mode === 'v1') {
|
||||
const map: Record<string, string> = {};
|
||||
for (const r of rows) {
|
||||
if (!r.name) continue;
|
||||
map[r.name] = r.value ?? '';
|
||||
}
|
||||
return map;
|
||||
}
|
||||
const map: Record<string, string[]> = {};
|
||||
for (const r of rows) {
|
||||
if (!r.name) continue;
|
||||
const list = map[r.name] ?? [];
|
||||
list.push(r.value ?? '');
|
||||
map[r.name] = list;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export default function HeaderMapEditor({ mode, value, onChange }: HeaderMapEditorProps) {
|
||||
// Local state holds rows including blanks. Without it, addRow() would
|
||||
// append a {name:'', value:''} that rowsToMap immediately filters out
|
||||
// before reaching the form, so the new row would never reach UI. The
|
||||
// form-bound map only sees rows with non-empty names; blank rows live
|
||||
// here until the user fills them in.
|
||||
const [rows, setRows] = useState<HeaderRow[]>(() => mapToRows(value));
|
||||
const lastEmittedRef = useRef<string>(JSON.stringify(rowsToMap(rows, mode)));
|
||||
|
||||
// Re-sync local rows when the form value changes from outside (modal
|
||||
// re-open with edit data, JSON tab edits, etc.) but not when it's our
|
||||
// own emission echoing back.
|
||||
useEffect(() => {
|
||||
const incoming = JSON.stringify(value ?? {});
|
||||
if (incoming === lastEmittedRef.current) return;
|
||||
setRows(mapToRows(value));
|
||||
lastEmittedRef.current = incoming;
|
||||
}, [value]);
|
||||
|
||||
function commit(next: HeaderRow[]) {
|
||||
setRows(next);
|
||||
const map = rowsToMap(next, mode);
|
||||
lastEmittedRef.current = JSON.stringify(map);
|
||||
onChange?.(map);
|
||||
}
|
||||
|
||||
function setRow(index: number, patch: Partial<HeaderRow>) {
|
||||
const next = rows.slice();
|
||||
next[index] = { ...next[index], ...patch };
|
||||
commit(next);
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
commit([...rows, { name: '', value: '' }]);
|
||||
}
|
||||
|
||||
function removeRow(index: number) {
|
||||
const next = rows.slice();
|
||||
next.splice(index, 1);
|
||||
commit(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{rows.map((row, idx) => (
|
||||
<Space.Compact key={idx} block className="mb-8">
|
||||
<InputAddon>{`${idx + 1}`}</InputAddon>
|
||||
<Input
|
||||
value={row.name}
|
||||
placeholder="Name"
|
||||
onChange={(e) => setRow(idx, { name: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
value={row.value}
|
||||
placeholder="Value"
|
||||
onChange={(e) => setRow(idx, { value: e.target.value })}
|
||||
/>
|
||||
<Button icon={<MinusOutlined />} onClick={() => removeRow(idx)} />
|
||||
</Space.Compact>
|
||||
))}
|
||||
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={addRow}>
|
||||
Add
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,22 +5,15 @@
|
||||
height: 32px;
|
||||
font-size: 14px;
|
||||
line-height: 30px;
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
border: 1px solid #d9d9d9;
|
||||
background-color: var(--ant-color-fill-tertiary);
|
||||
border: 1px solid var(--ant-color-border);
|
||||
border-radius: 6px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
color: var(--ant-color-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
body.dark .input-addon,
|
||||
html[data-theme='ultra-dark'] .input-addon {
|
||||
background-color: rgba(255, 255, 255, 0.04);
|
||||
border-color: #424242;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.ant-space-compact > .input-addon:not(:first-child) {
|
||||
margin-inline-start: -1px;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
.json-editor-host {
|
||||
border: 1px solid var(--ant-color-border, #d9d9d9);
|
||||
border: 1px solid var(--ant-color-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--ant-color-bg-container, #fff);
|
||||
background: var(--ant-color-bg-container);
|
||||
}
|
||||
|
||||
.json-editor-host .cm-editor,
|
||||
@@ -11,16 +11,6 @@
|
||||
}
|
||||
|
||||
.json-editor-host:focus-within {
|
||||
border-color: var(--ant-color-primary, #1677ff);
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
|
||||
body.dark .json-editor-host {
|
||||
border-color: #3a3a3c;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
html[data-theme="ultra-dark"] .json-editor-host {
|
||||
border-color: #1f1f1f;
|
||||
background: #0a0a0a;
|
||||
border-color: var(--ant-color-primary);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ant-color-primary) 10%, transparent);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Input, Modal } from 'antd';
|
||||
import type { InputRef } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface PromptModalProps {
|
||||
open: boolean;
|
||||
@@ -17,12 +18,13 @@ export default function PromptModal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
okText = 'OK',
|
||||
okText,
|
||||
type = 'input',
|
||||
initialValue = '',
|
||||
loading = false,
|
||||
onConfirm,
|
||||
}: PromptModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const inputRef = useRef<InputRef | null>(null);
|
||||
@@ -53,8 +55,8 @@ export default function PromptModal({
|
||||
<Modal
|
||||
open={open}
|
||||
title={title}
|
||||
okText={okText}
|
||||
cancelText="Cancel"
|
||||
okText={okText ?? t('confirm')}
|
||||
cancelText={t('cancel')}
|
||||
mask={{ closable: false }}
|
||||
confirmLoading={loading}
|
||||
onOk={() => onConfirm(value)}
|
||||
|
||||
@@ -2,18 +2,13 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||
}
|
||||
|
||||
.setting-list-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
body.dark .setting-list-item,
|
||||
html[data-theme='ultra-dark'] .setting-list-item {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.setting-list-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -22,22 +17,12 @@ html[data-theme='ultra-dark'] .setting-list-item {
|
||||
|
||||
.setting-list-title {
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
color: var(--ant-color-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.setting-list-description {
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
color: var(--ant-color-text-tertiary);
|
||||
line-height: 1.5715;
|
||||
}
|
||||
|
||||
body.dark .setting-list-title,
|
||||
html[data-theme='ultra-dark'] .setting-list-title {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
body.dark .setting-list-description,
|
||||
html[data-theme='ultra-dark'] .setting-list-description {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
@@ -3,42 +3,32 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sparkline-svg .cpu-grid-y-text,
|
||||
.sparkline-svg .cpu-grid-x-text {
|
||||
fill: rgba(0, 0, 0, 0.55);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
letter-spacing: 0.2px;
|
||||
.sparkline-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sparkline-svg .cpu-grid-text {
|
||||
fill: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.sparkline-svg .cpu-grid-line {
|
||||
stroke: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.sparkline-svg .cpu-tooltip-text {
|
||||
.sparkline-extrema {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 2px 8px;
|
||||
background: color-mix(in srgb, var(--ant-color-bg-elevated) 88%, transparent);
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.sparkline-svg .cpu-tooltip-pill {
|
||||
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.18));
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-grid-y-text,
|
||||
body.dark .sparkline-svg .cpu-grid-x-text {
|
||||
fill: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-grid-text {
|
||||
fill: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-grid-line {
|
||||
stroke: rgba(255, 255, 255, 0.10);
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-tooltip-pill {
|
||||
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.6));
|
||||
.sparkline-extrema .extrema-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -1,368 +1,270 @@
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { useId, useMemo } from 'react';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ReferenceDot,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import './Sparkline.css';
|
||||
|
||||
export interface SparklineReferenceLine {
|
||||
y: number;
|
||||
label?: string;
|
||||
color?: string;
|
||||
dash?: string;
|
||||
}
|
||||
|
||||
export interface SparklineExtrema {
|
||||
show?: boolean;
|
||||
formatter?: (v: number) => string;
|
||||
minColor?: string;
|
||||
maxColor?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_MIN_COLOR = '#52c41a';
|
||||
const DEFAULT_MAX_COLOR = '#fa541c';
|
||||
|
||||
interface SparklineProps {
|
||||
data: number[];
|
||||
labels?: (string | number)[];
|
||||
vbWidth?: number;
|
||||
height?: number;
|
||||
stroke?: string;
|
||||
strokeWidth?: number;
|
||||
maxPoints?: number;
|
||||
showGrid?: boolean;
|
||||
gridColor?: string;
|
||||
fillOpacity?: number;
|
||||
showMarker?: boolean;
|
||||
markerRadius?: number;
|
||||
showAxes?: boolean;
|
||||
yTickStep?: number;
|
||||
tickCountX?: number;
|
||||
paddingLeft?: number;
|
||||
paddingRight?: number;
|
||||
paddingTop?: number;
|
||||
paddingBottom?: number;
|
||||
showTooltip?: boolean;
|
||||
valueMin?: number;
|
||||
valueMax?: number | null;
|
||||
yFormatter?: (v: number) => string;
|
||||
tooltipFormatter?: ((v: number) => string) | null;
|
||||
tooltipLabelFormatter?: ((label: string) => string) | null;
|
||||
referenceLines?: SparklineReferenceLine[];
|
||||
extrema?: SparklineExtrema;
|
||||
}
|
||||
|
||||
interface ChartPoint {
|
||||
index: number;
|
||||
value: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export default function Sparkline({
|
||||
data,
|
||||
labels = [],
|
||||
vbWidth = 320,
|
||||
height = 80,
|
||||
stroke = '#008771',
|
||||
strokeWidth = 2,
|
||||
maxPoints = 120,
|
||||
showGrid = true,
|
||||
gridColor = 'rgba(0,0,0,0.08)',
|
||||
fillOpacity = 0.22,
|
||||
showMarker = true,
|
||||
markerRadius = 3,
|
||||
showAxes = false,
|
||||
yTickStep = 25,
|
||||
tickCountX = 4,
|
||||
paddingLeft = 56,
|
||||
paddingRight = 6,
|
||||
paddingTop = 6,
|
||||
paddingBottom = 20,
|
||||
showTooltip = false,
|
||||
valueMin = 0,
|
||||
valueMax = 100,
|
||||
yFormatter = (v: number) => `${Math.round(v)}%`,
|
||||
tooltipFormatter = null,
|
||||
tooltipLabelFormatter = null,
|
||||
referenceLines,
|
||||
extrema,
|
||||
}: SparklineProps) {
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const [measuredWidth, setMeasuredWidth] = useState(0);
|
||||
const [hoverIdx, setHoverIdx] = useState(-1);
|
||||
|
||||
const reactId = useId();
|
||||
const safeId = reactId.replace(/[^a-zA-Z0-9]/g, '');
|
||||
const gradId = `spkGrad-${safeId}`;
|
||||
const shadowId = `spkShadow-${safeId}`;
|
||||
const glowId = `spkGlow-${safeId}`;
|
||||
|
||||
useEffect(() => {
|
||||
const el = svgRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => {
|
||||
const w = el.getBoundingClientRect?.().width || 0;
|
||||
if (w > 0) setMeasuredWidth(Math.round(w));
|
||||
};
|
||||
measure();
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
const points = useMemo<ChartPoint[]>(() => {
|
||||
const n = Math.min(data.length, maxPoints);
|
||||
if (n === 0) return [];
|
||||
const sliceStart = data.length - n;
|
||||
const labelStart = Math.max(0, labels.length - n);
|
||||
return data.slice(sliceStart).map((value, i) => ({
|
||||
index: i,
|
||||
value: Number(value) || 0,
|
||||
label: String(labels[labelStart + i] ?? i + 1),
|
||||
}));
|
||||
}, [data, labels, maxPoints]);
|
||||
|
||||
const yDomain = useMemo<[number, number]>(() => {
|
||||
if (valueMax != null) return [valueMin, valueMax];
|
||||
let max = valueMin;
|
||||
for (const p of points) {
|
||||
if (Number.isFinite(p.value) && p.value > max) max = p.value;
|
||||
}
|
||||
window.addEventListener('resize', measure);
|
||||
return () => window.removeEventListener('resize', measure);
|
||||
}, []);
|
||||
|
||||
const effectiveVbWidth = measuredWidth > 0 ? measuredWidth : vbWidth;
|
||||
const drawWidth = Math.max(1, effectiveVbWidth - paddingLeft - paddingRight);
|
||||
const drawHeight = Math.max(1, height - paddingTop - paddingBottom);
|
||||
const nPoints = Math.min(data.length, maxPoints);
|
||||
|
||||
const dataSlice = useMemo(
|
||||
() => (nPoints === 0 ? [] : data.slice(data.length - nPoints)),
|
||||
[data, nPoints],
|
||||
);
|
||||
|
||||
const labelsSlice = useMemo(() => {
|
||||
if (!labels?.length || nPoints === 0) return [] as (string | number)[];
|
||||
const start = Math.max(0, labels.length - nPoints);
|
||||
return labels.slice(start);
|
||||
}, [labels, nPoints]);
|
||||
|
||||
const yDomain = useMemo(() => {
|
||||
const min = valueMin;
|
||||
if (valueMax != null) return { min, max: valueMax };
|
||||
let max = min;
|
||||
for (const v of dataSlice) {
|
||||
const n = Number(v);
|
||||
if (Number.isFinite(n) && n > max) max = n;
|
||||
}
|
||||
if (max <= min) max = min + 1;
|
||||
return { min, max: max * 1.1 };
|
||||
}, [dataSlice, valueMin, valueMax]);
|
||||
|
||||
const project = useCallback(
|
||||
(v: number) => {
|
||||
const { min, max } = yDomain;
|
||||
const span = max - min;
|
||||
if (span <= 0) return paddingTop + drawHeight;
|
||||
const clipped = Math.max(min, Math.min(max, Number(v) || 0));
|
||||
const ratio = (clipped - min) / span;
|
||||
return Math.round(paddingTop + (drawHeight - ratio * drawHeight));
|
||||
},
|
||||
[yDomain, paddingTop, drawHeight],
|
||||
);
|
||||
|
||||
const pointsArr = useMemo<[number, number][]>(() => {
|
||||
if (nPoints === 0) return [];
|
||||
const w = drawWidth;
|
||||
const dx = nPoints > 1 ? w / (nPoints - 1) : 0;
|
||||
return dataSlice.map((v, i) => {
|
||||
const x = Math.round(paddingLeft + i * dx);
|
||||
return [x, project(v)];
|
||||
});
|
||||
}, [dataSlice, nPoints, drawWidth, paddingLeft, project]);
|
||||
|
||||
const pointsStr = useMemo(() => pointsArr.map((p) => `${p[0]},${p[1]}`).join(' '), [pointsArr]);
|
||||
|
||||
const areaPath = useMemo(() => {
|
||||
if (pointsArr.length === 0) return '';
|
||||
const first = pointsArr[0];
|
||||
const last = pointsArr[pointsArr.length - 1];
|
||||
const baseY = paddingTop + drawHeight;
|
||||
const line = pointsStr.replace(/ /g, ' L ');
|
||||
return `M ${first[0]},${baseY} L ${line} L ${last[0]},${baseY} Z`;
|
||||
}, [pointsArr, pointsStr, paddingTop, drawHeight]);
|
||||
|
||||
const gridLines = useMemo(() => {
|
||||
if (!showGrid) return [];
|
||||
const h = drawHeight;
|
||||
const w = drawWidth;
|
||||
return [0, 0.25, 0.5, 0.75, 1].map((r) => {
|
||||
const y = Math.round(paddingTop + h * r);
|
||||
return { x1: paddingLeft, y1: y, x2: paddingLeft + w, y2: y };
|
||||
});
|
||||
}, [showGrid, drawHeight, drawWidth, paddingTop, paddingLeft]);
|
||||
|
||||
const lastPoint = pointsArr.length === 0 ? null : pointsArr[pointsArr.length - 1];
|
||||
if (max <= valueMin) max = valueMin + 1;
|
||||
return [valueMin, max * 1.1];
|
||||
}, [points, valueMin, valueMax]);
|
||||
|
||||
const yTicks = useMemo(() => {
|
||||
if (!showAxes) return [];
|
||||
const { min, max } = yDomain;
|
||||
const out: { y: number; label: string }[] = [];
|
||||
if (!showAxes) return undefined;
|
||||
const [min, max] = yDomain;
|
||||
if (valueMax === 100 && valueMin === 0 && yTickStep > 0) {
|
||||
for (let p = min; p <= max; p += yTickStep) {
|
||||
out.push({ y: project(p), label: yFormatter(p) });
|
||||
}
|
||||
const out: number[] = [];
|
||||
for (let v = min; v <= max; v += yTickStep) out.push(v);
|
||||
return out;
|
||||
}
|
||||
const ticks = 5;
|
||||
for (let i = 0; i < ticks; i++) {
|
||||
const v = min + ((max - min) * i) / (ticks - 1);
|
||||
out.push({ y: project(v), label: yFormatter(v) });
|
||||
}
|
||||
return out;
|
||||
}, [showAxes, yDomain, valueMax, valueMin, yTickStep, project, yFormatter]);
|
||||
const n = 5;
|
||||
return Array.from({ length: n }, (_, i) => min + ((max - min) * i) / (n - 1));
|
||||
}, [showAxes, yDomain, valueMin, valueMax, yTickStep]);
|
||||
|
||||
const xTicks = useMemo(() => {
|
||||
if (!showAxes) return [];
|
||||
if (nPoints === 0) return [];
|
||||
const xTickIndexes = useMemo(() => {
|
||||
if (!showAxes || points.length === 0) return undefined;
|
||||
const m = Math.max(2, tickCountX);
|
||||
const w = drawWidth;
|
||||
const dx = nPoints > 1 ? w / (nPoints - 1) : 0;
|
||||
const out: { x: number; label: string }[] = [];
|
||||
for (let i = 0; i < m; i++) {
|
||||
const idx = Math.round((i * (nPoints - 1)) / (m - 1));
|
||||
const label = labelsSlice[idx] != null ? String(labelsSlice[idx]) : String(idx);
|
||||
const x = Math.round(paddingLeft + idx * dx);
|
||||
out.push({ x, label });
|
||||
return Array.from({ length: m }, (_, i) => Math.round((i * (points.length - 1)) / (m - 1)));
|
||||
}, [showAxes, tickCountX, points.length]);
|
||||
|
||||
const fmtTooltip = tooltipFormatter ?? yFormatter;
|
||||
|
||||
const extremaPoints = useMemo(() => {
|
||||
if (!extrema?.show || points.length < 2) return null;
|
||||
let minIdx = 0;
|
||||
let maxIdx = 0;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
if (points[i].value < points[minIdx].value) minIdx = i;
|
||||
if (points[i].value > points[maxIdx].value) maxIdx = i;
|
||||
}
|
||||
return out;
|
||||
}, [showAxes, labelsSlice, nPoints, tickCountX, drawWidth, paddingLeft]);
|
||||
if (minIdx === maxIdx) return null;
|
||||
return { min: points[minIdx], max: points[maxIdx], minIdx, maxIdx };
|
||||
}, [points, extrema?.show]);
|
||||
|
||||
const onMouseMove = useCallback(
|
||||
(evt: MouseEvent<SVGSVGElement>) => {
|
||||
if (!showTooltip || pointsArr.length === 0) return;
|
||||
const rect = evt.currentTarget.getBoundingClientRect();
|
||||
const px = evt.clientX - rect.left;
|
||||
const x = (px / rect.width) * effectiveVbWidth;
|
||||
const dx = nPoints > 1 ? drawWidth / (nPoints - 1) : 0;
|
||||
const idx = Math.max(0, Math.min(nPoints - 1, Math.round((x - paddingLeft) / (dx || 1))));
|
||||
setHoverIdx(idx);
|
||||
},
|
||||
[showTooltip, pointsArr.length, effectiveVbWidth, nPoints, drawWidth, paddingLeft],
|
||||
);
|
||||
|
||||
const onMouseLeave = useCallback(() => setHoverIdx(-1), []);
|
||||
|
||||
const hoverText = useMemo(() => {
|
||||
const idx = hoverIdx;
|
||||
if (idx < 0 || idx >= dataSlice.length) return '';
|
||||
const raw = Number(dataSlice[idx] || 0);
|
||||
const fmt = tooltipFormatter || yFormatter;
|
||||
const val = fmt(Number.isFinite(raw) ? raw : 0);
|
||||
const lab = labelsSlice[idx] != null ? labelsSlice[idx] : '';
|
||||
return `${val}${lab ? ' • ' + lab : ''}`;
|
||||
}, [hoverIdx, dataSlice, labelsSlice, tooltipFormatter, yFormatter]);
|
||||
|
||||
const tooltipPillWidth = Math.max(48, hoverText.length * 6.2 + 14);
|
||||
const hoverPoint = hoverIdx >= 0 ? pointsArr[hoverIdx] : null;
|
||||
const tooltipX = hoverPoint
|
||||
? Math.max(
|
||||
paddingLeft + 2,
|
||||
Math.min(effectiveVbWidth - paddingRight - tooltipPillWidth - 2, hoverPoint[0] - tooltipPillWidth / 2),
|
||||
)
|
||||
: 0;
|
||||
const fmtExtrema = extrema?.formatter ?? yFormatter;
|
||||
const minColor = extrema?.minColor ?? DEFAULT_MIN_COLOR;
|
||||
const maxColor = extrema?.maxColor ?? DEFAULT_MAX_COLOR;
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width="100%"
|
||||
height={height}
|
||||
viewBox={`0 0 ${effectiveVbWidth} ${height}`}
|
||||
preserveAspectRatio="none"
|
||||
className="sparkline-svg"
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={stroke} stopOpacity={Math.min(1, fillOpacity * 1.8)} />
|
||||
<stop offset="50%" stopColor={stroke} stopOpacity={fillOpacity * 0.7} />
|
||||
<stop offset="100%" stopColor={stroke} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<filter id={shadowId} x="-10%" y="-50%" width="120%" height="200%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="2.4" />
|
||||
<feOffset dx="0" dy="2" result="offsetBlur" />
|
||||
<feComponentTransfer>
|
||||
<feFuncA type="linear" slope="0.45" />
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<radialGradient id={glowId}>
|
||||
<stop offset="0%" stopColor={stroke} stopOpacity="0.55" />
|
||||
<stop offset="100%" stopColor={stroke} stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
{showGrid && (
|
||||
<g>
|
||||
{gridLines.map((g, i) => (
|
||||
<line
|
||||
key={i}
|
||||
x1={g.x1}
|
||||
y1={g.y1}
|
||||
x2={g.x2}
|
||||
y2={g.y2}
|
||||
stroke={gridColor}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 5"
|
||||
className="cpu-grid-line"
|
||||
<div className="sparkline-container">
|
||||
{extremaPoints && (
|
||||
<div className="sparkline-extrema" aria-hidden="true">
|
||||
<span className="extrema-item" style={{ color: maxColor }}>
|
||||
▲ {fmtExtrema(extremaPoints.max.value)}
|
||||
</span>
|
||||
<span className="extrema-item" style={{ color: minColor }}>
|
||||
▼ {fmtExtrema(extremaPoints.min.value)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<ResponsiveContainer width="100%" height={height} className="sparkline-svg">
|
||||
<AreaChart
|
||||
data={points}
|
||||
margin={{
|
||||
top: showAxes ? 14 : 6,
|
||||
right: showAxes ? 12 : 6,
|
||||
bottom: showAxes ? 26 : 4,
|
||||
left: 4,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={stroke} stopOpacity={fillOpacity} />
|
||||
<stop offset="100%" stopColor={stroke} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{showGrid && (
|
||||
<CartesianGrid stroke="rgba(128, 128, 140, 0.35)" strokeDasharray="3 4" vertical={false} />
|
||||
)}
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
hide={!showAxes}
|
||||
tick={{ fontSize: 10, fill: 'var(--ant-color-text-tertiary)' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickMargin={14}
|
||||
interval={0}
|
||||
ticks={xTickIndexes?.map((i) => points[i]?.label).filter(Boolean) as string[] | undefined}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yDomain}
|
||||
hide={!showAxes}
|
||||
tick={{ fontSize: 10, fill: 'var(--ant-color-text-tertiary)', dx: -4 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={yFormatter}
|
||||
ticks={yTicks}
|
||||
width={56}
|
||||
/>
|
||||
{showTooltip && (
|
||||
<Tooltip
|
||||
cursor={{ stroke: 'var(--ant-color-border)', strokeDasharray: '2 4' }}
|
||||
contentStyle={{
|
||||
background: 'var(--ant-color-bg-elevated)',
|
||||
border: '1px solid var(--ant-color-border-secondary)',
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
padding: '6px 10px',
|
||||
boxShadow: '0 4px 14px rgba(0, 0, 0, 0.12)',
|
||||
}}
|
||||
labelStyle={{ color: 'var(--ant-color-text-tertiary)', marginBottom: 4, fontSize: 11 }}
|
||||
itemStyle={{ color: 'var(--ant-color-text)', padding: 0, fontWeight: 500 }}
|
||||
formatter={(v) => [fmtTooltip(Number(v) || 0), '']}
|
||||
labelFormatter={(label) => (tooltipLabelFormatter ? tooltipLabelFormatter(String(label)) : String(label))}
|
||||
separator=""
|
||||
/>
|
||||
)}
|
||||
{referenceLines?.map((rl, idx) => (
|
||||
<ReferenceLine
|
||||
key={`ref-${idx}-${rl.y}`}
|
||||
y={rl.y}
|
||||
stroke={rl.color || stroke}
|
||||
strokeDasharray={rl.dash || '5 4'}
|
||||
strokeWidth={1.4}
|
||||
label={rl.label ? {
|
||||
value: rl.label,
|
||||
position: 'insideTopRight',
|
||||
fill: rl.color || stroke,
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
} : undefined}
|
||||
ifOverflow="extendDomain"
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{showAxes && (
|
||||
<g>
|
||||
{yTicks.map((tk, i) => (
|
||||
<text
|
||||
key={`y${i}`}
|
||||
className="cpu-grid-y-text"
|
||||
x={Math.max(0, paddingLeft - 6)}
|
||||
y={tk.y + 4}
|
||||
textAnchor="end"
|
||||
fontSize={10.5}
|
||||
>
|
||||
{tk.label}
|
||||
</text>
|
||||
))}
|
||||
{xTicks.map((tk, i) => (
|
||||
<text
|
||||
key={`x${i}`}
|
||||
className="cpu-grid-x-text"
|
||||
x={tk.x}
|
||||
y={paddingTop + drawHeight + 14}
|
||||
textAnchor="middle"
|
||||
fontSize={10.5}
|
||||
>
|
||||
{tk.label}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{areaPath && <path d={areaPath} fill={`url(#${gradId})`} stroke="none" />}
|
||||
<polyline
|
||||
points={pointsStr}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
filter={`url(#${shadowId})`}
|
||||
/>
|
||||
{showMarker && lastPoint && (
|
||||
<>
|
||||
<circle cx={lastPoint[0]} cy={lastPoint[1]} r={markerRadius * 3} fill={`url(#${glowId})`}>
|
||||
<animate attributeName="r" values={`${markerRadius * 2.4};${markerRadius * 3.4};${markerRadius * 2.4}`} dur="2.6s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx={lastPoint[0]} cy={lastPoint[1]} r={markerRadius + 1.5} fill={stroke} fillOpacity={0.25} />
|
||||
<circle cx={lastPoint[0]} cy={lastPoint[1]} r={markerRadius} fill={stroke} stroke="#fff" strokeWidth={1.5} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{showTooltip && hoverIdx >= 0 && pointsArr[hoverIdx] && (
|
||||
<g>
|
||||
<line
|
||||
className="cpu-grid-h-line"
|
||||
x1={pointsArr[hoverIdx][0]}
|
||||
x2={pointsArr[hoverIdx][0]}
|
||||
y1={paddingTop}
|
||||
y2={paddingTop + drawHeight}
|
||||
{extremaPoints && (
|
||||
<>
|
||||
<ReferenceDot
|
||||
x={extremaPoints.max.label}
|
||||
y={extremaPoints.max.value}
|
||||
r={4.5}
|
||||
fill={maxColor}
|
||||
stroke="var(--ant-color-bg-elevated)"
|
||||
strokeWidth={2}
|
||||
ifOverflow="extendDomain"
|
||||
/>
|
||||
<ReferenceDot
|
||||
x={extremaPoints.min.label}
|
||||
y={extremaPoints.min.value}
|
||||
r={4.5}
|
||||
fill={minColor}
|
||||
stroke="var(--ant-color-bg-elevated)"
|
||||
strokeWidth={2}
|
||||
ifOverflow="extendDomain"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={stroke}
|
||||
strokeOpacity={0.45}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 4"
|
||||
strokeWidth={strokeWidth}
|
||||
fill={`url(#${gradId})`}
|
||||
dot={false}
|
||||
activeDot={showMarker ? { r: markerRadius, fill: stroke, strokeWidth: 0 } : false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<circle cx={pointsArr[hoverIdx][0]} cy={pointsArr[hoverIdx][1]} r={5} fill={stroke} fillOpacity={0.25} />
|
||||
<circle cx={pointsArr[hoverIdx][0]} cy={pointsArr[hoverIdx][1]} r={3.5} fill={stroke} stroke="#fff" strokeWidth={1.5} />
|
||||
<rect
|
||||
x={tooltipX}
|
||||
y={paddingTop + 2}
|
||||
width={tooltipPillWidth}
|
||||
height={18}
|
||||
rx={9}
|
||||
ry={9}
|
||||
className="cpu-tooltip-pill"
|
||||
fill={stroke}
|
||||
fillOpacity={0.92}
|
||||
/>
|
||||
<text
|
||||
className="cpu-tooltip-text"
|
||||
x={tooltipX + tooltipPillWidth / 2}
|
||||
y={paddingTop + 14}
|
||||
textAnchor="middle"
|
||||
fontSize={11}
|
||||
fontWeight={600}
|
||||
fill="#fff"
|
||||
>
|
||||
{hoverText}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Button, Input, Modal, message } from 'antd';
|
||||
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ClipboardManager, FileManager } from '@/utils';
|
||||
|
||||
@@ -12,11 +13,12 @@ interface TextModalProps {
|
||||
}
|
||||
|
||||
export default function TextModal({ open, onClose, title, content, fileName = '' }: TextModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
async function copy() {
|
||||
const ok = await ClipboardManager.copyText(content || '');
|
||||
if (ok) {
|
||||
messageApi.success('Copied');
|
||||
messageApi.success(t('copied'));
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
@@ -39,7 +41,7 @@ export default function TextModal({ open, onClose, title, content, fileName = ''
|
||||
{fileName && (
|
||||
<Button icon={<DownloadOutlined />} onClick={download}>{fileName}</Button>
|
||||
)}
|
||||
<Button type="primary" icon={<CopyOutlined />} onClick={copy}>Copy</Button>
|
||||
<Button type="primary" icon={<CopyOutlined />} onClick={copy}>{t('copy')}</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import ApiDocsPage from '@/pages/api-docs/ApiDocsPage';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
const root = document.getElementById('app');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<ApiDocsPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import ClientsPage from '@/pages/clients/ClientsPage';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
const root = document.getElementById('app');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<ClientsPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import InboundsPage from '@/pages/inbounds/InboundsPage';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
const root = document.getElementById('app');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<InboundsPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -2,10 +2,11 @@ import { createRoot } from 'react-dom/client';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import { setupAxios } from '@/api/axios-init';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import { QueryProvider } from '@/api/QueryProvider';
|
||||
import LoginPage from '@/pages/login/LoginPage';
|
||||
|
||||
setupAxios();
|
||||
@@ -21,7 +22,9 @@ readyI18n().then(() => {
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<LoginPage />
|
||||
<QueryProvider>
|
||||
<LoginPage />
|
||||
</QueryProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import NodesPage from '@/pages/nodes/NodesPage';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
const root = document.getElementById('app');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<NodesPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import SettingsPage from '@/pages/settings/SettingsPage';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
const root = document.getElementById('app');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<SettingsPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import 'antd/dist/reset.css';
|
||||
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import { QueryProvider } from '@/api/QueryProvider';
|
||||
import SubPage from '@/pages/sub/SubPage';
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
@@ -16,7 +17,9 @@ readyI18n().then(() => {
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<SubPage />
|
||||
<QueryProvider>
|
||||
<SubPage />
|
||||
</QueryProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import XrayPage from '@/pages/xray/XrayPage';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
const root = document.getElementById('app');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<XrayPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
});
|
||||
23
frontend/src/env.d.ts
vendored
23
frontend/src/env.d.ts
vendored
@@ -16,6 +16,7 @@ interface SubPageData {
|
||||
subClashUrl?: string;
|
||||
subTitle?: string;
|
||||
links?: string[];
|
||||
emails?: string[];
|
||||
datepicker?: 'gregorian' | 'jalalian';
|
||||
downloadByte?: string | number;
|
||||
uploadByte?: string | number;
|
||||
@@ -28,6 +29,28 @@ interface Window {
|
||||
__SUB_PAGE_DATA__?: SubPageData;
|
||||
}
|
||||
|
||||
declare module 'qs' {
|
||||
interface StringifyOptions {
|
||||
arrayFormat?: 'indices' | 'brackets' | 'repeat' | 'comma';
|
||||
encode?: boolean;
|
||||
encoder?: (str: unknown, defaultEncoder: (s: unknown) => string, charset: string, type: 'key' | 'value') => string;
|
||||
allowDots?: boolean;
|
||||
skipNulls?: boolean;
|
||||
addQueryPrefix?: boolean;
|
||||
}
|
||||
interface ParseOptions {
|
||||
depth?: number;
|
||||
arrayLimit?: number;
|
||||
allowDots?: boolean;
|
||||
parseArrays?: boolean;
|
||||
ignoreQueryPrefix?: boolean;
|
||||
}
|
||||
export function stringify(obj: unknown, options?: StringifyOptions): string;
|
||||
export function parse(str: string, options?: ParseOptions): Record<string, unknown>;
|
||||
const qs: { stringify: typeof stringify; parse: typeof parse };
|
||||
export default qs;
|
||||
}
|
||||
|
||||
declare module 'persian-calendar-suite' {
|
||||
import type { ComponentType, ReactNode } from 'react';
|
||||
|
||||
|
||||
359
frontend/src/generated/types.ts
Normal file
359
frontend/src/generated/types.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
// Code generated by tools/openapigen. DO NOT EDIT.
|
||||
export type Protocol = string;
|
||||
|
||||
export interface AllSetting {
|
||||
datepicker: string;
|
||||
expireDiff: number;
|
||||
externalTrafficInformEnable: boolean;
|
||||
externalTrafficInformURI: string;
|
||||
ldapAutoCreate: boolean;
|
||||
ldapAutoDelete: boolean;
|
||||
ldapBaseDN: string;
|
||||
ldapBindDN: string;
|
||||
ldapDefaultExpiryDays: number;
|
||||
ldapDefaultLimitIP: number;
|
||||
ldapDefaultTotalGB: number;
|
||||
ldapEnable: boolean;
|
||||
ldapFlagField: string;
|
||||
ldapHost: string;
|
||||
ldapInboundTags: string;
|
||||
ldapInvertFlag: boolean;
|
||||
ldapPassword: string;
|
||||
ldapPort: number;
|
||||
ldapSyncCron: string;
|
||||
ldapTruthyValues: string;
|
||||
ldapUseTLS: boolean;
|
||||
ldapUserAttr: string;
|
||||
ldapUserFilter: string;
|
||||
ldapVlessField: string;
|
||||
pageSize: number;
|
||||
remarkModel: string;
|
||||
restartXrayOnClientDisable: boolean;
|
||||
sessionMaxAge: number;
|
||||
subAnnounce: string;
|
||||
subCertFile: string;
|
||||
subClashEnable: boolean;
|
||||
subClashPath: string;
|
||||
subClashURI: string;
|
||||
subDomain: string;
|
||||
subEmailInRemark: boolean;
|
||||
subEnable: boolean;
|
||||
subEnableRouting: boolean;
|
||||
subEncrypt: boolean;
|
||||
subJsonEnable: boolean;
|
||||
subJsonFragment: string;
|
||||
subJsonMux: string;
|
||||
subJsonNoises: string;
|
||||
subJsonPath: string;
|
||||
subJsonRules: string;
|
||||
subJsonURI: string;
|
||||
subKeyFile: string;
|
||||
subListen: string;
|
||||
subPath: string;
|
||||
subPort: number;
|
||||
subProfileUrl: string;
|
||||
subRoutingRules: string;
|
||||
subShowInfo: boolean;
|
||||
subSupportUrl: string;
|
||||
subTitle: string;
|
||||
subURI: string;
|
||||
subUpdates: number;
|
||||
tgBotAPIServer: string;
|
||||
tgBotBackup: boolean;
|
||||
tgBotChatId: string;
|
||||
tgBotEnable: boolean;
|
||||
tgBotLoginNotify: boolean;
|
||||
tgBotProxy: string;
|
||||
tgBotToken: string;
|
||||
tgCpu: number;
|
||||
tgLang: string;
|
||||
tgRunTime: string;
|
||||
timeLocation: string;
|
||||
trafficDiff: number;
|
||||
trustedProxyCIDRs: string;
|
||||
twoFactorEnable: boolean;
|
||||
twoFactorToken: string;
|
||||
webBasePath: string;
|
||||
webCertFile: string;
|
||||
webDomain: string;
|
||||
webKeyFile: string;
|
||||
webListen: string;
|
||||
webPort: number;
|
||||
}
|
||||
|
||||
export interface AllSettingView {
|
||||
datepicker: string;
|
||||
expireDiff: number;
|
||||
externalTrafficInformEnable: boolean;
|
||||
externalTrafficInformURI: string;
|
||||
hasApiToken: boolean;
|
||||
hasLdapPassword: boolean;
|
||||
hasNordSecret: boolean;
|
||||
hasTgBotToken: boolean;
|
||||
hasTwoFactorToken: boolean;
|
||||
hasWarpSecret: boolean;
|
||||
ldapAutoCreate: boolean;
|
||||
ldapAutoDelete: boolean;
|
||||
ldapBaseDN: string;
|
||||
ldapBindDN: string;
|
||||
ldapDefaultExpiryDays: number;
|
||||
ldapDefaultLimitIP: number;
|
||||
ldapDefaultTotalGB: number;
|
||||
ldapEnable: boolean;
|
||||
ldapFlagField: string;
|
||||
ldapHost: string;
|
||||
ldapInboundTags: string;
|
||||
ldapInvertFlag: boolean;
|
||||
ldapPassword: string;
|
||||
ldapPort: number;
|
||||
ldapSyncCron: string;
|
||||
ldapTruthyValues: string;
|
||||
ldapUseTLS: boolean;
|
||||
ldapUserAttr: string;
|
||||
ldapUserFilter: string;
|
||||
ldapVlessField: string;
|
||||
pageSize: number;
|
||||
remarkModel: string;
|
||||
restartXrayOnClientDisable: boolean;
|
||||
sessionMaxAge: number;
|
||||
subAnnounce: string;
|
||||
subCertFile: string;
|
||||
subClashEnable: boolean;
|
||||
subClashPath: string;
|
||||
subClashURI: string;
|
||||
subDomain: string;
|
||||
subEmailInRemark: boolean;
|
||||
subEnable: boolean;
|
||||
subEnableRouting: boolean;
|
||||
subEncrypt: boolean;
|
||||
subJsonEnable: boolean;
|
||||
subJsonFragment: string;
|
||||
subJsonMux: string;
|
||||
subJsonNoises: string;
|
||||
subJsonPath: string;
|
||||
subJsonRules: string;
|
||||
subJsonURI: string;
|
||||
subKeyFile: string;
|
||||
subListen: string;
|
||||
subPath: string;
|
||||
subPort: number;
|
||||
subProfileUrl: string;
|
||||
subRoutingRules: string;
|
||||
subShowInfo: boolean;
|
||||
subSupportUrl: string;
|
||||
subTitle: string;
|
||||
subURI: string;
|
||||
subUpdates: number;
|
||||
tgBotAPIServer: string;
|
||||
tgBotBackup: boolean;
|
||||
tgBotChatId: string;
|
||||
tgBotEnable: boolean;
|
||||
tgBotLoginNotify: boolean;
|
||||
tgBotProxy: string;
|
||||
tgBotToken: string;
|
||||
tgCpu: number;
|
||||
tgLang: string;
|
||||
tgRunTime: string;
|
||||
timeLocation: string;
|
||||
trafficDiff: number;
|
||||
trustedProxyCIDRs: string;
|
||||
twoFactorEnable: boolean;
|
||||
twoFactorToken: string;
|
||||
webBasePath: string;
|
||||
webCertFile: string;
|
||||
webDomain: string;
|
||||
webKeyFile: string;
|
||||
webListen: string;
|
||||
webPort: number;
|
||||
}
|
||||
|
||||
export interface ApiToken {
|
||||
createdAt: number;
|
||||
enabled: boolean;
|
||||
id: number;
|
||||
name: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface Client {
|
||||
auth?: string;
|
||||
comment: string;
|
||||
created_at?: number;
|
||||
email: string;
|
||||
enable: boolean;
|
||||
expiryTime: number;
|
||||
flow?: string;
|
||||
id?: string;
|
||||
limitIp: number;
|
||||
password?: string;
|
||||
reset: number;
|
||||
reverse?: ClientReverse | null;
|
||||
security: string;
|
||||
subId: string;
|
||||
tgId: number;
|
||||
totalGB: number;
|
||||
updated_at?: number;
|
||||
}
|
||||
|
||||
export interface ClientInbound {
|
||||
clientId: number;
|
||||
createdAt: number;
|
||||
flowOverride: string;
|
||||
inboundId: number;
|
||||
}
|
||||
|
||||
export interface ClientRecord {
|
||||
auth: string;
|
||||
comment: string;
|
||||
createdAt: number;
|
||||
email: string;
|
||||
enable: boolean;
|
||||
expiryTime: number;
|
||||
flow: string;
|
||||
id: number;
|
||||
limitIp: number;
|
||||
password: string;
|
||||
reset: number;
|
||||
reverse: unknown;
|
||||
security: string;
|
||||
subId: string;
|
||||
tgId: number;
|
||||
totalGB: number;
|
||||
updatedAt: number;
|
||||
uuid: string;
|
||||
}
|
||||
|
||||
export interface ClientReverse {
|
||||
tag: string;
|
||||
}
|
||||
|
||||
export interface ClientTraffic {
|
||||
down: number;
|
||||
email: string;
|
||||
enable: boolean;
|
||||
expiryTime: number;
|
||||
id: number;
|
||||
inboundId: number;
|
||||
lastOnline: number;
|
||||
reset: number;
|
||||
subId: string;
|
||||
total: number;
|
||||
up: number;
|
||||
uuid: string;
|
||||
}
|
||||
|
||||
export interface CustomGeoResource {
|
||||
alias: string;
|
||||
createdAt: number;
|
||||
id: number;
|
||||
lastModified: string;
|
||||
lastUpdatedAt: number;
|
||||
localPath: string;
|
||||
type: string;
|
||||
updatedAt: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface FallbackParentInfo {
|
||||
masterId: number;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface HistoryOfSeeders {
|
||||
id: number;
|
||||
seederName: string;
|
||||
}
|
||||
|
||||
export interface Inbound {
|
||||
clientStats: ClientTraffic[];
|
||||
down: number;
|
||||
enable: boolean;
|
||||
expiryTime: number;
|
||||
fallbackParent?: FallbackParentInfo | null;
|
||||
id: number;
|
||||
lastTrafficResetTime: number;
|
||||
listen: string;
|
||||
nodeId?: number | null;
|
||||
port: number;
|
||||
protocol: Protocol;
|
||||
remark: string;
|
||||
settings: unknown;
|
||||
sniffing: unknown;
|
||||
streamSettings: unknown;
|
||||
tag: string;
|
||||
total: number;
|
||||
trafficReset: string;
|
||||
up: number;
|
||||
}
|
||||
|
||||
export interface InboundClientIps {
|
||||
clientEmail: string;
|
||||
id: number;
|
||||
ips: unknown;
|
||||
}
|
||||
|
||||
export interface InboundFallback {
|
||||
alpn: string;
|
||||
childId: number;
|
||||
id: number;
|
||||
masterId: number;
|
||||
name: string;
|
||||
path: string;
|
||||
sortOrder: number;
|
||||
xver: number;
|
||||
}
|
||||
|
||||
export interface Msg {
|
||||
msg: string;
|
||||
obj: unknown;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface Node {
|
||||
address: string;
|
||||
allowPrivateAddress: boolean;
|
||||
apiToken: string;
|
||||
basePath: string;
|
||||
clientCount: number;
|
||||
cpuPct: number;
|
||||
createdAt: number;
|
||||
depletedCount: number;
|
||||
enable: boolean;
|
||||
id: number;
|
||||
inboundCount: number;
|
||||
lastError: string;
|
||||
lastHeartbeat: number;
|
||||
latencyMs: number;
|
||||
memPct: number;
|
||||
name: string;
|
||||
onlineCount: number;
|
||||
panelVersion: string;
|
||||
port: number;
|
||||
remark: string;
|
||||
scheme: string;
|
||||
status: string;
|
||||
updatedAt: number;
|
||||
uptimeSecs: number;
|
||||
xrayVersion: string;
|
||||
}
|
||||
|
||||
export interface OutboundTraffics {
|
||||
down: number;
|
||||
id: number;
|
||||
tag: string;
|
||||
total: number;
|
||||
up: number;
|
||||
}
|
||||
|
||||
export interface Setting {
|
||||
id: number;
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
password: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
380
frontend/src/generated/zod.ts
Normal file
380
frontend/src/generated/zod.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
// Code generated by tools/openapigen. DO NOT EDIT.
|
||||
import { z } from 'zod';
|
||||
export const ProtocolSchema = z.string();
|
||||
export type Protocol = z.infer<typeof ProtocolSchema>;
|
||||
|
||||
export const AllSettingSchema = z.object({
|
||||
datepicker: z.string(),
|
||||
expireDiff: z.number().int().min(0),
|
||||
externalTrafficInformEnable: z.boolean(),
|
||||
externalTrafficInformURI: z.string(),
|
||||
ldapAutoCreate: z.boolean(),
|
||||
ldapAutoDelete: z.boolean(),
|
||||
ldapBaseDN: z.string(),
|
||||
ldapBindDN: z.string(),
|
||||
ldapDefaultExpiryDays: z.number().int().min(0),
|
||||
ldapDefaultLimitIP: z.number().int().min(0),
|
||||
ldapDefaultTotalGB: z.number().int().min(0),
|
||||
ldapEnable: z.boolean(),
|
||||
ldapFlagField: z.string(),
|
||||
ldapHost: z.string(),
|
||||
ldapInboundTags: z.string(),
|
||||
ldapInvertFlag: z.boolean(),
|
||||
ldapPassword: z.string(),
|
||||
ldapPort: z.number().int().min(0).max(65535),
|
||||
ldapSyncCron: z.string(),
|
||||
ldapTruthyValues: z.string(),
|
||||
ldapUseTLS: z.boolean(),
|
||||
ldapUserAttr: z.string(),
|
||||
ldapUserFilter: z.string(),
|
||||
ldapVlessField: z.string(),
|
||||
pageSize: z.number().int().min(1).max(1000),
|
||||
remarkModel: z.string(),
|
||||
restartXrayOnClientDisable: z.boolean(),
|
||||
sessionMaxAge: z.number().int().min(0).max(525600),
|
||||
subAnnounce: z.string(),
|
||||
subCertFile: z.string(),
|
||||
subClashEnable: z.boolean(),
|
||||
subClashPath: z.string(),
|
||||
subClashURI: z.string(),
|
||||
subDomain: z.string(),
|
||||
subEmailInRemark: z.boolean(),
|
||||
subEnable: z.boolean(),
|
||||
subEnableRouting: z.boolean(),
|
||||
subEncrypt: z.boolean(),
|
||||
subJsonEnable: z.boolean(),
|
||||
subJsonFragment: z.string(),
|
||||
subJsonMux: z.string(),
|
||||
subJsonNoises: z.string(),
|
||||
subJsonPath: z.string(),
|
||||
subJsonRules: z.string(),
|
||||
subJsonURI: z.string(),
|
||||
subKeyFile: z.string(),
|
||||
subListen: z.string(),
|
||||
subPath: z.string(),
|
||||
subPort: z.number().int().min(1).max(65535),
|
||||
subProfileUrl: z.string(),
|
||||
subRoutingRules: z.string(),
|
||||
subShowInfo: z.boolean(),
|
||||
subSupportUrl: z.string(),
|
||||
subTitle: z.string(),
|
||||
subURI: z.string(),
|
||||
subUpdates: z.number().int().min(0).max(525600),
|
||||
tgBotAPIServer: z.string(),
|
||||
tgBotBackup: z.boolean(),
|
||||
tgBotChatId: z.string(),
|
||||
tgBotEnable: z.boolean(),
|
||||
tgBotLoginNotify: z.boolean(),
|
||||
tgBotProxy: z.string(),
|
||||
tgBotToken: z.string(),
|
||||
tgCpu: z.number().int().min(0).max(100),
|
||||
tgLang: z.string(),
|
||||
tgRunTime: z.string(),
|
||||
timeLocation: z.string(),
|
||||
trafficDiff: z.number().int().min(0).max(100),
|
||||
trustedProxyCIDRs: z.string(),
|
||||
twoFactorEnable: z.boolean(),
|
||||
twoFactorToken: z.string(),
|
||||
webBasePath: z.string(),
|
||||
webCertFile: z.string(),
|
||||
webDomain: z.string(),
|
||||
webKeyFile: z.string(),
|
||||
webListen: z.string(),
|
||||
webPort: z.number().int().min(1).max(65535),
|
||||
});
|
||||
export type AllSetting = z.infer<typeof AllSettingSchema>;
|
||||
|
||||
export const AllSettingViewSchema = z.object({
|
||||
datepicker: z.string(),
|
||||
expireDiff: z.number().int().min(0),
|
||||
externalTrafficInformEnable: z.boolean(),
|
||||
externalTrafficInformURI: z.string(),
|
||||
hasApiToken: z.boolean(),
|
||||
hasLdapPassword: z.boolean(),
|
||||
hasNordSecret: z.boolean(),
|
||||
hasTgBotToken: z.boolean(),
|
||||
hasTwoFactorToken: z.boolean(),
|
||||
hasWarpSecret: z.boolean(),
|
||||
ldapAutoCreate: z.boolean(),
|
||||
ldapAutoDelete: z.boolean(),
|
||||
ldapBaseDN: z.string(),
|
||||
ldapBindDN: z.string(),
|
||||
ldapDefaultExpiryDays: z.number().int().min(0),
|
||||
ldapDefaultLimitIP: z.number().int().min(0),
|
||||
ldapDefaultTotalGB: z.number().int().min(0),
|
||||
ldapEnable: z.boolean(),
|
||||
ldapFlagField: z.string(),
|
||||
ldapHost: z.string(),
|
||||
ldapInboundTags: z.string(),
|
||||
ldapInvertFlag: z.boolean(),
|
||||
ldapPassword: z.string(),
|
||||
ldapPort: z.number().int().min(0).max(65535),
|
||||
ldapSyncCron: z.string(),
|
||||
ldapTruthyValues: z.string(),
|
||||
ldapUseTLS: z.boolean(),
|
||||
ldapUserAttr: z.string(),
|
||||
ldapUserFilter: z.string(),
|
||||
ldapVlessField: z.string(),
|
||||
pageSize: z.number().int().min(1).max(1000),
|
||||
remarkModel: z.string(),
|
||||
restartXrayOnClientDisable: z.boolean(),
|
||||
sessionMaxAge: z.number().int().min(0).max(525600),
|
||||
subAnnounce: z.string(),
|
||||
subCertFile: z.string(),
|
||||
subClashEnable: z.boolean(),
|
||||
subClashPath: z.string(),
|
||||
subClashURI: z.string(),
|
||||
subDomain: z.string(),
|
||||
subEmailInRemark: z.boolean(),
|
||||
subEnable: z.boolean(),
|
||||
subEnableRouting: z.boolean(),
|
||||
subEncrypt: z.boolean(),
|
||||
subJsonEnable: z.boolean(),
|
||||
subJsonFragment: z.string(),
|
||||
subJsonMux: z.string(),
|
||||
subJsonNoises: z.string(),
|
||||
subJsonPath: z.string(),
|
||||
subJsonRules: z.string(),
|
||||
subJsonURI: z.string(),
|
||||
subKeyFile: z.string(),
|
||||
subListen: z.string(),
|
||||
subPath: z.string(),
|
||||
subPort: z.number().int().min(1).max(65535),
|
||||
subProfileUrl: z.string(),
|
||||
subRoutingRules: z.string(),
|
||||
subShowInfo: z.boolean(),
|
||||
subSupportUrl: z.string(),
|
||||
subTitle: z.string(),
|
||||
subURI: z.string(),
|
||||
subUpdates: z.number().int().min(0).max(525600),
|
||||
tgBotAPIServer: z.string(),
|
||||
tgBotBackup: z.boolean(),
|
||||
tgBotChatId: z.string(),
|
||||
tgBotEnable: z.boolean(),
|
||||
tgBotLoginNotify: z.boolean(),
|
||||
tgBotProxy: z.string(),
|
||||
tgBotToken: z.string(),
|
||||
tgCpu: z.number().int().min(0).max(100),
|
||||
tgLang: z.string(),
|
||||
tgRunTime: z.string(),
|
||||
timeLocation: z.string(),
|
||||
trafficDiff: z.number().int().min(0).max(100),
|
||||
trustedProxyCIDRs: z.string(),
|
||||
twoFactorEnable: z.boolean(),
|
||||
twoFactorToken: z.string(),
|
||||
webBasePath: z.string(),
|
||||
webCertFile: z.string(),
|
||||
webDomain: z.string(),
|
||||
webKeyFile: z.string(),
|
||||
webListen: z.string(),
|
||||
webPort: z.number().int().min(1).max(65535),
|
||||
});
|
||||
export type AllSettingView = z.infer<typeof AllSettingViewSchema>;
|
||||
|
||||
export const ApiTokenSchema = z.object({
|
||||
createdAt: z.number().int(),
|
||||
enabled: z.boolean(),
|
||||
id: z.number().int(),
|
||||
name: z.string(),
|
||||
token: z.string(),
|
||||
});
|
||||
export type ApiToken = z.infer<typeof ApiTokenSchema>;
|
||||
|
||||
export const ClientSchema = z.object({
|
||||
auth: z.string().optional(),
|
||||
comment: z.string(),
|
||||
created_at: z.number().int().optional(),
|
||||
email: z.string(),
|
||||
enable: z.boolean(),
|
||||
expiryTime: z.number().int(),
|
||||
flow: z.string().optional(),
|
||||
id: z.string().optional(),
|
||||
limitIp: z.number().int(),
|
||||
password: z.string().optional(),
|
||||
reset: z.number().int(),
|
||||
reverse: z.lazy(() => ClientReverseSchema).nullable().optional(),
|
||||
security: z.string(),
|
||||
subId: z.string(),
|
||||
tgId: z.number().int(),
|
||||
totalGB: z.number().int(),
|
||||
updated_at: z.number().int().optional(),
|
||||
});
|
||||
export type Client = z.infer<typeof ClientSchema>;
|
||||
|
||||
export const ClientInboundSchema = z.object({
|
||||
clientId: z.number().int(),
|
||||
createdAt: z.number().int(),
|
||||
flowOverride: z.string(),
|
||||
inboundId: z.number().int(),
|
||||
});
|
||||
export type ClientInbound = z.infer<typeof ClientInboundSchema>;
|
||||
|
||||
export const ClientRecordSchema = z.object({
|
||||
auth: z.string(),
|
||||
comment: z.string(),
|
||||
createdAt: z.number().int(),
|
||||
email: z.string(),
|
||||
enable: z.boolean(),
|
||||
expiryTime: z.number().int(),
|
||||
flow: z.string(),
|
||||
id: z.number().int(),
|
||||
limitIp: z.number().int(),
|
||||
password: z.string(),
|
||||
reset: z.number().int(),
|
||||
reverse: z.unknown(),
|
||||
security: z.string(),
|
||||
subId: z.string(),
|
||||
tgId: z.number().int(),
|
||||
totalGB: z.number().int(),
|
||||
updatedAt: z.number().int(),
|
||||
uuid: z.string(),
|
||||
});
|
||||
export type ClientRecord = z.infer<typeof ClientRecordSchema>;
|
||||
|
||||
export const ClientReverseSchema = z.object({
|
||||
tag: z.string(),
|
||||
});
|
||||
export type ClientReverse = z.infer<typeof ClientReverseSchema>;
|
||||
|
||||
export const ClientTrafficSchema = z.object({
|
||||
down: z.number().int(),
|
||||
email: z.string(),
|
||||
enable: z.boolean(),
|
||||
expiryTime: z.number().int(),
|
||||
id: z.number().int(),
|
||||
inboundId: z.number().int(),
|
||||
lastOnline: z.number().int(),
|
||||
reset: z.number().int(),
|
||||
subId: z.string(),
|
||||
total: z.number().int(),
|
||||
up: z.number().int(),
|
||||
uuid: z.string(),
|
||||
});
|
||||
export type ClientTraffic = z.infer<typeof ClientTrafficSchema>;
|
||||
|
||||
export const CustomGeoResourceSchema = z.object({
|
||||
alias: z.string(),
|
||||
createdAt: z.number().int(),
|
||||
id: z.number().int(),
|
||||
lastModified: z.string(),
|
||||
lastUpdatedAt: z.number().int(),
|
||||
localPath: z.string(),
|
||||
type: z.string(),
|
||||
updatedAt: z.number().int(),
|
||||
url: z.string(),
|
||||
});
|
||||
export type CustomGeoResource = z.infer<typeof CustomGeoResourceSchema>;
|
||||
|
||||
export const FallbackParentInfoSchema = z.object({
|
||||
masterId: z.number().int(),
|
||||
path: z.string().optional(),
|
||||
});
|
||||
export type FallbackParentInfo = z.infer<typeof FallbackParentInfoSchema>;
|
||||
|
||||
export const HistoryOfSeedersSchema = z.object({
|
||||
id: z.number().int(),
|
||||
seederName: z.string(),
|
||||
});
|
||||
export type HistoryOfSeeders = z.infer<typeof HistoryOfSeedersSchema>;
|
||||
|
||||
export const InboundSchema = z.object({
|
||||
clientStats: z.array(z.lazy(() => ClientTrafficSchema)),
|
||||
down: z.number().int(),
|
||||
enable: z.boolean(),
|
||||
expiryTime: z.number().int(),
|
||||
fallbackParent: z.lazy(() => FallbackParentInfoSchema).nullable().optional(),
|
||||
id: z.number().int(),
|
||||
lastTrafficResetTime: z.number().int(),
|
||||
listen: z.string(),
|
||||
nodeId: z.number().int().nullable().optional(),
|
||||
port: z.number().int().min(1).max(65535),
|
||||
protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'hysteria2', 'http', 'mixed', 'tunnel']),
|
||||
remark: z.string(),
|
||||
settings: z.unknown(),
|
||||
sniffing: z.unknown(),
|
||||
streamSettings: z.unknown(),
|
||||
tag: z.string(),
|
||||
total: z.number().int(),
|
||||
trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']),
|
||||
up: z.number().int(),
|
||||
});
|
||||
export type Inbound = z.infer<typeof InboundSchema>;
|
||||
|
||||
export const InboundClientIpsSchema = z.object({
|
||||
clientEmail: z.string(),
|
||||
id: z.number().int(),
|
||||
ips: z.unknown(),
|
||||
});
|
||||
export type InboundClientIps = z.infer<typeof InboundClientIpsSchema>;
|
||||
|
||||
export const InboundFallbackSchema = z.object({
|
||||
alpn: z.string(),
|
||||
childId: z.number().int(),
|
||||
id: z.number().int(),
|
||||
masterId: z.number().int(),
|
||||
name: z.string(),
|
||||
path: z.string(),
|
||||
sortOrder: z.number().int(),
|
||||
xver: z.number().int(),
|
||||
});
|
||||
export type InboundFallback = z.infer<typeof InboundFallbackSchema>;
|
||||
|
||||
export const MsgSchema = z.object({
|
||||
msg: z.string(),
|
||||
obj: z.unknown(),
|
||||
success: z.boolean(),
|
||||
});
|
||||
export type Msg = z.infer<typeof MsgSchema>;
|
||||
|
||||
export const NodeSchema = z.object({
|
||||
address: z.string(),
|
||||
allowPrivateAddress: z.boolean(),
|
||||
apiToken: z.string(),
|
||||
basePath: z.string(),
|
||||
clientCount: z.number().int(),
|
||||
cpuPct: z.number(),
|
||||
createdAt: z.number().int(),
|
||||
depletedCount: z.number().int(),
|
||||
enable: z.boolean(),
|
||||
id: z.number().int(),
|
||||
inboundCount: z.number().int(),
|
||||
lastError: z.string(),
|
||||
lastHeartbeat: z.number().int(),
|
||||
latencyMs: z.number().int(),
|
||||
memPct: z.number(),
|
||||
name: z.string(),
|
||||
onlineCount: z.number().int(),
|
||||
panelVersion: z.string(),
|
||||
port: z.number().int().min(1).max(65535),
|
||||
remark: z.string(),
|
||||
scheme: z.enum(['http', 'https']),
|
||||
status: z.string(),
|
||||
updatedAt: z.number().int(),
|
||||
uptimeSecs: z.number().int(),
|
||||
xrayVersion: z.string(),
|
||||
});
|
||||
export type Node = z.infer<typeof NodeSchema>;
|
||||
|
||||
export const OutboundTrafficsSchema = z.object({
|
||||
down: z.number().int(),
|
||||
id: z.number().int(),
|
||||
tag: z.string(),
|
||||
total: z.number().int(),
|
||||
up: z.number().int(),
|
||||
});
|
||||
export type OutboundTraffics = z.infer<typeof OutboundTrafficsSchema>;
|
||||
|
||||
export const SettingSchema = z.object({
|
||||
id: z.number().int(),
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
});
|
||||
export type Setting = z.infer<typeof SettingSchema>;
|
||||
|
||||
export const UserSchema = z.object({
|
||||
id: z.number().int(),
|
||||
password: z.string(),
|
||||
username: z.string(),
|
||||
});
|
||||
export type User = z.infer<typeof UserSchema>;
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { AllSetting } from '@/models/setting';
|
||||
|
||||
interface ApiMsg<T = unknown> {
|
||||
success?: boolean;
|
||||
obj?: T;
|
||||
}
|
||||
|
||||
export function useAllSetting() {
|
||||
const [allSetting, setAllSetting] = useState<AllSetting>(() => new AllSetting());
|
||||
const [oldAllSetting, setOldAllSetting] = useState<AllSetting>(() => new AllSetting());
|
||||
const [fetched, setFetched] = useState(false);
|
||||
const [spinning, setSpinning] = useState(false);
|
||||
const fetchedRef = useRef(false);
|
||||
|
||||
const applyServerState = useCallback((obj: unknown) => {
|
||||
setAllSetting(new AllSetting(obj));
|
||||
setOldAllSetting(new AllSetting(obj));
|
||||
}, []);
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
const msg = await HttpUtil.post('/panel/setting/all') as ApiMsg;
|
||||
if (msg?.success) {
|
||||
applyServerState(msg.obj);
|
||||
fetchedRef.current = true;
|
||||
setFetched(true);
|
||||
}
|
||||
}, [applyServerState]);
|
||||
|
||||
const saveAll = useCallback(async () => {
|
||||
setSpinning(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/setting/update', allSetting) as ApiMsg;
|
||||
if (msg?.success) await fetchAll();
|
||||
} finally {
|
||||
setSpinning(false);
|
||||
}
|
||||
}, [allSetting, fetchAll]);
|
||||
|
||||
const updateSetting = useCallback((patch: Partial<AllSetting>) => {
|
||||
setAllSetting((prev) => {
|
||||
const next = new AllSetting(prev);
|
||||
Object.assign(next, patch);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const saveDisabled = useMemo(
|
||||
() => allSetting.equals(oldAllSetting),
|
||||
[allSetting, oldAllSetting],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
fetchAll();
|
||||
}, [fetchAll]);
|
||||
|
||||
return {
|
||||
allSetting,
|
||||
updateSetting,
|
||||
fetched,
|
||||
spinning,
|
||||
setSpinning,
|
||||
saveDisabled,
|
||||
fetchAll,
|
||||
saveAll,
|
||||
};
|
||||
}
|
||||
@@ -1,105 +1,124 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { HttpUtil, Msg } from '@/utils';
|
||||
import { parseMsg } from '@/utils/zodValidate';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
import { markLocalInvalidate } from '@/api/invalidationTracker';
|
||||
import {
|
||||
ClientHydrateSchema,
|
||||
ClientPageResponseSchema,
|
||||
InboundOptionsSchema,
|
||||
OnlinesSchema,
|
||||
BulkAdjustResultSchema,
|
||||
BulkAttachResultSchema,
|
||||
BulkCreateResultSchema,
|
||||
BulkDeleteResultSchema,
|
||||
BulkDetachResultSchema,
|
||||
DelDepletedResultSchema,
|
||||
type ClientHydrate,
|
||||
type ClientRecord,
|
||||
type ClientTraffic,
|
||||
type ClientsSummary,
|
||||
type ClientPageResponse,
|
||||
type InboundOption,
|
||||
type BulkAdjustResult,
|
||||
type BulkAttachResult,
|
||||
type BulkCreateResult,
|
||||
type BulkDeleteResult,
|
||||
type BulkDetachResult,
|
||||
} from '@/schemas/client';
|
||||
import { DefaultsPayloadSchema } from '@/schemas/defaults';
|
||||
|
||||
export type { ClientRecord, ClientTraffic, ClientsSummary, InboundOption };
|
||||
|
||||
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
|
||||
|
||||
export interface ClientTraffic {
|
||||
up?: number;
|
||||
down?: number;
|
||||
total?: number;
|
||||
expiryTime?: number;
|
||||
enable?: boolean;
|
||||
lastOnline?: number;
|
||||
}
|
||||
|
||||
export interface ClientRecord {
|
||||
email: string;
|
||||
subId?: string;
|
||||
uuid?: string;
|
||||
password?: string;
|
||||
auth?: string;
|
||||
flow?: string;
|
||||
totalGB?: number;
|
||||
expiryTime?: number;
|
||||
limitIp?: number;
|
||||
tgId?: number | string;
|
||||
comment?: string;
|
||||
enable?: boolean;
|
||||
inboundIds?: number[];
|
||||
traffic?: ClientTraffic;
|
||||
reverse?: { tag?: string };
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface InboundOption {
|
||||
id: number;
|
||||
remark?: string;
|
||||
protocol?: string;
|
||||
port?: number;
|
||||
tlsFlowCapable?: boolean;
|
||||
}
|
||||
|
||||
interface ApiMsg<T = unknown> {
|
||||
success?: boolean;
|
||||
msg?: string;
|
||||
obj?: T;
|
||||
}
|
||||
|
||||
interface SubSettings {
|
||||
enable: boolean;
|
||||
subURI: string;
|
||||
subJsonURI: string;
|
||||
subJsonEnable: boolean;
|
||||
subClashURI: string;
|
||||
subClashEnable: boolean;
|
||||
}
|
||||
|
||||
export interface ClientQueryParams {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
search?: string;
|
||||
// CSV strings — frontend joins arrays on ',', backend splits the same way.
|
||||
filter?: string;
|
||||
protocol?: string;
|
||||
inbound?: number;
|
||||
inbound?: string;
|
||||
sort?: string;
|
||||
order?: 'ascend' | 'descend';
|
||||
}
|
||||
|
||||
export interface ClientsSummary {
|
||||
total: number;
|
||||
active: number;
|
||||
online: string[];
|
||||
depleted: string[];
|
||||
expiring: string[];
|
||||
deactive: string[];
|
||||
}
|
||||
|
||||
interface ClientPageResponse {
|
||||
items: ClientRecord[];
|
||||
total: number;
|
||||
filtered: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
summary?: ClientsSummary;
|
||||
expiryFrom?: number;
|
||||
expiryTo?: number;
|
||||
usageFrom?: number;
|
||||
usageTo?: number;
|
||||
autoRenew?: 'on' | 'off' | '';
|
||||
hasTgId?: 'yes' | 'no' | '';
|
||||
hasComment?: 'yes' | 'no' | '';
|
||||
group?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 };
|
||||
const DEFAULT_SUMMARY: ClientsSummary = {
|
||||
total: 0, active: 0, online: [], depleted: [], expiring: [], deactive: [],
|
||||
};
|
||||
|
||||
function buildQS(p: ClientQueryParams): string {
|
||||
const sp = new URLSearchParams();
|
||||
sp.set('page', String(p.page || 1));
|
||||
sp.set('pageSize', String(p.pageSize || DEFAULT_QUERY.pageSize));
|
||||
if (p.search) sp.set('search', p.search);
|
||||
if (p.filter) sp.set('filter', p.filter);
|
||||
if (p.protocol) sp.set('protocol', p.protocol);
|
||||
if (p.inbound) sp.set('inbound', p.inbound);
|
||||
if (p.sort) sp.set('sort', p.sort);
|
||||
if (p.order) sp.set('order', p.order);
|
||||
if (p.expiryFrom && p.expiryFrom > 0) sp.set('expiryFrom', String(p.expiryFrom));
|
||||
if (p.expiryTo && p.expiryTo > 0) sp.set('expiryTo', String(p.expiryTo));
|
||||
if (p.usageFrom && p.usageFrom > 0) sp.set('usageFrom', String(p.usageFrom));
|
||||
if (p.usageTo && p.usageTo > 0) sp.set('usageTo', String(p.usageTo));
|
||||
if (p.autoRenew) sp.set('autoRenew', p.autoRenew);
|
||||
if (p.hasTgId) sp.set('hasTgId', p.hasTgId);
|
||||
if (p.hasComment) sp.set('hasComment', p.hasComment);
|
||||
if (p.group) sp.set('group', p.group);
|
||||
return sp.toString();
|
||||
}
|
||||
|
||||
async function fetchClientPage(params: ClientQueryParams): Promise<ClientPageResponse> {
|
||||
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');
|
||||
if (!validated.obj) throw new Error('Empty clients response');
|
||||
return validated.obj;
|
||||
}
|
||||
|
||||
async function fetchInboundOptions(): Promise<InboundOption[]> {
|
||||
const msg = await HttpUtil.get('/panel/api/inbounds/options', undefined, { silent: true });
|
||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch inbound options');
|
||||
const validated = parseMsg(msg, InboundOptionsSchema, 'inbounds/options');
|
||||
return Array.isArray(validated.obj) ? validated.obj : [];
|
||||
}
|
||||
|
||||
async function fetchDefaults(): Promise<Record<string, unknown>> {
|
||||
const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true });
|
||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
|
||||
const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
|
||||
return validated.obj || {};
|
||||
}
|
||||
|
||||
export function useClients() {
|
||||
const [clients, setClients] = useState<ClientRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [filtered, setFiltered] = useState(0);
|
||||
const [summary, setSummary] = useState<ClientsSummary>({
|
||||
total: 0, active: 0, online: [], depleted: [], expiring: [], deactive: [],
|
||||
});
|
||||
const [inbounds, setInbounds] = useState<InboundOption[]>([]);
|
||||
const [onlines, setOnlines] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetched, setFetched] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [query, setQueryState] = useState<ClientQueryParams>(DEFAULT_QUERY);
|
||||
// Shallow-compare against the previous query so callers can pass a fresh
|
||||
// object on every render (the common React pattern) without triggering a
|
||||
// re-fetch when nothing actually changed.
|
||||
// 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 (
|
||||
@@ -108,182 +127,273 @@ export function useClients() {
|
||||
&& (prev.search ?? '') === (next.search ?? '')
|
||||
&& (prev.filter ?? '') === (next.filter ?? '')
|
||||
&& (prev.protocol ?? '') === (next.protocol ?? '')
|
||||
&& (prev.inbound ?? 0) === (next.inbound ?? 0)
|
||||
&& (prev.inbound ?? '') === (next.inbound ?? '')
|
||||
&& (prev.sort ?? '') === (next.sort ?? '')
|
||||
&& (prev.order ?? '') === (next.order ?? '')
|
||||
&& (prev.expiryFrom ?? 0) === (next.expiryFrom ?? 0)
|
||||
&& (prev.expiryTo ?? 0) === (next.expiryTo ?? 0)
|
||||
&& (prev.usageFrom ?? 0) === (next.usageFrom ?? 0)
|
||||
&& (prev.usageTo ?? 0) === (next.usageTo ?? 0)
|
||||
&& (prev.autoRenew ?? '') === (next.autoRenew ?? '')
|
||||
&& (prev.hasTgId ?? '') === (next.hasTgId ?? '')
|
||||
&& (prev.hasComment ?? '') === (next.hasComment ?? '')
|
||||
&& (prev.group ?? '') === (next.group ?? '')
|
||||
) return prev;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const [subSettings, setSubSettings] = useState<SubSettings>({
|
||||
enable: false, subURI: '', subJsonURI: '', subJsonEnable: false,
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: keys.clients.list(query),
|
||||
queryFn: () => fetchClientPage(query),
|
||||
staleTime: Infinity,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const [ipLimitEnable, setIpLimitEnable] = useState(false);
|
||||
const [tgBotEnable, setTgBotEnable] = useState(false);
|
||||
const [expireDiff, setExpireDiff] = useState(0);
|
||||
const [trafficDiff, setTrafficDiff] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(0);
|
||||
|
||||
const clientsRef = useRef<ClientRecord[]>([]);
|
||||
const queryRef = useRef<ClientQueryParams>(query);
|
||||
const invalidateTimerRef = useRef<number | null>(null);
|
||||
const inboundOptionsQuery = useQuery({
|
||||
queryKey: keys.inbounds.options(),
|
||||
queryFn: fetchInboundOptions,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
useEffect(() => { clientsRef.current = clients; }, [clients]);
|
||||
useEffect(() => { queryRef.current = query; }, [query]);
|
||||
const defaultsQuery = useQuery({
|
||||
queryKey: keys.settings.defaults(),
|
||||
queryFn: fetchDefaults,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const buildQS = (p: ClientQueryParams) => {
|
||||
const sp = new URLSearchParams();
|
||||
sp.set('page', String(p.page || 1));
|
||||
sp.set('pageSize', String(p.pageSize || DEFAULT_QUERY.pageSize));
|
||||
if (p.search) sp.set('search', p.search);
|
||||
if (p.filter) sp.set('filter', p.filter);
|
||||
if (p.protocol) sp.set('protocol', p.protocol);
|
||||
if (p.inbound && p.inbound > 0) sp.set('inbound', String(p.inbound));
|
||||
if (p.sort) sp.set('sort', p.sort);
|
||||
if (p.order) sp.set('order', p.order);
|
||||
return sp.toString();
|
||||
};
|
||||
const onlinesQuery = useQuery({
|
||||
queryKey: keys.clients.onlines(),
|
||||
queryFn: async () => {
|
||||
const msg = await HttpUtil.post('/panel/api/clients/onlines', undefined, { silent: true });
|
||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlines');
|
||||
const validated = parseMsg(msg, OnlinesSchema, 'clients/onlines');
|
||||
return Array.isArray(validated.obj) ? validated.obj : [];
|
||||
},
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const refresh = useCallback(async (override?: ClientQueryParams) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = override ?? queryRef.current;
|
||||
const qs = buildQS(params);
|
||||
const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`) as ApiMsg<ClientPageResponse>;
|
||||
if (msg?.success && msg.obj) {
|
||||
setClients(Array.isArray(msg.obj.items) ? msg.obj.items : []);
|
||||
setTotal(msg.obj.total ?? 0);
|
||||
setFiltered(msg.obj.filtered ?? 0);
|
||||
if (msg.obj.summary) setSummary(msg.obj.summary);
|
||||
}
|
||||
setFetched(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
const clients = listQuery.data?.items ?? [];
|
||||
const total = listQuery.data?.total ?? 0;
|
||||
const filtered = listQuery.data?.filtered ?? 0;
|
||||
const summary = listQuery.data?.summary ?? DEFAULT_SUMMARY;
|
||||
const allGroups = listQuery.data?.groups ?? [];
|
||||
const fetched = listQuery.data !== undefined;
|
||||
const loading = listQuery.isFetching;
|
||||
|
||||
// Inbound options are picker-shaped and don't depend on the clients query —
|
||||
// fetch them once on mount instead of every refresh.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const msg = await HttpUtil.get('/panel/api/inbounds/options') as ApiMsg<InboundOption[]>;
|
||||
if (cancelled) return;
|
||||
if (msg?.success) setInbounds(Array.isArray(msg.obj) ? msg.obj : []);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
const inbounds = inboundOptionsQuery.data ?? [];
|
||||
const onlines = onlinesQuery.data ?? [];
|
||||
|
||||
const fetchSubSettings = useCallback(async () => {
|
||||
const msg = await HttpUtil.post('/panel/setting/defaultSettings') as ApiMsg<Record<string, unknown>>;
|
||||
if (!msg?.success) return;
|
||||
const s = msg.obj || {};
|
||||
setSubSettings({
|
||||
enable: !!s.subEnable,
|
||||
subURI: (s.subURI as string) || '',
|
||||
subJsonURI: (s.subJsonURI as string) || '',
|
||||
subJsonEnable: !!s.subJsonEnable,
|
||||
});
|
||||
setIpLimitEnable(!!s.ipLimitEnable);
|
||||
setTgBotEnable(!!s.tgBotEnable);
|
||||
setExpireDiff(((s.expireDiff as number) ?? 0) * 86400000);
|
||||
setTrafficDiff(((s.trafficDiff as number) ?? 0) * 1073741824);
|
||||
setPageSize((s.pageSize as number) ?? 0);
|
||||
}, []);
|
||||
const defaults = defaultsQuery.data ?? {};
|
||||
const subSettings: SubSettings = useMemo(() => ({
|
||||
enable: !!defaults.subEnable,
|
||||
subURI: (defaults.subURI as string) || '',
|
||||
subJsonURI: (defaults.subJsonURI as string) || '',
|
||||
subJsonEnable: !!defaults.subJsonEnable,
|
||||
subClashURI: (defaults.subClashURI as string) || '',
|
||||
subClashEnable: !!defaults.subClashEnable,
|
||||
}), [
|
||||
defaults.subEnable,
|
||||
defaults.subURI,
|
||||
defaults.subJsonURI,
|
||||
defaults.subJsonEnable,
|
||||
defaults.subClashURI,
|
||||
defaults.subClashEnable,
|
||||
]);
|
||||
|
||||
// hydrate fetches the full client record (uuid, password, flow, ...) for a
|
||||
// single email. The paged list endpoint omits these to keep the row payload
|
||||
// tiny; edit / info / qr / link modals call this to get a complete record
|
||||
// before opening.
|
||||
const hydrate = useCallback(async (email: string): Promise<{ client: ClientRecord; inboundIds: number[] } | null> => {
|
||||
const ipLimitEnable = !!defaults.ipLimitEnable;
|
||||
const tgBotEnable = !!defaults.tgBotEnable;
|
||||
const expireDiff = ((defaults.expireDiff as number) ?? 0) * 86400000;
|
||||
const trafficDiff = ((defaults.trafficDiff as number) ?? 0) * 1073741824;
|
||||
const pageSize = (defaults.pageSize as number) ?? 0;
|
||||
|
||||
// Client mutations (add/update/remove/attach/detach/resetTraffic/…) all
|
||||
// mutate inbound rows server-side too — adding a client appends to
|
||||
// settings.clients on each attached inbound, the slim list's per-inbound
|
||||
// client count is derived from that. Invalidate both buckets so the
|
||||
// Inbounds page and any open edit modal pick up the new shape without
|
||||
// a manual reload.
|
||||
const invalidateAll = useCallback(
|
||||
() => {
|
||||
markLocalInvalidate();
|
||||
return Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: keys.clients.root() }),
|
||||
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
|
||||
]);
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
await invalidateAll();
|
||||
}, [invalidateAll]);
|
||||
|
||||
const hydrate = useCallback(async (email: string): Promise<ClientHydrate | null> => {
|
||||
if (!email) return null;
|
||||
const msg = await HttpUtil.get(`/panel/api/clients/get/${encodeURIComponent(email)}`) as ApiMsg<{ client: ClientRecord; inboundIds: number[] }>;
|
||||
const msg = await HttpUtil.get(`/panel/api/clients/get/${encodeURIComponent(email)}`);
|
||||
if (!msg?.success || !msg.obj) return null;
|
||||
return msg.obj;
|
||||
const validated = parseMsg(msg, ClientHydrateSchema, 'clients/get');
|
||||
return validated.obj;
|
||||
}, []);
|
||||
|
||||
const create = useCallback(async (payload: unknown) => {
|
||||
const msg = await HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
const createMut = useMutation({
|
||||
mutationFn: (payload: unknown) =>
|
||||
HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
});
|
||||
|
||||
const update = useCallback(async (email: string, client: unknown) => {
|
||||
if (!email) return null;
|
||||
const encoded = encodeURIComponent(email);
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/update/${encoded}`, client, JSON_HEADERS) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
const bulkAddToGroupMut = useMutation({
|
||||
mutationFn: (body: { emails: string[]; group: string }) =>
|
||||
HttpUtil.post('/panel/api/clients/groups/bulkAdd', body, JSON_HEADERS),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
});
|
||||
|
||||
const remove = useCallback(async (email: string, keepTraffic = false) => {
|
||||
if (!email) return null;
|
||||
const encoded = encodeURIComponent(email);
|
||||
const url = keepTraffic
|
||||
? `/panel/api/clients/del/${encoded}?keepTraffic=1`
|
||||
: `/panel/api/clients/del/${encoded}`;
|
||||
const msg = await HttpUtil.post(url) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
const bulkRemoveFromGroupMut = useMutation({
|
||||
mutationFn: (body: { emails: string[] }) =>
|
||||
HttpUtil.post('/panel/api/clients/groups/bulkRemove', body, JSON_HEADERS),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
});
|
||||
|
||||
const removeMany = useCallback(async (emails: string[], keepTraffic = false) => {
|
||||
if (!Array.isArray(emails) || emails.length === 0) return [];
|
||||
const suffix = keepTraffic ? '?keepTraffic=1' : '';
|
||||
const results = await Promise.all(emails.map((email) => {
|
||||
const url = `/panel/api/clients/del/${encodeURIComponent(email)}${suffix}`;
|
||||
return HttpUtil.post(url, undefined, { silent: true }) as Promise<ApiMsg>;
|
||||
}));
|
||||
await refresh();
|
||||
return results;
|
||||
}, [refresh]);
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ email, client }: { email: string; client: unknown }) =>
|
||||
HttpUtil.post(`/panel/api/clients/update/${encodeURIComponent(email)}`, client, JSON_HEADERS),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
});
|
||||
|
||||
const bulkAdjust = useCallback(async (emails: string[], addDays: number, addBytes: number) => {
|
||||
if (!Array.isArray(emails) || emails.length === 0) return null;
|
||||
const msg = await HttpUtil.post(
|
||||
'/panel/api/clients/bulkAdjust',
|
||||
{ emails, addDays, addBytes },
|
||||
JSON_HEADERS,
|
||||
) as ApiMsg<{ adjusted: number; skipped?: { email: string; reason: string }[] }>;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
const removeMut = useMutation({
|
||||
mutationFn: ({ email, keepTraffic }: { email: string; keepTraffic?: boolean }) => {
|
||||
const url = keepTraffic
|
||||
? `/panel/api/clients/del/${encodeURIComponent(email)}?keepTraffic=1`
|
||||
: `/panel/api/clients/del/${encodeURIComponent(email)}`;
|
||||
return HttpUtil.post(url);
|
||||
},
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
});
|
||||
|
||||
const attach = useCallback(async (email: string, inboundIds: number[]) => {
|
||||
if (!email) return null;
|
||||
const encoded = encodeURIComponent(email);
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/${encoded}/attach`, { inboundIds }, JSON_HEADERS) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
const bulkDeleteMut = useMutation({
|
||||
mutationFn: async (payload: { emails: string[]; keepTraffic?: boolean }): Promise<Msg<BulkDeleteResult>> => {
|
||||
const raw = await HttpUtil.post('/panel/api/clients/bulkDel', payload, JSON_HEADERS);
|
||||
return parseMsg(raw, BulkDeleteResultSchema, 'clients/bulkDel');
|
||||
},
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
});
|
||||
|
||||
const detach = useCallback(async (email: string, inboundIds: number[]) => {
|
||||
if (!email) return null;
|
||||
const encoded = encodeURIComponent(email);
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/${encoded}/detach`, { inboundIds }, JSON_HEADERS) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
const bulkCreateMut = useMutation({
|
||||
mutationFn: async (payloads: unknown[]): Promise<Msg<BulkCreateResult>> => {
|
||||
const raw = await HttpUtil.post('/panel/api/clients/bulkCreate', payloads, JSON_HEADERS);
|
||||
return parseMsg(raw, BulkCreateResultSchema, 'clients/bulkCreate');
|
||||
},
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
});
|
||||
|
||||
const resetTraffic = useCallback(async (client: ClientRecord) => {
|
||||
if (!client?.email) return null;
|
||||
const url = `/panel/api/clients/resetTraffic/${encodeURIComponent(client.email)}`;
|
||||
const msg = await HttpUtil.post(url) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
const bulkAdjustMut = useMutation({
|
||||
mutationFn: async (payload: { emails: string[]; addDays: number; addBytes: number }): Promise<Msg<BulkAdjustResult>> => {
|
||||
const raw = await HttpUtil.post('/panel/api/clients/bulkAdjust', payload, JSON_HEADERS);
|
||||
return parseMsg(raw, BulkAdjustResultSchema, 'clients/bulkAdjust');
|
||||
},
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
});
|
||||
|
||||
const resetAllTraffics = useCallback(async () => {
|
||||
const msg = await HttpUtil.post('/panel/api/clients/resetAllTraffics') as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
const attachMut = useMutation({
|
||||
mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) =>
|
||||
HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/attach`, { inboundIds }, JSON_HEADERS),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
});
|
||||
|
||||
const delDepleted = useCallback(async () => {
|
||||
const msg = await HttpUtil.post('/panel/api/clients/delDepleted') as ApiMsg<{ deleted?: number }>;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
const bulkAttachMut = useMutation({
|
||||
mutationFn: async (payload: { emails: string[]; inboundIds: number[] }): Promise<Msg<BulkAttachResult>> => {
|
||||
const raw = await HttpUtil.post('/panel/api/clients/bulkAttach', payload, JSON_HEADERS);
|
||||
return parseMsg(raw, BulkAttachResultSchema, 'clients/bulkAttach');
|
||||
},
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
});
|
||||
|
||||
const detachMut = useMutation({
|
||||
mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) =>
|
||||
HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/detach`, { inboundIds }, JSON_HEADERS),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
});
|
||||
|
||||
const bulkDetachMut = useMutation({
|
||||
mutationFn: async (payload: { emails: string[]; inboundIds: number[] }): Promise<Msg<BulkDetachResult>> => {
|
||||
const raw = await HttpUtil.post('/panel/api/clients/bulkDetach', payload, JSON_HEADERS);
|
||||
return parseMsg(raw, BulkDetachResultSchema, 'clients/bulkDetach');
|
||||
},
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
});
|
||||
|
||||
const resetTrafficMut = useMutation({
|
||||
mutationFn: (email: string) =>
|
||||
HttpUtil.post(`/panel/api/clients/resetTraffic/${encodeURIComponent(email)}`),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
});
|
||||
|
||||
const resetAllTrafficsMut = useMutation({
|
||||
mutationFn: () => HttpUtil.post('/panel/api/clients/resetAllTraffics'),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
});
|
||||
|
||||
const delDepletedMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const raw = await HttpUtil.post('/panel/api/clients/delDepleted');
|
||||
return parseMsg(raw, DelDepletedResultSchema, 'clients/delDepleted');
|
||||
},
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
});
|
||||
|
||||
const create = useCallback((payload: unknown) => createMut.mutateAsync(payload), [createMut]);
|
||||
const update = useCallback((email: string, client: unknown) => {
|
||||
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
|
||||
return updateMut.mutateAsync({ email, client });
|
||||
}, [updateMut]);
|
||||
const remove = useCallback((email: string, keepTraffic = false) => {
|
||||
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
|
||||
return removeMut.mutateAsync({ email, keepTraffic });
|
||||
}, [removeMut]);
|
||||
const bulkDelete = useCallback((emails: string[], keepTraffic = false) => {
|
||||
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkDeleteResult>);
|
||||
return bulkDeleteMut.mutateAsync({ emails, keepTraffic });
|
||||
}, [bulkDeleteMut]);
|
||||
const bulkCreate = useCallback((payloads: unknown[]) => {
|
||||
if (!Array.isArray(payloads) || payloads.length === 0) return Promise.resolve(null as unknown as Msg<BulkCreateResult>);
|
||||
return bulkCreateMut.mutateAsync(payloads);
|
||||
}, [bulkCreateMut]);
|
||||
const bulkAdjust = useCallback((emails: string[], addDays: number, addBytes: number) => {
|
||||
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
|
||||
return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes });
|
||||
}, [bulkAdjustMut]);
|
||||
const bulkAddToGroup = useCallback((emails: string[], group: string) => {
|
||||
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
|
||||
return bulkAddToGroupMut.mutateAsync({ emails, group });
|
||||
}, [bulkAddToGroupMut]);
|
||||
const bulkRemoveFromGroup = useCallback((emails: string[]) => {
|
||||
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
|
||||
return bulkRemoveFromGroupMut.mutateAsync({ emails });
|
||||
}, [bulkRemoveFromGroupMut]);
|
||||
const attach = useCallback((email: string, inboundIds: number[]) => {
|
||||
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
|
||||
return attachMut.mutateAsync({ email, inboundIds });
|
||||
}, [attachMut]);
|
||||
const bulkAttach = useCallback((emails: string[], inboundIds: number[]) => {
|
||||
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkAttachResult>);
|
||||
if (!Array.isArray(inboundIds) || inboundIds.length === 0) return Promise.resolve(null as unknown as Msg<BulkAttachResult>);
|
||||
return bulkAttachMut.mutateAsync({ emails, inboundIds });
|
||||
}, [bulkAttachMut]);
|
||||
const detach = useCallback((email: string, inboundIds: number[]) => {
|
||||
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
|
||||
return detachMut.mutateAsync({ email, inboundIds });
|
||||
}, [detachMut]);
|
||||
const bulkDetach = useCallback((emails: string[], inboundIds: number[]) => {
|
||||
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkDetachResult>);
|
||||
if (!Array.isArray(inboundIds) || inboundIds.length === 0) return Promise.resolve(null as unknown as Msg<BulkDetachResult>);
|
||||
return bulkDetachMut.mutateAsync({ emails, inboundIds });
|
||||
}, [bulkDetachMut]);
|
||||
const resetTraffic = useCallback((client: ClientRecord) => {
|
||||
if (!client?.email) return Promise.resolve(null as unknown as Msg<unknown>);
|
||||
return resetTrafficMut.mutateAsync(client.email);
|
||||
}, [resetTrafficMut]);
|
||||
const resetAllTraffics = useCallback(() => resetAllTrafficsMut.mutateAsync(), [resetAllTrafficsMut]);
|
||||
const delDepleted = useCallback(() => delDepletedMut.mutateAsync(), [delDepletedMut]);
|
||||
|
||||
const setEnable = useCallback(async (client: ClientRecord, enable: boolean) => {
|
||||
if (!client?.email) return null;
|
||||
@@ -302,63 +412,60 @@ export function useClients() {
|
||||
return update(client.email, payload);
|
||||
}, [update]);
|
||||
|
||||
// WS-driven in-place merges. Page wires these via useWebSocket; the bridge
|
||||
// covers coarse 'invalidate' and 'inbounds' events centrally.
|
||||
const queryRef = useRef(query);
|
||||
queryRef.current = query;
|
||||
|
||||
const applyTrafficEvent = useCallback((payload: unknown) => {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
const p = payload as { onlineClients?: string[] };
|
||||
if (Array.isArray(p.onlineClients)) {
|
||||
setOnlines(p.onlineClients);
|
||||
queryClient.setQueryData(keys.clients.onlines(), p.onlineClients);
|
||||
}
|
||||
}, []);
|
||||
}, [queryClient]);
|
||||
|
||||
const applyClientStatsEvent = useCallback((payload: unknown) => {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
const p = payload as { clients?: ClientTraffic[] & { email?: string }[] };
|
||||
const p = payload as { clients?: (ClientTraffic & { email?: string })[] };
|
||||
if (!Array.isArray(p.clients) || p.clients.length === 0) return;
|
||||
const byEmail = new Map<string, ClientTraffic>();
|
||||
for (const row of p.clients as (ClientTraffic & { email?: string })[]) {
|
||||
for (const row of p.clients) {
|
||||
if (row && row.email) byEmail.set(row.email, row);
|
||||
}
|
||||
const cur = clientsRef.current || [];
|
||||
let touched = false;
|
||||
const next = cur.slice();
|
||||
for (let i = 0; i < next.length; i++) {
|
||||
const row = next[i];
|
||||
const upd = byEmail.get(row?.email);
|
||||
if (!upd) continue;
|
||||
const merged: ClientTraffic = { ...(row.traffic || {}) };
|
||||
if (typeof upd.up === 'number') merged.up = upd.up;
|
||||
if (typeof upd.down === 'number') merged.down = upd.down;
|
||||
if (typeof upd.total === 'number') merged.total = upd.total;
|
||||
if (typeof upd.expiryTime === 'number') merged.expiryTime = upd.expiryTime;
|
||||
if (typeof upd.enable === 'boolean') merged.enable = upd.enable;
|
||||
if (typeof upd.lastOnline === 'number') merged.lastOnline = upd.lastOnline;
|
||||
next[i] = { ...row, traffic: merged };
|
||||
touched = true;
|
||||
}
|
||||
if (touched) setClients(next);
|
||||
}, []);
|
||||
|
||||
const applyInvalidate = useCallback((payload: unknown) => {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
const p = payload as { type?: string };
|
||||
if (p.type !== 'inbounds' && p.type !== 'clients') return;
|
||||
if (invalidateTimerRef.current != null) clearTimeout(invalidateTimerRef.current);
|
||||
invalidateTimerRef.current = window.setTimeout(() => {
|
||||
invalidateTimerRef.current = null;
|
||||
refresh();
|
||||
}, 200);
|
||||
}, [refresh]);
|
||||
queryClient.setQueryData<ClientPageResponse>(keys.clients.list(queryRef.current), (prev) => {
|
||||
if (!prev) return prev;
|
||||
let touched = false;
|
||||
const next = prev.items.slice();
|
||||
for (let i = 0; i < next.length; i++) {
|
||||
const row = next[i];
|
||||
const upd = byEmail.get(row?.email);
|
||||
if (!upd) continue;
|
||||
const merged: ClientTraffic = { ...(row.traffic || {}) };
|
||||
if (typeof upd.up === 'number') merged.up = upd.up;
|
||||
if (typeof upd.down === 'number') merged.down = upd.down;
|
||||
if (typeof upd.total === 'number') merged.total = upd.total;
|
||||
if (typeof upd.expiryTime === 'number') merged.expiryTime = upd.expiryTime;
|
||||
if (typeof upd.enable === 'boolean') merged.enable = upd.enable;
|
||||
if (typeof upd.lastOnline === 'number') merged.lastOnline = upd.lastOnline;
|
||||
next[i] = { ...row, traffic: merged };
|
||||
touched = true;
|
||||
}
|
||||
if (!touched) return prev;
|
||||
return { ...prev, items: next };
|
||||
});
|
||||
}, [queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([refresh(query), fetchSubSettings()]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query, fetchSubSettings]);
|
||||
queryRef.current = query;
|
||||
}, [query]);
|
||||
|
||||
return {
|
||||
clients,
|
||||
total,
|
||||
filtered,
|
||||
summary,
|
||||
allGroups,
|
||||
hydrate,
|
||||
query,
|
||||
setQuery,
|
||||
@@ -374,18 +481,22 @@ export function useClients() {
|
||||
pageSize,
|
||||
refresh,
|
||||
create,
|
||||
bulkCreate,
|
||||
update,
|
||||
remove,
|
||||
removeMany,
|
||||
bulkDelete,
|
||||
bulkAdjust,
|
||||
bulkAddToGroup,
|
||||
bulkRemoveFromGroup,
|
||||
attach,
|
||||
bulkAttach,
|
||||
detach,
|
||||
bulkDetach,
|
||||
resetTraffic,
|
||||
resetAllTraffics,
|
||||
delDepleted,
|
||||
setEnable,
|
||||
applyTrafficEvent,
|
||||
applyClientStatsEvent,
|
||||
applyInvalidate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { parseMsg } from '@/utils/zodValidate';
|
||||
import { DefaultsPayloadSchema } from '@/schemas/defaults';
|
||||
|
||||
type Calendar = 'gregorian' | 'jalalian';
|
||||
|
||||
@@ -20,12 +22,10 @@ async function loadOnce(): Promise<void> {
|
||||
}
|
||||
pending = (async () => {
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/setting/defaultSettings') as {
|
||||
success?: boolean;
|
||||
obj?: { datepicker?: Calendar };
|
||||
};
|
||||
const msg = await HttpUtil.post('/panel/setting/defaultSettings');
|
||||
if (msg?.success) {
|
||||
cachedValue = msg.obj?.datepicker || 'gregorian';
|
||||
const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
|
||||
cachedValue = validated.obj?.datepicker || 'gregorian';
|
||||
notify(cachedValue);
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
export interface NodeRecord {
|
||||
id: number;
|
||||
name?: string;
|
||||
remark?: string;
|
||||
scheme?: string;
|
||||
address?: string;
|
||||
port?: number;
|
||||
basePath?: string;
|
||||
apiToken?: string;
|
||||
enable?: boolean;
|
||||
status?: 'online' | 'offline' | string;
|
||||
latencyMs?: number;
|
||||
cpuPct?: number;
|
||||
memPct?: number;
|
||||
xrayVersion?: string;
|
||||
panelVersion?: string;
|
||||
uptimeSecs?: number;
|
||||
inboundCount?: number;
|
||||
clientCount?: number;
|
||||
onlineCount?: number;
|
||||
depletedCount?: number;
|
||||
lastHeartbeat?: number;
|
||||
lastError?: string;
|
||||
allowPrivateAddress?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ApiMsg<T = unknown> {
|
||||
success?: boolean;
|
||||
msg?: string;
|
||||
obj?: T;
|
||||
}
|
||||
|
||||
interface NodeTotals {
|
||||
total: number;
|
||||
online: number;
|
||||
offline: number;
|
||||
avgLatency: number;
|
||||
inbounds: number;
|
||||
clients: number;
|
||||
onlineClients: number;
|
||||
depleted: number;
|
||||
}
|
||||
|
||||
export function useNodes() {
|
||||
const [nodes, setNodes] = useState<NodeRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetched, setFetched] = useState(false);
|
||||
const fetchedRef = useRef(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/nodes/list') as ApiMsg<NodeRecord[]>;
|
||||
if (msg?.success) {
|
||||
setNodes(Array.isArray(msg.obj) ? msg.obj : []);
|
||||
}
|
||||
fetchedRef.current = true;
|
||||
setFetched(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const applyNodesEvent = useCallback((payload: unknown) => {
|
||||
if (Array.isArray(payload)) {
|
||||
setNodes(payload as NodeRecord[]);
|
||||
if (!fetchedRef.current) {
|
||||
fetchedRef.current = true;
|
||||
setFetched(true);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const create = useCallback(async (payload: Partial<NodeRecord>) => {
|
||||
const msg = await HttpUtil.post('/panel/api/nodes/add', payload) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const update = useCallback(async (id: number, payload: Partial<NodeRecord>) => {
|
||||
const msg = await HttpUtil.post(`/panel/api/nodes/update/${id}`, payload) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const remove = useCallback(async (id: number) => {
|
||||
const msg = await HttpUtil.post(`/panel/api/nodes/del/${id}`) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const setEnable = useCallback(async (id: number, enable: boolean) => {
|
||||
const msg = await HttpUtil.post(`/panel/api/nodes/setEnable/${id}`, { enable }) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const testConnection = useCallback(async (payload: Partial<NodeRecord>) => {
|
||||
return await HttpUtil.post('/panel/api/nodes/test', payload) as ApiMsg<{
|
||||
status: string;
|
||||
latencyMs?: number;
|
||||
xrayVersion?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
}, []);
|
||||
|
||||
const probe = useCallback(async (id: number) => {
|
||||
const msg = await HttpUtil.post(`/panel/api/nodes/probe/${id}`) as ApiMsg<{
|
||||
status: string;
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
}>;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const totals = useMemo<NodeTotals>(() => {
|
||||
let online = 0;
|
||||
let offline = 0;
|
||||
let latencySum = 0;
|
||||
let latencyCount = 0;
|
||||
let inbounds = 0;
|
||||
let clients = 0;
|
||||
let onlineClients = 0;
|
||||
let depleted = 0;
|
||||
for (const n of nodes) {
|
||||
inbounds += n.inboundCount || 0;
|
||||
clients += n.clientCount || 0;
|
||||
onlineClients += n.onlineCount || 0;
|
||||
depleted += n.depletedCount || 0;
|
||||
if (!n.enable) continue;
|
||||
if (n.status === 'online') {
|
||||
online += 1;
|
||||
if (n.latencyMs && n.latencyMs > 0) {
|
||||
latencySum += n.latencyMs;
|
||||
latencyCount += 1;
|
||||
}
|
||||
} else if (n.status === 'offline') {
|
||||
offline += 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
total: nodes.length,
|
||||
online,
|
||||
offline,
|
||||
avgLatency: latencyCount > 0 ? Math.round(latencySum / latencyCount) : 0,
|
||||
inbounds,
|
||||
clients,
|
||||
onlineClients,
|
||||
depleted,
|
||||
};
|
||||
}, [nodes]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return {
|
||||
nodes,
|
||||
loading,
|
||||
fetched,
|
||||
totals,
|
||||
refresh,
|
||||
applyNodesEvent,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
setEnable,
|
||||
testConnection,
|
||||
probe,
|
||||
};
|
||||
}
|
||||
26
frontend/src/hooks/usePageTitle.ts
Normal file
26
frontend/src/hooks/usePageTitle.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const TITLE_KEYS: Record<string, string> = {
|
||||
'/': 'menu.dashboard',
|
||||
'/inbounds': 'menu.inbounds',
|
||||
'/clients': 'menu.clients',
|
||||
'/groups': 'menu.groups',
|
||||
'/nodes': 'menu.nodes',
|
||||
'/settings': 'menu.settings',
|
||||
'/xray': 'menu.xray',
|
||||
'/api-docs': 'menu.apiDocs',
|
||||
};
|
||||
|
||||
export function usePageTitle() {
|
||||
const { pathname } = useLocation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
const key = TITLE_KEYS[pathname];
|
||||
const title = key ? t(key) : '3X-UI';
|
||||
const host = window.location.hostname;
|
||||
document.title = host ? `${host} - ${title}` : title;
|
||||
}, [pathname, t]);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { Status } from '@/models/status';
|
||||
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
|
||||
export function useStatus() {
|
||||
const [status, setStatus] = useState<Status>(() => new Status());
|
||||
const [fetched, setFetched] = useState(false);
|
||||
const fetchedRef = useRef(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/server/status');
|
||||
if (msg?.success) {
|
||||
setStatus(new Status(msg.obj));
|
||||
if (!fetchedRef.current) {
|
||||
fetchedRef.current = true;
|
||||
setFetched(true);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to get status:', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
const timer = window.setInterval(refresh, POLL_INTERVAL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [refresh]);
|
||||
|
||||
return { status, fetched, refresh };
|
||||
}
|
||||
@@ -68,10 +68,25 @@ const ULTRA_DARK_MENU_TOKENS = {
|
||||
darkSubMenuItemBg: '#000',
|
||||
darkPopupBg: '#101013',
|
||||
};
|
||||
const DARK_CARD_TOKENS = {
|
||||
colorBorderSecondary: 'rgba(255, 255, 255, 0.06)',
|
||||
};
|
||||
const ULTRA_DARK_CARD_TOKENS = {
|
||||
colorBorderSecondary: 'rgba(255, 255, 255, 0.04)',
|
||||
};
|
||||
const STATISTIC_TOKENS = {
|
||||
contentFontSize: 17,
|
||||
titleFontSize: 11,
|
||||
};
|
||||
|
||||
export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeConfig {
|
||||
if (!isDark) {
|
||||
return { algorithm: antdTheme.defaultAlgorithm };
|
||||
return {
|
||||
algorithm: antdTheme.defaultAlgorithm,
|
||||
components: {
|
||||
Statistic: STATISTIC_TOKENS,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
algorithm: antdTheme.darkAlgorithm,
|
||||
@@ -79,6 +94,8 @@ export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeCo
|
||||
components: {
|
||||
Layout: isUltra ? ULTRA_DARK_LAYOUT_TOKENS : DARK_LAYOUT_TOKENS,
|
||||
Menu: isUltra ? ULTRA_DARK_MENU_TOKENS : DARK_MENU_TOKENS,
|
||||
Card: isUltra ? ULTRA_DARK_CARD_TOKENS : DARK_CARD_TOKENS,
|
||||
Statistic: STATISTIC_TOKENS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { WebSocketClient } from '@/api/websocket.js';
|
||||
import { WebSocketClient } from '@/api/websocket';
|
||||
|
||||
type Handler = (payload: unknown) => void;
|
||||
|
||||
|
||||
@@ -1,27 +1,25 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { HttpUtil, PromiseUtil } from '@/utils';
|
||||
import { HttpUtil, Msg, PromiseUtil } from '@/utils';
|
||||
import { parseMsg } from '@/utils/zodValidate';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
import {
|
||||
OutboundTrafficListSchema,
|
||||
OutboundTestResultSchema,
|
||||
XrayConfigPayloadSchema,
|
||||
XraySettingsValueSchema,
|
||||
type OutboundTestResult,
|
||||
type OutboundTrafficRow,
|
||||
} from '@/schemas/xray';
|
||||
|
||||
const DIRTY_POLL_MS = 1000;
|
||||
const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
|
||||
|
||||
export interface OutboundTrafficRow {
|
||||
tag: string;
|
||||
up: number;
|
||||
down: number;
|
||||
}
|
||||
export type { OutboundTrafficRow, OutboundTestResult };
|
||||
|
||||
export interface OutboundTestResult {
|
||||
success: boolean;
|
||||
delay?: number;
|
||||
error?: string;
|
||||
mode?: string;
|
||||
ttfbMs?: number;
|
||||
tlsMs?: number;
|
||||
connectMs?: number;
|
||||
dnsMs?: number;
|
||||
statusCode?: number;
|
||||
endpoints?: { address: string; delay?: number; success: boolean; error?: string }[];
|
||||
}
|
||||
export type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
|
||||
|
||||
export interface OutboundTestState {
|
||||
testing?: boolean;
|
||||
@@ -29,23 +27,6 @@ export interface OutboundTestState {
|
||||
mode?: string;
|
||||
}
|
||||
|
||||
export interface XraySettingsValue {
|
||||
inbounds?: unknown[];
|
||||
outbounds?: { tag?: string; protocol?: string; settings?: unknown; streamSettings?: unknown }[];
|
||||
routing?: {
|
||||
rules?: { type?: string; outboundTag?: string; balancerTag?: string; [key: string]: unknown }[];
|
||||
balancers?: unknown[];
|
||||
domainStrategy?: string;
|
||||
};
|
||||
dns?: { tag?: string; servers?: unknown[] };
|
||||
log?: Record<string, unknown>;
|
||||
policy?: { system?: Record<string, boolean> };
|
||||
observatory?: unknown;
|
||||
burstObservatory?: unknown;
|
||||
fakedns?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type SetTemplate = (
|
||||
next: XraySettingsValue | null | ((prev: XraySettingsValue | null) => XraySettingsValue | null),
|
||||
) => void;
|
||||
@@ -70,7 +51,6 @@ export interface UseXraySettingResult {
|
||||
fetchAll: () => Promise<void>;
|
||||
fetchOutboundsTraffic: () => Promise<void>;
|
||||
resetOutboundsTraffic: (tag: string) => Promise<void>;
|
||||
applyOutboundsEvent: (payload: unknown) => void;
|
||||
testOutbound: (
|
||||
index: number,
|
||||
outbound: unknown,
|
||||
@@ -82,18 +62,56 @@ export interface UseXraySettingResult {
|
||||
restartXray: () => Promise<void>;
|
||||
}
|
||||
|
||||
type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
|
||||
|
||||
async function fetchXrayConfig(): Promise<XrayConfigPayload> {
|
||||
const msg = await HttpUtil.post('/panel/xray/', undefined, { silent: true });
|
||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to load xray config');
|
||||
if (typeof msg.obj !== 'string') throw new Error('Malformed xray config response: expected string');
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(msg.obj);
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
throw new Error(`Malformed xray config response: ${err.message}`, { cause: e });
|
||||
}
|
||||
const result = XrayConfigPayloadSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
console.warn('[zod] xray/ config payload failed validation', result.error.issues);
|
||||
return parsed as XrayConfigPayload;
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async function fetchOutboundsTraffic(): Promise<OutboundTrafficRow[]> {
|
||||
const msg = await HttpUtil.get('/panel/xray/getOutboundsTraffic', undefined, { silent: true });
|
||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch outbounds traffic');
|
||||
const validated = parseMsg(msg, OutboundTrafficListSchema, 'xray/getOutboundsTraffic');
|
||||
return Array.isArray(validated.obj) ? validated.obj : [];
|
||||
}
|
||||
|
||||
export function useXraySetting(): UseXraySettingResult {
|
||||
const [fetched, setFetched] = useState(false);
|
||||
const [spinning, setSpinning] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const configQuery = useQuery({
|
||||
queryKey: keys.xray.config(),
|
||||
queryFn: fetchXrayConfig,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const trafficQuery = useQuery({
|
||||
queryKey: keys.xray.outboundsTraffic(),
|
||||
queryFn: fetchOutboundsTraffic,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const [saveDisabled, setSaveDisabled] = useState(true);
|
||||
const [fetchError, setFetchError] = useState('');
|
||||
const [xraySetting, setXraySettingState] = useState('');
|
||||
const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null);
|
||||
const [outboundTestUrl, setOutboundTestUrlState] = useState('https://www.google.com/generate_204');
|
||||
const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
|
||||
const [inboundTags, setInboundTags] = useState<string[]>([]);
|
||||
const [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
|
||||
const [restartResult, setRestartResult] = useState('');
|
||||
const [outboundsTraffic, setOutboundsTraffic] = useState<OutboundTrafficRow[]>([]);
|
||||
const [outboundTestStates, setOutboundTestStates] = useState<Record<number, OutboundTestState>>({});
|
||||
const [testingAll, setTestingAll] = useState(false);
|
||||
|
||||
@@ -108,6 +126,28 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
outboundTestUrlRef.current = outboundTestUrl;
|
||||
templateSettingsRef.current = templateSettings;
|
||||
|
||||
// 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;
|
||||
setInboundTags(obj.inboundTags || []);
|
||||
setClientReverseTags(obj.clientReverseTags || []);
|
||||
const nextUrl = obj.outboundTestUrl || DEFAULT_TEST_URL;
|
||||
setOutboundTestUrlState(nextUrl);
|
||||
oldOutboundTestUrlRef.current = nextUrl;
|
||||
setSaveDisabled(true);
|
||||
}, [configQuery.data]);
|
||||
|
||||
const fetched = configQuery.data !== undefined || configQuery.isError;
|
||||
const fetchError = configQuery.error ? (configQuery.error as Error).message : '';
|
||||
|
||||
const setXraySetting = useCallback((next: string) => {
|
||||
setXraySettingState(next);
|
||||
if (syncingRef.current) return;
|
||||
@@ -142,63 +182,63 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
}, []);
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
setFetchError('');
|
||||
const msg = await HttpUtil.post('/panel/xray/');
|
||||
if (!msg?.success) {
|
||||
setFetchError(msg?.msg || 'Failed to load xray config');
|
||||
setFetched(true);
|
||||
return;
|
||||
}
|
||||
let obj;
|
||||
try {
|
||||
obj = JSON.parse(msg.obj);
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
setFetchError(`Malformed xray config response: ${err?.message || String(err)}`);
|
||||
setFetched(true);
|
||||
return;
|
||||
}
|
||||
const pretty = JSON.stringify(obj.xraySetting, null, 2);
|
||||
syncingRef.current = true;
|
||||
setXraySettingState(pretty);
|
||||
setTemplateSettingsState(obj.xraySetting);
|
||||
oldXraySettingRef.current = pretty;
|
||||
syncingRef.current = false;
|
||||
setInboundTags(obj.inboundTags || []);
|
||||
setClientReverseTags(obj.clientReverseTags || []);
|
||||
const nextUrl = obj.outboundTestUrl || 'https://www.google.com/generate_204';
|
||||
setOutboundTestUrlState(nextUrl);
|
||||
oldOutboundTestUrlRef.current = nextUrl;
|
||||
setFetched(true);
|
||||
setSaveDisabled(true);
|
||||
}, []);
|
||||
await queryClient.invalidateQueries({ queryKey: keys.xray.config() });
|
||||
}, [queryClient]);
|
||||
|
||||
const saveAll = useCallback(async () => {
|
||||
setSpinning(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/xray/update', {
|
||||
const fetchOutboundsTrafficCb = useCallback(async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
|
||||
}, [queryClient]);
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async () =>
|
||||
HttpUtil.post('/panel/xray/update', {
|
||||
xraySetting: xraySettingRef.current,
|
||||
outboundTestUrl: outboundTestUrlRef.current || 'https://www.google.com/generate_204',
|
||||
});
|
||||
if (msg?.success) await fetchAll();
|
||||
} finally {
|
||||
setSpinning(false);
|
||||
}
|
||||
}, [fetchAll]);
|
||||
outboundTestUrl: outboundTestUrlRef.current || DEFAULT_TEST_URL,
|
||||
}),
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.config() });
|
||||
},
|
||||
});
|
||||
|
||||
const fetchOutboundsTraffic = useCallback(async () => {
|
||||
const msg = await HttpUtil.get('/panel/xray/getOutboundsTraffic');
|
||||
if (msg?.success) setOutboundsTraffic(msg.obj || []);
|
||||
}, []);
|
||||
const resetTrafficMut = useMutation({
|
||||
mutationFn: (tag: string) =>
|
||||
HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag }),
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
|
||||
},
|
||||
});
|
||||
|
||||
const resetOutboundsTraffic = useCallback(async (tag: string) => {
|
||||
const msg = await HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag });
|
||||
if (msg?.success) await fetchOutboundsTraffic();
|
||||
}, [fetchOutboundsTraffic]);
|
||||
const restartMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const msg = await HttpUtil.post('/panel/api/server/restartXrayService');
|
||||
if (!msg?.success) return msg;
|
||||
await PromiseUtil.sleep(500);
|
||||
const r = await HttpUtil.get('/panel/xray/getXrayResult');
|
||||
const validated = parseMsg(r, z.string(), 'xray/getXrayResult');
|
||||
if (validated?.success) setRestartResult(validated.obj || '');
|
||||
return msg;
|
||||
},
|
||||
});
|
||||
|
||||
const applyOutboundsEvent = useCallback((payload: unknown) => {
|
||||
if (Array.isArray(payload)) setOutboundsTraffic(payload as OutboundTrafficRow[]);
|
||||
}, []);
|
||||
const resetDefaultMut = useMutation({
|
||||
mutationFn: async (): Promise<Msg<XraySettingsValue>> => {
|
||||
const raw = await HttpUtil.get('/panel/setting/getDefaultJsonConfig');
|
||||
return parseMsg(raw, XraySettingsValueSchema, 'setting/getDefaultJsonConfig');
|
||||
},
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success && msg.obj) {
|
||||
const cloned = JSON.parse(JSON.stringify(msg.obj));
|
||||
setTemplateSettings(cloned);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const saveAll = useCallback(async () => { await saveMut.mutateAsync(); }, [saveMut]);
|
||||
const resetOutboundsTraffic = useCallback(async (tag: string) => { await resetTrafficMut.mutateAsync(tag); }, [resetTrafficMut]);
|
||||
const restartXray = useCallback(async () => { await restartMut.mutateAsync(); }, [restartMut]);
|
||||
const resetToDefault = useCallback(async () => { await resetDefaultMut.mutateAsync(); }, [resetDefaultMut]);
|
||||
|
||||
const spinning = saveMut.isPending || restartMut.isPending || resetDefaultMut.isPending;
|
||||
|
||||
const testOutbound = useCallback(
|
||||
async (index: number, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
|
||||
@@ -208,12 +248,13 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
[index]: { testing: true, result: null, mode },
|
||||
}));
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/xray/testOutbound', {
|
||||
const raw = await HttpUtil.post('/panel/xray/testOutbound', {
|
||||
outbound: JSON.stringify(outbound),
|
||||
allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
|
||||
mode,
|
||||
});
|
||||
if (msg?.success) {
|
||||
const msg = parseMsg(raw, OutboundTestResultSchema, 'xray/testOutbound');
|
||||
if (msg?.success && msg.obj) {
|
||||
setOutboundTestStates((prev) => ({
|
||||
...prev,
|
||||
[index]: { testing: false, result: msg.obj },
|
||||
@@ -273,43 +314,16 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
}
|
||||
}, [testingAll, testOutbound]);
|
||||
|
||||
const resetToDefault = useCallback(async () => {
|
||||
setSpinning(true);
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/setting/getDefaultJsonConfig');
|
||||
if (msg?.success) {
|
||||
const cloned = JSON.parse(JSON.stringify(msg.obj));
|
||||
setTemplateSettings(cloned);
|
||||
}
|
||||
} finally {
|
||||
setSpinning(false);
|
||||
}
|
||||
}, [setTemplateSettings]);
|
||||
|
||||
const restartXray = useCallback(async () => {
|
||||
setSpinning(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/api/server/restartXrayService');
|
||||
if (msg?.success) {
|
||||
await PromiseUtil.sleep(500);
|
||||
const r = await HttpUtil.get('/panel/xray/getXrayResult');
|
||||
if (r?.success) setRestartResult(r.obj || '');
|
||||
}
|
||||
} finally {
|
||||
setSpinning(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAll();
|
||||
fetchOutboundsTraffic();
|
||||
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);
|
||||
}, [fetchAll, fetchOutboundsTraffic]);
|
||||
}, []);
|
||||
|
||||
const outboundsTraffic = useMemo(() => trafficQuery.data ?? [], [trafficQuery.data]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
@@ -330,9 +344,8 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
outboundTestStates,
|
||||
testingAll,
|
||||
fetchAll,
|
||||
fetchOutboundsTraffic,
|
||||
fetchOutboundsTraffic: fetchOutboundsTrafficCb,
|
||||
resetOutboundsTraffic,
|
||||
applyOutboundsEvent,
|
||||
testOutbound,
|
||||
testAllOutbounds,
|
||||
saveAll,
|
||||
@@ -357,9 +370,8 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
outboundTestStates,
|
||||
testingAll,
|
||||
fetchAll,
|
||||
fetchOutboundsTraffic,
|
||||
fetchOutboundsTrafficCb,
|
||||
resetOutboundsTraffic,
|
||||
applyOutboundsEvent,
|
||||
testOutbound,
|
||||
testAllOutbounds,
|
||||
saveAll,
|
||||
|
||||
10
frontend/src/layouts/PanelLayout.tsx
Normal file
10
frontend/src/layouts/PanelLayout.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
import { useWebSocketBridge } from '@/api/websocketBridge';
|
||||
import { usePageTitle } from '@/hooks/usePageTitle';
|
||||
|
||||
export default function PanelLayout() {
|
||||
useWebSocketBridge();
|
||||
usePageTitle();
|
||||
return <Outlet />;
|
||||
}
|
||||
78
frontend/src/lib/xray/headers.ts
Normal file
78
frontend/src/lib/xray/headers.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
// Pure helpers for header-shape conversion between the panel's internal
|
||||
// HeaderEntry[] form and Xray's V2-style header map. Extracted from
|
||||
// XrayCommonClass.toHeaders / .toV2Headers so callers can stop relying on
|
||||
// the class hierarchy. Behavior is byte-equivalent to the legacy methods —
|
||||
// the shadow tests in src/test/headers.test.ts pin that.
|
||||
|
||||
export interface HeaderEntry {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export type V2HeaderMap = Record<string, string | string[]>;
|
||||
|
||||
// Expand a V2-style header map into the panel's flat HeaderEntry[]. A
|
||||
// header whose value is an array yields one entry per item, preserving
|
||||
// order; a string value yields a single entry. Non-object inputs (null,
|
||||
// undefined, primitives) yield [].
|
||||
export function toHeaders(v2Headers: unknown): HeaderEntry[] {
|
||||
const out: HeaderEntry[] = [];
|
||||
if (!v2Headers || typeof v2Headers !== 'object') return out;
|
||||
const map = v2Headers as Record<string, unknown>;
|
||||
for (const key of Object.keys(map)) {
|
||||
const values = map[key];
|
||||
if (typeof values === 'string') {
|
||||
out.push({ name: key, value: values });
|
||||
} else if (Array.isArray(values)) {
|
||||
for (const v of values) {
|
||||
if (typeof v === 'string') out.push({ name: key, value: v });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Case-insensitive lookup against a wire-shape header map. The legacy
|
||||
// `Inbound.getHeader(obj, name)` iterated `obj.headers` as a HeaderEntry[];
|
||||
// this version reads the Record map our Zod schemas store. For repeated
|
||||
// header names (string[] in TCP/WS-style maps) the first value wins —
|
||||
// matches the legacy iteration order. Returns '' when missing, mirroring
|
||||
// the legacy fallback so link-generator call sites stay simple.
|
||||
export function getHeaderValue(
|
||||
headers: Readonly<Record<string, string | string[]>> | undefined | null,
|
||||
name: string,
|
||||
): string {
|
||||
if (!headers || typeof headers !== 'object') return '';
|
||||
const lower = name.toLowerCase();
|
||||
for (const key of Object.keys(headers)) {
|
||||
if (key.toLowerCase() !== lower) continue;
|
||||
const value = headers[key];
|
||||
if (typeof value === 'string') return value;
|
||||
if (Array.isArray(value)) return value[0] ?? '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Collapse a HeaderEntry[] back into a V2-style header map. When `arr` is
|
||||
// true (the default — matches Xray's TCP/WS/HTTP request/response shape),
|
||||
// duplicate header names accumulate into a string[]. When false (used for
|
||||
// WS/HTTPUpgrade/xHTTP top-level headers, sockopt portMap, etc.), the
|
||||
// last value wins. Entries with empty name or value are skipped — same as
|
||||
// the legacy ObjectUtil.isEmpty() filter.
|
||||
export function toV2Headers(headers: HeaderEntry[], arr: boolean = true): V2HeaderMap {
|
||||
const out: V2HeaderMap = {};
|
||||
for (const { name, value } of headers) {
|
||||
if (name == null || name === '' || value == null || value === '') continue;
|
||||
if (!(name in out)) {
|
||||
out[name] = arr ? [value] : value;
|
||||
continue;
|
||||
}
|
||||
const existing = out[name];
|
||||
if (arr && Array.isArray(existing)) {
|
||||
existing.push(value);
|
||||
} else {
|
||||
out[name] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
280
frontend/src/lib/xray/inbound-defaults.ts
Normal file
280
frontend/src/lib/xray/inbound-defaults.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import { RandomUtil, Wireguard } from '@/utils';
|
||||
|
||||
import type { HttpInboundSettings } from '@/schemas/protocols/inbound/http';
|
||||
import type { HysteriaClient, HysteriaInboundSettings } from '@/schemas/protocols/inbound/hysteria';
|
||||
import type { MixedInboundSettings } from '@/schemas/protocols/inbound/mixed';
|
||||
import type { ShadowsocksClient, ShadowsocksInboundSettings } from '@/schemas/protocols/inbound/shadowsocks';
|
||||
import type { TrojanClient, TrojanInboundSettings } from '@/schemas/protocols/inbound/trojan';
|
||||
import type { TunInboundSettings } from '@/schemas/protocols/inbound/tun';
|
||||
import type { TunnelInboundSettings } from '@/schemas/protocols/inbound/tunnel';
|
||||
import type { VlessClient, VlessInboundSettings } from '@/schemas/protocols/inbound/vless';
|
||||
import type { VmessClient, VmessInboundSettings } from '@/schemas/protocols/inbound/vmess';
|
||||
import type { WireguardInboundSettings } from '@/schemas/protocols/inbound/wireguard';
|
||||
|
||||
// Plain-object factories for protocol clients. Each returns a Zod-parsable
|
||||
// object matching the wire shape. Random fields (id, password, auth,
|
||||
// email, subId) call RandomUtil at invocation time — pass them in
|
||||
// `overrides` for deterministic tests or for forms that pre-seed values.
|
||||
//
|
||||
// These replace the legacy `new Inbound.<Settings>.<Client>()` constructors
|
||||
// and the Inbound.ClientBase machinery. Callers no longer carry the
|
||||
// XrayCommonClass dependency once the swap lands.
|
||||
|
||||
interface ClientBaseSeed {
|
||||
email?: string;
|
||||
subId?: string;
|
||||
limitIp?: number;
|
||||
totalGB?: number;
|
||||
expiryTime?: number;
|
||||
enable?: boolean;
|
||||
tgId?: number;
|
||||
comment?: string;
|
||||
reset?: number;
|
||||
}
|
||||
|
||||
interface ClientBase {
|
||||
email: string;
|
||||
limitIp: number;
|
||||
totalGB: number;
|
||||
expiryTime: number;
|
||||
enable: boolean;
|
||||
tgId: number;
|
||||
subId: string;
|
||||
comment: string;
|
||||
reset: number;
|
||||
}
|
||||
|
||||
function clientBase(seed: ClientBaseSeed = {}): ClientBase {
|
||||
return {
|
||||
email: seed.email ?? RandomUtil.randomLowerAndNum(10),
|
||||
limitIp: seed.limitIp ?? 0,
|
||||
totalGB: seed.totalGB ?? 0,
|
||||
expiryTime: seed.expiryTime ?? 0,
|
||||
enable: seed.enable ?? true,
|
||||
tgId: seed.tgId ?? 0,
|
||||
subId: seed.subId ?? RandomUtil.randomLowerAndNum(16),
|
||||
comment: seed.comment ?? '',
|
||||
reset: seed.reset ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export interface VlessClientSeed extends ClientBaseSeed {
|
||||
id?: string;
|
||||
flow?: VlessClient['flow'];
|
||||
}
|
||||
|
||||
export function createDefaultVlessClient(seed: VlessClientSeed = {}): VlessClient {
|
||||
return {
|
||||
id: seed.id ?? RandomUtil.randomUUID(),
|
||||
flow: seed.flow ?? '',
|
||||
...clientBase(seed),
|
||||
};
|
||||
}
|
||||
|
||||
export interface VmessClientSeed extends ClientBaseSeed {
|
||||
id?: string;
|
||||
security?: VmessClient['security'];
|
||||
}
|
||||
|
||||
export function createDefaultVmessClient(seed: VmessClientSeed = {}): VmessClient {
|
||||
return {
|
||||
id: seed.id ?? RandomUtil.randomUUID(),
|
||||
security: seed.security ?? 'auto',
|
||||
...clientBase(seed),
|
||||
};
|
||||
}
|
||||
|
||||
export interface TrojanClientSeed extends ClientBaseSeed {
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export function createDefaultTrojanClient(seed: TrojanClientSeed = {}): TrojanClient {
|
||||
return {
|
||||
password: seed.password ?? RandomUtil.randomSeq(10),
|
||||
...clientBase(seed),
|
||||
};
|
||||
}
|
||||
|
||||
export interface ShadowsocksClientSeed extends ClientBaseSeed {
|
||||
method?: string;
|
||||
password?: string;
|
||||
ssMethod?: string;
|
||||
}
|
||||
|
||||
// Shadowsocks clients ship with an empty `method` on single-user inbounds
|
||||
// (the parent inbound's method is authoritative); only 2022-blake3 multi-
|
||||
// user inbounds use the per-client method. Callers pass `ssMethod` to seed
|
||||
// a method-specific password length when creating a multi-user client.
|
||||
export function createDefaultShadowsocksClient(seed: ShadowsocksClientSeed = {}): ShadowsocksClient {
|
||||
const method = seed.method ?? '';
|
||||
const password = seed.password ?? RandomUtil.randomShadowsocksPassword(seed.ssMethod ?? '2022-blake3-aes-256-gcm');
|
||||
return {
|
||||
method,
|
||||
password,
|
||||
...clientBase(seed),
|
||||
};
|
||||
}
|
||||
|
||||
export interface HysteriaClientSeed extends ClientBaseSeed {
|
||||
auth?: string;
|
||||
}
|
||||
|
||||
export function createDefaultHysteriaClient(seed: HysteriaClientSeed = {}): HysteriaClient {
|
||||
return {
|
||||
auth: seed.auth ?? RandomUtil.randomSeq(10),
|
||||
...clientBase(seed),
|
||||
};
|
||||
}
|
||||
|
||||
// Inbound-settings factories. Each returns a Zod-parsable wire-shape with
|
||||
// schema defaults already applied — no class instance, no XrayCommonClass.
|
||||
// Callers (form modals via Step 4, InboundsPage clone via Step 5) call
|
||||
// these instead of the legacy `Inbound.Settings.getSettings(protocol)`.
|
||||
|
||||
export function createDefaultVlessInboundSettings(): VlessInboundSettings {
|
||||
return {
|
||||
clients: [],
|
||||
decryption: 'none',
|
||||
encryption: 'none',
|
||||
fallbacks: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function createDefaultVmessInboundSettings(): VmessInboundSettings {
|
||||
return { clients: [] };
|
||||
}
|
||||
|
||||
export function createDefaultTrojanInboundSettings(): TrojanInboundSettings {
|
||||
return { clients: [], fallbacks: [] };
|
||||
}
|
||||
|
||||
export interface ShadowsocksInboundSeed {
|
||||
method?: ShadowsocksInboundSettings['method'];
|
||||
password?: string;
|
||||
network?: ShadowsocksInboundSettings['network'];
|
||||
ivCheck?: boolean;
|
||||
}
|
||||
|
||||
export function createDefaultShadowsocksInboundSettings(
|
||||
seed: ShadowsocksInboundSeed = {},
|
||||
): ShadowsocksInboundSettings {
|
||||
const method = seed.method ?? '2022-blake3-aes-256-gcm';
|
||||
return {
|
||||
method,
|
||||
password: seed.password ?? RandomUtil.randomShadowsocksPassword(method),
|
||||
network: seed.network ?? 'tcp',
|
||||
clients: [],
|
||||
ivCheck: seed.ivCheck ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
// Hysteria v1 defaults still emit `version: 2` to match the legacy panel
|
||||
// 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;
|
||||
}
|
||||
|
||||
export function createDefaultHysteriaInboundSettings(
|
||||
seed: HysteriaInboundSeed = {},
|
||||
): HysteriaInboundSettings {
|
||||
return {
|
||||
version: seed.version ?? 2,
|
||||
clients: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function createDefaultHttpInboundSettings(): HttpInboundSettings {
|
||||
return {
|
||||
accounts: [{ user: RandomUtil.randomLowerAndNum(8), pass: RandomUtil.randomLowerAndNum(12) }],
|
||||
allowTransparent: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function createDefaultMixedInboundSettings(): MixedInboundSettings {
|
||||
return {
|
||||
auth: 'password',
|
||||
accounts: [{ user: RandomUtil.randomLowerAndNum(8), pass: RandomUtil.randomLowerAndNum(12) }],
|
||||
udp: false,
|
||||
ip: '127.0.0.1',
|
||||
};
|
||||
}
|
||||
|
||||
export function createDefaultTunnelInboundSettings(): TunnelInboundSettings {
|
||||
return {
|
||||
portMap: {},
|
||||
allowedNetwork: 'tcp,udp',
|
||||
followRedirect: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function createDefaultTunInboundSettings(): TunInboundSettings {
|
||||
return {
|
||||
name: 'xray0',
|
||||
mtu: 1500,
|
||||
gateway: [],
|
||||
dns: [],
|
||||
userLevel: 0,
|
||||
autoSystemRoutingTable: [],
|
||||
autoOutboundsInterface: 'auto',
|
||||
};
|
||||
}
|
||||
|
||||
export interface WireguardInboundSeed {
|
||||
mtu?: number;
|
||||
secretKey?: string;
|
||||
noKernelTun?: boolean;
|
||||
peerPrivateKey?: string;
|
||||
}
|
||||
|
||||
export function createDefaultWireguardInboundSettings(
|
||||
seed: WireguardInboundSeed = {},
|
||||
): WireguardInboundSettings {
|
||||
const peerKp = seed.peerPrivateKey
|
||||
? { privateKey: seed.peerPrivateKey, publicKey: Wireguard.generateKeypair(seed.peerPrivateKey).publicKey }
|
||||
: Wireguard.generateKeypair();
|
||||
return {
|
||||
mtu: seed.mtu ?? 1420,
|
||||
secretKey: seed.secretKey ?? Wireguard.generateKeypair().privateKey,
|
||||
peers: [{
|
||||
privateKey: peerKp.privateKey,
|
||||
publicKey: peerKp.publicKey,
|
||||
allowedIPs: ['10.0.0.2/32'],
|
||||
keepAlive: 0,
|
||||
}],
|
||||
noKernelTun: seed.noKernelTun ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
// Protocol-aware dispatch over every inbound-settings factory. Mirrors
|
||||
// the legacy `Inbound.Settings.getSettings(protocol)` dispatcher, but
|
||||
// returns a plain Zod-parsable object instead of a class instance.
|
||||
// Callers swapping off the class hierarchy use this in place of
|
||||
// `getSettings(p)` + `.toJson()`.
|
||||
export type AnyInboundSettings =
|
||||
| VlessInboundSettings
|
||||
| VmessInboundSettings
|
||||
| TrojanInboundSettings
|
||||
| ShadowsocksInboundSettings
|
||||
| HysteriaInboundSettings
|
||||
| HttpInboundSettings
|
||||
| MixedInboundSettings
|
||||
| TunInboundSettings
|
||||
| TunnelInboundSettings
|
||||
| WireguardInboundSettings;
|
||||
|
||||
export function createDefaultInboundSettings(protocol: string): AnyInboundSettings | null {
|
||||
switch (protocol) {
|
||||
case 'vless': return createDefaultVlessInboundSettings();
|
||||
case 'vmess': return createDefaultVmessInboundSettings();
|
||||
case 'trojan': return createDefaultTrojanInboundSettings();
|
||||
case 'shadowsocks': return createDefaultShadowsocksInboundSettings();
|
||||
case 'hysteria': return createDefaultHysteriaInboundSettings();
|
||||
case 'http': return createDefaultHttpInboundSettings();
|
||||
case 'mixed': return createDefaultMixedInboundSettings();
|
||||
case 'tunnel': return createDefaultTunnelInboundSettings();
|
||||
case 'tun': return createDefaultTunInboundSettings();
|
||||
case 'wireguard': return createDefaultWireguardInboundSettings();
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
306
frontend/src/lib/xray/inbound-form-adapter.ts
Normal file
306
frontend/src/lib/xray/inbound-form-adapter.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import type { InboundFormValues, TrafficReset } from '@/schemas/forms/inbound-form';
|
||||
import type { InboundSettings } from '@/schemas/protocols/inbound';
|
||||
import {
|
||||
HysteriaClientSchema,
|
||||
ShadowsocksClientSchema,
|
||||
TrojanClientSchema,
|
||||
VlessClientSchema,
|
||||
VmessClientSchema,
|
||||
} from '@/schemas/protocols/inbound';
|
||||
import type { StreamSettings } from '@/schemas/api/inbound';
|
||||
import type { Sniffing } from '@/schemas/primitives';
|
||||
import type { z } from 'zod';
|
||||
|
||||
// Plain-data adapter between the panel's stored inbound row shape and
|
||||
// the typed InboundFormValues that Form.useForm<T> carries inside
|
||||
// InboundFormModal. No dependency on the legacy Inbound/DBInbound
|
||||
// classes — the modal hands the raw row in, takes typed values out, and
|
||||
// on submit calls formValuesToWirePayload() to get a payload ready to
|
||||
// POST to /panel/api/inbounds/add or /update/:id.
|
||||
|
||||
export interface RawInboundRow {
|
||||
port?: number;
|
||||
listen?: string;
|
||||
protocol?: string;
|
||||
tag?: string;
|
||||
settings?: unknown;
|
||||
streamSettings?: unknown;
|
||||
sniffing?: unknown;
|
||||
up?: number;
|
||||
down?: number;
|
||||
total?: number;
|
||||
remark?: string;
|
||||
enable?: boolean;
|
||||
expiryTime?: number;
|
||||
trafficReset?: string;
|
||||
lastTrafficResetTime?: number;
|
||||
nodeId?: number | null;
|
||||
clientStats?: unknown;
|
||||
}
|
||||
|
||||
// The wire payload — settings/streamSettings/sniffing arrive as JSON
|
||||
// strings, mirroring what the Go endpoints expect (xray-core wants the
|
||||
// nested config slices as strings to round-trip through its loader).
|
||||
export interface WireInboundPayload {
|
||||
up: number;
|
||||
down: number;
|
||||
total: number;
|
||||
remark: string;
|
||||
enable: boolean;
|
||||
expiryTime: number;
|
||||
trafficReset: TrafficReset;
|
||||
lastTrafficResetTime: number;
|
||||
listen: string;
|
||||
port: number;
|
||||
protocol: string;
|
||||
settings: string;
|
||||
streamSettings: string;
|
||||
sniffing: string;
|
||||
tag: string;
|
||||
clientStats?: unknown;
|
||||
nodeId?: number;
|
||||
}
|
||||
|
||||
function coerceJsonObject(value: unknown): Record<string, unknown> {
|
||||
if (value == null) return {};
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
if (typeof value !== 'string') return {};
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === '') return {};
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const TRAFFIC_RESETS: TrafficReset[] = ['never', 'hourly', 'daily', 'weekly', 'monthly'];
|
||||
|
||||
function coerceTrafficReset(v: unknown): TrafficReset {
|
||||
return typeof v === 'string' && (TRAFFIC_RESETS as string[]).includes(v)
|
||||
? (v as TrafficReset)
|
||||
: 'never';
|
||||
}
|
||||
|
||||
// Network values that map to a required `${network}Settings` key in
|
||||
// NetworkSettingsSchema. Older saved inbounds may be missing the per-
|
||||
// network sub-object (the legacy panel sometimes emitted streamSettings
|
||||
// without it, and an earlier panel-side prune wrongly stripped empty
|
||||
// `tcpSettings: {}` out of the wire payload). Reseat an empty object
|
||||
// here so InboundFormSchema.safeParse doesn't blow up at edit time.
|
||||
const NETWORK_SETTINGS_KEY: Record<string, string> = {
|
||||
tcp: 'tcpSettings',
|
||||
kcp: 'kcpSettings',
|
||||
ws: 'wsSettings',
|
||||
grpc: 'grpcSettings',
|
||||
httpupgrade: 'httpupgradeSettings',
|
||||
xhttp: 'xhttpSettings',
|
||||
hysteria: 'hysteriaSettings',
|
||||
};
|
||||
|
||||
function healStreamNetworkKey(stream: Record<string, unknown>): void {
|
||||
const network = typeof stream.network === 'string' ? stream.network : '';
|
||||
const key = NETWORK_SETTINGS_KEY[network];
|
||||
if (!key) return;
|
||||
if (stream[key] == null || typeof stream[key] !== 'object') {
|
||||
stream[key] = {};
|
||||
}
|
||||
}
|
||||
|
||||
function tlsCerts(stream: Record<string, unknown>): Record<string, unknown>[] {
|
||||
const tls = stream.tlsSettings as { certificates?: unknown } | undefined;
|
||||
return Array.isArray(tls?.certificates) ? tls.certificates as Record<string, unknown>[] : [];
|
||||
}
|
||||
|
||||
function synthesizeTlsCertUseFile(stream: Record<string, unknown>): void {
|
||||
for (const c of tlsCerts(stream)) {
|
||||
if (typeof c.useFile === 'boolean') continue;
|
||||
const hasFile = !!c.certificateFile || !!c.keyFile;
|
||||
const hasInline =
|
||||
(Array.isArray(c.certificate) && c.certificate.length > 0) ||
|
||||
(Array.isArray(c.key) && c.key.length > 0);
|
||||
c.useFile = hasFile || !hasInline;
|
||||
}
|
||||
}
|
||||
|
||||
function stripTlsCertUseFile(stream: Record<string, unknown>): void {
|
||||
for (const c of tlsCerts(stream)) delete c.useFile;
|
||||
}
|
||||
|
||||
export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
|
||||
const protocol = (row.protocol || 'vless') as InboundSettings['protocol'];
|
||||
const settings = coerceJsonObject(row.settings) as InboundSettings['settings'];
|
||||
const rawStream = coerceJsonObject(row.streamSettings);
|
||||
const streamSettings = Object.keys(rawStream).length > 0
|
||||
? (rawStream as StreamSettings)
|
||||
: undefined;
|
||||
if (streamSettings) {
|
||||
healStreamNetworkKey(streamSettings as unknown as Record<string, unknown>);
|
||||
synthesizeTlsCertUseFile(streamSettings as unknown as Record<string, unknown>);
|
||||
}
|
||||
const sniffing = coerceJsonObject(row.sniffing) as unknown as Sniffing;
|
||||
|
||||
return {
|
||||
remark: row.remark ?? '',
|
||||
enable: row.enable ?? true,
|
||||
port: row.port ?? 0,
|
||||
listen: row.listen ?? '',
|
||||
tag: row.tag ?? '',
|
||||
expiryTime: row.expiryTime ?? 0,
|
||||
sniffing,
|
||||
streamSettings,
|
||||
up: row.up ?? 0,
|
||||
down: row.down ?? 0,
|
||||
total: row.total ?? 0,
|
||||
trafficReset: coerceTrafficReset(row.trafficReset),
|
||||
lastTrafficResetTime: row.lastTrafficResetTime ?? 0,
|
||||
nodeId: row.nodeId ?? null,
|
||||
protocol,
|
||||
settings,
|
||||
} as InboundFormValues;
|
||||
}
|
||||
|
||||
// Recursively strip undefined leaves from the wire payload. Empty arrays
|
||||
// and empty objects are PRESERVED — legacy XrayCommonClass.toJson() kept
|
||||
// shells like `tcpSettings: {}` so xray-core picks up its built-in
|
||||
// defaults, and stripping them led the FE to lose required-but-empty
|
||||
// arrays (vless clients, wireguard peers, etc.) which the Go side then
|
||||
// serialized back as `null`. Primitive values (including 0, false, '')
|
||||
// are kept verbatim.
|
||||
export function pruneEmpty(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(pruneEmpty);
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
const p = pruneEmpty(v);
|
||||
if (p === undefined) continue;
|
||||
out[k] = p;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Per-protocol client field whitelist — the Zod schemas in
|
||||
// schemas/protocols/inbound/<proto>.ts define which keys a given
|
||||
// protocol's clients accept on the wire. When a global client is created
|
||||
// the panel may persist cross-protocol fields on the same row (`auth` for
|
||||
// hysteria, `password` for trojan, `security` for vmess, etc.); rendering
|
||||
// those inside a vless inbound's settings.clients is confusing and rides
|
||||
// dead weight in the wire payload. Parsing through the protocol's schema
|
||||
// gives us the canonical projection.
|
||||
function clientSchemaForProtocol(protocol: string): z.ZodType | null {
|
||||
switch (protocol) {
|
||||
case 'vless': return VlessClientSchema;
|
||||
case 'vmess': return VmessClientSchema;
|
||||
case 'trojan': return TrojanClientSchema;
|
||||
case 'shadowsocks': return ShadowsocksClientSchema;
|
||||
case 'hysteria': return HysteriaClientSchema;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeClients(protocol: string, clients: unknown): unknown {
|
||||
const schema = clientSchemaForProtocol(protocol);
|
||||
if (!schema || !Array.isArray(clients)) return clients;
|
||||
return clients.map((c) => {
|
||||
const parsed = schema.safeParse(c);
|
||||
return parsed.success ? parsed.data : c;
|
||||
});
|
||||
}
|
||||
|
||||
// Sniffing normalizer matching the legacy Sniffing.toJson(): when
|
||||
// disabled the payload is the bare `{ enabled: false }` regardless of
|
||||
// what the form holds; when enabled, only non-default fields ride.
|
||||
export function normalizeSniffing(s: Sniffing | undefined): Record<string, unknown> {
|
||||
if (!s || !s.enabled) return { enabled: false };
|
||||
const out: Record<string, unknown> = {
|
||||
enabled: true,
|
||||
destOverride: s.destOverride,
|
||||
};
|
||||
if (s.metadataOnly) out.metadataOnly = true;
|
||||
if (s.routeOnly) out.routeOnly = true;
|
||||
if (s.ipsExcluded?.length) out.ipsExcluded = s.ipsExcluded;
|
||||
if (s.domainsExcluded?.length) out.domainsExcluded = s.domainsExcluded;
|
||||
return out;
|
||||
}
|
||||
|
||||
// Drops cosmetic empty-array keys that legacy XrayCommonClass.toJson()
|
||||
// explicitly skipped (fallbacks/finalmask). Mutates the pruned settings
|
||||
// objects in place; called AFTER pruneEmpty so we can lean on the
|
||||
// already-shallow shape.
|
||||
export function dropLegacyOptionalEmpties(
|
||||
settings: Record<string, unknown>,
|
||||
stream: Record<string, unknown> | undefined,
|
||||
): void {
|
||||
// VLESS/Trojan emit `fallbacks` only when non-empty.
|
||||
const fb = settings.fallbacks;
|
||||
if (Array.isArray(fb) && fb.length === 0) delete settings.fallbacks;
|
||||
|
||||
if (stream) {
|
||||
// StreamSettings emits `finalmask` only when at least one transport
|
||||
// mask exists (legacy `hasFinalMask`). Drop the whole block when all
|
||||
// sub-fields are empty; otherwise drop only the empty sub-arrays so
|
||||
// the wire payload doesn't carry a stray `"tcp": []` next to a
|
||||
// populated UDP mask list (and vice versa).
|
||||
const fm = stream.finalmask as { tcp?: unknown[]; udp?: unknown[]; quicParams?: unknown } | undefined;
|
||||
if (fm && typeof fm === 'object') {
|
||||
const hasTcp = Array.isArray(fm.tcp) && fm.tcp.length > 0;
|
||||
const hasUdp = Array.isArray(fm.udp) && fm.udp.length > 0;
|
||||
const hasQuic = fm.quicParams != null;
|
||||
if (!hasTcp && !hasUdp && !hasQuic) {
|
||||
delete stream.finalmask;
|
||||
} else {
|
||||
if (!hasTcp) delete fm.tcp;
|
||||
if (!hasUdp) delete fm.udp;
|
||||
}
|
||||
}
|
||||
|
||||
// Hysteria's per-client auth lives in settings.clients[*].auth; the
|
||||
// streamSettings.hysteriaSettings.auth slot is a holdover from older
|
||||
// hysteria builds and serves no purpose on the inbound side, so an
|
||||
// empty value shouldn't ride along in the JSON payload.
|
||||
const hs = stream.hysteriaSettings as { auth?: string } | undefined;
|
||||
if (hs && typeof hs === 'object' && (hs.auth === '' || hs.auth == null)) {
|
||||
delete hs.auth;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function formValuesToWirePayload(values: InboundFormValues): WireInboundPayload {
|
||||
const settingsPruned = (pruneEmpty(values.settings ?? {}) ?? {}) as Record<string, unknown>;
|
||||
if (Array.isArray(settingsPruned.clients)) {
|
||||
settingsPruned.clients = normalizeClients(values.protocol, settingsPruned.clients);
|
||||
}
|
||||
const streamPruned = values.streamSettings
|
||||
? ((pruneEmpty(values.streamSettings) ?? {}) as Record<string, unknown>)
|
||||
: undefined;
|
||||
if (streamPruned) stripTlsCertUseFile(streamPruned);
|
||||
dropLegacyOptionalEmpties(settingsPruned, streamPruned);
|
||||
const payload: WireInboundPayload = {
|
||||
up: values.up,
|
||||
down: values.down,
|
||||
total: values.total,
|
||||
remark: values.remark,
|
||||
enable: values.enable,
|
||||
expiryTime: values.expiryTime,
|
||||
trafficReset: values.trafficReset,
|
||||
lastTrafficResetTime: values.lastTrafficResetTime,
|
||||
listen: values.listen,
|
||||
port: values.port,
|
||||
protocol: values.protocol,
|
||||
settings: JSON.stringify(settingsPruned),
|
||||
streamSettings: streamPruned ? JSON.stringify(streamPruned) : '',
|
||||
sniffing: JSON.stringify(normalizeSniffing(values.sniffing)),
|
||||
tag: values.tag,
|
||||
};
|
||||
if (values.nodeId != null) payload.nodeId = values.nodeId;
|
||||
return payload;
|
||||
}
|
||||
55
frontend/src/lib/xray/inbound-from-db.ts
Normal file
55
frontend/src/lib/xray/inbound-from-db.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { Inbound } from '@/schemas/api/inbound';
|
||||
import { InboundSettingsSchema } from '@/schemas/protocols/inbound';
|
||||
import { coerceInboundJsonField } from '@/models/dbinbound';
|
||||
|
||||
import { fillStreamDefaults } from './stream-defaults';
|
||||
|
||||
export interface DbInboundLike {
|
||||
port: number;
|
||||
listen: string;
|
||||
protocol: string;
|
||||
settings: unknown;
|
||||
streamSettings: unknown;
|
||||
sniffing: unknown;
|
||||
tag?: string;
|
||||
remark?: string;
|
||||
enable?: boolean;
|
||||
expiryTime?: number;
|
||||
up?: number;
|
||||
down?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
function fillProtocolSettingsDefaults(protocol: string, settings: Record<string, unknown>): Record<string, unknown> {
|
||||
const parsed = InboundSettingsSchema.safeParse({ protocol, settings });
|
||||
if (parsed.success) {
|
||||
const tagged = parsed.data as { settings: Record<string, unknown> };
|
||||
return { ...tagged.settings };
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
export function inboundFromDb(raw: DbInboundLike): Inbound {
|
||||
const rawSettings = coerceInboundJsonField(raw.settings);
|
||||
const settings = fillProtocolSettingsDefaults(raw.protocol, rawSettings);
|
||||
const streamSettingsRaw = coerceInboundJsonField(raw.streamSettings);
|
||||
const sniffing = coerceInboundJsonField(raw.sniffing);
|
||||
const streamSettings = Object.keys(streamSettingsRaw).length === 0
|
||||
? streamSettingsRaw
|
||||
: fillStreamDefaults(streamSettingsRaw);
|
||||
return {
|
||||
protocol: raw.protocol,
|
||||
port: raw.port,
|
||||
listen: raw.listen ?? '',
|
||||
tag: raw.tag ?? '',
|
||||
remark: raw.remark ?? '',
|
||||
enable: raw.enable ?? true,
|
||||
expiryTime: raw.expiryTime ?? 0,
|
||||
up: raw.up ?? 0,
|
||||
down: raw.down ?? 0,
|
||||
total: raw.total ?? 0,
|
||||
settings,
|
||||
streamSettings,
|
||||
sniffing,
|
||||
} as unknown as Inbound;
|
||||
}
|
||||
946
frontend/src/lib/xray/inbound-link.ts
Normal file
946
frontend/src/lib/xray/inbound-link.ts
Normal file
@@ -0,0 +1,946 @@
|
||||
import { Base64, Wireguard } from '@/utils';
|
||||
|
||||
import type { Inbound } from '@/schemas/api/inbound';
|
||||
import type { VlessClient } from '@/schemas/protocols/inbound/vless';
|
||||
import type { VmessSecurity } from '@/schemas/protocols/inbound/vmess';
|
||||
import type {
|
||||
WireguardInboundPeer,
|
||||
WireguardInboundSettings,
|
||||
} from '@/schemas/protocols/inbound/wireguard';
|
||||
import type { ExternalProxyEntry } from '@/schemas/protocols/stream/external-proxy';
|
||||
import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalmask';
|
||||
import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp';
|
||||
|
||||
import { getHeaderValue } from './headers';
|
||||
|
||||
// Share-link generators. Each per-protocol fn takes a typed inbound plus
|
||||
// client overrides and returns a URL (or '' when the protocol doesn't
|
||||
// support shareable links). The helpers below were previously static
|
||||
// methods on the Inbound class; extracting them removes the
|
||||
// XrayCommonClass dependency and lets these run against Zod-parsed data
|
||||
// directly.
|
||||
|
||||
type ForceTls = 'same' | 'tls' | 'none';
|
||||
|
||||
// xHTTP headers ship as Record<string, string> on the wire (Zod schema)
|
||||
// rather than the legacy class's HeaderEntry[]. Lookup by case-folded key.
|
||||
function xhttpHostFallback(xhttp: XHttpStreamSettings | undefined): string {
|
||||
return getHeaderValue(xhttp?.headers, 'host');
|
||||
}
|
||||
|
||||
// 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.
|
||||
function buildXhttpExtra(xhttp: XHttpStreamSettings | undefined): Record<string, unknown> | null {
|
||||
if (!xhttp) return null;
|
||||
const extra: Record<string, unknown> = {};
|
||||
|
||||
if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
|
||||
extra.xPaddingBytes = xhttp.xPaddingBytes;
|
||||
}
|
||||
if (xhttp.xPaddingObfsMode === true) {
|
||||
extra.xPaddingObfsMode = true;
|
||||
for (const k of ['xPaddingKey', 'xPaddingHeader', 'xPaddingPlacement', 'xPaddingMethod'] as const) {
|
||||
const v = xhttp[k];
|
||||
if (typeof v === 'string' && v.length > 0) extra[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
const stringFields = [
|
||||
'uplinkHTTPMethod',
|
||||
'sessionPlacement',
|
||||
'sessionKey',
|
||||
'seqPlacement',
|
||||
'seqKey',
|
||||
'uplinkDataPlacement',
|
||||
'uplinkDataKey',
|
||||
'scMaxEachPostBytes',
|
||||
] as const;
|
||||
for (const k of stringFields) {
|
||||
const v = xhttp[k];
|
||||
if (typeof v === 'string' && v.length > 0) extra[k] = v;
|
||||
}
|
||||
|
||||
// Headers on the wire are a record; emit them as a map upstream's
|
||||
// SplitHTTPConfig.headers expects, dropping Host (already on the URL).
|
||||
if (xhttp.headers && Object.keys(xhttp.headers).length > 0) {
|
||||
const headersMap: Record<string, string> = {};
|
||||
for (const [name, value] of Object.entries(xhttp.headers)) {
|
||||
if (name.toLowerCase() === 'host') continue;
|
||||
headersMap[name] = value;
|
||||
}
|
||||
if (Object.keys(headersMap).length > 0) extra.headers = headersMap;
|
||||
}
|
||||
|
||||
return Object.keys(extra).length > 0 ? extra : null;
|
||||
}
|
||||
|
||||
function applyXhttpExtraToObj(xhttp: XHttpStreamSettings | undefined, obj: Record<string, unknown>): void {
|
||||
if (!xhttp) return;
|
||||
if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
|
||||
obj.x_padding_bytes = xhttp.xPaddingBytes;
|
||||
}
|
||||
const extra = buildXhttpExtra(xhttp);
|
||||
if (!extra) return;
|
||||
for (const [k, v] of Object.entries(extra)) obj[k] = v;
|
||||
}
|
||||
|
||||
// Recursively checks whether a finalmask payload has any non-empty
|
||||
// content. Empty arrays / empty objects / empty strings all return false;
|
||||
// any truthy primitive returns true. Used to decide whether the link
|
||||
// should carry an `fm` blob at all.
|
||||
function hasShareableFinalMaskValue(value: unknown): boolean {
|
||||
if (value == null) return false;
|
||||
if (Array.isArray(value)) return value.some(hasShareableFinalMaskValue);
|
||||
if (typeof value === 'object') {
|
||||
return Object.values(value as Record<string, unknown>).some(hasShareableFinalMaskValue);
|
||||
}
|
||||
if (typeof value === 'string') return value.length > 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
function serializeFinalMask(finalmask: FinalMaskStreamSettings | undefined): string {
|
||||
if (!finalmask) return '';
|
||||
return hasShareableFinalMaskValue(finalmask) ? JSON.stringify(finalmask) : '';
|
||||
}
|
||||
|
||||
function applyFinalMaskToObj(
|
||||
finalmask: FinalMaskStreamSettings | undefined,
|
||||
obj: Record<string, unknown>,
|
||||
): void {
|
||||
const payload = serializeFinalMask(finalmask);
|
||||
if (payload.length > 0) obj.fm = payload;
|
||||
}
|
||||
|
||||
function externalProxyAlpn(value: ExternalProxyEntry['alpn']): string {
|
||||
if (Array.isArray(value)) return value.filter(Boolean).join(',');
|
||||
return '';
|
||||
}
|
||||
|
||||
function applyExternalProxyTLSObj(
|
||||
externalProxy: ExternalProxyEntry | null | undefined,
|
||||
obj: Record<string, unknown>,
|
||||
security: string,
|
||||
): void {
|
||||
if (!externalProxy || security !== 'tls') return;
|
||||
const sni = externalProxy.sni && externalProxy.sni.length > 0 ? externalProxy.sni : externalProxy.dest;
|
||||
if (sni && sni.length > 0) obj.sni = sni;
|
||||
if (externalProxy.fingerprint && externalProxy.fingerprint.length > 0) obj.fp = externalProxy.fingerprint;
|
||||
const alpn = externalProxyAlpn(externalProxy.alpn);
|
||||
if (alpn.length > 0) obj.alpn = alpn;
|
||||
}
|
||||
|
||||
export interface GenVmessLinkInput {
|
||||
inbound: Inbound;
|
||||
address: string;
|
||||
port?: number;
|
||||
forceTls?: ForceTls;
|
||||
remark?: string;
|
||||
clientId: string;
|
||||
security?: VmessSecurity;
|
||||
externalProxy?: ExternalProxyEntry | null;
|
||||
}
|
||||
|
||||
// VMess share link: `vmess://` followed by base64-encoded JSON. The JSON
|
||||
// schema is the v2rayN-compatible "v2" shape. Returns '' if the inbound
|
||||
// is not vmess so dispatcher code can fall through cleanly.
|
||||
export function genVmessLink(input: GenVmessLinkInput): string {
|
||||
const {
|
||||
inbound,
|
||||
address,
|
||||
port = inbound.port,
|
||||
forceTls = 'same',
|
||||
remark = '',
|
||||
clientId,
|
||||
security,
|
||||
externalProxy = null,
|
||||
} = input;
|
||||
|
||||
if (inbound.protocol !== 'vmess') return '';
|
||||
|
||||
const stream = inbound.streamSettings;
|
||||
if (!stream) return '';
|
||||
|
||||
const tls = forceTls === 'same' ? stream.security : forceTls;
|
||||
const obj: Record<string, unknown> = {
|
||||
v: '2',
|
||||
ps: remark,
|
||||
add: address,
|
||||
port,
|
||||
id: clientId,
|
||||
scy: security,
|
||||
net: stream.network,
|
||||
tls,
|
||||
};
|
||||
|
||||
if (stream.network === 'tcp') {
|
||||
const tcp = stream.tcpSettings;
|
||||
const header = tcp.header;
|
||||
if (header) {
|
||||
obj.type = header.type;
|
||||
if (header.type === 'http') {
|
||||
const request = header.request;
|
||||
if (request) {
|
||||
obj.path = request.path.join(',');
|
||||
const host =
|
||||
getHeaderValue(header.response?.headers, 'host')
|
||||
|| getHeaderValue(request.headers, 'host');
|
||||
if (host) obj.host = host;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
obj.type = 'none';
|
||||
}
|
||||
} else if (stream.network === 'kcp') {
|
||||
const kcp = stream.kcpSettings;
|
||||
obj.mtu = kcp.mtu;
|
||||
obj.tti = kcp.tti;
|
||||
} else if (stream.network === 'ws') {
|
||||
const ws = stream.wsSettings;
|
||||
obj.path = ws.path;
|
||||
obj.host = ws.host.length > 0 ? ws.host : getHeaderValue(ws.headers, 'host');
|
||||
} else if (stream.network === 'grpc') {
|
||||
const grpc = stream.grpcSettings;
|
||||
obj.path = grpc.serviceName;
|
||||
obj.authority = grpc.authority;
|
||||
if (grpc.multiMode) obj.type = 'multi';
|
||||
} else if (stream.network === 'httpupgrade') {
|
||||
const hu = stream.httpupgradeSettings;
|
||||
obj.path = hu.path;
|
||||
obj.host = hu.host.length > 0 ? hu.host : getHeaderValue(hu.headers, 'host');
|
||||
} else if (stream.network === 'xhttp') {
|
||||
const xhttp = stream.xhttpSettings;
|
||||
obj.path = xhttp.path;
|
||||
obj.host = xhttp.host.length > 0 ? xhttp.host : xhttpHostFallback(xhttp);
|
||||
obj.type = xhttp.mode;
|
||||
applyXhttpExtraToObj(xhttp, obj);
|
||||
}
|
||||
|
||||
applyFinalMaskToObj(stream.finalmask, obj);
|
||||
|
||||
if (tls === 'tls' && stream.security === 'tls') {
|
||||
const tlsSettings = stream.tlsSettings;
|
||||
if (tlsSettings.serverName.length > 0) obj.sni = tlsSettings.serverName;
|
||||
if (tlsSettings.settings.fingerprint.length > 0) obj.fp = tlsSettings.settings.fingerprint;
|
||||
if (tlsSettings.alpn.length > 0) obj.alpn = tlsSettings.alpn.join(',');
|
||||
if (tlsSettings.settings.pinnedPeerCertSha256.length > 0) {
|
||||
obj.pcs = tlsSettings.settings.pinnedPeerCertSha256.join(',');
|
||||
}
|
||||
}
|
||||
|
||||
applyExternalProxyTLSObj(externalProxy, obj, tls);
|
||||
|
||||
return 'vmess://' + Base64.encode(JSON.stringify(obj, null, 2));
|
||||
}
|
||||
|
||||
// Param-style helpers (vless/trojan/ss/hysteria links). These mirror the
|
||||
// legacy applyXhttpExtraToParams / applyFinalMaskToParams /
|
||||
// applyExternalProxyTLSParams but write to a URLSearchParams instance
|
||||
// directly. Number values get coerced via .toString() on set — same as
|
||||
// what URLSearchParams does internally so the resulting URL bytes match.
|
||||
|
||||
function applyXhttpExtraToParams(xhttp: XHttpStreamSettings | undefined, params: URLSearchParams): void {
|
||||
if (!xhttp) return;
|
||||
params.set('path', xhttp.path);
|
||||
const host = xhttp.host.length > 0 ? xhttp.host : xhttpHostFallback(xhttp);
|
||||
params.set('host', host);
|
||||
params.set('mode', xhttp.mode);
|
||||
if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
|
||||
params.set('x_padding_bytes', xhttp.xPaddingBytes);
|
||||
}
|
||||
const extra = buildXhttpExtra(xhttp);
|
||||
if (extra) params.set('extra', JSON.stringify(extra));
|
||||
}
|
||||
|
||||
function applyFinalMaskToParams(finalmask: FinalMaskStreamSettings | undefined, params: URLSearchParams): void {
|
||||
const payload = serializeFinalMask(finalmask);
|
||||
if (payload.length > 0) params.set('fm', payload);
|
||||
}
|
||||
|
||||
function applyExternalProxyTLSParams(
|
||||
externalProxy: ExternalProxyEntry | null | undefined,
|
||||
params: URLSearchParams,
|
||||
security: string,
|
||||
): void {
|
||||
if (!externalProxy || security !== 'tls') return;
|
||||
const sni = externalProxy.sni && externalProxy.sni.length > 0 ? externalProxy.sni : externalProxy.dest;
|
||||
if (sni && sni.length > 0) params.set('sni', sni);
|
||||
if (externalProxy.fingerprint && externalProxy.fingerprint.length > 0) params.set('fp', externalProxy.fingerprint);
|
||||
const alpn = externalProxyAlpn(externalProxy.alpn);
|
||||
if (alpn.length > 0) params.set('alpn', alpn);
|
||||
}
|
||||
|
||||
export interface GenVlessLinkInput {
|
||||
inbound: Inbound;
|
||||
address: string;
|
||||
port?: number;
|
||||
forceTls?: ForceTls;
|
||||
remark?: string;
|
||||
clientId: string;
|
||||
flow?: VlessClient['flow'];
|
||||
externalProxy?: ExternalProxyEntry | null;
|
||||
}
|
||||
|
||||
// VLESS share link: vless://<uuid>@<host>:<port>?<query>#<remark>. The
|
||||
// query carries network type, encryption, network-specific knobs, and
|
||||
// security-specific knobs (TLS fingerprint/alpn/sni or Reality
|
||||
// pbk/sid/spx). Returns '' if the inbound isn't vless.
|
||||
export function genVlessLink(input: GenVlessLinkInput): string {
|
||||
const {
|
||||
inbound,
|
||||
address,
|
||||
port = inbound.port,
|
||||
forceTls = 'same',
|
||||
remark = '',
|
||||
clientId,
|
||||
flow = '',
|
||||
externalProxy = null,
|
||||
} = input;
|
||||
|
||||
if (inbound.protocol !== 'vless') return '';
|
||||
const stream = inbound.streamSettings;
|
||||
if (!stream) return '';
|
||||
|
||||
const security = forceTls === 'same' ? stream.security : forceTls;
|
||||
const params = new URLSearchParams();
|
||||
params.set('type', stream.network);
|
||||
params.set('encryption', inbound.settings.encryption);
|
||||
|
||||
if (stream.network === 'tcp') {
|
||||
const tcp = stream.tcpSettings;
|
||||
if (tcp.header?.type === 'http') {
|
||||
const request = tcp.header.request;
|
||||
if (request) {
|
||||
params.set('path', request.path.join(','));
|
||||
const host =
|
||||
getHeaderValue(tcp.header.response?.headers, 'host')
|
||||
|| getHeaderValue(request.headers, 'host');
|
||||
if (host) params.set('host', host);
|
||||
params.set('headerType', 'http');
|
||||
}
|
||||
}
|
||||
} else if (stream.network === 'kcp') {
|
||||
const kcp = stream.kcpSettings;
|
||||
params.set('mtu', String(kcp.mtu));
|
||||
params.set('tti', String(kcp.tti));
|
||||
} else if (stream.network === 'ws') {
|
||||
const ws = stream.wsSettings;
|
||||
params.set('path', ws.path);
|
||||
params.set('host', ws.host.length > 0 ? ws.host : getHeaderValue(ws.headers, 'host'));
|
||||
} else if (stream.network === 'grpc') {
|
||||
const grpc = stream.grpcSettings;
|
||||
params.set('serviceName', grpc.serviceName);
|
||||
params.set('authority', grpc.authority);
|
||||
if (grpc.multiMode) params.set('mode', 'multi');
|
||||
} else if (stream.network === 'httpupgrade') {
|
||||
const hu = stream.httpupgradeSettings;
|
||||
params.set('path', hu.path);
|
||||
params.set('host', hu.host.length > 0 ? hu.host : getHeaderValue(hu.headers, 'host'));
|
||||
} else if (stream.network === 'xhttp') {
|
||||
applyXhttpExtraToParams(stream.xhttpSettings, params);
|
||||
}
|
||||
|
||||
applyFinalMaskToParams(stream.finalmask, params);
|
||||
|
||||
if (security === 'tls') {
|
||||
params.set('security', 'tls');
|
||||
if (stream.security === 'tls') {
|
||||
const tls = stream.tlsSettings;
|
||||
params.set('fp', tls.settings.fingerprint);
|
||||
params.set('alpn', tls.alpn.join(','));
|
||||
if (tls.serverName.length > 0) params.set('sni', tls.serverName);
|
||||
if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList);
|
||||
if (tls.settings.pinnedPeerCertSha256.length > 0) {
|
||||
params.set('pcs', tls.settings.pinnedPeerCertSha256.join(','));
|
||||
}
|
||||
if (stream.network === 'tcp' && flow.length > 0) params.set('flow', flow);
|
||||
}
|
||||
applyExternalProxyTLSParams(externalProxy, params, security);
|
||||
} else if (security === 'reality') {
|
||||
params.set('security', 'reality');
|
||||
if (stream.security === 'reality') {
|
||||
const reality = stream.realitySettings;
|
||||
params.set('pbk', reality.settings.publicKey);
|
||||
params.set('fp', reality.settings.fingerprint);
|
||||
|
||||
const sni =
|
||||
reality.settings.serverName ||
|
||||
reality.serverNames?.[0] ||
|
||||
reality.target?.split(':')[0];
|
||||
|
||||
if (sni && sni.length > 0) params.set('sni', sni);
|
||||
|
||||
if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
|
||||
if (reality.settings.spiderX.length > 0) params.set('spx', reality.settings.spiderX);
|
||||
if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
|
||||
if (stream.network === 'tcp' && flow.length > 0) params.set('flow', flow);
|
||||
}
|
||||
} else {
|
||||
params.set('security', 'none');
|
||||
}
|
||||
|
||||
const url = new URL(`vless://${clientId}@${address}:${port}`);
|
||||
for (const [key, value] of params) url.searchParams.set(key, value);
|
||||
url.hash = encodeURIComponent(remark);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
// Shared network-branch writer used by trojan + shadowsocks links.
|
||||
// VLESS and VMess don't call this because they have minor per-protocol
|
||||
// quirks inline (vmess maps `multi` differently into obj.type; vless sets
|
||||
// encryption=none up-front).
|
||||
function writeNetworkParams(stream: NonNullable<Inbound['streamSettings']>, params: URLSearchParams): void {
|
||||
if (stream.network === 'tcp') {
|
||||
const tcp = stream.tcpSettings;
|
||||
if (tcp.header?.type === 'http') {
|
||||
const request = tcp.header.request;
|
||||
if (request) {
|
||||
params.set('path', request.path.join(','));
|
||||
const host =
|
||||
getHeaderValue(tcp.header.response?.headers, 'host')
|
||||
|| getHeaderValue(request.headers, 'host');
|
||||
if (host) params.set('host', host);
|
||||
params.set('headerType', 'http');
|
||||
}
|
||||
}
|
||||
} else if (stream.network === 'kcp') {
|
||||
const kcp = stream.kcpSettings;
|
||||
params.set('mtu', String(kcp.mtu));
|
||||
params.set('tti', String(kcp.tti));
|
||||
} else if (stream.network === 'ws') {
|
||||
const ws = stream.wsSettings;
|
||||
params.set('path', ws.path);
|
||||
params.set('host', ws.host.length > 0 ? ws.host : getHeaderValue(ws.headers, 'host'));
|
||||
} else if (stream.network === 'grpc') {
|
||||
const grpc = stream.grpcSettings;
|
||||
params.set('serviceName', grpc.serviceName);
|
||||
params.set('authority', grpc.authority);
|
||||
if (grpc.multiMode) params.set('mode', 'multi');
|
||||
} else if (stream.network === 'httpupgrade') {
|
||||
const hu = stream.httpupgradeSettings;
|
||||
params.set('path', hu.path);
|
||||
params.set('host', hu.host.length > 0 ? hu.host : getHeaderValue(hu.headers, 'host'));
|
||||
} else if (stream.network === 'xhttp') {
|
||||
applyXhttpExtraToParams(stream.xhttpSettings, params);
|
||||
}
|
||||
}
|
||||
|
||||
function writeTlsParams(stream: NonNullable<Inbound['streamSettings']>, params: URLSearchParams): void {
|
||||
if (stream.security !== 'tls') return;
|
||||
const tls = stream.tlsSettings;
|
||||
params.set('fp', tls.settings.fingerprint);
|
||||
params.set('alpn', tls.alpn.join(','));
|
||||
if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList);
|
||||
if (tls.serverName.length > 0) params.set('sni', tls.serverName);
|
||||
if (tls.settings.pinnedPeerCertSha256.length > 0) {
|
||||
params.set('pcs', tls.settings.pinnedPeerCertSha256.join(','));
|
||||
}
|
||||
}
|
||||
|
||||
// Reality query-string writer shared by VLESS and Trojan. Preserves the
|
||||
// legacy SNI-omission quirk (see genVlessLink for the full story).
|
||||
function writeRealityParams(stream: NonNullable<Inbound['streamSettings']>, params: URLSearchParams): void {
|
||||
if (stream.security !== 'reality') return;
|
||||
const reality = stream.realitySettings;
|
||||
params.set('pbk', reality.settings.publicKey);
|
||||
params.set('fp', reality.settings.fingerprint);
|
||||
|
||||
const sni =
|
||||
reality.settings.serverName ||
|
||||
reality.serverNames?.[0] ||
|
||||
reality.target?.split(':')[0];
|
||||
|
||||
if (sni && sni.length > 0) params.set('sni', sni);
|
||||
|
||||
if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
|
||||
if (reality.settings.spiderX.length > 0) params.set('spx', reality.settings.spiderX);
|
||||
if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
|
||||
}
|
||||
|
||||
export interface GenTrojanLinkInput {
|
||||
inbound: Inbound;
|
||||
address: string;
|
||||
port?: number;
|
||||
forceTls?: ForceTls;
|
||||
remark?: string;
|
||||
clientPassword: string;
|
||||
externalProxy?: ExternalProxyEntry | null;
|
||||
}
|
||||
|
||||
// Trojan share link: trojan://<password>@<host>:<port>?<query>#<remark>.
|
||||
// Same query-string shape as VLESS minus the `encryption` and `flow`
|
||||
// fields. Returns '' if the inbound isn't trojan.
|
||||
export function genTrojanLink(input: GenTrojanLinkInput): string {
|
||||
const {
|
||||
inbound,
|
||||
address,
|
||||
port = inbound.port,
|
||||
forceTls = 'same',
|
||||
remark = '',
|
||||
clientPassword,
|
||||
externalProxy = null,
|
||||
} = input;
|
||||
|
||||
if (inbound.protocol !== 'trojan') return '';
|
||||
const stream = inbound.streamSettings;
|
||||
if (!stream) return '';
|
||||
|
||||
const security = forceTls === 'same' ? stream.security : forceTls;
|
||||
const params = new URLSearchParams();
|
||||
params.set('type', stream.network);
|
||||
|
||||
writeNetworkParams(stream, params);
|
||||
applyFinalMaskToParams(stream.finalmask, params);
|
||||
|
||||
if (security === 'tls') {
|
||||
params.set('security', 'tls');
|
||||
writeTlsParams(stream, params);
|
||||
applyExternalProxyTLSParams(externalProxy, params, security);
|
||||
} else if (security === 'reality') {
|
||||
params.set('security', 'reality');
|
||||
writeRealityParams(stream, params);
|
||||
} else {
|
||||
params.set('security', 'none');
|
||||
}
|
||||
|
||||
const url = new URL(`trojan://${encodeURIComponent(clientPassword)}@${address}:${port}`);
|
||||
for (const [key, value] of params) url.searchParams.set(key, value);
|
||||
url.hash = encodeURIComponent(remark);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export interface GenShadowsocksLinkInput {
|
||||
inbound: Inbound;
|
||||
address: string;
|
||||
port?: number;
|
||||
forceTls?: ForceTls;
|
||||
remark?: string;
|
||||
clientPassword?: string;
|
||||
externalProxy?: ExternalProxyEntry | null;
|
||||
}
|
||||
|
||||
// Shadowsocks 2022 share link. The userinfo portion is base64(method:pw)
|
||||
// for single-user and base64(method:settingsPw:clientPw) for multi-user
|
||||
// 2022-blake3. Legacy SS (non-2022) leaves the password out of the
|
||||
// userinfo entirely — matches the legacy class's password-array logic.
|
||||
// Note: legacy `isSSMultiUser` returns true for everything except
|
||||
// 2022-blake3-chacha20-poly1305 (a curious classification, but we
|
||||
// preserve it for byte-stable parity).
|
||||
export function genShadowsocksLink(input: GenShadowsocksLinkInput): string {
|
||||
const {
|
||||
inbound,
|
||||
address,
|
||||
port = inbound.port,
|
||||
forceTls = 'same',
|
||||
remark = '',
|
||||
clientPassword = '',
|
||||
externalProxy = null,
|
||||
} = input;
|
||||
|
||||
if (inbound.protocol !== 'shadowsocks') return '';
|
||||
const stream = inbound.streamSettings;
|
||||
if (!stream) return '';
|
||||
const settings = inbound.settings;
|
||||
|
||||
const security = forceTls === 'same' ? stream.security : forceTls;
|
||||
const params = new URLSearchParams();
|
||||
params.set('type', stream.network);
|
||||
|
||||
writeNetworkParams(stream, params);
|
||||
applyFinalMaskToParams(stream.finalmask, params);
|
||||
|
||||
if (security === 'tls') {
|
||||
params.set('security', 'tls');
|
||||
writeTlsParams(stream, params);
|
||||
applyExternalProxyTLSParams(externalProxy, params, security);
|
||||
}
|
||||
|
||||
const isSS2022 = settings.method.substring(0, 4) === '2022';
|
||||
const isSSMultiUser = settings.method !== '2022-blake3-chacha20-poly1305';
|
||||
const passwords: string[] = [];
|
||||
if (isSS2022) passwords.push(settings.password);
|
||||
if (isSSMultiUser) passwords.push(clientPassword);
|
||||
|
||||
const userinfo = Base64.encode(`${settings.method}:${passwords.join(':')}`, true);
|
||||
const url = new URL(`ss://${userinfo}@${address}:${port}`);
|
||||
for (const [key, value] of params) url.searchParams.set(key, value);
|
||||
url.hash = encodeURIComponent(remark);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export interface GenHysteriaLinkInput {
|
||||
inbound: Inbound;
|
||||
address: string;
|
||||
port?: number;
|
||||
remark?: string;
|
||||
clientAuth: 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.
|
||||
//
|
||||
// Note: legacy genHysteriaLink reads stream.tls.settings.allowInsecure,
|
||||
// which isn't a field on TlsStreamSettings.Settings — the guard is always
|
||||
// false. We omit the `insecure` param here to stay byte-stable.
|
||||
export function genHysteriaLink(input: GenHysteriaLinkInput): string {
|
||||
const {
|
||||
inbound,
|
||||
address,
|
||||
port = inbound.port,
|
||||
remark = '',
|
||||
clientAuth,
|
||||
} = input;
|
||||
|
||||
if (inbound.protocol !== 'hysteria') return '';
|
||||
const stream = inbound.streamSettings;
|
||||
if (!stream || stream.security !== 'tls') return '';
|
||||
|
||||
const settings = inbound.settings;
|
||||
const scheme = settings.version === 2 ? 'hysteria2' : 'hysteria';
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('security', 'tls');
|
||||
const tls = stream.tlsSettings;
|
||||
if (tls.settings.fingerprint.length > 0) params.set('fp', tls.settings.fingerprint);
|
||||
if (tls.alpn.length > 0) params.set('alpn', tls.alpn.join(','));
|
||||
if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList);
|
||||
if (tls.serverName.length > 0) params.set('sni', tls.serverName);
|
||||
|
||||
const udpMasks = stream.finalmask?.udp;
|
||||
if (Array.isArray(udpMasks)) {
|
||||
const salamander = udpMasks.find((m) => m?.type === 'salamander');
|
||||
const obfsPassword = salamander?.settings?.password;
|
||||
if (typeof obfsPassword === 'string' && obfsPassword.length > 0) {
|
||||
params.set('obfs', 'salamander');
|
||||
params.set('obfs-password', obfsPassword);
|
||||
}
|
||||
}
|
||||
|
||||
applyFinalMaskToParams(stream.finalmask, params);
|
||||
|
||||
const url = new URL(`${scheme}://${clientAuth}@${address}:${port}`);
|
||||
for (const [key, value] of params) url.searchParams.set(key, value);
|
||||
url.hash = encodeURIComponent(remark);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export interface GenWireguardLinkInput {
|
||||
settings: WireguardInboundSettings;
|
||||
address: string;
|
||||
port: number;
|
||||
remark?: string;
|
||||
peerIndex: number;
|
||||
}
|
||||
|
||||
// Wireguard share link: wireguard://<peerPrivKey>@<host>:<port>
|
||||
// ?publickey=<serverPub>&address=<peerAllowedIP>&mtu=<mtu>#<remark>
|
||||
// pubKey is derived from the server's secretKey via Wireguard.generateKeypair
|
||||
// at call time (Zod's schema stores secretKey only — pubKey isn't on the
|
||||
// wire). Returns '' when the peer index is out of bounds.
|
||||
export function genWireguardLink(input: GenWireguardLinkInput): string {
|
||||
const { settings, address, port, remark = '', peerIndex } = input;
|
||||
const peer = settings.peers[peerIndex];
|
||||
if (!peer) return '';
|
||||
|
||||
const url = new URL(`wireguard://${address}:${port}`);
|
||||
url.username = peer.privateKey ?? '';
|
||||
|
||||
const pubKey = settings.secretKey.length > 0
|
||||
? Wireguard.generateKeypair(settings.secretKey).publicKey
|
||||
: '';
|
||||
if (pubKey.length > 0) url.searchParams.set('publickey', pubKey);
|
||||
if (peer.allowedIPs.length > 0 && peer.allowedIPs[0]) {
|
||||
url.searchParams.set('address', peer.allowedIPs[0]);
|
||||
}
|
||||
if (typeof settings.mtu === 'number' && settings.mtu > 0) {
|
||||
url.searchParams.set('mtu', String(settings.mtu));
|
||||
}
|
||||
|
||||
url.hash = encodeURIComponent(remark);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
// Plain-text WireGuard client config (.conf format). Mirrors the legacy
|
||||
// getWireguardTxt — same DNS defaults (1.1.1.1, 1.0.0.1), MTU optional,
|
||||
// presharedKey + keepAlive only emitted when present on the peer. The
|
||||
// final newline structure follows the legacy: no newline after Endpoint,
|
||||
// optional preSharedKey appended with leading \n, keepAlive appended
|
||||
// with leading \n AND trailing \n.
|
||||
export function genWireguardConfig(input: GenWireguardLinkInput): string {
|
||||
const { settings, address, port, remark = '', peerIndex } = input;
|
||||
const peer = settings.peers[peerIndex];
|
||||
if (!peer) return '';
|
||||
|
||||
const pubKey = settings.secretKey.length > 0
|
||||
? Wireguard.generateKeypair(settings.secretKey).publicKey
|
||||
: '';
|
||||
|
||||
let txt = `[Interface]\n`;
|
||||
txt += `PrivateKey = ${peer.privateKey ?? ''}\n`;
|
||||
txt += `Address = ${peer.allowedIPs[0] ?? ''}\n`;
|
||||
txt += `DNS = 1.1.1.1, 1.0.0.1\n`;
|
||||
if (typeof settings.mtu === 'number' && settings.mtu > 0) {
|
||||
txt += `MTU = ${settings.mtu}\n`;
|
||||
}
|
||||
txt += `\n# ${remark}\n`;
|
||||
txt += `[Peer]\n`;
|
||||
txt += `PublicKey = ${pubKey}\n`;
|
||||
txt += `AllowedIPs = 0.0.0.0/0, ::/0\n`;
|
||||
txt += `Endpoint = ${address}:${port}`;
|
||||
if (peer.preSharedKey && peer.preSharedKey.length > 0) {
|
||||
txt += `\nPresharedKey = ${peer.preSharedKey}`;
|
||||
}
|
||||
if (typeof peer.keepAlive === 'number' && peer.keepAlive > 0) {
|
||||
txt += `\nPersistentKeepalive = ${peer.keepAlive}\n`;
|
||||
}
|
||||
return txt;
|
||||
}
|
||||
|
||||
export type { WireguardInboundPeer };
|
||||
|
||||
// Orchestrators.
|
||||
// resolveAddr picks the host that goes into share/sub links. Order:
|
||||
// 1. hostOverride (caller supplies node address for node-managed inbounds)
|
||||
// 2. inbound's bind listen (when explicit, not 0.0.0.0)
|
||||
// 3. fallbackHostname (caller-supplied — typically window.location.hostname
|
||||
// in the browser; tests pass a fixed value)
|
||||
export function resolveAddr(inbound: Inbound, hostOverride: string, fallbackHostname: string): string {
|
||||
if (hostOverride.length > 0) return hostOverride;
|
||||
if (inbound.listen.length > 0 && inbound.listen !== '0.0.0.0') return inbound.listen;
|
||||
return fallbackHostname;
|
||||
}
|
||||
|
||||
// Returns the client array for protocols that have one. SS returns its
|
||||
// clients only in 2022-blake3 multi-user mode (matches the legacy
|
||||
// `this.clients` getter, which used isSSMultiUser to gate). Returns null
|
||||
// for SS single-user, http, mixed, tunnel, wireguard, hysteria2-without-
|
||||
// clients, and any protocol without a clients array.
|
||||
type ClientShape = { id?: string; security?: VmessSecurity; flow?: VlessClient['flow']; password?: string; auth?: string; email?: string };
|
||||
|
||||
export function getInboundClients(inbound: Inbound): ClientShape[] | null {
|
||||
switch (inbound.protocol) {
|
||||
case 'vmess':
|
||||
return (inbound.settings.clients ?? []) as ClientShape[];
|
||||
case 'vless':
|
||||
return (inbound.settings.clients ?? []) as ClientShape[];
|
||||
case 'trojan':
|
||||
return (inbound.settings.clients ?? []) as ClientShape[];
|
||||
case 'hysteria':
|
||||
return (inbound.settings.clients ?? []) as ClientShape[];
|
||||
case 'shadowsocks': {
|
||||
const isMultiUser = inbound.settings.method !== '2022-blake3-chacha20-poly1305';
|
||||
return isMultiUser ? ((inbound.settings.clients ?? []) as ClientShape[]) : null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface GenLinkInput {
|
||||
inbound: Inbound;
|
||||
address: string;
|
||||
port?: number;
|
||||
forceTls?: ForceTls;
|
||||
remark?: string;
|
||||
client: ClientShape;
|
||||
externalProxy?: ExternalProxyEntry | null;
|
||||
}
|
||||
|
||||
// Per-protocol dispatcher matching the legacy `genLink` switch. Returns
|
||||
// '' for protocols that don't have client-based share links (wireguard
|
||||
// goes through genWireguardLinks/Configs separately, http/mixed/tunnel
|
||||
// don't have share URLs).
|
||||
export function genLink(input: GenLinkInput): string {
|
||||
const { inbound, address, port = inbound.port, forceTls = 'same', remark = '', client, externalProxy = null } = input;
|
||||
switch (inbound.protocol) {
|
||||
case 'vmess':
|
||||
return genVmessLink({
|
||||
inbound, address, port, forceTls, remark,
|
||||
clientId: client.id ?? '',
|
||||
security: client.security,
|
||||
externalProxy,
|
||||
});
|
||||
case 'vless':
|
||||
return genVlessLink({
|
||||
inbound, address, port, forceTls, remark,
|
||||
clientId: client.id ?? '',
|
||||
flow: client.flow,
|
||||
externalProxy,
|
||||
});
|
||||
case 'shadowsocks': {
|
||||
const isMultiUser = inbound.settings.method !== '2022-blake3-chacha20-poly1305';
|
||||
return genShadowsocksLink({
|
||||
inbound, address, port, forceTls, remark,
|
||||
clientPassword: isMultiUser ? (client.password ?? '') : '',
|
||||
externalProxy,
|
||||
});
|
||||
}
|
||||
case 'trojan':
|
||||
return genTrojanLink({
|
||||
inbound, address, port, forceTls, remark,
|
||||
clientPassword: client.password ?? '',
|
||||
externalProxy,
|
||||
});
|
||||
case 'hysteria':
|
||||
return genHysteriaLink({
|
||||
inbound, address, port, remark,
|
||||
clientAuth: client.auth ?? '',
|
||||
});
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export interface GenAllLinksEntry {
|
||||
remark: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
export interface GenAllLinksInput {
|
||||
inbound: Inbound;
|
||||
remark?: string;
|
||||
remarkModel?: string;
|
||||
client: ClientShape;
|
||||
hostOverride?: string;
|
||||
fallbackHostname: string;
|
||||
}
|
||||
|
||||
// Fans out a single client's link per externalProxy entry, or just one
|
||||
// link when there are no external proxies. remarkModel is a 4-char
|
||||
// string: first char is the separator, remaining chars pick which
|
||||
// pieces to compose into the per-link remark — 'i' = inbound remark,
|
||||
// 'e' = client email, 'o' = externalProxy remark. Defaults to '-io'
|
||||
// (dash-separated, inbound + email + proxy).
|
||||
export function genAllLinks(input: GenAllLinksInput): GenAllLinksEntry[] {
|
||||
const {
|
||||
inbound,
|
||||
remark = '',
|
||||
remarkModel = '-io',
|
||||
client,
|
||||
hostOverride = '',
|
||||
fallbackHostname,
|
||||
} = input;
|
||||
|
||||
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
|
||||
const port = inbound.port;
|
||||
const separationChar = remarkModel.charAt(0);
|
||||
const orderChars = remarkModel.slice(1);
|
||||
const email = client.email ?? '';
|
||||
|
||||
const composeRemark = (proxyRemark: string): string => {
|
||||
const orders: Record<string, string> = { i: remark, e: email, o: proxyRemark };
|
||||
return orderChars.split('')
|
||||
.map((c) => orders[c] ?? '')
|
||||
.filter((x) => x.length > 0)
|
||||
.join(separationChar);
|
||||
};
|
||||
|
||||
const externals = inbound.streamSettings?.externalProxy;
|
||||
if (!externals || externals.length === 0) {
|
||||
const r = composeRemark('');
|
||||
return [{ remark: r, link: genLink({ inbound, address: addr, port, forceTls: 'same', remark: r, client }) }];
|
||||
}
|
||||
return externals.map((ep) => {
|
||||
const r = composeRemark(ep.remark);
|
||||
return {
|
||||
remark: r,
|
||||
link: genLink({
|
||||
inbound,
|
||||
address: ep.dest,
|
||||
port: ep.port,
|
||||
forceTls: ep.forceTls,
|
||||
remark: r,
|
||||
client,
|
||||
externalProxy: ep,
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export interface GenInboundLinksInput {
|
||||
inbound: Inbound;
|
||||
remark?: string;
|
||||
remarkModel?: string;
|
||||
hostOverride?: string;
|
||||
fallbackHostname: string;
|
||||
}
|
||||
|
||||
// Top-level entrypoint that produces the full \r\n-joined block a user
|
||||
// pastes into a client. Iterates per-client for protocols with clients,
|
||||
// falls back to a single SS link for single-user 2022-blake3-chacha20,
|
||||
// and emits per-peer .conf blocks for wireguard. Returns '' for the
|
||||
// other clientless protocols (http, mixed, tunnel).
|
||||
export function genInboundLinks(input: GenInboundLinksInput): string {
|
||||
const {
|
||||
inbound,
|
||||
remark = '',
|
||||
remarkModel = '-io',
|
||||
hostOverride = '',
|
||||
fallbackHostname,
|
||||
} = input;
|
||||
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
|
||||
const clients = getInboundClients(inbound);
|
||||
if (clients) {
|
||||
const links: string[] = [];
|
||||
for (const client of clients) {
|
||||
const entries = genAllLinks({ inbound, remark, remarkModel, client, hostOverride, fallbackHostname });
|
||||
for (const e of entries) links.push(e.link);
|
||||
}
|
||||
return links.join('\r\n');
|
||||
}
|
||||
if (inbound.protocol === 'shadowsocks') {
|
||||
return genShadowsocksLink({ inbound, address: addr, port: inbound.port, forceTls: 'same', remark });
|
||||
}
|
||||
if (inbound.protocol === 'wireguard') {
|
||||
return genWireguardConfigs({ inbound, remark, remarkModel, hostOverride, fallbackHostname });
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Per-peer wireguard fanout. Each peer gets its own link (or .conf
|
||||
// block) with an index-suffixed remark, joined by \r\n. Matches the
|
||||
// legacy genWireguardLinks / genWireguardConfigs exactly.
|
||||
export interface GenWireguardFanoutInput {
|
||||
inbound: Inbound;
|
||||
remark?: string;
|
||||
remarkModel?: string;
|
||||
hostOverride?: string;
|
||||
fallbackHostname: string;
|
||||
}
|
||||
|
||||
export function genWireguardLinks(input: GenWireguardFanoutInput): string {
|
||||
const { inbound, remark = '', remarkModel = '-io', hostOverride = '', fallbackHostname } = input;
|
||||
if (inbound.protocol !== 'wireguard') return '';
|
||||
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
|
||||
const sep = remarkModel.charAt(0);
|
||||
return inbound.settings.peers
|
||||
.map((_p, i) => genWireguardLink({
|
||||
settings: inbound.settings as WireguardInboundSettings,
|
||||
address: addr,
|
||||
port: inbound.port,
|
||||
remark: `${remark}${sep}${i + 1}`,
|
||||
peerIndex: i,
|
||||
}))
|
||||
.join('\r\n');
|
||||
}
|
||||
|
||||
export function genWireguardConfigs(input: GenWireguardFanoutInput): string {
|
||||
const { inbound, remark = '', remarkModel = '-io', hostOverride = '', fallbackHostname } = input;
|
||||
if (inbound.protocol !== 'wireguard') return '';
|
||||
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
|
||||
const sep = remarkModel.charAt(0);
|
||||
return inbound.settings.peers
|
||||
.map((_p, i) => genWireguardConfig({
|
||||
settings: inbound.settings as WireguardInboundSettings,
|
||||
address: addr,
|
||||
port: inbound.port,
|
||||
remark: `${remark}${sep}${i + 1}`,
|
||||
peerIndex: i,
|
||||
}))
|
||||
.join('\r\n');
|
||||
}
|
||||
167
frontend/src/lib/xray/outbound-defaults.ts
Normal file
167
frontend/src/lib/xray/outbound-defaults.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { RandomUtil, Wireguard } from '@/utils';
|
||||
|
||||
import type { BlackholeOutboundSettings } from '@/schemas/protocols/outbound/blackhole';
|
||||
import type { DNSOutboundSettings } from '@/schemas/protocols/outbound/dns';
|
||||
import type { FreedomOutboundSettings } from '@/schemas/protocols/outbound/freedom';
|
||||
import type { HttpOutboundSettings } from '@/schemas/protocols/outbound/http';
|
||||
import type { HysteriaOutboundSettings } from '@/schemas/protocols/outbound/hysteria';
|
||||
import type { LoopbackOutboundSettings } from '@/schemas/protocols/outbound/loopback';
|
||||
import type { ShadowsocksOutboundSettings } from '@/schemas/protocols/outbound/shadowsocks';
|
||||
import type { SocksOutboundSettings } from '@/schemas/protocols/outbound/socks';
|
||||
import type { TrojanOutboundSettings } from '@/schemas/protocols/outbound/trojan';
|
||||
import type { VlessOutboundSettings } from '@/schemas/protocols/outbound/vless';
|
||||
import type { VmessOutboundSettings } from '@/schemas/protocols/outbound/vmess';
|
||||
import type { WireguardOutboundSettings } from '@/schemas/protocols/outbound/wireguard';
|
||||
|
||||
// Plain-object factories mirroring `new Outbound.<X>Settings()` from the
|
||||
// legacy class hierarchy, then `.toJson()`. The output matches the wire
|
||||
// shape — the same starting state the OutboundFormModal's `ob.settings`
|
||||
// holds the first time the user picks a protocol.
|
||||
//
|
||||
// Required-by-schema fields the legacy class leaves undefined (address,
|
||||
// port, user-supplied ids/passwords) become empty stubs here. Zod will
|
||||
// reject the default output until the user fills them in via the form;
|
||||
// this is intentional and matches the legacy "scaffold object" behavior.
|
||||
|
||||
export function createDefaultFreedomOutboundSettings(): FreedomOutboundSettings {
|
||||
return {};
|
||||
}
|
||||
|
||||
export function createDefaultBlackholeOutboundSettings(): BlackholeOutboundSettings {
|
||||
return {};
|
||||
}
|
||||
|
||||
export function createDefaultLoopbackOutboundSettings(): LoopbackOutboundSettings {
|
||||
return { inboundTag: '' };
|
||||
}
|
||||
|
||||
export function createDefaultDNSOutboundSettings(): DNSOutboundSettings {
|
||||
return {
|
||||
rewriteNetwork: '',
|
||||
rewriteAddress: '',
|
||||
rewritePort: 53,
|
||||
userLevel: 0,
|
||||
rules: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function createDefaultVmessOutboundSettings(): VmessOutboundSettings {
|
||||
return {
|
||||
vnext: [{
|
||||
address: '',
|
||||
port: 443,
|
||||
users: [{ id: '', security: 'auto' }],
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
export function createDefaultVlessOutboundSettings(): VlessOutboundSettings {
|
||||
return {
|
||||
address: '',
|
||||
port: 443,
|
||||
id: '',
|
||||
flow: '',
|
||||
encryption: 'none',
|
||||
};
|
||||
}
|
||||
|
||||
export function createDefaultTrojanOutboundSettings(): TrojanOutboundSettings {
|
||||
return {
|
||||
servers: [{ address: '', port: 443, password: '' }],
|
||||
};
|
||||
}
|
||||
|
||||
// Why: legacy constructor leaves method undefined; the form's Select
|
||||
// snaps to the first option when the user opens it. We pick the same
|
||||
// modern default the inbound shadowsocks factory uses
|
||||
// (2022-blake3-aes-128-gcm) so the OutboundFormModal renders a coherent
|
||||
// initial state instead of an empty Select.
|
||||
export function createDefaultShadowsocksOutboundSettings(): ShadowsocksOutboundSettings {
|
||||
return {
|
||||
servers: [{
|
||||
address: '',
|
||||
port: 443,
|
||||
password: '',
|
||||
method: '2022-blake3-aes-128-gcm',
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
export function createDefaultSocksOutboundSettings(): SocksOutboundSettings {
|
||||
return {
|
||||
servers: [{ address: '', port: 1080, users: [] }],
|
||||
};
|
||||
}
|
||||
|
||||
export function createDefaultHttpOutboundSettings(): HttpOutboundSettings {
|
||||
return {
|
||||
servers: [{ address: '', port: 8080, users: [] }],
|
||||
};
|
||||
}
|
||||
|
||||
interface WireguardOutboundSeed {
|
||||
secretKey?: string;
|
||||
}
|
||||
|
||||
export function createDefaultWireguardOutboundSettings(
|
||||
seed: WireguardOutboundSeed = {},
|
||||
): WireguardOutboundSettings {
|
||||
const secretKey = seed.secretKey ?? Wireguard.generateKeypair().privateKey;
|
||||
return {
|
||||
mtu: 1420,
|
||||
secretKey,
|
||||
address: [],
|
||||
workers: 2,
|
||||
peers: [{
|
||||
publicKey: '',
|
||||
allowedIPs: ['0.0.0.0/0', '::/0'],
|
||||
endpoint: '',
|
||||
}],
|
||||
noKernelTun: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function createDefaultHysteriaOutboundSettings(): HysteriaOutboundSettings {
|
||||
return { address: '', port: 443, version: 2 };
|
||||
}
|
||||
|
||||
export type AnyOutboundSettings =
|
||||
| BlackholeOutboundSettings
|
||||
| DNSOutboundSettings
|
||||
| FreedomOutboundSettings
|
||||
| HttpOutboundSettings
|
||||
| HysteriaOutboundSettings
|
||||
| LoopbackOutboundSettings
|
||||
| ShadowsocksOutboundSettings
|
||||
| SocksOutboundSettings
|
||||
| TrojanOutboundSettings
|
||||
| VlessOutboundSettings
|
||||
| VmessOutboundSettings
|
||||
| WireguardOutboundSettings;
|
||||
|
||||
// Protocol-aware dispatch. Mirrors the legacy
|
||||
// `Outbound.Settings.getSettings(protocol)` switch. Note: the inbound
|
||||
// dispatcher returns `null` for unknown protocols and so does this one,
|
||||
// keeping the contract identical so callers can stay protocol-agnostic.
|
||||
//
|
||||
// The `RandomUtil` reference is held to silence unused-import warnings
|
||||
// when no per-call randomization happens at the dispatcher level —
|
||||
// individual factories may pull from it via their own seeds.
|
||||
export function createDefaultOutboundSettings(protocol: string): AnyOutboundSettings | null {
|
||||
void RandomUtil;
|
||||
switch (protocol) {
|
||||
case 'freedom': return createDefaultFreedomOutboundSettings();
|
||||
case 'blackhole': return createDefaultBlackholeOutboundSettings();
|
||||
case 'dns': return createDefaultDNSOutboundSettings();
|
||||
case 'vmess': return createDefaultVmessOutboundSettings();
|
||||
case 'vless': return createDefaultVlessOutboundSettings();
|
||||
case 'trojan': return createDefaultTrojanOutboundSettings();
|
||||
case 'shadowsocks': return createDefaultShadowsocksOutboundSettings();
|
||||
case 'socks': return createDefaultSocksOutboundSettings();
|
||||
case 'http': return createDefaultHttpOutboundSettings();
|
||||
case 'wireguard': return createDefaultWireguardOutboundSettings();
|
||||
case 'hysteria': return createDefaultHysteriaOutboundSettings();
|
||||
case 'loopback': return createDefaultLoopbackOutboundSettings();
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
619
frontend/src/lib/xray/outbound-form-adapter.ts
Normal file
619
frontend/src/lib/xray/outbound-form-adapter.ts
Normal file
@@ -0,0 +1,619 @@
|
||||
import { Wireguard } from '@/utils';
|
||||
|
||||
import type {
|
||||
DnsOutboundFormSettings,
|
||||
DnsRuleForm,
|
||||
FreedomFinalRuleForm,
|
||||
FreedomOutboundFormSettings,
|
||||
HysteriaOutboundFormSettings,
|
||||
LoopbackOutboundFormSettings,
|
||||
MuxForm,
|
||||
OutboundFormSettings,
|
||||
OutboundFormValues,
|
||||
OutboundStreamFormValues,
|
||||
ReverseSniffingForm,
|
||||
ShadowsocksOutboundFormSettings,
|
||||
TrojanOutboundFormSettings,
|
||||
VlessOutboundFormSettings,
|
||||
VmessOutboundFormSettings,
|
||||
WireguardOutboundFormPeer,
|
||||
WireguardOutboundFormSettings,
|
||||
} from '@/schemas/forms/outbound-form';
|
||||
|
||||
type Raw = Record<string, unknown>;
|
||||
|
||||
function asObject(value: unknown): Raw {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Raw) : {};
|
||||
}
|
||||
|
||||
function asArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function asString(value: unknown, fallback = ''): string {
|
||||
return typeof value === 'string' ? value : fallback;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown, fallback = 0): number {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function asBool(value: unknown): boolean {
|
||||
return value === true;
|
||||
}
|
||||
|
||||
function asPort(value: unknown, fallback: number): number {
|
||||
const n = asNumber(value, fallback);
|
||||
if (!Number.isInteger(n) || n < 1 || n > 65535) return fallback;
|
||||
return n;
|
||||
}
|
||||
|
||||
const REVERSE_SNIFFING_DEFAULT: ReverseSniffingForm = {
|
||||
enabled: false,
|
||||
destOverride: ['http', 'tls', 'quic', 'fakedns'],
|
||||
metadataOnly: false,
|
||||
routeOnly: false,
|
||||
ipsExcluded: [],
|
||||
domainsExcluded: [],
|
||||
};
|
||||
|
||||
function reverseSniffingFromWire(raw: unknown): ReverseSniffingForm {
|
||||
const r = asObject(raw);
|
||||
const dest = asArray(r.destOverride).map((x) => asString(x));
|
||||
return {
|
||||
enabled: asBool(r.enabled),
|
||||
destOverride: dest.length > 0 ? dest : ['http', 'tls', 'quic', 'fakedns'],
|
||||
metadataOnly: asBool(r.metadataOnly),
|
||||
routeOnly: asBool(r.routeOnly),
|
||||
ipsExcluded: asArray(r.ipsExcluded).map((x) => asString(x)),
|
||||
domainsExcluded: asArray(r.domainsExcluded).map((x) => asString(x)),
|
||||
};
|
||||
}
|
||||
|
||||
function vmessFromWire(raw: Raw): VmessOutboundFormSettings {
|
||||
const vnext = asArray(raw.vnext);
|
||||
const v = asObject(vnext[0]);
|
||||
const u = asObject(asArray(v.users)[0]);
|
||||
return {
|
||||
address: asString(v.address),
|
||||
port: asPort(v.port, 443),
|
||||
id: asString(u.id),
|
||||
security: ((): VmessOutboundFormSettings['security'] => {
|
||||
const s = asString(u.security);
|
||||
const allowed = ['aes-128-gcm', 'chacha20-poly1305', 'auto', 'none', 'zero'];
|
||||
return (allowed.includes(s) ? s : 'auto') as VmessOutboundFormSettings['security'];
|
||||
})(),
|
||||
};
|
||||
}
|
||||
|
||||
function vlessFromWire(raw: Raw): VlessOutboundFormSettings {
|
||||
let address = asString(raw.address);
|
||||
let port = asPort(raw.port, 443);
|
||||
let id = asString(raw.id);
|
||||
let flow = asString(raw.flow);
|
||||
let encryption = asString(raw.encryption, 'none');
|
||||
const vnext = asArray(raw.vnext);
|
||||
if (vnext.length > 0) {
|
||||
const v = asObject(vnext[0]);
|
||||
const u = asObject(asArray(v.users)[0]);
|
||||
address = asString(v.address);
|
||||
port = asPort(v.port, 443);
|
||||
id = asString(u.id);
|
||||
flow = asString(u.flow);
|
||||
encryption = asString(u.encryption, 'none');
|
||||
}
|
||||
const reverse = asObject(raw.reverse);
|
||||
const reverseTag = asString(reverse.tag);
|
||||
const reverseSniffing = reverseTag
|
||||
? reverseSniffingFromWire(reverse.sniffing)
|
||||
: REVERSE_SNIFFING_DEFAULT;
|
||||
const savedSeed = asArray(raw.testseed);
|
||||
const testseed = savedSeed.length === 4
|
||||
&& savedSeed.every((n) => Number.isInteger(n) && (n as number) > 0)
|
||||
? (savedSeed as number[])
|
||||
: [900, 500, 900, 256];
|
||||
return {
|
||||
address,
|
||||
port,
|
||||
id,
|
||||
flow,
|
||||
encryption: (encryption === 'none' ? 'none' : 'none') as 'none',
|
||||
reverseTag,
|
||||
reverseSniffing,
|
||||
testpre: asNumber(raw.testpre, 0),
|
||||
testseed,
|
||||
};
|
||||
}
|
||||
|
||||
function trojanFromWire(raw: Raw): TrojanOutboundFormSettings {
|
||||
const s = asObject(asArray(raw.servers)[0]);
|
||||
return {
|
||||
address: asString(s.address),
|
||||
port: asPort(s.port, 443),
|
||||
password: asString(s.password),
|
||||
};
|
||||
}
|
||||
|
||||
function shadowsocksFromWire(raw: Raw): ShadowsocksOutboundFormSettings {
|
||||
const s = asObject(asArray(raw.servers)[0]);
|
||||
return {
|
||||
address: asString(s.address),
|
||||
port: asPort(s.port, 443),
|
||||
password: asString(s.password),
|
||||
method: asString(s.method, '2022-blake3-aes-128-gcm') as ShadowsocksOutboundFormSettings['method'],
|
||||
uot: asBool(s.uot),
|
||||
UoTVersion: asNumber(s.UoTVersion, 1),
|
||||
};
|
||||
}
|
||||
|
||||
interface SimpleAuthFormSettings {
|
||||
address: string;
|
||||
port: number;
|
||||
user: string;
|
||||
pass: string;
|
||||
}
|
||||
|
||||
function simpleAuthFromWire(raw: Raw, defaultPort: number): SimpleAuthFormSettings {
|
||||
const s = asObject(asArray(raw.servers)[0]);
|
||||
const u = asObject(asArray(s.users)[0]);
|
||||
return {
|
||||
address: asString(s.address),
|
||||
port: asPort(s.port, defaultPort),
|
||||
user: asString(u.user),
|
||||
pass: asString(u.pass),
|
||||
};
|
||||
}
|
||||
|
||||
function wireguardFromWire(raw: Raw): WireguardOutboundFormSettings {
|
||||
const secretKey = asString(raw.secretKey);
|
||||
const pubKey = secretKey.length > 0
|
||||
? Wireguard.generateKeypair(secretKey).publicKey
|
||||
: '';
|
||||
const addressArr = asArray(raw.address).map((x) =>
|
||||
typeof x === 'number' ? String(x) : asString(x),
|
||||
);
|
||||
const reservedArr = asArray(raw.reserved).map((x) =>
|
||||
typeof x === 'number' ? String(x) : asString(x),
|
||||
);
|
||||
const peers: WireguardOutboundFormPeer[] = asArray(raw.peers).map((p) => {
|
||||
const pp = asObject(p);
|
||||
const allowed = asArray(pp.allowedIPs).map((x) => asString(x));
|
||||
return {
|
||||
publicKey: asString(pp.publicKey),
|
||||
psk: asString(pp.preSharedKey),
|
||||
allowedIPs: allowed.length > 0 ? allowed : ['0.0.0.0/0', '::/0'],
|
||||
endpoint: asString(pp.endpoint),
|
||||
keepAlive: asNumber(pp.keepAlive, 0),
|
||||
};
|
||||
});
|
||||
return {
|
||||
mtu: asNumber(raw.mtu, 1420),
|
||||
secretKey,
|
||||
pubKey,
|
||||
address: addressArr.join(','),
|
||||
workers: asNumber(raw.workers, 2),
|
||||
domainStrategy: ((): WireguardOutboundFormSettings['domainStrategy'] => {
|
||||
const allowed = ['ForceIP', 'ForceIPv4', 'ForceIPv4v6', 'ForceIPv6', 'ForceIPv6v4'];
|
||||
const s = asString(raw.domainStrategy);
|
||||
return (allowed.includes(s) ? s : '') as WireguardOutboundFormSettings['domainStrategy'];
|
||||
})(),
|
||||
reserved: reservedArr.join(','),
|
||||
peers,
|
||||
noKernelTun: asBool(raw.noKernelTun),
|
||||
};
|
||||
}
|
||||
|
||||
function hysteriaFromWire(raw: Raw): HysteriaOutboundFormSettings {
|
||||
return {
|
||||
address: asString(raw.address),
|
||||
port: asPort(raw.port, 443),
|
||||
version: 2,
|
||||
};
|
||||
}
|
||||
|
||||
function freedomFromWire(raw: Raw): FreedomOutboundFormSettings {
|
||||
const fragment = asObject(raw.fragment);
|
||||
const noises = asArray(raw.noises).map((n) => {
|
||||
const nn = asObject(n);
|
||||
return {
|
||||
type: (asString(nn.type, 'rand') as FreedomOutboundFormSettings['noises'][number]['type']),
|
||||
packet: asString(nn.packet, '10-20'),
|
||||
delay: asString(nn.delay, '10-16'),
|
||||
applyTo: (asString(nn.applyTo, 'ip') as FreedomOutboundFormSettings['noises'][number]['applyTo']),
|
||||
};
|
||||
});
|
||||
const finalRulesRaw = asArray(raw.finalRules);
|
||||
const finalRules: FreedomFinalRuleForm[] = finalRulesRaw.map((r) => {
|
||||
const rr = asObject(r);
|
||||
const network = Array.isArray(rr.network)
|
||||
? rr.network.map((x) => asString(x)).join(',')
|
||||
: asString(rr.network);
|
||||
return {
|
||||
action: (asString(rr.action, 'block') === 'allow' ? 'allow' : 'block') as FreedomFinalRuleForm['action'],
|
||||
network,
|
||||
port: asString(rr.port),
|
||||
ip: asArray(rr.ip).map((x) => asString(x)),
|
||||
blockDelay: asString(rr.blockDelay),
|
||||
};
|
||||
});
|
||||
// Legacy ipsBlocked → finalRule(block) backfill
|
||||
if (finalRules.length === 0) {
|
||||
const ipsBlocked = asArray(raw.ipsBlocked).map((x) => asString(x));
|
||||
if (ipsBlocked.length > 0) {
|
||||
finalRules.push({ action: 'block', network: '', port: '', ip: ipsBlocked, blockDelay: '' });
|
||||
}
|
||||
}
|
||||
// Wire fragment is either missing or a populated object. Mirror the
|
||||
// legacy behavior: when the wire omits fragment, leave all four fields
|
||||
// empty so the modal's "Fragment" Switch starts off. When present,
|
||||
// surface whatever the wire holds verbatim.
|
||||
const wireHasFragment = raw.fragment != null
|
||||
&& typeof raw.fragment === 'object'
|
||||
&& Object.keys(fragment).length > 0;
|
||||
return {
|
||||
domainStrategy: ((): FreedomOutboundFormSettings['domainStrategy'] => {
|
||||
const allowed = [
|
||||
'AsIs', 'UseIP', 'UseIPv4', 'UseIPv6', 'UseIPv6v4', 'UseIPv4v6',
|
||||
'ForceIP', 'ForceIPv6v4', 'ForceIPv6', 'ForceIPv4v6', 'ForceIPv4',
|
||||
];
|
||||
const s = asString(raw.domainStrategy);
|
||||
return (allowed.includes(s) ? s : '') as FreedomOutboundFormSettings['domainStrategy'];
|
||||
})(),
|
||||
redirect: asString(raw.redirect),
|
||||
fragment: wireHasFragment
|
||||
? {
|
||||
packets: asString(fragment.packets, '1-3'),
|
||||
length: asString(fragment.length),
|
||||
interval: asString(fragment.interval),
|
||||
maxSplit: asString(fragment.maxSplit),
|
||||
}
|
||||
: { packets: '', length: '', interval: '', maxSplit: '' },
|
||||
noises,
|
||||
finalRules,
|
||||
};
|
||||
}
|
||||
|
||||
function blackholeFromWire(raw: Raw) {
|
||||
const response = asObject(raw.response);
|
||||
const t = asString(response.type);
|
||||
return { type: (t === 'none' || t === 'http' ? t : '') as '' | 'none' | 'http' };
|
||||
}
|
||||
|
||||
function dnsRuleFromWire(raw: unknown): DnsRuleForm {
|
||||
const r = asObject(raw);
|
||||
const qtype = Array.isArray(r.qtype)
|
||||
? r.qtype.map((x) => String(x)).join(',')
|
||||
: typeof r.qtype === 'number'
|
||||
? String(r.qtype)
|
||||
: asString(r.qtype);
|
||||
const domain = Array.isArray(r.domain)
|
||||
? r.domain.map((x) => asString(x)).join(',')
|
||||
: asString(r.domain);
|
||||
const action = asString(r.action, 'direct');
|
||||
const validAction = ['direct', 'reject', 'rejectIPv4', 'rejectIPv6'].includes(action)
|
||||
? action
|
||||
: 'direct';
|
||||
return { action: validAction as DnsRuleForm['action'], qtype, domain };
|
||||
}
|
||||
|
||||
function dnsFromWire(raw: Raw): DnsOutboundFormSettings {
|
||||
const rules = asArray(raw.rules).map(dnsRuleFromWire);
|
||||
return {
|
||||
rewriteNetwork: ((): DnsOutboundFormSettings['rewriteNetwork'] => {
|
||||
const s = asString(raw.rewriteNetwork ?? raw.network);
|
||||
return (s === 'udp' || s === 'tcp') ? s : '';
|
||||
})(),
|
||||
rewriteAddress: asString(raw.rewriteAddress ?? raw.address),
|
||||
rewritePort: asPort(raw.rewritePort ?? raw.port, 53),
|
||||
userLevel: asNumber(raw.userLevel, 0),
|
||||
rules,
|
||||
};
|
||||
}
|
||||
|
||||
function loopbackFromWire(raw: Raw): LoopbackOutboundFormSettings {
|
||||
return { inboundTag: asString(raw.inboundTag) };
|
||||
}
|
||||
|
||||
function muxFromWire(raw: unknown): MuxForm {
|
||||
const m = asObject(raw);
|
||||
return {
|
||||
enabled: asBool(m.enabled),
|
||||
concurrency: asNumber(m.concurrency, 8),
|
||||
xudpConcurrency: asNumber(m.xudpConcurrency, 16),
|
||||
xudpProxyUDP443: ((): MuxForm['xudpProxyUDP443'] => {
|
||||
const s = asString(m.xudpProxyUDP443, 'reject');
|
||||
return (['reject', 'allow', 'skip'].includes(s) ? s : 'reject') as MuxForm['xudpProxyUDP443'];
|
||||
})(),
|
||||
};
|
||||
}
|
||||
|
||||
export interface RawOutboundRow {
|
||||
tag?: string;
|
||||
protocol?: string;
|
||||
sendThrough?: string;
|
||||
settings?: unknown;
|
||||
streamSettings?: unknown;
|
||||
mux?: unknown;
|
||||
}
|
||||
|
||||
export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues {
|
||||
const protocol = asString(raw.protocol, 'vless');
|
||||
const settings = asObject(raw.settings);
|
||||
const tag = asString(raw.tag);
|
||||
const sendThrough = asString(raw.sendThrough);
|
||||
const mux = muxFromWire(raw.mux);
|
||||
const hasStream = raw.streamSettings
|
||||
&& typeof raw.streamSettings === 'object'
|
||||
&& Object.keys(raw.streamSettings as Raw).length > 0;
|
||||
const streamSettings = hasStream
|
||||
? (raw.streamSettings as unknown as OutboundStreamFormValues)
|
||||
: undefined;
|
||||
|
||||
let typed: OutboundFormSettings;
|
||||
switch (protocol) {
|
||||
case 'vmess': typed = { protocol: 'vmess', settings: vmessFromWire(settings) }; break;
|
||||
case 'vless': typed = { protocol: 'vless', settings: vlessFromWire(settings) }; break;
|
||||
case 'trojan': typed = { protocol: 'trojan', settings: trojanFromWire(settings) }; break;
|
||||
case 'shadowsocks': typed = { protocol: 'shadowsocks', settings: shadowsocksFromWire(settings) }; break;
|
||||
case 'socks': typed = { protocol: 'socks', settings: simpleAuthFromWire(settings, 1080) }; break;
|
||||
case 'http': typed = { protocol: 'http', settings: simpleAuthFromWire(settings, 8080) }; break;
|
||||
case 'wireguard': typed = { protocol: 'wireguard', settings: wireguardFromWire(settings) }; break;
|
||||
case 'hysteria': typed = { protocol: 'hysteria', settings: hysteriaFromWire(settings) }; break;
|
||||
case 'freedom': typed = { protocol: 'freedom', settings: freedomFromWire(settings) }; break;
|
||||
case 'blackhole': typed = { protocol: 'blackhole', settings: blackholeFromWire(settings) }; break;
|
||||
case 'dns': typed = { protocol: 'dns', settings: dnsFromWire(settings) }; break;
|
||||
case 'loopback': typed = { protocol: 'loopback', settings: loopbackFromWire(settings) }; break;
|
||||
default: typed = { protocol: 'vless', settings: vlessFromWire(settings) };
|
||||
}
|
||||
|
||||
return {
|
||||
...typed,
|
||||
tag,
|
||||
sendThrough,
|
||||
mux,
|
||||
streamSettings,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Form values -> wire payload --------------------------------------
|
||||
|
||||
function vmessToWire(s: VmessOutboundFormSettings) {
|
||||
return {
|
||||
vnext: [{
|
||||
address: s.address,
|
||||
port: s.port,
|
||||
users: [{ id: s.id, security: s.security }],
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function reverseSniffingToWire(s: ReverseSniffingForm) {
|
||||
return {
|
||||
enabled: s.enabled,
|
||||
destOverride: s.destOverride,
|
||||
metadataOnly: s.metadataOnly,
|
||||
routeOnly: s.routeOnly,
|
||||
ipsExcluded: s.ipsExcluded.length > 0 ? s.ipsExcluded : undefined,
|
||||
domainsExcluded: s.domainsExcluded.length > 0 ? s.domainsExcluded : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function vlessToWire(s: VlessOutboundFormSettings) {
|
||||
const result: Raw = {
|
||||
address: s.address,
|
||||
port: s.port,
|
||||
id: s.id,
|
||||
flow: s.flow,
|
||||
encryption: s.encryption || 'none',
|
||||
};
|
||||
if (s.reverseTag) {
|
||||
const sn = reverseSniffingToWire(s.reverseSniffing);
|
||||
const defaultSn = reverseSniffingToWire(REVERSE_SNIFFING_DEFAULT);
|
||||
result.reverse = {
|
||||
tag: s.reverseTag,
|
||||
sniffing: JSON.stringify(sn) === JSON.stringify(defaultSn) ? {} : sn,
|
||||
};
|
||||
}
|
||||
if (s.flow === 'xtls-rprx-vision') {
|
||||
if (s.testpre > 0) result.testpre = s.testpre;
|
||||
if (s.testseed.length === 4 && s.testseed.every((v) => Number.isInteger(v) && v > 0)) {
|
||||
result.testseed = s.testseed;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function trojanToWire(s: TrojanOutboundFormSettings) {
|
||||
return { servers: [{ address: s.address, port: s.port, password: s.password }] };
|
||||
}
|
||||
|
||||
function shadowsocksToWire(s: ShadowsocksOutboundFormSettings) {
|
||||
return {
|
||||
servers: [{
|
||||
address: s.address,
|
||||
port: s.port,
|
||||
password: s.password,
|
||||
method: s.method,
|
||||
uot: s.uot,
|
||||
UoTVersion: s.UoTVersion,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function simpleAuthToWire(s: SimpleAuthFormSettings) {
|
||||
return {
|
||||
servers: [{
|
||||
address: s.address,
|
||||
port: s.port,
|
||||
users: s.user ? [{ user: s.user, pass: s.pass }] : [],
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function wireguardToWire(s: WireguardOutboundFormSettings) {
|
||||
return {
|
||||
mtu: s.mtu || undefined,
|
||||
secretKey: s.secretKey,
|
||||
address: s.address ? s.address.split(',').map((x) => x.trim()).filter(Boolean) : [],
|
||||
workers: s.workers || undefined,
|
||||
domainStrategy: s.domainStrategy || undefined,
|
||||
reserved: s.reserved
|
||||
? s.reserved.split(',').map((x) => Number(x.trim())).filter((n) => Number.isFinite(n))
|
||||
: undefined,
|
||||
peers: s.peers.map((p) => ({
|
||||
publicKey: p.publicKey,
|
||||
preSharedKey: p.psk.length > 0 ? p.psk : undefined,
|
||||
allowedIPs: p.allowedIPs.length > 0 ? p.allowedIPs : undefined,
|
||||
endpoint: p.endpoint,
|
||||
keepAlive: p.keepAlive || undefined,
|
||||
})),
|
||||
noKernelTun: s.noKernelTun,
|
||||
};
|
||||
}
|
||||
|
||||
function hysteriaToWire(s: HysteriaOutboundFormSettings) {
|
||||
return { address: s.address, port: s.port, version: s.version };
|
||||
}
|
||||
|
||||
function freedomToWire(s: FreedomOutboundFormSettings) {
|
||||
// Legacy semantics: emit fragment only when the user actually populated
|
||||
// at least one of the four sub-fields. Defaults like packets='1-3' alone
|
||||
// are not enough — the modal's Fragment Switch sets all four together.
|
||||
const fragmentEntries = Object.entries(s.fragment).filter(([, v]) => v !== '' && v != null);
|
||||
const fragmentEnabled = !!s.fragment.length || !!s.fragment.interval || !!s.fragment.maxSplit;
|
||||
return {
|
||||
domainStrategy: s.domainStrategy || undefined,
|
||||
redirect: s.redirect || undefined,
|
||||
fragment: fragmentEnabled ? Object.fromEntries(fragmentEntries) : undefined,
|
||||
noises: s.noises.length > 0 ? s.noises : undefined,
|
||||
finalRules: s.finalRules.length > 0
|
||||
? s.finalRules.map((r) => ({
|
||||
action: r.action,
|
||||
network: r.network || undefined,
|
||||
port: r.port || undefined,
|
||||
ip: r.ip.length > 0 ? r.ip : undefined,
|
||||
blockDelay: r.action === 'block' && r.blockDelay ? r.blockDelay : undefined,
|
||||
}))
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function blackholeToWire(s: { type: '' | 'none' | 'http' }) {
|
||||
return { response: s.type ? { type: s.type } : undefined };
|
||||
}
|
||||
|
||||
function dnsRuleToWire(r: DnsRuleForm) {
|
||||
const action = ['direct', 'reject', 'rejectIPv4', 'rejectIPv6'].includes(r.action)
|
||||
? r.action
|
||||
: 'direct';
|
||||
const result: Raw = { action };
|
||||
const qtype = r.qtype.trim();
|
||||
if (qtype) {
|
||||
result.qtype = /^\d+$/.test(qtype) ? Number(qtype) : qtype;
|
||||
}
|
||||
const domains = r.domain.split(',').map((d) => d.trim()).filter(Boolean);
|
||||
if (domains.length > 0) result.domain = domains;
|
||||
return result;
|
||||
}
|
||||
|
||||
function dnsToWire(s: DnsOutboundFormSettings) {
|
||||
const result: Raw = {};
|
||||
if (s.rewriteNetwork) result.rewriteNetwork = s.rewriteNetwork;
|
||||
if (s.rewriteAddress) result.rewriteAddress = s.rewriteAddress;
|
||||
if (s.rewritePort) result.rewritePort = s.rewritePort;
|
||||
if (s.userLevel) result.userLevel = s.userLevel;
|
||||
if (s.rules.length > 0) result.rules = s.rules.map(dnsRuleToWire);
|
||||
return result;
|
||||
}
|
||||
|
||||
function loopbackToWire(s: LoopbackOutboundFormSettings) {
|
||||
return { inboundTag: s.inboundTag || undefined };
|
||||
}
|
||||
|
||||
// canEnableMux mirrors the legacy Outbound.canEnableMux().
|
||||
const MUX_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'shadowsocks', 'http', 'socks']);
|
||||
const STREAM_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'shadowsocks', 'hysteria']);
|
||||
|
||||
function dropEmptyStrings(obj: Raw): Raw {
|
||||
const out: Raw = {};
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (v === '') continue;
|
||||
out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function stripUiOnlyStreamFields(stream: unknown): Raw {
|
||||
const next = { ...(stream as Raw) };
|
||||
const xh = next.xhttpSettings;
|
||||
if (xh && typeof xh === 'object') {
|
||||
const cleaned = { ...(xh as Raw) };
|
||||
delete cleaned.enableXmux;
|
||||
next.xhttpSettings = dropEmptyStrings(cleaned);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function muxAllowed(values: OutboundFormValues): boolean {
|
||||
if (!MUX_PROTOCOLS.has(values.protocol)) return false;
|
||||
const flow = values.protocol === 'vless'
|
||||
? (values.settings as VlessOutboundFormSettings).flow
|
||||
: '';
|
||||
if (flow) return false;
|
||||
const network = values.streamSettings && 'network' in values.streamSettings
|
||||
? values.streamSettings.network
|
||||
: undefined;
|
||||
if (network === 'xhttp') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export type WireOutboundPayload = Raw;
|
||||
|
||||
export function formValuesToWirePayload(values: OutboundFormValues): WireOutboundPayload {
|
||||
let settings: Raw;
|
||||
switch (values.protocol) {
|
||||
case 'vmess': settings = vmessToWire(values.settings); break;
|
||||
case 'vless': settings = vlessToWire(values.settings); break;
|
||||
case 'trojan': settings = trojanToWire(values.settings); break;
|
||||
case 'shadowsocks': settings = shadowsocksToWire(values.settings); break;
|
||||
case 'socks': settings = simpleAuthToWire(values.settings); break;
|
||||
case 'http': settings = simpleAuthToWire(values.settings); break;
|
||||
case 'wireguard': settings = wireguardToWire(values.settings); break;
|
||||
case 'hysteria': settings = hysteriaToWire(values.settings); break;
|
||||
case 'freedom': settings = freedomToWire(values.settings); break;
|
||||
case 'blackhole': settings = blackholeToWire(values.settings); break;
|
||||
case 'dns': settings = dnsToWire(values.settings); break;
|
||||
case 'loopback': settings = loopbackToWire(values.settings); break;
|
||||
}
|
||||
|
||||
const result: Raw = {
|
||||
protocol: values.protocol,
|
||||
settings,
|
||||
};
|
||||
if (values.tag) result.tag = values.tag;
|
||||
|
||||
// streamSettings emission gates on canEnableStream — non-stream protocols
|
||||
// still emit just `sockopt` if that key is present (legacy behavior).
|
||||
if (values.streamSettings) {
|
||||
if (STREAM_PROTOCOLS.has(values.protocol)) {
|
||||
result.streamSettings = stripUiOnlyStreamFields(values.streamSettings);
|
||||
} else {
|
||||
const sockopt = (values.streamSettings as { sockopt?: unknown }).sockopt;
|
||||
if (sockopt) result.streamSettings = { sockopt };
|
||||
}
|
||||
}
|
||||
|
||||
if (values.sendThrough) result.sendThrough = values.sendThrough;
|
||||
// mux may be absent when the modal didn't render the Mux switch (non-
|
||||
// stream protocols or when isMuxAllowed gated it out). validateFields()
|
||||
// only returns registered fields, so values.mux can be undefined.
|
||||
if (values.mux?.enabled && muxAllowed(values)) {
|
||||
result.mux = values.mux;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
439
frontend/src/lib/xray/outbound-link-parser.ts
Normal file
439
frontend/src/lib/xray/outbound-link-parser.ts
Normal file
@@ -0,0 +1,439 @@
|
||||
import { Base64 } from '@/utils';
|
||||
|
||||
// Focused share-link parser for the OutboundFormModal's link-import
|
||||
// helper. Each parser returns a wire-shape outbound record (the same
|
||||
// shape OutboundsTab.tsx stores in templateSettings.outbounds[]) or
|
||||
// null when the input doesn't match.
|
||||
//
|
||||
// Scope: address + port + auth + remark, plus the network/security
|
||||
// fields the common vmess:// / vless:// links carry as query params.
|
||||
// XHTTP advanced fields (xPaddingBytes, scMaxEachPostBytes,
|
||||
// scMinPostsIntervalMs, uplinkChunkSize, noGRPCHeader) round-trip when
|
||||
// present in either the JSON or URL params. xmux, reality shortIds,
|
||||
// padding obfs key/header/placement, hysteria udphop are still left
|
||||
// to the user to fill in after import — the legacy Outbound.fromLink
|
||||
// was ~250 lines of dense edge-case handling we don't need to
|
||||
// replicate verbatim for the common phone-to-panel workflow.
|
||||
|
||||
type Raw = Record<string, unknown>;
|
||||
|
||||
// XHTTP knob keys grouped by wire type. Used by both the URL query-param
|
||||
// (vless/trojan) branch and the vmess JSON branch to consistently pull
|
||||
// the same set of advanced fields when present. Keep order ~stable to
|
||||
// match the schema's authoring order so diffs read naturally.
|
||||
const XHTTP_STRING_KEYS = [
|
||||
'xPaddingBytes', 'xPaddingKey', 'xPaddingHeader', 'xPaddingPlacement',
|
||||
'xPaddingMethod', 'sessionPlacement', 'sessionKey', 'seqPlacement',
|
||||
'seqKey', 'uplinkDataPlacement', 'uplinkDataKey', 'scMaxEachPostBytes',
|
||||
'scMinPostsIntervalMs', 'scStreamUpServerSecs', 'uplinkHTTPMethod',
|
||||
] as const;
|
||||
const XHTTP_NUMBER_KEYS = [
|
||||
'scMaxBufferedPosts', 'serverMaxHeaderBytes', 'uplinkChunkSize',
|
||||
] as const;
|
||||
const XHTTP_BOOL_KEYS = [
|
||||
'xPaddingObfsMode', 'noSSEHeader', 'noGRPCHeader',
|
||||
] as const;
|
||||
|
||||
function asBool(s: string | null): boolean | undefined {
|
||||
if (s === null) return undefined;
|
||||
return s === 'true' || s === '1';
|
||||
}
|
||||
|
||||
function applyXhttpStringFromParams(xhttp: Raw, params: URLSearchParams): void {
|
||||
// Precedence from lowest to highest: stream-init default →
|
||||
// x_padding_bytes snake_case alias → extra JSON payload →
|
||||
// explicit camelCase URL param. Apply in that order so each tier
|
||||
// overwrites the previous when present.
|
||||
const padBytesAlt = params.get('x_padding_bytes');
|
||||
if (padBytesAlt !== null && padBytesAlt !== '') {
|
||||
xhttp.xPaddingBytes = padBytesAlt;
|
||||
}
|
||||
// The inbound link bundles advanced xhttp knobs into `extra=<json>`.
|
||||
// Decode and merge so re-importing a share link round-trips the full
|
||||
// xhttp config (xPaddingBytes, scMaxEachPostBytes, sessionKey, etc.).
|
||||
const extra = params.get('extra');
|
||||
if (extra) {
|
||||
try {
|
||||
const parsed = JSON.parse(extra) as Record<string, unknown>;
|
||||
applyXhttpStringFromJson(xhttp, parsed);
|
||||
if (parsed.headers && typeof parsed.headers === 'object') {
|
||||
xhttp.headers = parsed.headers;
|
||||
}
|
||||
} catch {
|
||||
// malformed extra — silently ignore, the panel can still operate
|
||||
// on the rest of the link
|
||||
}
|
||||
}
|
||||
for (const k of XHTTP_STRING_KEYS) {
|
||||
const v = params.get(k);
|
||||
if (v !== null && v !== '') xhttp[k] = v;
|
||||
}
|
||||
for (const k of XHTTP_NUMBER_KEYS) {
|
||||
const v = params.get(k);
|
||||
if (v !== null && v !== '') xhttp[k] = Number(v) || 0;
|
||||
}
|
||||
for (const k of XHTTP_BOOL_KEYS) {
|
||||
const v = params.get(k);
|
||||
if (v !== null && v !== '') xhttp[k] = asBool(v);
|
||||
}
|
||||
}
|
||||
|
||||
function applyXhttpStringFromJson(xhttp: Raw, json: Record<string, unknown>): void {
|
||||
for (const k of XHTTP_STRING_KEYS) {
|
||||
if (typeof json[k] === 'string') xhttp[k] = json[k];
|
||||
}
|
||||
for (const k of XHTTP_NUMBER_KEYS) {
|
||||
if (typeof json[k] === 'number') xhttp[k] = json[k];
|
||||
}
|
||||
for (const k of XHTTP_BOOL_KEYS) {
|
||||
if (typeof json[k] === 'boolean') xhttp[k] = json[k];
|
||||
}
|
||||
}
|
||||
|
||||
function buildStream(network: string, security: string): Raw {
|
||||
const stream: Raw = { network, security };
|
||||
switch (network) {
|
||||
case 'tcp':
|
||||
stream.tcpSettings = { header: { type: 'none' } };
|
||||
break;
|
||||
case 'kcp':
|
||||
stream.kcpSettings = {
|
||||
mtu: 1350, tti: 20, uplinkCapacity: 5, downlinkCapacity: 20,
|
||||
cwndMultiplier: 1, maxSendingWindow: 2097152,
|
||||
};
|
||||
break;
|
||||
case 'ws':
|
||||
stream.wsSettings = { path: '/', host: '', headers: {}, heartbeatPeriod: 0 };
|
||||
break;
|
||||
case 'grpc':
|
||||
stream.grpcSettings = { serviceName: '', authority: '', multiMode: false };
|
||||
break;
|
||||
case 'httpupgrade':
|
||||
stream.httpupgradeSettings = { path: '/', host: '', headers: {} };
|
||||
break;
|
||||
case 'xhttp':
|
||||
stream.xhttpSettings = {
|
||||
path: '/', host: '', mode: 'auto', headers: {},
|
||||
xPaddingBytes: '100-1000', scMaxEachPostBytes: '1000000',
|
||||
};
|
||||
break;
|
||||
default:
|
||||
stream.tcpSettings = { header: { type: 'none' } };
|
||||
}
|
||||
if (security === 'tls') {
|
||||
stream.tlsSettings = {
|
||||
serverName: '', alpn: [], fingerprint: '',
|
||||
echConfigList: '', verifyPeerCertByName: '', pinnedPeerCertSha256: '',
|
||||
};
|
||||
} else if (security === 'reality') {
|
||||
stream.realitySettings = {
|
||||
publicKey: '', fingerprint: 'chrome', serverName: '',
|
||||
shortId: '', spiderX: '', mldsa65Verify: '',
|
||||
};
|
||||
}
|
||||
return stream;
|
||||
}
|
||||
|
||||
function applyTransportParams(stream: Raw, params: URLSearchParams): void {
|
||||
const network = stream.network as string;
|
||||
const host = params.get('host') ?? '';
|
||||
const path = params.get('path') ?? '/';
|
||||
switch (network) {
|
||||
case 'ws':
|
||||
(stream.wsSettings as Raw).host = host;
|
||||
(stream.wsSettings as Raw).path = path;
|
||||
break;
|
||||
case 'grpc': {
|
||||
const grpc = stream.grpcSettings as Raw;
|
||||
const serviceName = params.get('serviceName') ?? params.get('path') ?? '';
|
||||
grpc.serviceName = serviceName;
|
||||
grpc.authority = params.get('authority') ?? '';
|
||||
grpc.multiMode = params.get('mode') === 'multi';
|
||||
break;
|
||||
}
|
||||
case 'httpupgrade':
|
||||
(stream.httpupgradeSettings as Raw).host = host;
|
||||
(stream.httpupgradeSettings as Raw).path = path;
|
||||
break;
|
||||
case 'xhttp': {
|
||||
const xhttp = stream.xhttpSettings as Raw;
|
||||
xhttp.host = host;
|
||||
xhttp.path = path;
|
||||
if (params.get('mode')) xhttp.mode = params.get('mode');
|
||||
applyXhttpStringFromParams(xhttp, params);
|
||||
break;
|
||||
}
|
||||
case 'tcp':
|
||||
// vless/trojan TCP HTTP camouflage rides on header=http+host+path
|
||||
if (params.get('headerType') === 'http' || params.get('type') === 'http') {
|
||||
(stream.tcpSettings as Raw).header = {
|
||||
type: 'http',
|
||||
request: {
|
||||
version: '1.1',
|
||||
method: 'GET',
|
||||
path: path.split(',').filter(Boolean),
|
||||
headers: host ? { Host: host.split(',').filter(Boolean) } : {},
|
||||
},
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// The inbound link emits the entire finalmask object as a JSON-encoded
|
||||
// `fm` query param. Decode and attach to streamSettings so udpHop /
|
||||
// quicParams / tcp+udp masks round-trip on outbound import.
|
||||
function applyFinalMaskParam(stream: Raw, params: URLSearchParams): void {
|
||||
const fm = params.get('fm');
|
||||
if (!fm) return;
|
||||
try {
|
||||
const parsed = JSON.parse(fm) as Record<string, unknown>;
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
stream.finalmask = parsed;
|
||||
}
|
||||
} catch {
|
||||
// malformed fm — leave streamSettings.finalmask absent
|
||||
}
|
||||
}
|
||||
|
||||
function applySecurityParams(stream: Raw, params: URLSearchParams): void {
|
||||
if (stream.security === 'tls') {
|
||||
const tls = stream.tlsSettings as Raw;
|
||||
tls.serverName = params.get('sni') ?? '';
|
||||
tls.fingerprint = params.get('fp') ?? '';
|
||||
const alpn = params.get('alpn');
|
||||
if (alpn) tls.alpn = alpn.split(',');
|
||||
} else if (stream.security === 'reality') {
|
||||
const reality = stream.realitySettings as Raw;
|
||||
reality.serverName = params.get('sni') ?? '';
|
||||
reality.fingerprint = params.get('fp') ?? 'chrome';
|
||||
reality.publicKey = params.get('pbk') ?? '';
|
||||
reality.shortId = params.get('sid') ?? '';
|
||||
reality.spiderX = params.get('spx') ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
function decodeRemark(url: URL): string {
|
||||
try {
|
||||
return decodeURIComponent(url.hash.replace(/^#/, ''));
|
||||
} catch {
|
||||
return url.hash.replace(/^#/, '');
|
||||
}
|
||||
}
|
||||
|
||||
export function parseVmessLink(link: string): Raw | null {
|
||||
if (!link.startsWith('vmess://')) return null;
|
||||
try {
|
||||
const decoded = Base64.decode(link.slice('vmess://'.length));
|
||||
const json = JSON.parse(decoded) as Record<string, unknown>;
|
||||
const network = (json.net as string) || 'tcp';
|
||||
const security = json.tls === 'tls' ? 'tls' : 'none';
|
||||
const stream = buildStream(network, security);
|
||||
// Map the vmess JSON's net-specific keys onto the stream branch.
|
||||
if (network === 'tcp' && json.type === 'http') {
|
||||
(stream.tcpSettings as Raw).header = {
|
||||
type: 'http',
|
||||
request: {
|
||||
version: '1.1', method: 'GET',
|
||||
path: (json.path as string ?? '/').split(',').filter(Boolean),
|
||||
headers: json.host ? { Host: (json.host as string).split(',').filter(Boolean) } : {},
|
||||
},
|
||||
};
|
||||
} else if (network === 'ws') {
|
||||
(stream.wsSettings as Raw).host = json.host ?? '';
|
||||
(stream.wsSettings as Raw).path = json.path ?? '/';
|
||||
} else if (network === 'grpc') {
|
||||
(stream.grpcSettings as Raw).serviceName = json.path ?? '';
|
||||
(stream.grpcSettings as Raw).authority = json.authority ?? '';
|
||||
(stream.grpcSettings as Raw).multiMode = json.type === 'multi';
|
||||
} else if (network === 'httpupgrade') {
|
||||
(stream.httpupgradeSettings as Raw).host = json.host ?? '';
|
||||
(stream.httpupgradeSettings as Raw).path = json.path ?? '/';
|
||||
} else if (network === 'xhttp') {
|
||||
const xhttp = stream.xhttpSettings as Raw;
|
||||
xhttp.host = json.host ?? '';
|
||||
xhttp.path = json.path ?? '/';
|
||||
if (json.mode) xhttp.mode = json.mode;
|
||||
applyXhttpStringFromJson(xhttp, json);
|
||||
}
|
||||
if (security === 'tls') {
|
||||
const tls = stream.tlsSettings as Raw;
|
||||
tls.serverName = json.sni ?? '';
|
||||
tls.fingerprint = json.fp ?? '';
|
||||
if (json.alpn) tls.alpn = (json.alpn as string).split(',');
|
||||
}
|
||||
|
||||
const port = Number(json.port) || 443;
|
||||
return {
|
||||
protocol: 'vmess',
|
||||
tag: typeof json.ps === 'string' ? json.ps : '',
|
||||
settings: {
|
||||
vnext: [{
|
||||
address: json.add ?? '',
|
||||
port,
|
||||
users: [{ id: json.id ?? '', security: (json.scy as string) || 'auto' }],
|
||||
}],
|
||||
},
|
||||
streamSettings: stream,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseUrlLink(link: string, expectedProto: string): URL | null {
|
||||
try {
|
||||
const url = new URL(link);
|
||||
if (url.protocol.replace(/:$/, '') !== expectedProto) return null;
|
||||
return url;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseVlessLink(link: string): Raw | null {
|
||||
const url = parseUrlLink(link, 'vless');
|
||||
if (!url) return null;
|
||||
const id = url.username;
|
||||
const address = url.hostname;
|
||||
const port = Number(url.port) || 443;
|
||||
const params = url.searchParams;
|
||||
const network = params.get('type') ?? 'tcp';
|
||||
const security = (params.get('security') ?? 'none') as string;
|
||||
const stream = buildStream(network, security);
|
||||
applyTransportParams(stream, params);
|
||||
applySecurityParams(stream, params);
|
||||
applyFinalMaskParam(stream, params);
|
||||
return {
|
||||
protocol: 'vless',
|
||||
tag: decodeRemark(url),
|
||||
settings: {
|
||||
address,
|
||||
port,
|
||||
id,
|
||||
flow: params.get('flow') ?? '',
|
||||
encryption: params.get('encryption') ?? 'none',
|
||||
},
|
||||
streamSettings: stream,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseTrojanLink(link: string): Raw | null {
|
||||
const url = parseUrlLink(link, 'trojan');
|
||||
if (!url) return null;
|
||||
const password = url.username;
|
||||
const address = url.hostname;
|
||||
const port = Number(url.port) || 443;
|
||||
const params = url.searchParams;
|
||||
const network = params.get('type') ?? 'tcp';
|
||||
const security = (params.get('security') ?? 'tls') as string;
|
||||
const stream = buildStream(network, security);
|
||||
applyTransportParams(stream, params);
|
||||
applySecurityParams(stream, params);
|
||||
applyFinalMaskParam(stream, params);
|
||||
return {
|
||||
protocol: 'trojan',
|
||||
tag: decodeRemark(url),
|
||||
settings: {
|
||||
servers: [{ address, port, password }],
|
||||
},
|
||||
streamSettings: stream,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseShadowsocksLink(link: string): Raw | null {
|
||||
if (!link.startsWith('ss://')) return null;
|
||||
// Two link shapes coexist:
|
||||
// modern: ss://base64(method:password)@host:port#remark
|
||||
// legacy: ss://base64(method:password@host:port)#remark
|
||||
// Try modern first; fall back to legacy decode of the whole userinfo+host.
|
||||
let userInfo: string;
|
||||
let host: string;
|
||||
let port: number;
|
||||
let remark = '';
|
||||
const hashIndex = link.indexOf('#');
|
||||
const linkNoHash = hashIndex >= 0 ? link.slice(0, hashIndex) : link;
|
||||
if (hashIndex >= 0) {
|
||||
try { remark = decodeURIComponent(link.slice(hashIndex + 1)); } catch { remark = ''; }
|
||||
}
|
||||
const atIndex = linkNoHash.indexOf('@');
|
||||
if (atIndex >= 0) {
|
||||
try { userInfo = Base64.decode(linkNoHash.slice('ss://'.length, atIndex)); }
|
||||
catch { userInfo = linkNoHash.slice('ss://'.length, atIndex); }
|
||||
const hostPort = linkNoHash.slice(atIndex + 1);
|
||||
const colon = hostPort.lastIndexOf(':');
|
||||
if (colon < 0) return null;
|
||||
host = hostPort.slice(0, colon);
|
||||
port = Number(hostPort.slice(colon + 1)) || 443;
|
||||
} else {
|
||||
let decoded: string;
|
||||
try { decoded = Base64.decode(linkNoHash.slice('ss://'.length)); }
|
||||
catch { return null; }
|
||||
const at = decoded.indexOf('@');
|
||||
if (at < 0) return null;
|
||||
userInfo = decoded.slice(0, at);
|
||||
const hostPort = decoded.slice(at + 1);
|
||||
const colon = hostPort.lastIndexOf(':');
|
||||
if (colon < 0) return null;
|
||||
host = hostPort.slice(0, colon);
|
||||
port = Number(hostPort.slice(colon + 1)) || 443;
|
||||
}
|
||||
const sep = userInfo.indexOf(':');
|
||||
const method = sep < 0 ? '2022-blake3-aes-128-gcm' : userInfo.slice(0, sep);
|
||||
const password = sep < 0 ? userInfo : userInfo.slice(sep + 1);
|
||||
return {
|
||||
protocol: 'shadowsocks',
|
||||
tag: remark,
|
||||
settings: {
|
||||
servers: [{ address: host, port, password, method }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function parseHysteria2Link(link: string): Raw | null {
|
||||
const url = parseUrlLink(link, 'hysteria2') ?? parseUrlLink(link, 'hy2');
|
||||
if (!url) return null;
|
||||
// hysteria2's auth rides as the URL userinfo. The streamSettings
|
||||
// network branch is the dedicated 'hysteria' transport — the modal's
|
||||
// newStreamSlice('hysteria') initializer fills in receive-window
|
||||
// defaults; we override the user-set fields here.
|
||||
const auth = url.username;
|
||||
const address = url.hostname;
|
||||
const port = Number(url.port) || 443;
|
||||
const params = url.searchParams;
|
||||
const stream: Raw = {
|
||||
network: 'hysteria',
|
||||
security: 'tls',
|
||||
hysteriaSettings: {
|
||||
version: 2, auth, udpIdleTimeout: 60,
|
||||
},
|
||||
tlsSettings: {
|
||||
serverName: params.get('sni') ?? '',
|
||||
alpn: ['h3'],
|
||||
fingerprint: '',
|
||||
echConfigList: '',
|
||||
verifyPeerCertByName: '',
|
||||
pinnedPeerCertSha256: params.get('pinSHA256') ?? '',
|
||||
},
|
||||
};
|
||||
return {
|
||||
protocol: 'hysteria',
|
||||
tag: decodeRemark(url),
|
||||
settings: { address, port, version: 2 },
|
||||
streamSettings: stream,
|
||||
};
|
||||
}
|
||||
|
||||
// Dispatcher — first non-null parser wins. Returns null when no parser
|
||||
// recognizes the link's protocol scheme.
|
||||
export function parseOutboundLink(link: string): Raw | null {
|
||||
const trimmed = link.trim();
|
||||
if (!trimmed) return null;
|
||||
return (
|
||||
parseVmessLink(trimmed)
|
||||
?? parseVlessLink(trimmed)
|
||||
?? parseTrojanLink(trimmed)
|
||||
?? parseShadowsocksLink(trimmed)
|
||||
?? parseHysteria2Link(trimmed)
|
||||
);
|
||||
}
|
||||
74
frontend/src/lib/xray/protocol-capabilities.ts
Normal file
74
frontend/src/lib/xray/protocol-capabilities.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
// Pure-function ports of the legacy Inbound class capability predicates
|
||||
// (canEnableTls, canEnableReality, canEnableTlsFlow, canEnableStream,
|
||||
// canEnableVisionSeed, isSS2022, isSSMultiUser). Each accepts the minimal
|
||||
// slice of an InboundFormValues it needs, so the same predicate can be
|
||||
// called against a partial-row, a full form value, or a hand-built test
|
||||
// fixture without the caller projecting a whole object.
|
||||
|
||||
const TLS_ELIGIBLE_PROTOCOLS = ['vmess', 'vless', 'trojan', 'shadowsocks'];
|
||||
const TLS_NETWORKS = ['tcp', 'ws', 'http', 'grpc', 'httpupgrade', 'xhttp'];
|
||||
const REALITY_ELIGIBLE_PROTOCOLS = ['vless', 'trojan'];
|
||||
const REALITY_NETWORKS = ['tcp', 'http', 'grpc', 'xhttp'];
|
||||
const STREAM_PROTOCOLS = ['vmess', 'vless', 'trojan', 'shadowsocks', 'hysteria'];
|
||||
const VISION_FLOW = 'xtls-rprx-vision';
|
||||
const SS_2022_PREFIX = '2022';
|
||||
const SS_BLAKE3_CHACHA20 = '2022-blake3-chacha20-poly1305';
|
||||
|
||||
export interface CapabilityProtocolSlice {
|
||||
protocol: string;
|
||||
streamSettings?: { network?: string; security?: string };
|
||||
}
|
||||
|
||||
export interface CapabilityVlessSlice extends CapabilityProtocolSlice {
|
||||
settings?: { clients?: { flow?: string }[] };
|
||||
}
|
||||
|
||||
export interface CapabilityShadowsocksSlice {
|
||||
protocol: string;
|
||||
settings?: { method?: string };
|
||||
}
|
||||
|
||||
export function canEnableTls(values: CapabilityProtocolSlice): boolean {
|
||||
if (values.protocol === 'hysteria') return true;
|
||||
if (!TLS_ELIGIBLE_PROTOCOLS.includes(values.protocol)) return false;
|
||||
return TLS_NETWORKS.includes(values.streamSettings?.network ?? '');
|
||||
}
|
||||
|
||||
export function canEnableReality(values: CapabilityProtocolSlice): boolean {
|
||||
if (!REALITY_ELIGIBLE_PROTOCOLS.includes(values.protocol)) return false;
|
||||
return REALITY_NETWORKS.includes(values.streamSettings?.network ?? '');
|
||||
}
|
||||
|
||||
export function canEnableTlsFlow(values: CapabilityProtocolSlice): boolean {
|
||||
const security = values.streamSettings?.security;
|
||||
if (security !== 'tls' && security !== 'reality') return false;
|
||||
if (values.streamSettings?.network !== 'tcp') return false;
|
||||
return values.protocol === 'vless';
|
||||
}
|
||||
|
||||
export function canEnableStream(values: { protocol: string }): boolean {
|
||||
return STREAM_PROTOCOLS.includes(values.protocol);
|
||||
}
|
||||
|
||||
// Vision seed applies only when XTLS Vision (TCP/TLS) flow is selected
|
||||
// AND at least one VLESS client uses the vision flow. Excludes UDP variant.
|
||||
export function canEnableVisionSeed(values: CapabilityVlessSlice): boolean {
|
||||
if (!canEnableTlsFlow(values)) return false;
|
||||
const clients = values.settings?.clients;
|
||||
if (!Array.isArray(clients)) return false;
|
||||
return clients.some((c) => c?.flow === VISION_FLOW);
|
||||
}
|
||||
|
||||
// Why: legacy returns true on non-SS protocols too (the method getter
|
||||
// resolves to "" and "" !== blake3-chacha20-poly1305). Preserved for
|
||||
// parity with the legacy class; in practice the callers all narrow on
|
||||
// protocol === shadowsocks before checking.
|
||||
export function isSSMultiUser(values: CapabilityShadowsocksSlice): boolean {
|
||||
const method = values.protocol === 'shadowsocks' ? (values.settings?.method ?? '') : '';
|
||||
return method !== SS_BLAKE3_CHACHA20;
|
||||
}
|
||||
|
||||
export function isSS2022(values: CapabilityShadowsocksSlice): boolean {
|
||||
const method = values.protocol === 'shadowsocks' ? (values.settings?.method ?? '') : '';
|
||||
return method.substring(0, 4) === SS_2022_PREFIX;
|
||||
}
|
||||
69
frontend/src/lib/xray/stream-defaults.ts
Normal file
69
frontend/src/lib/xray/stream-defaults.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
GrpcStreamSettingsSchema,
|
||||
HttpUpgradeStreamSettingsSchema,
|
||||
HysteriaStreamSettingsSchema,
|
||||
KcpStreamSettingsSchema,
|
||||
TcpStreamSettingsSchema,
|
||||
WsStreamSettingsSchema,
|
||||
XHttpStreamSettingsSchema,
|
||||
} from '@/schemas/protocols/stream';
|
||||
import {
|
||||
RealityStreamSettingsSchema,
|
||||
TlsStreamSettingsSchema,
|
||||
} from '@/schemas/protocols/security';
|
||||
|
||||
const NETWORK_KEY_MAP = {
|
||||
tcp: 'tcpSettings',
|
||||
kcp: 'kcpSettings',
|
||||
ws: 'wsSettings',
|
||||
grpc: 'grpcSettings',
|
||||
httpupgrade: 'httpupgradeSettings',
|
||||
xhttp: 'xhttpSettings',
|
||||
hysteria: 'hysteriaSettings',
|
||||
} as const;
|
||||
|
||||
type SchemaWithParse = { safeParse: (v: unknown) => { success: boolean; data?: unknown } };
|
||||
|
||||
function parseOrDefault(schema: SchemaWithParse, value: unknown): unknown {
|
||||
const parsed = schema.safeParse(value ?? {});
|
||||
if (parsed.success) return parsed.data;
|
||||
const fallback = schema.safeParse({});
|
||||
return fallback.success ? fallback.data : value;
|
||||
}
|
||||
|
||||
function networkSchemaFor(network: string): SchemaWithParse | null {
|
||||
switch (network) {
|
||||
case 'tcp': return TcpStreamSettingsSchema;
|
||||
case 'kcp': return KcpStreamSettingsSchema;
|
||||
case 'ws': return WsStreamSettingsSchema;
|
||||
case 'grpc': return GrpcStreamSettingsSchema;
|
||||
case 'httpupgrade': return HttpUpgradeStreamSettingsSchema;
|
||||
case 'xhttp': return XHttpStreamSettingsSchema;
|
||||
case 'hysteria': return HysteriaStreamSettingsSchema;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
function securitySchemaFor(security: string): { key: string; schema: SchemaWithParse } | null {
|
||||
switch (security) {
|
||||
case 'tls': return { key: 'tlsSettings', schema: TlsStreamSettingsSchema };
|
||||
case 'reality': return { key: 'realitySettings', schema: RealityStreamSettingsSchema };
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function fillStreamDefaults(stream: Record<string, unknown>): Record<string, unknown> {
|
||||
const network = (stream.network as string | undefined) ?? 'tcp';
|
||||
const security = (stream.security as string | undefined) ?? 'none';
|
||||
const out: Record<string, unknown> = { ...stream, network, security };
|
||||
const subKey = NETWORK_KEY_MAP[network as keyof typeof NETWORK_KEY_MAP];
|
||||
const netSchema = networkSchemaFor(network);
|
||||
if (subKey && netSchema) {
|
||||
out[subKey] = parseOrDefault(netSchema, out[subKey]);
|
||||
}
|
||||
const sec = securitySchemaFor(security);
|
||||
if (sec) {
|
||||
out[sec.key] = parseOrDefault(sec.schema, out[sec.key]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
import '@/styles/utils.css';
|
||||
import '@/styles/page-shell.css';
|
||||
import '@/styles/page-cards.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import { setupAxios } from '@/api/axios-init';
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import IndexPage from '@/pages/index/IndexPage';
|
||||
import { QueryProvider } from '@/api/QueryProvider';
|
||||
import { router } from '@/routes';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
@@ -21,7 +24,9 @@ readyI18n().then(() => {
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<IndexPage />
|
||||
<QueryProvider>
|
||||
<RouterProvider router={router} />
|
||||
</QueryProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { ObjectUtil, NumberFormatter, SizeFormatter } from '@/utils';
|
||||
import { Inbound, Protocols } from './inbound.js';
|
||||
|
||||
export function coerceInboundJsonField(value) {
|
||||
if (value == null) return {};
|
||||
if (typeof value === 'object') return value;
|
||||
if (typeof value !== 'string') return {};
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === '') return {};
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch (_e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export class DBInbound {
|
||||
|
||||
constructor(data) {
|
||||
this.id = 0;
|
||||
this.userId = 0;
|
||||
this.up = 0;
|
||||
this.down = 0;
|
||||
this.total = 0;
|
||||
this.remark = "";
|
||||
this.enable = true;
|
||||
this.expiryTime = 0;
|
||||
this.trafficReset = "never";
|
||||
this.lastTrafficResetTime = 0;
|
||||
|
||||
this.listen = "";
|
||||
this.port = 0;
|
||||
this.protocol = "";
|
||||
this.settings = "";
|
||||
this.streamSettings = "";
|
||||
this.tag = "";
|
||||
this.sniffing = "";
|
||||
this.clientStats = ""
|
||||
// Optional FK to web/runtime registered Node. null/undefined =
|
||||
// local panel; otherwise the inbound lives on the named node.
|
||||
this.nodeId = null;
|
||||
// Populated by the API when this inbound is a fallback child of
|
||||
// a VLESS/Trojan TCP-TLS master. Shape: { masterId, path }.
|
||||
this.fallbackParent = null;
|
||||
if (data == null) {
|
||||
return;
|
||||
}
|
||||
ObjectUtil.cloneProps(this, data);
|
||||
}
|
||||
|
||||
get totalGB() {
|
||||
return NumberFormatter.toFixed(this.total / SizeFormatter.ONE_GB, 2);
|
||||
}
|
||||
|
||||
set totalGB(gb) {
|
||||
this.total = NumberFormatter.toFixed(gb * SizeFormatter.ONE_GB, 0);
|
||||
}
|
||||
|
||||
get isVMess() {
|
||||
return this.protocol === Protocols.VMESS;
|
||||
}
|
||||
|
||||
get isVLess() {
|
||||
return this.protocol === Protocols.VLESS;
|
||||
}
|
||||
|
||||
get isTrojan() {
|
||||
return this.protocol === Protocols.TROJAN;
|
||||
}
|
||||
|
||||
get isSS() {
|
||||
return this.protocol === Protocols.SHADOWSOCKS;
|
||||
}
|
||||
|
||||
get isMixed() {
|
||||
return this.protocol === Protocols.MIXED;
|
||||
}
|
||||
|
||||
get isHTTP() {
|
||||
return this.protocol === Protocols.HTTP;
|
||||
}
|
||||
|
||||
get isWireguard() {
|
||||
return this.protocol === Protocols.WIREGUARD;
|
||||
}
|
||||
|
||||
get isHysteria() {
|
||||
return this.protocol === Protocols.HYSTERIA;
|
||||
}
|
||||
|
||||
get address() {
|
||||
let address = location.hostname;
|
||||
if (!ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0") {
|
||||
address = this.listen;
|
||||
}
|
||||
return address;
|
||||
}
|
||||
|
||||
get _expiryTime() {
|
||||
if (this.expiryTime === 0) {
|
||||
return null;
|
||||
}
|
||||
return dayjs(this.expiryTime);
|
||||
}
|
||||
|
||||
set _expiryTime(t) {
|
||||
if (t == null) {
|
||||
this.expiryTime = 0;
|
||||
} else {
|
||||
this.expiryTime = t.valueOf();
|
||||
}
|
||||
}
|
||||
|
||||
get isExpiry() {
|
||||
return this.expiryTime < new Date().getTime();
|
||||
}
|
||||
|
||||
invalidateCache() {
|
||||
this._cachedInbound = null;
|
||||
this._clientStatsMap = null;
|
||||
}
|
||||
|
||||
toInbound() {
|
||||
if (this._cachedInbound) {
|
||||
return this._cachedInbound;
|
||||
}
|
||||
|
||||
const settings = coerceInboundJsonField(this.settings);
|
||||
const streamSettings = coerceInboundJsonField(this.streamSettings);
|
||||
const sniffing = coerceInboundJsonField(this.sniffing);
|
||||
|
||||
const config = {
|
||||
port: this.port,
|
||||
listen: this.listen,
|
||||
protocol: this.protocol,
|
||||
settings: settings,
|
||||
streamSettings: streamSettings,
|
||||
tag: this.tag,
|
||||
sniffing: sniffing,
|
||||
clientStats: this.clientStats,
|
||||
};
|
||||
|
||||
this._cachedInbound = Inbound.fromJson(config);
|
||||
return this._cachedInbound;
|
||||
}
|
||||
|
||||
getClientStats(email) {
|
||||
if (!this._clientStatsMap) {
|
||||
this._clientStatsMap = new Map();
|
||||
if (this.clientStats && Array.isArray(this.clientStats)) {
|
||||
for (const stats of this.clientStats) {
|
||||
this._clientStatsMap.set(stats.email, stats);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this._clientStatsMap.get(email);
|
||||
}
|
||||
|
||||
isMultiUser() {
|
||||
switch (this.protocol) {
|
||||
case Protocols.VMESS:
|
||||
case Protocols.VLESS:
|
||||
case Protocols.TROJAN:
|
||||
case Protocols.HYSTERIA:
|
||||
return true;
|
||||
case Protocols.SHADOWSOCKS:
|
||||
return this.toInbound().isSSMultiUser;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
hasLink() {
|
||||
switch (this.protocol) {
|
||||
case Protocols.VMESS:
|
||||
case Protocols.VLESS:
|
||||
case Protocols.TROJAN:
|
||||
case Protocols.SHADOWSOCKS:
|
||||
case Protocols.HYSTERIA:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
genInboundLinks(remarkModel, hostOverride = '') {
|
||||
const inbound = this.toInbound();
|
||||
return inbound.genInboundLinks(this.remark, remarkModel, hostOverride);
|
||||
}
|
||||
}
|
||||
207
frontend/src/models/dbinbound.ts
Normal file
207
frontend/src/models/dbinbound.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { ObjectUtil, NumberFormatter, SizeFormatter } from '@/utils';
|
||||
import { Protocols } from '@/schemas/primitives';
|
||||
|
||||
export type RawJsonField = string | Record<string, unknown> | unknown[];
|
||||
|
||||
export interface ClientStats {
|
||||
email: string;
|
||||
up: number;
|
||||
down: number;
|
||||
total: number;
|
||||
expiryTime: number;
|
||||
enable?: boolean;
|
||||
inboundId?: number;
|
||||
reset?: number;
|
||||
}
|
||||
|
||||
export interface FallbackParentRef {
|
||||
masterId: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export type DBInboundInit = Partial<{
|
||||
id: number;
|
||||
userId: number;
|
||||
up: number;
|
||||
down: number;
|
||||
total: number;
|
||||
remark: string;
|
||||
enable: boolean;
|
||||
expiryTime: number;
|
||||
trafficReset: string;
|
||||
lastTrafficResetTime: number;
|
||||
listen: string;
|
||||
port: number;
|
||||
protocol: string;
|
||||
settings: RawJsonField;
|
||||
streamSettings: RawJsonField;
|
||||
tag: string;
|
||||
sniffing: RawJsonField;
|
||||
clientStats: ClientStats[];
|
||||
nodeId: number | null;
|
||||
fallbackParent: FallbackParentRef | null;
|
||||
}>;
|
||||
|
||||
export function coerceInboundJsonField(value: unknown): Record<string, unknown> {
|
||||
if (value == null) return {};
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
if (typeof value !== 'string') return {};
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === '') return {};
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
return {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export class DBInbound {
|
||||
id: number;
|
||||
userId: number;
|
||||
up: number;
|
||||
down: number;
|
||||
total: number;
|
||||
remark: string;
|
||||
enable: boolean;
|
||||
expiryTime: number;
|
||||
trafficReset: string;
|
||||
lastTrafficResetTime: number;
|
||||
|
||||
listen: string;
|
||||
port: number;
|
||||
protocol: string;
|
||||
settings: RawJsonField;
|
||||
streamSettings: RawJsonField;
|
||||
tag: string;
|
||||
sniffing: RawJsonField;
|
||||
clientStats: ClientStats[];
|
||||
nodeId: number | null;
|
||||
fallbackParent: FallbackParentRef | null;
|
||||
|
||||
private _clientStatsMap: Map<string, ClientStats> | null = null;
|
||||
|
||||
constructor(data?: DBInboundInit) {
|
||||
this.id = 0;
|
||||
this.userId = 0;
|
||||
this.up = 0;
|
||||
this.down = 0;
|
||||
this.total = 0;
|
||||
this.remark = "";
|
||||
this.enable = true;
|
||||
this.expiryTime = 0;
|
||||
this.trafficReset = "never";
|
||||
this.lastTrafficResetTime = 0;
|
||||
|
||||
this.listen = "";
|
||||
this.port = 0;
|
||||
this.protocol = "";
|
||||
this.settings = "";
|
||||
this.streamSettings = "";
|
||||
this.tag = "";
|
||||
this.sniffing = "";
|
||||
this.clientStats = [];
|
||||
this.nodeId = null;
|
||||
this.fallbackParent = null;
|
||||
if (data == null) {
|
||||
return;
|
||||
}
|
||||
ObjectUtil.cloneProps(this, data);
|
||||
}
|
||||
|
||||
get totalGB(): number {
|
||||
return NumberFormatter.toFixed(this.total / SizeFormatter.ONE_GB, 2);
|
||||
}
|
||||
|
||||
set totalGB(gb: number) {
|
||||
this.total = NumberFormatter.toFixed(gb * SizeFormatter.ONE_GB, 0);
|
||||
}
|
||||
|
||||
get isVMess() {
|
||||
return this.protocol === Protocols.VMESS;
|
||||
}
|
||||
|
||||
get isVLess() {
|
||||
return this.protocol === Protocols.VLESS;
|
||||
}
|
||||
|
||||
get isTrojan() {
|
||||
return this.protocol === Protocols.TROJAN;
|
||||
}
|
||||
|
||||
get isSS() {
|
||||
return this.protocol === Protocols.SHADOWSOCKS;
|
||||
}
|
||||
|
||||
get isMixed() {
|
||||
return this.protocol === Protocols.MIXED;
|
||||
}
|
||||
|
||||
get isHTTP() {
|
||||
return this.protocol === Protocols.HTTP;
|
||||
}
|
||||
|
||||
get isWireguard() {
|
||||
return this.protocol === Protocols.WIREGUARD;
|
||||
}
|
||||
|
||||
get isHysteria() {
|
||||
return this.protocol === Protocols.HYSTERIA;
|
||||
}
|
||||
|
||||
get isTunnel() {
|
||||
return this.protocol === Protocols.TUNNEL;
|
||||
}
|
||||
|
||||
get address(): string {
|
||||
let address = location.hostname;
|
||||
if (!ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0") {
|
||||
address = this.listen;
|
||||
}
|
||||
return address;
|
||||
}
|
||||
|
||||
get _expiryTime(): Dayjs | null {
|
||||
if (this.expiryTime === 0) {
|
||||
return null;
|
||||
}
|
||||
return dayjs(this.expiryTime);
|
||||
}
|
||||
|
||||
set _expiryTime(t: Dayjs | null | undefined) {
|
||||
if (t == null) {
|
||||
this.expiryTime = 0;
|
||||
} else {
|
||||
this.expiryTime = t.valueOf();
|
||||
}
|
||||
}
|
||||
|
||||
get isExpiry(): boolean {
|
||||
return this.expiryTime < new Date().getTime();
|
||||
}
|
||||
|
||||
invalidateCache(): void {
|
||||
this._clientStatsMap = null;
|
||||
}
|
||||
|
||||
getClientStats(email: string): ClientStats | undefined {
|
||||
if (!this._clientStatsMap) {
|
||||
this._clientStatsMap = new Map();
|
||||
if (Array.isArray(this.clientStats)) {
|
||||
for (const stats of this.clientStats) {
|
||||
if (stats && stats.email) {
|
||||
this._clientStatsMap.set(stats.email, stats);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return this._clientStatsMap.get(email);
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,24 +0,0 @@
|
||||
// List of popular services for VLESS Reality Target/SNI randomization
|
||||
export const REALITY_TARGETS = [
|
||||
{ target: 'www.amazon.com:443', sni: 'www.amazon.com' },
|
||||
{ target: 'aws.amazon.com:443', sni: 'aws.amazon.com' },
|
||||
{ target: 'www.oracle.com:443', sni: 'www.oracle.com' },
|
||||
{ target: 'www.nvidia.com:443', sni: 'www.nvidia.com' },
|
||||
{ target: 'www.amd.com:443', sni: 'www.amd.com' },
|
||||
{ target: 'www.intel.com:443', sni: 'www.intel.com' },
|
||||
{ target: 'www.sony.com:443', sni: 'www.sony.com' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns a random Reality target configuration from the predefined list
|
||||
* @returns {Object} Object with target and sni properties
|
||||
*/
|
||||
export function getRandomRealityTarget() {
|
||||
const randomIndex = Math.floor(Math.random() * REALITY_TARGETS.length);
|
||||
const selected = REALITY_TARGETS[randomIndex];
|
||||
// Return a copy to avoid reference issues
|
||||
return {
|
||||
target: selected.target,
|
||||
sni: selected.sni
|
||||
};
|
||||
}
|
||||
23
frontend/src/models/reality-targets.ts
Normal file
23
frontend/src/models/reality-targets.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export interface RealityTarget {
|
||||
target: string;
|
||||
sni: string;
|
||||
}
|
||||
|
||||
export const REALITY_TARGETS: readonly RealityTarget[] = [
|
||||
{ target: 'www.amazon.com:443', sni: 'www.amazon.com' },
|
||||
{ target: 'aws.amazon.com:443', sni: 'aws.amazon.com' },
|
||||
{ target: 'www.oracle.com:443', sni: 'www.oracle.com' },
|
||||
{ target: 'www.nvidia.com:443', sni: 'www.nvidia.com' },
|
||||
{ target: 'www.amd.com:443', sni: 'www.amd.com' },
|
||||
{ target: 'www.intel.com:443', sni: 'www.intel.com' },
|
||||
{ target: 'www.sony.com:443', sni: 'www.sony.com' },
|
||||
];
|
||||
|
||||
export function getRandomRealityTarget(): RealityTarget {
|
||||
const randomIndex = Math.floor(Math.random() * REALITY_TARGETS.length);
|
||||
const selected = REALITY_TARGETS[randomIndex];
|
||||
return {
|
||||
target: selected.target,
|
||||
sni: selected.sni,
|
||||
};
|
||||
}
|
||||
@@ -9,10 +9,11 @@ export class AllSetting {
|
||||
webBasePath = '/';
|
||||
sessionMaxAge = 360;
|
||||
trustedProxyCIDRs = '127.0.0.1/32,::1/128';
|
||||
panelProxy = '';
|
||||
pageSize = 25;
|
||||
expireDiff = 0;
|
||||
trafficDiff = 0;
|
||||
remarkModel = '-ieo';
|
||||
remarkModel = '-io';
|
||||
datepicker: 'gregorian' | 'jalalian' = 'gregorian';
|
||||
tgBotEnable = false;
|
||||
tgBotToken = '';
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
.api-docs-page {
|
||||
--bg-page: #e6e8ec;
|
||||
--bg-card: #ffffff;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark {
|
||||
--bg-page: #1a1b1f;
|
||||
--bg-card: #23252b;
|
||||
--sw-bg: #1f2026;
|
||||
--sw-bg-soft: #25272e;
|
||||
--sw-bg-input: #15161a;
|
||||
--sw-bg-code: #0d0e12;
|
||||
--sw-border: rgba(255, 255, 255, 0.08);
|
||||
--sw-border-strong: rgba(255, 255, 255, 0.15);
|
||||
--sw-text: rgba(255, 255, 255, 0.88);
|
||||
--sw-text-muted: rgba(255, 255, 255, 0.6);
|
||||
--sw-text-dim: rgba(255, 255, 255, 0.45);
|
||||
--sw-accent: #58a6ff;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark.is-ultra {
|
||||
--bg-page: #000;
|
||||
--bg-card: #101013;
|
||||
--sw-bg: #0a0a0d;
|
||||
--sw-bg-soft: #131316;
|
||||
--sw-bg-input: #050507;
|
||||
--sw-bg-code: #000;
|
||||
--sw-border: rgba(255, 255, 255, 0.06);
|
||||
--sw-border-strong: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.api-docs-page .content-shell {
|
||||
@@ -20,273 +26,396 @@
|
||||
}
|
||||
|
||||
.api-docs-page .content-area {
|
||||
padding: 24px;
|
||||
padding: 16px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.api-docs-page .content-area {
|
||||
padding: 16px 12px 12px;
|
||||
padding-top: 64px;
|
||||
padding: 8px;
|
||||
padding-top: 56px;
|
||||
}
|
||||
}
|
||||
|
||||
.docs-wrapper {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.docs-header {
|
||||
margin-bottom: 20px;
|
||||
padding: 24px;
|
||||
.api-docs-page .docs-wrapper {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid rgba(128, 128, 128, 0.12);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.docs-title {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
margin: 0 0 8px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.docs-lead {
|
||||
margin: 0;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
line-height: 1.65;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.docs-lead code,
|
||||
.token-hint code {
|
||||
background: rgba(128, 128, 128, 0.12);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.token-card,
|
||||
.curl-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.token-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.token-card-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.token-hint {
|
||||
margin: 10px 0 0;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.match-count {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toc-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 8px 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid rgba(128, 128, 128, 0.12);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toc-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
padding-top: 3px;
|
||||
flex-shrink: 0;
|
||||
.api-docs-page .swagger-ui {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.toc-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
/* ──────────────────────────────────────────────────────────────────
|
||||
Dark mode — Swagger UI does not ship a dark theme, so every visual
|
||||
surface needs an explicit override. Method-color chips (GET / POST /
|
||||
…) are left untouched because they carry meaning at a glance.
|
||||
────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui,
|
||||
.api-docs-page.is-dark .swagger-ui .info .title,
|
||||
.api-docs-page.is-dark .swagger-ui .info .title small pre,
|
||||
.api-docs-page.is-dark .swagger-ui .info p,
|
||||
.api-docs-page.is-dark .swagger-ui .info li,
|
||||
.api-docs-page.is-dark .swagger-ui .info table,
|
||||
.api-docs-page.is-dark .swagger-ui .opblock-tag,
|
||||
.api-docs-page.is-dark .swagger-ui .opblock-tag small,
|
||||
.api-docs-page.is-dark .swagger-ui .opblock .opblock-summary-path,
|
||||
.api-docs-page.is-dark .swagger-ui .opblock .opblock-summary-path__deprecated,
|
||||
.api-docs-page.is-dark .swagger-ui .opblock .opblock-summary-description,
|
||||
.api-docs-page.is-dark .swagger-ui .opblock-description-wrapper p,
|
||||
.api-docs-page.is-dark .swagger-ui .opblock-external-docs-wrapper p,
|
||||
.api-docs-page.is-dark .swagger-ui .opblock-title_normal p,
|
||||
.api-docs-page.is-dark .swagger-ui table thead tr td,
|
||||
.api-docs-page.is-dark .swagger-ui table thead tr th,
|
||||
.api-docs-page.is-dark .swagger-ui table tbody tr td,
|
||||
.api-docs-page.is-dark .swagger-ui .parameter__name,
|
||||
.api-docs-page.is-dark .swagger-ui .parameter__type,
|
||||
.api-docs-page.is-dark .swagger-ui .parameter__in,
|
||||
.api-docs-page.is-dark .swagger-ui .parameter__extension,
|
||||
.api-docs-page.is-dark .swagger-ui .response-col_status,
|
||||
.api-docs-page.is-dark .swagger-ui .response-col_description,
|
||||
.api-docs-page.is-dark .swagger-ui .response-col_links,
|
||||
.api-docs-page.is-dark .swagger-ui .responses-inner h4,
|
||||
.api-docs-page.is-dark .swagger-ui .responses-inner h5,
|
||||
.api-docs-page.is-dark .swagger-ui label,
|
||||
.api-docs-page.is-dark .swagger-ui .tab li,
|
||||
.api-docs-page.is-dark .swagger-ui .tab li button,
|
||||
.api-docs-page.is-dark .swagger-ui .markdown,
|
||||
.api-docs-page.is-dark .swagger-ui .markdown p,
|
||||
.api-docs-page.is-dark .swagger-ui .markdown li,
|
||||
.api-docs-page.is-dark .swagger-ui .renderedmarkdown p,
|
||||
.api-docs-page.is-dark .swagger-ui .renderedmarkdown li,
|
||||
.api-docs-page.is-dark .swagger-ui .model-title,
|
||||
.api-docs-page.is-dark .swagger-ui .model,
|
||||
.api-docs-page.is-dark .swagger-ui .model-toggle:after,
|
||||
.api-docs-page.is-dark .swagger-ui section.models h4,
|
||||
.api-docs-page.is-dark .swagger-ui section.models h5,
|
||||
.api-docs-page.is-dark .swagger-ui .auth-container h4,
|
||||
.api-docs-page.is-dark .swagger-ui .auth-container h6,
|
||||
.api-docs-page.is-dark .swagger-ui .scopes h2,
|
||||
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux-header h3,
|
||||
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux-content h4,
|
||||
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux-content p,
|
||||
.api-docs-page.is-dark .swagger-ui .servers-title {
|
||||
color: var(--sw-text);
|
||||
}
|
||||
|
||||
.toc-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 12.5px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
background: rgba(128, 128, 128, 0.06);
|
||||
border: 1px solid transparent;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
.api-docs-page.is-dark .swagger-ui .opblock-tag small,
|
||||
.api-docs-page.is-dark .swagger-ui .opblock-summary-description,
|
||||
.api-docs-page.is-dark .swagger-ui .parameter__in,
|
||||
.api-docs-page.is-dark .swagger-ui .parameter__type,
|
||||
.api-docs-page.is-dark .swagger-ui .parameter__extension,
|
||||
.api-docs-page.is-dark .swagger-ui .opblock-title_normal small,
|
||||
.api-docs-page.is-dark .swagger-ui .servers > label,
|
||||
.api-docs-page.is-dark .swagger-ui .servers > label span,
|
||||
.api-docs-page.is-dark .swagger-ui .response-control-media-type__accept-message {
|
||||
color: var(--sw-text-muted);
|
||||
}
|
||||
|
||||
.toc-link:hover {
|
||||
background: rgba(22, 119, 255, 0.08);
|
||||
color: #1677ff;
|
||||
border-color: rgba(22, 119, 255, 0.2);
|
||||
.api-docs-page.is-dark .swagger-ui .opblock-tag {
|
||||
border-bottom-color: var(--sw-border);
|
||||
}
|
||||
|
||||
.toc-link.active {
|
||||
background: rgba(22, 119, 255, 0.12);
|
||||
color: #1677ff;
|
||||
border-color: rgba(22, 119, 255, 0.3);
|
||||
font-weight: 600;
|
||||
.api-docs-page.is-dark .swagger-ui .opblock {
|
||||
background: var(--sw-bg-soft);
|
||||
border-color: var(--sw-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.toc-icon {
|
||||
font-size: 13px;
|
||||
opacity: 0.8;
|
||||
.api-docs-page.is-dark .swagger-ui .opblock .opblock-section-header {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
box-shadow: inset 0 -1px 0 var(--sw-border);
|
||||
}
|
||||
|
||||
.toc-text {
|
||||
font-size: 12.5px;
|
||||
.api-docs-page.is-dark .swagger-ui .opblock .opblock-section-header h4,
|
||||
.api-docs-page.is-dark .swagger-ui .opblock .opblock-section-header label {
|
||||
color: var(--sw-text);
|
||||
}
|
||||
|
||||
.toc-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 9px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
background: rgba(22, 119, 255, 0.12);
|
||||
color: #1677ff;
|
||||
line-height: 1;
|
||||
.api-docs-page.is-dark .swagger-ui .opblock .opblock-summary {
|
||||
border-bottom-color: var(--sw-border);
|
||||
}
|
||||
|
||||
.toc-link.active .toc-badge {
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
body.dark .docs-title {
|
||||
.api-docs-page.is-dark .swagger-ui .opblock-body pre.microlight,
|
||||
.api-docs-page.is-dark .swagger-ui .highlight-code,
|
||||
.api-docs-page.is-dark .swagger-ui .microlight,
|
||||
.api-docs-page.is-dark .swagger-ui pre.example,
|
||||
.api-docs-page.is-dark .swagger-ui code {
|
||||
background: var(--sw-bg-code);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .docs-title {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
body.dark .docs-header {
|
||||
background: #252526;
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .docs-header {
|
||||
background: #0a0a0a;
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
body.dark .docs-lead,
|
||||
body.dark .token-hint {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .docs-lead,
|
||||
html[data-theme='ultra-dark'] .token-hint {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
body.dark .docs-lead code,
|
||||
body.dark .token-hint code {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .docs-lead code,
|
||||
html[data-theme='ultra-dark'] .token-hint code {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
body.dark .toc-nav {
|
||||
background: #252526;
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .toc-nav {
|
||||
background: #0a0a0a;
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
body.dark .toc-label {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .toc-label {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
body.dark .toc-link {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
.api-docs-page.is-dark .swagger-ui .highlight-code .copy-to-clipboard {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .toc-link {
|
||||
.api-docs-page.is-dark .swagger-ui input[type=text],
|
||||
.api-docs-page.is-dark .swagger-ui input[type=password],
|
||||
.api-docs-page.is-dark .swagger-ui input[type=search],
|
||||
.api-docs-page.is-dark .swagger-ui input[type=email],
|
||||
.api-docs-page.is-dark .swagger-ui input[type=file],
|
||||
.api-docs-page.is-dark .swagger-ui textarea {
|
||||
background: var(--sw-bg-input);
|
||||
color: var(--sw-text);
|
||||
border-color: var(--sw-border-strong);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui select {
|
||||
background-color: var(--sw-bg-input);
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'><path fill='%23b7bcbf' d='M13.418 7.859a.695.695 0 0 1 .978 0 .68.68 0 0 1 0 .969l-3.908 3.83a.697.697 0 0 1-.979 0l-3.908-3.83a.68.68 0 0 1 0-.969.695.695 0 0 1 .978 0L10 11z'/></svg>");
|
||||
background-position: right 10px center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 20px;
|
||||
color: var(--sw-text);
|
||||
border-color: var(--sw-border-strong);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui select option {
|
||||
background-color: var(--sw-bg-input);
|
||||
color: var(--sw-text);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui textarea {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui input::placeholder,
|
||||
.api-docs-page.is-dark .swagger-ui textarea::placeholder {
|
||||
color: var(--sw-text-dim);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .scheme-container {
|
||||
background: var(--sw-bg-soft);
|
||||
box-shadow: inset 0 -1px 0 var(--sw-border);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .auth-wrapper .authorize {
|
||||
color: var(--sw-text);
|
||||
border-color: var(--sw-border-strong);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .auth-wrapper .authorize svg {
|
||||
fill: var(--sw-text);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .model-box,
|
||||
.api-docs-page.is-dark .swagger-ui section.models {
|
||||
background: var(--sw-bg-soft);
|
||||
border-color: var(--sw-border);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui section.models.is-open h4 {
|
||||
border-bottom-color: var(--sw-border);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui section.models .model-container {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-color: var(--sw-border);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui section.models .model-container:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
body.dark .toc-link:hover {
|
||||
background: rgba(88, 166, 255, 0.12);
|
||||
color: #58a6ff;
|
||||
border-color: rgba(88, 166, 255, 0.25);
|
||||
.api-docs-page.is-dark .swagger-ui .prop-type,
|
||||
.api-docs-page.is-dark .swagger-ui .prop-format {
|
||||
color: var(--sw-accent);
|
||||
}
|
||||
|
||||
body.dark .toc-link.active {
|
||||
background: rgba(88, 166, 255, 0.15);
|
||||
color: #58a6ff;
|
||||
border-color: rgba(88, 166, 255, 0.35);
|
||||
.api-docs-page.is-dark .swagger-ui .property.primitive {
|
||||
color: var(--sw-text-muted);
|
||||
}
|
||||
|
||||
body.dark .toc-badge {
|
||||
background: rgba(88, 166, 255, 0.15);
|
||||
color: #58a6ff;
|
||||
.api-docs-page.is-dark .swagger-ui .opblock-title_normal h4 {
|
||||
color: var(--sw-text);
|
||||
border-bottom-color: var(--sw-border);
|
||||
}
|
||||
|
||||
body.dark .toc-link.active .toc-badge {
|
||||
background: #58a6ff;
|
||||
.api-docs-page.is-dark .swagger-ui table.parameters,
|
||||
.api-docs-page.is-dark .swagger-ui table.responses-table,
|
||||
.api-docs-page.is-dark .swagger-ui table thead tr {
|
||||
border-color: var(--sw-border);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui table tbody tr td {
|
||||
border-bottom-color: var(--sw-border);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui table thead tr {
|
||||
border-bottom-color: var(--sw-border-strong);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .response-col_status {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .btn {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--sw-text);
|
||||
border-color: var(--sw-border-strong);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .btn.execute {
|
||||
background: var(--sw-accent);
|
||||
color: #0d1117;
|
||||
border-color: var(--sw-accent);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .btn.execute:hover {
|
||||
background: #79b9ff;
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .btn.authorize {
|
||||
color: #52c41a;
|
||||
border-color: rgba(82, 196, 26, 0.4);
|
||||
background: rgba(82, 196, 26, 0.08);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .btn.authorize svg {
|
||||
fill: #52c41a;
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .authorization__btn svg,
|
||||
.api-docs-page.is-dark .swagger-ui .expand-operation svg,
|
||||
.api-docs-page.is-dark .swagger-ui .opblock-control-arrow svg {
|
||||
fill: var(--sw-text);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .authorization__btn .locked,
|
||||
.api-docs-page.is-dark .swagger-ui .authorization__btn .unlocked {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .btn.cancel {
|
||||
color: #ff7875;
|
||||
border-color: rgba(255, 120, 117, 0.4);
|
||||
background: rgba(255, 120, 117, 0.08);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .btn.btn-clear,
|
||||
.api-docs-page.is-dark .swagger-ui .btn-clear,
|
||||
.api-docs-page.is-dark .swagger-ui .try-out__btn {
|
||||
color: var(--sw-text);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: var(--sw-border-strong);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .btn.btn-clear:hover,
|
||||
.api-docs-page.is-dark .swagger-ui .btn-clear:hover,
|
||||
.api-docs-page.is-dark .swagger-ui .try-out__btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .filter .operation-filter-input {
|
||||
background: var(--sw-bg-input);
|
||||
border-color: var(--sw-border-strong);
|
||||
color: var(--sw-text);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux {
|
||||
background: var(--sw-bg);
|
||||
border-color: var(--sw-border-strong);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux-header {
|
||||
border-bottom-color: var(--sw-border);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux-content {
|
||||
color: var(--sw-text);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .arrow,
|
||||
.api-docs-page.is-dark .swagger-ui svg.arrow {
|
||||
fill: var(--sw-text-muted);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .opblock-summary-control:focus {
|
||||
outline-color: var(--sw-accent);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui a,
|
||||
.api-docs-page.is-dark .swagger-ui .info a,
|
||||
.api-docs-page.is-dark .swagger-ui .info hgroup.main a,
|
||||
.api-docs-page.is-dark .swagger-ui .info .base-url,
|
||||
.api-docs-page.is-dark .swagger-ui .info__contact a,
|
||||
.api-docs-page.is-dark .swagger-ui .info__license a,
|
||||
.api-docs-page.is-dark .swagger-ui .info__tos a {
|
||||
color: var(--sw-accent);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui a:hover {
|
||||
color: #79b9ff;
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .info .title small {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--sw-text);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .info .title small pre {
|
||||
color: var(--sw-text);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .response-control-media-type--accept-controller select {
|
||||
border-color: rgba(82, 196, 26, 0.5);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .loading-container .loading:before {
|
||||
border-color: var(--sw-accent) transparent transparent;
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .json-schema-form-item input,
|
||||
.api-docs-page.is-dark .swagger-ui .json-schema-form-item select {
|
||||
background: var(--sw-bg-input);
|
||||
color: var(--sw-text);
|
||||
border-color: var(--sw-border-strong);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .topbar {
|
||||
background: var(--sw-bg);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .information-container {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .opblock-summary-method {
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .auth-btn-wrapper {
|
||||
border-top-color: var(--sw-border);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .servers .computed-url,
|
||||
.api-docs-page.is-dark .swagger-ui .computed-url {
|
||||
background: var(--sw-bg-code);
|
||||
color: var(--sw-text);
|
||||
border: 1px solid var(--sw-border);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .computed-url code,
|
||||
.api-docs-page.is-dark .swagger-ui .servers .computed-url code {
|
||||
background: transparent;
|
||||
color: var(--sw-text);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .errors-wrapper {
|
||||
background: rgba(255, 77, 79, 0.08);
|
||||
border-color: rgba(255, 77, 79, 0.3);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .errors-wrapper .errors h4,
|
||||
.api-docs-page.is-dark .swagger-ui .errors-wrapper .errors small {
|
||||
color: var(--sw-text);
|
||||
}
|
||||
|
||||
@@ -1,143 +1,18 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { ComponentType, MouseEvent } from 'react';
|
||||
import { Button, Card, ConfigProvider, Input, Layout, Space } from 'antd';
|
||||
import {
|
||||
ApiOutlined,
|
||||
CloudServerOutlined,
|
||||
ClusterOutlined,
|
||||
CompressOutlined,
|
||||
ExpandOutlined,
|
||||
GlobalOutlined,
|
||||
KeyOutlined,
|
||||
LinkOutlined,
|
||||
NodeIndexOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
SaveOutlined,
|
||||
SearchOutlined,
|
||||
SettingOutlined,
|
||||
WifiOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useMemo } from 'react';
|
||||
import { ConfigProvider, Layout } from 'antd';
|
||||
import SwaggerUI from 'swagger-ui-react';
|
||||
import 'swagger-ui-react/swagger-ui.css';
|
||||
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import AppSidebar from '@/components/AppSidebar';
|
||||
import { sections as allSections } from './endpoints.js';
|
||||
import EndpointSection from './EndpointSection';
|
||||
import type { Section } from './EndpointSection';
|
||||
import CodeBlock from './CodeBlock';
|
||||
import '@/styles/page-cards.css';
|
||||
import './ApiDocsPage.css';
|
||||
|
||||
const sectionIcons: Record<string, ComponentType<{ className?: string }>> = {
|
||||
authentication: SafetyCertificateOutlined,
|
||||
inbounds: NodeIndexOutlined,
|
||||
server: CloudServerOutlined,
|
||||
nodes: ClusterOutlined,
|
||||
'custom-geo': GlobalOutlined,
|
||||
backup: SaveOutlined,
|
||||
settings: SettingOutlined,
|
||||
'api-tokens': KeyOutlined,
|
||||
'xray-settings': WifiOutlined,
|
||||
subscription: LinkOutlined,
|
||||
websocket: ApiOutlined,
|
||||
};
|
||||
|
||||
const curlExample = `curl -X GET \\
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN" \\
|
||||
-H "Accept: application/json" \\
|
||||
https://your-panel.example.com/panel/api/inbounds/list`;
|
||||
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
const requestUri = window.location.pathname;
|
||||
const settingsHref = `${basePath}panel/settings#security`;
|
||||
|
||||
const endpointCount = (allSections as Section[]).reduce(
|
||||
(sum, s) => sum + s.endpoints.length,
|
||||
0,
|
||||
);
|
||||
const openApiUrl = `${basePath}panel/api/openapi.json`;
|
||||
|
||||
export default function ApiDocsPage() {
|
||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [collapsedSections, setCollapsedSections] = useState<Set<string>>(() => new Set());
|
||||
const [activeSection, setActiveSection] = useState('');
|
||||
|
||||
const sections = useMemo<Section[]>(() => {
|
||||
const q = searchQuery.toLowerCase().trim();
|
||||
if (!q) return allSections as Section[];
|
||||
return (allSections as Section[])
|
||||
.map((s) => ({
|
||||
...s,
|
||||
endpoints: s.endpoints.filter((e) =>
|
||||
e.path.toLowerCase().includes(q)
|
||||
|| e.summary?.toLowerCase().includes(q)
|
||||
|| e.method.toLowerCase().includes(q),
|
||||
),
|
||||
}))
|
||||
.filter((s) => s.endpoints.length > 0);
|
||||
}, [searchQuery]);
|
||||
|
||||
const visibleEndpoints = useMemo(
|
||||
() => sections.reduce((sum, s) => sum + s.endpoints.length, 0),
|
||||
[sections],
|
||||
);
|
||||
|
||||
const toggleSection = useCallback((id: string) => {
|
||||
setCollapsedSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const expandAll = useCallback(() => setCollapsedSections(new Set()), []);
|
||||
const collapseAll = useCallback(
|
||||
() => setCollapsedSections(new Set((allSections as Section[]).map((s) => s.id))),
|
||||
[],
|
||||
);
|
||||
|
||||
const scrollToSection = useCallback((id: string) => (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
if (window.location.hash !== `#${id}`) {
|
||||
history.replaceState(null, '', `#${id}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onHashChange = () => {
|
||||
const id = window.location.hash.slice(1);
|
||||
if (!id) return;
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.scrollIntoView({ behavior: 'auto', block: 'start' });
|
||||
};
|
||||
requestAnimationFrame(onHashChange);
|
||||
window.addEventListener('hashchange', onHashChange);
|
||||
return () => window.removeEventListener('hashchange', onHashChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
const toc = document.querySelector('.toc-nav');
|
||||
const tocHeight = toc instanceof HTMLElement ? toc.offsetHeight : 56;
|
||||
let current = '';
|
||||
for (const s of sections) {
|
||||
const el = document.getElementById(s.id);
|
||||
if (!el) continue;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.top <= tocHeight + 20) {
|
||||
current = s.id;
|
||||
}
|
||||
}
|
||||
setActiveSection(current);
|
||||
};
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
requestAnimationFrame(onScroll);
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, [sections]);
|
||||
|
||||
const pageClass = useMemo(() => {
|
||||
const classes = ['api-docs-page'];
|
||||
if (isDark) classes.push('is-dark');
|
||||
@@ -148,96 +23,17 @@ export default function ApiDocsPage() {
|
||||
return (
|
||||
<ConfigProvider theme={antdThemeConfig}>
|
||||
<Layout className={pageClass}>
|
||||
<AppSidebar basePath={basePath} requestUri={requestUri} />
|
||||
<AppSidebar />
|
||||
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content className="content-area">
|
||||
<div className="docs-wrapper">
|
||||
<header className="docs-header">
|
||||
<h1 className="docs-title">API Documentation</h1>
|
||||
<p className="docs-lead">
|
||||
The 3x-ui panel exposes a REST API under <code>/panel/api/</code>. Authenticate with the panel session
|
||||
cookie, or with the <code>Authorization: Bearer <token></code> header below. Every endpoint
|
||||
returns a uniform <code>{'{ success, msg, obj }'}</code> envelope unless otherwise noted.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<Card className="token-card" size="small">
|
||||
<div className="token-card-head">
|
||||
<div className="token-card-title">
|
||||
<KeyOutlined />
|
||||
<span>API Tokens</span>
|
||||
</div>
|
||||
<Button type="primary" size="small" href={settingsHref}>
|
||||
Manage tokens
|
||||
</Button>
|
||||
</div>
|
||||
<p className="token-hint">
|
||||
Create, enable, or revoke named Bearer tokens in{' '}
|
||||
<a href={settingsHref}>Settings → Security</a>. Send each request as{' '}
|
||||
<code>Authorization: Bearer <token></code>. Token-authenticated callers skip CSRF and don't
|
||||
need a session cookie. Deleting a token revokes it immediately — running bots will need a new one.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card className="curl-card" size="small" title="Quick example">
|
||||
<CodeBlock code={curlExample} lang="text" />
|
||||
</Card>
|
||||
|
||||
<div className="toolbar">
|
||||
<Input
|
||||
className="search-bar"
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="Search endpoints by path, method, or description…"
|
||||
allowClear
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<span className="match-count">
|
||||
{visibleEndpoints} / {endpointCount} endpoints
|
||||
</span>
|
||||
)}
|
||||
<Space size="small">
|
||||
<Button size="small" icon={<ExpandOutlined />} onClick={expandAll}>
|
||||
Expand all
|
||||
</Button>
|
||||
<Button size="small" icon={<CompressOutlined />} onClick={collapseAll}>
|
||||
Collapse all
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<nav className="toc-nav">
|
||||
<span className="toc-label">On this page:</span>
|
||||
<div className="toc-links">
|
||||
{sections.map((s) => {
|
||||
const Icon = sectionIcons[s.id];
|
||||
return (
|
||||
<a
|
||||
key={s.id}
|
||||
className={`toc-link${activeSection === s.id ? ' active' : ''}`}
|
||||
href={`#${s.id}`}
|
||||
onClick={scrollToSection(s.id)}
|
||||
>
|
||||
{Icon && <Icon />}
|
||||
<span className="toc-text">{s.title}</span>
|
||||
<span className="toc-badge">{s.endpoints.length}</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{sections.map((s) => (
|
||||
<EndpointSection
|
||||
key={s.id}
|
||||
section={s}
|
||||
icon={sectionIcons[s.id]}
|
||||
collapsed={collapsedSections.has(s.id)}
|
||||
onToggle={() => toggleSection(s.id)}
|
||||
/>
|
||||
))}
|
||||
<SwaggerUI
|
||||
url={openApiUrl}
|
||||
docExpansion="list"
|
||||
deepLinking={false}
|
||||
tryItOutEnabled
|
||||
/>
|
||||
</div>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
.code-block-wrapper {
|
||||
position: relative;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
.code-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 8px;
|
||||
background: rgba(128, 128, 128, 0.06);
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.1);
|
||||
}
|
||||
|
||||
.lang-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
text-transform: uppercase;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
background: #fff;
|
||||
color: #1677ff;
|
||||
border-color: #1677ff;
|
||||
}
|
||||
|
||||
.copy-btn.copied {
|
||||
background: #52c41a;
|
||||
color: #fff;
|
||||
border-color: #52c41a;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: rgba(128, 128, 128, 0.04);
|
||||
padding: 10px 12px;
|
||||
margin: 0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-x: auto;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.json-key { color: #0550ae; }
|
||||
.json-string { color: #116329; }
|
||||
.json-number { color: #9a6700; }
|
||||
.json-boolean { color: #cf222e; }
|
||||
.json-null { color: #8250df; }
|
||||
|
||||
body.dark .code-block-wrapper {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
body.dark .code-toolbar {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
body.dark .lang-badge {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
body.dark .code-block {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
body.dark .json-key { color: #79c0ff; }
|
||||
body.dark .json-string { color: #7ee787; }
|
||||
body.dark .json-number { color: #d29922; }
|
||||
body.dark .json-boolean { color: #ff7b72; }
|
||||
body.dark .json-null { color: #d2a8ff; }
|
||||
|
||||
body.dark .copy-btn {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
body.dark .copy-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #58a6ff;
|
||||
border-color: #58a6ff;
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { message } from 'antd';
|
||||
import { CheckOutlined, CopyOutlined } from '@ant-design/icons';
|
||||
import { ClipboardManager } from '@/utils';
|
||||
import './CodeBlock.css';
|
||||
|
||||
interface CodeBlockProps {
|
||||
code?: string;
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function highlightJson(str: string): string {
|
||||
const escaped = escapeHtml(str);
|
||||
return escaped.replace(
|
||||
/("(?:[^"\\]|\\.)*")\s*(:)|("(?:[^"\\]|\\.)*")|(-?\d+\.?\d*(?:[eE][+-]?\d+)?)\b|(true|false)|(null)|([{}[\]])/g,
|
||||
(_m, key, colon, string, number, bool, nil) => {
|
||||
if (colon) return `<span class="json-key">${key}</span>${colon}`;
|
||||
if (string) return `<span class="json-string">${string}</span>`;
|
||||
if (number) return `<span class="json-number">${number}</span>`;
|
||||
if (bool) return `<span class="json-boolean">${bool}</span>`;
|
||||
if (nil) return `<span class="json-null">${nil}</span>`;
|
||||
return _m;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export default function CodeBlock({ code = '', lang = 'json' }: CodeBlockProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
|
||||
const highlighted = useMemo(
|
||||
() => (lang === 'json' ? highlightJson(code) : escapeHtml(code)),
|
||||
[code, lang],
|
||||
);
|
||||
|
||||
async function copyCode() {
|
||||
const ok = await ClipboardManager.copyText(code);
|
||||
if (ok) {
|
||||
setCopied(true);
|
||||
messageApi.success('Copied');
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
} else {
|
||||
messageApi.error('Copy failed');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="code-block-wrapper">
|
||||
{messageContextHolder}
|
||||
<div className="code-toolbar">
|
||||
<span className="lang-badge">{lang.toUpperCase()}</span>
|
||||
<button
|
||||
className={`copy-btn${copied ? ' copied' : ''}`}
|
||||
onClick={copyCode}
|
||||
title={copied ? 'Copied' : 'Copy'}
|
||||
>
|
||||
{copied ? <CheckOutlined /> : <CopyOutlined />}
|
||||
</button>
|
||||
</div>
|
||||
<pre className={`code-block lang-${lang}`}>
|
||||
<code dangerouslySetInnerHTML={{ __html: highlighted }} />
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
.endpoint-row {
|
||||
padding: 14px 8px;
|
||||
margin: 0 -8px;
|
||||
transition: background 0.15s;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.endpoint-row:hover {
|
||||
background: rgba(128, 128, 128, 0.03);
|
||||
}
|
||||
|
||||
.endpoint-row + .endpoint-row {
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.1);
|
||||
}
|
||||
|
||||
.endpoint-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.method-tag {
|
||||
font-weight: 700;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.5px;
|
||||
min-width: 56px;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.endpoint-path {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13.5px;
|
||||
word-break: break-all;
|
||||
color: rgba(0, 0, 0, 0.8);
|
||||
background: rgba(128, 128, 128, 0.06);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.endpoint-summary {
|
||||
margin: 8px 0 0;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
line-height: 1.6;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.endpoint-block {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.block-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.error-label {
|
||||
color: #cf222e;
|
||||
}
|
||||
|
||||
body.dark .endpoint-row:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
body.dark .endpoint-row + .endpoint-row {
|
||||
border-top-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
body.dark .endpoint-path {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
body.dark .endpoint-summary {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
body.dark .block-label {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
body.dark .error-label {
|
||||
color: #ff7b72;
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { Table, Tag } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { methodColors, safeInlineHtml } from './endpoints.js';
|
||||
import CodeBlock from './CodeBlock';
|
||||
import './EndpointRow.css';
|
||||
|
||||
interface Param {
|
||||
name: string;
|
||||
in?: string;
|
||||
type?: string;
|
||||
desc?: string;
|
||||
}
|
||||
|
||||
export interface Endpoint {
|
||||
method: string;
|
||||
path: string;
|
||||
summary?: string;
|
||||
params?: Param[];
|
||||
body?: string;
|
||||
response?: string;
|
||||
errorResponse?: string;
|
||||
}
|
||||
|
||||
const paramColumns: ColumnsType<Param> = [
|
||||
{ title: 'Name', dataIndex: 'name', key: 'name', width: 180 },
|
||||
{ title: 'In', dataIndex: 'in', key: 'in', width: 100 },
|
||||
{ title: 'Type', dataIndex: 'type', key: 'type', width: 120 },
|
||||
{ title: 'Description', dataIndex: 'desc', key: 'desc' },
|
||||
];
|
||||
|
||||
export default function EndpointRow({ endpoint }: { endpoint: Endpoint }) {
|
||||
const tagColor = (methodColors as Record<string, string>)[endpoint.method] || 'default';
|
||||
const hasParams = Array.isArray(endpoint.params) && endpoint.params.length > 0;
|
||||
|
||||
return (
|
||||
<div className="endpoint-row">
|
||||
<div className="endpoint-header">
|
||||
<Tag color={tagColor} className="method-tag">{endpoint.method}</Tag>
|
||||
<code className="endpoint-path">{endpoint.path}</code>
|
||||
</div>
|
||||
|
||||
{endpoint.summary && (
|
||||
<p
|
||||
className="endpoint-summary"
|
||||
dangerouslySetInnerHTML={{ __html: safeInlineHtml(endpoint.summary) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasParams && (
|
||||
<div className="endpoint-block">
|
||||
<div className="block-label">Parameters</div>
|
||||
<Table
|
||||
columns={paramColumns}
|
||||
dataSource={endpoint.params}
|
||||
pagination={false}
|
||||
size="small"
|
||||
rowKey="name"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{endpoint.body && (
|
||||
<div className="endpoint-block">
|
||||
<div className="block-label">Request body</div>
|
||||
<CodeBlock code={endpoint.body} lang="json" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{endpoint.response && (
|
||||
<div className="endpoint-block">
|
||||
<div className="block-label">Response</div>
|
||||
<CodeBlock code={endpoint.response} lang="json" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{endpoint.errorResponse && (
|
||||
<div className="endpoint-block">
|
||||
<div className="block-label error-label">Error response</div>
|
||||
<CodeBlock code={endpoint.errorResponse} lang="json" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
.api-section {
|
||||
background: #fff;
|
||||
border: 1px solid rgba(128, 128, 128, 0.12);
|
||||
border-radius: 8px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 16px;
|
||||
transition: box-shadow 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.api-section:hover {
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.section-header:hover .collapse-icon,
|
||||
.section-header:hover .section-icon {
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.section-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.collapse-icon {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
font-size: 18px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.endpoint-count {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
white-space: nowrap;
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
margin: 12px 0 14px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.sub-header-block {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.section-block-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.endpoints {
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.1);
|
||||
}
|
||||
|
||||
.endpoints > :first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
body.dark .api-section {
|
||||
background: #252526;
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
body.dark .api-section:hover {
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .api-section {
|
||||
background: #0a0a0a;
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .api-section:hover {
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
body.dark .section-title {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
body.dark .section-icon {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
body.dark .section-description {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
body.dark .section-block-label {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
body.dark .endpoint-count {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { Table } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { DownOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import EndpointRow from './EndpointRow';
|
||||
import type { Endpoint } from './EndpointRow';
|
||||
import { safeInlineHtml } from './endpoints.js';
|
||||
import './EndpointSection.css';
|
||||
|
||||
interface SubHeader {
|
||||
name: string;
|
||||
desc?: string;
|
||||
}
|
||||
|
||||
export interface Section {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
endpoints: Endpoint[];
|
||||
subHeader?: SubHeader[];
|
||||
}
|
||||
|
||||
interface EndpointSectionProps {
|
||||
section: Section;
|
||||
icon?: ComponentType<{ className?: string }> | null;
|
||||
collapsed?: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
const subHeaderColumns: ColumnsType<SubHeader> = [
|
||||
{ title: 'Header', dataIndex: 'name', key: 'name', width: 240 },
|
||||
{
|
||||
title: 'Description',
|
||||
dataIndex: 'desc',
|
||||
key: 'desc',
|
||||
render: (value: string) => (
|
||||
<span dangerouslySetInnerHTML={{ __html: safeInlineHtml(value || '') }} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export default function EndpointSection({
|
||||
section,
|
||||
icon: Icon = null,
|
||||
collapsed = false,
|
||||
onToggle,
|
||||
}: EndpointSectionProps) {
|
||||
const endpointLabel = section.endpoints.length === 1
|
||||
? '1 endpoint'
|
||||
: `${section.endpoints.length} endpoints`;
|
||||
|
||||
return (
|
||||
<section id={section.id} className="api-section">
|
||||
<div className="section-header" onClick={onToggle}>
|
||||
<div className="section-header-left">
|
||||
{collapsed ? <RightOutlined className="collapse-icon" /> : <DownOutlined className="collapse-icon" />}
|
||||
{Icon && <Icon className="section-icon" />}
|
||||
<h2 className="section-title">{section.title}</h2>
|
||||
</div>
|
||||
<span className="endpoint-count">{endpointLabel}</span>
|
||||
</div>
|
||||
|
||||
{section.description && !collapsed && (
|
||||
<p
|
||||
className="section-description"
|
||||
dangerouslySetInnerHTML={{ __html: safeInlineHtml(section.description) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{section.subHeader && !collapsed && (
|
||||
<div className="sub-header-block">
|
||||
<div className="section-block-label">Response headers</div>
|
||||
<Table
|
||||
columns={subHeaderColumns}
|
||||
dataSource={section.subHeader}
|
||||
pagination={false}
|
||||
size="small"
|
||||
rowKey="name"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="endpoints" style={{ display: collapsed ? 'none' : undefined }}>
|
||||
{section.endpoints.map((endpoint, idx) => (
|
||||
<EndpointRow key={idx} endpoint={endpoint} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +1,59 @@
|
||||
export function safeInlineHtml(input) {
|
||||
if (!input) return '';
|
||||
const escape = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const open = '<code>';
|
||||
const close = '</code>';
|
||||
let out = '';
|
||||
let i = 0;
|
||||
while (i < input.length) {
|
||||
const oIdx = input.indexOf(open, i);
|
||||
if (oIdx === -1) {
|
||||
out += escape(input.slice(i));
|
||||
break;
|
||||
}
|
||||
out += escape(input.slice(i, oIdx));
|
||||
const cIdx = input.indexOf(close, oIdx + open.length);
|
||||
if (cIdx === -1) {
|
||||
out += escape(input.slice(oIdx));
|
||||
break;
|
||||
}
|
||||
out += '<code>' + escape(input.slice(oIdx + open.length, cIdx)) + '</code>';
|
||||
i = cIdx + close.length;
|
||||
}
|
||||
return out;
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'WS';
|
||||
export type ParamLocation =
|
||||
| 'path'
|
||||
| 'query'
|
||||
| 'header'
|
||||
| 'body'
|
||||
| 'body (form)'
|
||||
| 'body (json)'
|
||||
| 'body (multipart)';
|
||||
export type ParamType =
|
||||
| 'string'
|
||||
| 'integer'
|
||||
| 'integer[]'
|
||||
| 'number'
|
||||
| 'boolean'
|
||||
| 'object'
|
||||
| 'object[]'
|
||||
| 'array'
|
||||
| 'file';
|
||||
|
||||
export interface EndpointParam {
|
||||
name: string;
|
||||
in: ParamLocation;
|
||||
type: ParamType;
|
||||
desc?: string;
|
||||
optional?: boolean;
|
||||
defaultValue?: string | number | boolean;
|
||||
}
|
||||
|
||||
export const sections = [
|
||||
export interface Endpoint {
|
||||
method: HttpMethod;
|
||||
path: string;
|
||||
summary: string;
|
||||
description?: string;
|
||||
deprecated?: boolean;
|
||||
params?: EndpointParam[];
|
||||
body?: string;
|
||||
response?: string;
|
||||
errorResponse?: string;
|
||||
errorStatus?: number;
|
||||
}
|
||||
|
||||
export interface SubscriptionHeader {
|
||||
name: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export interface Section {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
subHeader?: SubscriptionHeader[];
|
||||
endpoints: Endpoint[];
|
||||
}
|
||||
|
||||
export const sections: readonly Section[] = [
|
||||
{
|
||||
id: 'authentication',
|
||||
title: 'Authentication',
|
||||
@@ -144,6 +174,15 @@ export const sections = [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/:id/delAllClients',
|
||||
summary: 'Remove every client attached to a single inbound while keeping the inbound itself. Collects emails from settings.clients[] and feeds them into the optimized bulk-delete path (runtime user removal + traffic-row cleanup + SyncInbound). Destructive and cannot be undone.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
],
|
||||
response: '{\n "success": true,\n "obj": {\n "deleted": 12\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/resetAllTraffics',
|
||||
@@ -160,12 +199,12 @@ export const sections = [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/inbounds/:id/fallbacks',
|
||||
summary: 'List the fallback rules attached to a master VLESS/Trojan TCP-TLS inbound. Each rule links one child inbound (the dest) to optional SNI/ALPN/path/xver match criteria.',
|
||||
summary: 'List the fallback rules attached to a master VLESS/Trojan TCP-TLS inbound. Each rule links one child inbound (the dest) to optional SNI/ALPN/path/dest/xver match criteria. When dest is empty the child inbound\'s listen+port is used.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Master inbound ID.' },
|
||||
],
|
||||
response:
|
||||
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "masterId": 10,\n "childId": 11,\n "name": "",\n "alpn": "",\n "path": "/vlws",\n "xver": 2,\n "sortOrder": 0\n }\n ]\n}',
|
||||
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "masterId": 10,\n "childId": 11,\n "name": "",\n "alpn": "",\n "path": "/vlws",\n "dest": "",\n "xver": 2,\n "sortOrder": 0\n }\n ]\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -173,9 +212,9 @@ export const sections = [
|
||||
summary: 'Replace the entire fallback list for a master inbound. Body is JSON. Triggers an Xray restart.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Master inbound ID.' },
|
||||
{ name: 'fallbacks', in: 'body (json)', type: 'object[]', desc: 'Array of {childId, name, alpn, path, xver, sortOrder} entries.' },
|
||||
{ name: 'fallbacks', in: 'body (json)', type: 'object[]', desc: 'Array of {childId, name, alpn, path, dest, xver, sortOrder} entries. Leave dest empty to auto-resolve from the child inbound\'s listen+port; set it (e.g. "8443", "127.0.0.1:8443", "/dev/shm/x.sock") to override.' },
|
||||
],
|
||||
body: '{\n "fallbacks": [\n { "childId": 11, "path": "/vlws", "xver": 2 },\n { "childId": 12, "alpn": "h2" }\n ]\n}',
|
||||
body: '{\n "fallbacks": [\n { "childId": 11, "path": "/vlws", "xver": 2 },\n { "childId": 12, "alpn": "h2", "dest": "8443" }\n ]\n}',
|
||||
response: '{\n "success": true,\n "msg": "Inbound updated"\n}',
|
||||
},
|
||||
],
|
||||
@@ -491,6 +530,99 @@ export const sections = [
|
||||
body: '{\n "emails": ["alice", "bob"],\n "addDays": 30,\n "addBytes": 53687091200\n}',
|
||||
response: '{\n "success": true,\n "obj": {\n "adjusted": 2,\n "skipped": [\n { "email": "carol", "reason": "unlimited expiry" }\n ]\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/bulkDel',
|
||||
summary: 'Delete many clients in one call. The server processes the list sequentially so each delete sees the committed state of the previous one — avoids the race the per-email fan-out had on the panel side. Pass keepTraffic=true to retain the xray_client_traffic rows after deletion.',
|
||||
body: '{\n "emails": ["alice", "bob"],\n "keepTraffic": false\n}',
|
||||
response: '{\n "success": true,\n "obj": {\n "deleted": 2,\n "skipped": [\n { "email": "carol", "reason": "client not found" }\n ]\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/bulkCreate',
|
||||
summary: 'Create many clients in one call. Body is a JSON array of {client, inboundIds} payloads — the same shape /add accepts. Items are processed sequentially; per-email skip reasons are returned for items that fail (e.g., duplicate email). Triggers a single Xray restart at the end if any inbound was running.',
|
||||
body: '[\n {\n "client": {\n "email": "alice@example.com",\n "totalGB": 53687091200,\n "expiryTime": 0,\n "enable": true\n },\n "inboundIds": [7]\n },\n {\n "client": {\n "email": "bob@example.com",\n "totalGB": 53687091200,\n "expiryTime": 0,\n "enable": true\n },\n "inboundIds": [7, 9]\n }\n]',
|
||||
response: '{\n "success": true,\n "obj": {\n "created": 2,\n "skipped": [\n { "email": "alice@example.com", "reason": "email already in use" }\n ]\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/groups/bulkAdd',
|
||||
summary: 'Add many clients to a group in one call. Updates clients.group_name and patches the matching client entry inside every owning inbound\'s settings JSON in a single transaction. If the group name does not yet exist (in client_groups or as a derived label), it is auto-created as a persistent group. To clear the group label, use /groups/bulkRemove instead.',
|
||||
body: '{\n "emails": ["alice", "bob"],\n "group": "customer-a"\n}',
|
||||
response: '{\n "success": true,\n "obj": {\n "affected": 2\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/groups/bulkRemove',
|
||||
summary: 'Clear the group label on many clients in one call. Inverse of /groups/bulkAdd. Clients themselves are kept — only the group label is cleared from clients.group_name and from each owning inbound\'s settings JSON. Groups become empty if all their members are removed.',
|
||||
body: '{\n "emails": ["alice", "bob"]\n}',
|
||||
response: '{\n "success": true,\n "obj": {\n "affected": 2\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/bulkAttach',
|
||||
summary: 'Attach many existing clients to many inbounds in one call. Each client keeps its identity (email/UUID/password/subId) and a shared traffic row; all clients are added to a target inbound in a single AddInboundClient call. Clients already present on a target are reported under skipped. Returns per-email attached/skipped/errors lists and triggers a single Xray restart if any target inbound was running.',
|
||||
params: [
|
||||
{ name: 'emails', in: 'body (json)', type: 'array', desc: 'Emails of existing clients to attach.' },
|
||||
{ name: 'inboundIds', in: 'body (json)', type: 'integer[]', desc: 'Target inbound IDs to attach every client to.' },
|
||||
],
|
||||
body: '{\n "emails": ["alice", "bob"],\n "inboundIds": [7, 9]\n}',
|
||||
response: '{\n "success": true,\n "obj": {\n "attached": ["alice", "bob"],\n "skipped": ["bob"],\n "errors": []\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/bulkDetach',
|
||||
summary: 'Mirror of bulkAttach: detach many existing clients from many inbounds in one call. For each email, intersects the client\'s current inbounds with the requested set and detaches from those only; (email, inbound) pairs where the client is not currently attached are silently no-ops. Emails not attached to any of the requested inbounds are reported under skipped. Client records are kept even if they become orphaned — use bulkDel for full removal. Returns per-email detached/skipped/errors lists and triggers a single Xray restart if any target inbound was running.',
|
||||
params: [
|
||||
{ name: 'emails', in: 'body (json)', type: 'array', desc: 'Emails of existing clients to detach.' },
|
||||
{ name: 'inboundIds', in: 'body (json)', type: 'integer[]', desc: 'Inbound IDs to detach the clients from.' },
|
||||
],
|
||||
body: '{\n "emails": ["alice", "bob"],\n "inboundIds": [7, 9]\n}',
|
||||
response: '{\n "success": true,\n "obj": {\n "detached": ["alice", "bob"],\n "skipped": [],\n "errors": []\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/bulkResetTraffic',
|
||||
summary: 'Zero up/down counters for many clients in one call. Loops the single-reset path so each client is re-enabled across its attached inbounds and pushed to Xray/remote nodes. Returns the count of successfully reset clients.',
|
||||
body: '{\n "emails": ["alice", "bob"]\n}',
|
||||
response: '{\n "success": true,\n "obj": {\n "affected": 2\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/clients/groups',
|
||||
summary: 'List all client groups with their member counts. Merges persisted groups (rows in client_groups, including empty placeholders) with the distinct group_name values currently set on clients. Sorted alphabetically (case-insensitive).',
|
||||
response: '{\n "success": true,\n "obj": [\n { "name": "customer-a", "clientCount": 5 },\n { "name": "internal", "clientCount": 0 }\n ]\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/clients/groups/:name/emails',
|
||||
summary: 'Return just the email list of clients that currently belong to the given group. Useful for fanning a single bulk action over an entire group without round-tripping the full client list.',
|
||||
params: [
|
||||
{ name: 'name', in: 'path', type: 'string', desc: 'Group name (URL-encoded).' },
|
||||
],
|
||||
response: '{\n "success": true,\n "obj": ["alice", "bob", "carol"]\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/groups/create',
|
||||
summary: 'Create a new empty (placeholder) group. The group becomes selectable in client forms and the filter drawer even before any client is added to it. Errors if a group with the same name already exists.',
|
||||
body: '{\n "name": "customer-a"\n}',
|
||||
response: '{\n "success": true,\n "obj": {\n "name": "customer-a"\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/groups/rename',
|
||||
summary: 'Rename a group. The new name is applied to the client_groups row AND propagated to every matching client (both clients.group_name and the client entry inside every owning inbound\'s settings JSON) in a single transaction. Returns the number of clients whose label was updated.',
|
||||
body: '{\n "oldName": "customer-a",\n "newName": "tier-1"\n}',
|
||||
response: '{\n "success": true,\n "obj": {\n "affected": 5\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/groups/delete',
|
||||
summary: 'Remove a group. Deletes the client_groups row and clears the group label from every matching client (both clients.group_name and the inbound settings JSON). The clients themselves are NOT deleted — use /bulkDel after filtering by group for that. Returns the count of clients whose label was cleared.',
|
||||
body: '{\n "name": "customer-a"\n}',
|
||||
response: '{\n "success": true,\n "obj": {\n "affected": 5\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/resetTraffic/:email',
|
||||
@@ -560,7 +692,7 @@ export const sections = [
|
||||
method: 'GET',
|
||||
path: '/panel/api/clients/links/:email',
|
||||
summary:
|
||||
"Return every URL for one client across all attached inbounds — the same strings the Copy URL button copies in the panel UI. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria, hysteria2. If streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.",
|
||||
"Return every URL for one client across all attached inbounds — the same strings the Copy URL button copies in the panel UI. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria. If streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.",
|
||||
params: [
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
|
||||
],
|
||||
@@ -975,12 +1107,3 @@ export const sections = [
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const methodColors = {
|
||||
GET: 'blue',
|
||||
POST: 'green',
|
||||
PUT: 'orange',
|
||||
PATCH: 'orange',
|
||||
DELETE: 'red',
|
||||
WS: 'purple',
|
||||
};
|
||||
81
frontend/src/pages/clients/BulkAddToGroupModal.tsx
Normal file
81
frontend/src/pages/clients/BulkAddToGroupModal.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AutoComplete, Form, Modal, message } from 'antd';
|
||||
|
||||
interface BulkAddToGroupModalProps {
|
||||
open: boolean;
|
||||
count: number;
|
||||
groups: string[];
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (group: string) => Promise<{ affected?: number } | null>;
|
||||
}
|
||||
|
||||
export default function BulkAddToGroupModal({
|
||||
open,
|
||||
count,
|
||||
groups,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: BulkAddToGroupModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [value, setValue] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setValue('');
|
||||
}, [open]);
|
||||
|
||||
async function submit() {
|
||||
const next = value.trim();
|
||||
if (!next) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await onSubmit(next);
|
||||
if (result) {
|
||||
const affected = result.affected ?? 0;
|
||||
messageApi.success(t('pages.clients.addToGroupSuccessToast', { count: affected, group: next }));
|
||||
onOpenChange(false);
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{messageContextHolder}
|
||||
<Modal
|
||||
open={open}
|
||||
title={t('pages.clients.addToGroupTitle', { count })}
|
||||
okText={t('add')}
|
||||
cancelText={t('cancel')}
|
||||
confirmLoading={submitting}
|
||||
okButtonProps={{ disabled: !value.trim() }}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onOk={submit}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item
|
||||
label={t('pages.clients.group')}
|
||||
tooltip={t('pages.clients.addToGroupTooltip')}
|
||||
>
|
||||
<AutoComplete
|
||||
value={value}
|
||||
placeholder={t('pages.clients.addToGroupPlaceholder')}
|
||||
options={groups.map((g) => ({ value: g }))}
|
||||
onChange={(v) => setValue(v ?? '')}
|
||||
filterOption={(input, option) =>
|
||||
String(option?.value ?? '').toLowerCase().includes((input || '').toLowerCase())
|
||||
}
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
autoFocus
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user